chore: 重写初始提交(清空历史,整理后全量提交)
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
# =============================================================
|
||||||
|
# YwxApp 环境变量模板 (.env.example)
|
||||||
|
# 复制本文件为 .env 后填写真实值。.env 已被 .gitignore 忽略,不会入库。
|
||||||
|
# 注意:键名前后不要留空格(如 `KEY=value`,不能是 `KEY =value`)。
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
# -------------------- 部署 FTP 密码 --------------------
|
||||||
|
# scripts/deploy_wxapp_ftp.ps1 部署脚本读取此密码(优先环境变量 $env:WXAPP_FTP_PASS,其次本文件)
|
||||||
|
WXAPP_FTP_PASS=在此填写FTP密码
|
||||||
|
|
||||||
|
# -------------------- 数据库(ThinkPHP 标准键) --------------------
|
||||||
|
# config/database.php 通过 env('DB_*') 读取
|
||||||
|
DB_TYPE=mysql
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_NAME=ywxapp_dev
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASS=
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_CHARSET=utf8
|
||||||
|
DB_PREFIX=wx_
|
||||||
|
DB_PERSISTENT=true
|
||||||
|
|
||||||
|
# -------------------- 应用 --------------------
|
||||||
|
# config/app.php 通过 env('APP_DEBUG' / 'APP_HOST') 读取
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_HOST=localhost
|
||||||
|
|
||||||
|
# -------------------- Redis 缓存(ThinkPHP 标准键) --------------------
|
||||||
|
# config/cache.php 通过 env('REDIS_*') 读取
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# -------------------- 应用市场 / 插件密钥 --------------------
|
||||||
|
# 后台 JWT 鉴权密钥(ywxapp/service/JwtService.php 读 env('JWT_SECRET'))
|
||||||
|
# 生产环境务必替换为强随机值
|
||||||
|
# HS256 要求密钥 ≥ 256 bits(32 字节),下面为 48 字节 base64 占位,生产请替换为自己的强随机值
|
||||||
|
JWT_SECRET=replace_with_a_48_byte_base64_strong_random_secret_key_2026xxxx
|
||||||
|
|
||||||
|
# 插件签名/下载校验密钥(ywxapp/service/AddonService.php、FrameworkService.php 读 config('ywxapp.addon_secret'))
|
||||||
|
# 中心站与客户机需一致;生产务必替换
|
||||||
|
ADDON_SECRET=change_me_to_a_strong_addon_secret
|
||||||
|
|
||||||
|
# 是否允许未知来源插件(离线包)安装:true 时跳过远程授权校验
|
||||||
|
# 读取:Env::get('app_debug') && config('ywxapp.unknownsources')
|
||||||
|
ADDON_UNKNOWN_SOURCES=false
|
||||||
|
|
||||||
|
# 开发者模式开关:开启后允许本地开发/调试插件发布
|
||||||
|
# 读取:Env::get('addon_developer') 或 config('ywxapp.addon_developer')
|
||||||
|
ADDON_DEVELOPER=false
|
||||||
|
|
||||||
|
# 发布到官方公共市场(www.ywxapp.cn)所需令牌,由官方运营分配
|
||||||
|
# 留空则后台「发布到官方市场」会提示先配置;读取:env('APPMARKET_DEV_TOKEN')
|
||||||
|
APPMARKET_DEV_TOKEN=
|
||||||
|
|
||||||
|
# -------------------- 应用市场授权校验开关(config/ywxapp.php) --------------------
|
||||||
|
# addon_license_check:控制客户机启用插件时的授权闸门与运行期巡检
|
||||||
|
# false → 关闭校验(开发/自用)
|
||||||
|
# true → 开启;中心站不可达也判定失败并禁用插件(防离线绕过)
|
||||||
|
# offline_tolerant → 开启;仅中心站明确拒绝(吊销/过期/域名不符)才禁用,不可达放行
|
||||||
|
# 说明:此键写在 config/ywxapp.php 中,不在此 .env;此处仅作说明,便于查阅。
|
||||||
|
# 对应代码:ywxapp/service/AddonService.php verifyRuntimeLicense() / checkLicenses()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
<html>
|
||||||
|
<head><title>404 Not Found</title></head>
|
||||||
|
<body>
|
||||||
|
<center><h1>404 Not Found</h1></center>
|
||||||
|
<hr><center>nginx</center>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
|
||||||
|
ThinkPHP遵循Apache2开源协议发布,并提供免费使用。
|
||||||
|
版权所有Copyright © 2006-2023 by ThinkPHP (http://thinkphp.cn)
|
||||||
|
All rights reserved。
|
||||||
|
ThinkPHP® 商标和著作权所有者为上海顶想信息科技有限公司。
|
||||||
|
|
||||||
|
Apache Licence是著名的非盈利开源组织Apache采用的协议。
|
||||||
|
该协议和BSD类似,鼓励代码共享和尊重原作者的著作权,
|
||||||
|
允许代码修改,再作为开源或商业软件发布。需要满足
|
||||||
|
的条件:
|
||||||
|
1. 需要给代码的用户一份Apache Licence ;
|
||||||
|
2. 如果你修改了代码,需要在被修改的文件中说明;
|
||||||
|
3. 在延伸的代码中(修改和有源代码衍生的代码中)需要
|
||||||
|
带有原来代码中的协议,商标,专利声明和其他原来作者规
|
||||||
|
定需要包含的说明;
|
||||||
|
4. 如果再发布的产品中包含一个Notice文件,则在Notice文
|
||||||
|
件中需要带有本协议内容。你可以在Notice中增加自己的
|
||||||
|
许可,但不可以表现为对Apache Licence构成更改。
|
||||||
|
具体的协议参考:http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
|
||||||
|
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||||
|
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||||
|
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||||
|
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||||
|
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
|
||||||
|
ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGE.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\appmall;
|
||||||
|
|
||||||
|
use think\facade\Config;
|
||||||
|
use think\facade\Db;
|
||||||
|
use ywxapp\AddonBase;
|
||||||
|
use ywxapp\model\BaseModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件应用市场(服务端)
|
||||||
|
*
|
||||||
|
* 把原核心里的「插件商店服务端」整体剥离为独立插件,部署在中心站(插件服务器)。
|
||||||
|
* 安装时由框架自动导入 install.sql 建表(appmall_addon_list / appmall_addon_submissions / appmall_developers);
|
||||||
|
* 卸载时清理这些服务端表。
|
||||||
|
*/
|
||||||
|
class Addon extends addon
|
||||||
|
{
|
||||||
|
|
||||||
|
public function install(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function uninstall(): bool
|
||||||
|
{
|
||||||
|
// 与 install.sql 表名前缀严格对齐(wxapp_appmall_*),
|
||||||
|
// 直接复用统一前缀,避免依赖 database.prefix 配置(install.sql 为硬写前缀)。
|
||||||
|
$prefix = 'wxapp_appmall_';
|
||||||
|
$tables = [
|
||||||
|
'addon_list', // 插件市场商品表
|
||||||
|
'addon_submissions', // 提交审核表
|
||||||
|
'developers', // 开发者表
|
||||||
|
'addon_revenues', // 收益账本
|
||||||
|
'addon_withdrawals', // 提现单
|
||||||
|
'addon_licenses', // 授权表
|
||||||
|
'addon_orders', // 购买订单表
|
||||||
|
'addon_download_logs', // 下载日志表
|
||||||
|
'sites', // 客户端站点(授权绑定维度)
|
||||||
|
'framework_list', // 主框架版本表
|
||||||
|
'framework_download_log',// 主框架下载日志表
|
||||||
|
'throttle', // 接口频率限制表
|
||||||
|
];
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 升级钩子(在线升级 / 后台手动升级均会触发)。
|
||||||
|
*
|
||||||
|
* 在线升级(AddonService::onlineUpgrade)已重导 install.sql 补全表;
|
||||||
|
* 但「已存在表新增列」install.sql 无力(CREATE TABLE IF NOT EXISTS 对已有表无效)。
|
||||||
|
* 此处作为升级收敛点:确保全部表存在 + 对已知易漂移列做幂等补列兜底。
|
||||||
|
* 未来新增列统一在 $columnFixes 登记,避免各控制器重复 ALTER 导致 DDL 漂移。
|
||||||
|
*
|
||||||
|
* @param string $currentVersion 升级前版本号
|
||||||
|
*/
|
||||||
|
public function upgrade($currentVersion = ''): bool
|
||||||
|
{
|
||||||
|
// 1) 确保全部表存在(幂等,单一事实源 install.sql)
|
||||||
|
$this->ensureTablesFromInstallSql();
|
||||||
|
|
||||||
|
// 2) 补列兜底:'完整表名(含前缀)' => ['列名' => '列定义']
|
||||||
|
// BaseModel::ensureColumn 先探测存在性,重复执行安全。
|
||||||
|
$prefix = 'wxapp_appmall_';
|
||||||
|
$columnFixes = [
|
||||||
|
$prefix . 'addon_download_logs' => [
|
||||||
|
'operator_id' => "int unsigned NOT NULL DEFAULT 0 COMMENT '运营后台下载审计(0=会员自助下载)'",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
foreach ($columnFixes as $table => $cols) {
|
||||||
|
foreach ($cols as $column => $def) {
|
||||||
|
BaseModel::ensureColumn($table, $column, $def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 应用市场(appmall)后台「插件配置」表单字段定义。
|
||||||
|
// | 仅描述「哪些字段可编辑 + 表单默认值」;真正的运行期默认以
|
||||||
|
// | config/appmall.php 为准,数据库保存值最高优先级覆盖。
|
||||||
|
// | 注意:radio/select 的 value 须为字符串('1'/'0')以正确预选,
|
||||||
|
// | 代码读取处统一 (bool) 强转,本文件不负责类型。
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
return [
|
||||||
|
// ===== 市场基础设置 =====
|
||||||
|
[
|
||||||
|
'name' => 'appmall_name',
|
||||||
|
'title' => '市场名称',
|
||||||
|
'type' => 'string',
|
||||||
|
'value' => 'YwxApp 应用市场',
|
||||||
|
'tip' => '显示在开发者注册页标题等位置',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'allow_register',
|
||||||
|
'title' => '开放开发者自助注册',
|
||||||
|
'type' => 'radio',
|
||||||
|
'options' => ['1' => '开放', '0' => '关闭'],
|
||||||
|
'value' => '1',
|
||||||
|
'tip' => '关闭后仅运营手动创建开发者',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'need_approval',
|
||||||
|
'title' => '注册后需审核',
|
||||||
|
'type' => 'radio',
|
||||||
|
'options' => ['1' => '需要', '0' => '注册即激活'],
|
||||||
|
'value' => '1',
|
||||||
|
'tip' => '需要审核时令牌经邮件下发',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'allow_upload',
|
||||||
|
'title' => '允许后台上传发布核心包',
|
||||||
|
'type' => 'radio',
|
||||||
|
'options' => ['1' => '允许', '0' => '关闭'],
|
||||||
|
'value' => '1',
|
||||||
|
'tip' => '关闭后仅能通过数据库/命令行维护主框架版本',
|
||||||
|
],
|
||||||
|
|
||||||
|
// ===== 官方市场发布(主动推送到 www.ywxapp.cn 公共市场)=====
|
||||||
|
[
|
||||||
|
'name' => 'developer_token',
|
||||||
|
'title' => '官方市场开发者令牌',
|
||||||
|
'type' => 'textarea',
|
||||||
|
'value' => env('APPMARKET_DEV_TOKEN', ''),
|
||||||
|
'tip' => '把插件发布到官方公共市场(www.ywxapp.cn)所需令牌,由官方运营分配;留空则后台「发布到官方市场」会提示先配置。也可在 .env 设 APPMARKET_DEV_TOKEN。',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'developer_name',
|
||||||
|
'title' => '开发者名称',
|
||||||
|
'type' => 'string',
|
||||||
|
'value' => env('APPMARKET_DEV_NAME', ''),
|
||||||
|
'tip' => '可选,开发者展示名;对应 .env 的 APPMARKET_DEV_NAME。',
|
||||||
|
],
|
||||||
|
|
||||||
|
// ===== 远程市场 / 中心站客户机模式(仅当 ywxapp.api_url 指向其他服务器时生效)=====
|
||||||
|
[
|
||||||
|
'name' => 'remote_token',
|
||||||
|
'title' => '远程市场共享密钥',
|
||||||
|
'type' => 'textarea',
|
||||||
|
'value' => env('APPMARKET_REMOTE_TOKEN', ''),
|
||||||
|
'tip' => '「谁是中心站」由 config/ywxapp.php 的 api_url 决定(留空=本机即中心站)。仅当 api_url 指向其他服务器(本机作客户机)时需要:与中心站 appmall.remote_token 一致的共享密钥;单站点中心站模式留空即可。',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'ssl_verify',
|
||||||
|
'title' => '远程市场 SSL 证书验证',
|
||||||
|
'type' => 'select',
|
||||||
|
'options' => ['1' => '严格验证(推荐)', '0' => '跳过验证(仅自托管/排障)'],
|
||||||
|
'value' => env('APPMARKET_SSL_VERIFY', true) ? '1' : '0',
|
||||||
|
'tip' => '连接远程市场时的 SSL 校验;跳过验证存在中间人风险;对应 .env 的 APPMARKET_SSL_VERIFY。',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'commission_rate',
|
||||||
|
'title' => '平台抽成比例(0~1)',
|
||||||
|
'type' => 'number',
|
||||||
|
'value' => env('APPMARKET_COMMISSION_RATE', 0.2),
|
||||||
|
'tip' => '开发者收益 = 订单金额 * (1 - 该比例);对应 .env 的 APPMARKET_COMMISSION_RATE。',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'security_scan',
|
||||||
|
'title' => '提交包安全扫描',
|
||||||
|
'type' => 'select',
|
||||||
|
'options' => ['1' => '开启', '0' => '关闭'],
|
||||||
|
'value' => env('APPMARKET_SECURITY_SCAN', true) ? '1' : '0',
|
||||||
|
'tip' => '发布到官方/远程市场前对 PHP 包做语法 + 高危函数扫描;对应 .env 的 APPMARKET_SECURITY_SCAN。',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 应用市场(appmall)插件【运行期配置的规范默认源】
|
||||||
|
// |
|
||||||
|
// | 本文件是 config('appmall.*') 的完整默认集合(代码级默认值,支持 .env 覆盖)。
|
||||||
|
// | 后台「插件配置」表单(config.php) 仅负责「可编辑字段 + 默认值展示」,
|
||||||
|
// | 数据库保存值按最高优先级覆盖本文件。读取一律用 config('appmall.xxx')。
|
||||||
|
// |
|
||||||
|
// | 约定:布尔项统一用 bool(true/false),不要写 '1'/'0'(字符串),
|
||||||
|
// | 以免代码 if(!$v) 判定出错('0' 在 PHP 里是 truthy)。
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
return [
|
||||||
|
// ===== 市场基础设置(仅中心站相关:运营与开发者自助)=====
|
||||||
|
'appmall_name' => 'YwxApp 应用市场',
|
||||||
|
'allow_register' => true, // 开放开发者自助注册
|
||||||
|
'need_approval' => true, // 注册后需审核(审核通过才下发开发者令牌)
|
||||||
|
'allow_upload' => true, // 允许后台上传发布核心包
|
||||||
|
|
||||||
|
// ===== 官方市场发布(把本机插件推送到 www.ywxapp.cn 官方公共市场)=====
|
||||||
|
// 与「远程中心站」是两回事:这里是【主动推送】,下面是【被动拉取】。
|
||||||
|
'developer_token' => env('APPMARKET_DEV_TOKEN', ''),
|
||||||
|
'developer_name' => env('APPMARKET_DEV_NAME', ''),
|
||||||
|
|
||||||
|
// ===== 远程市场 / 中心站客户机模式(仅当 ywxapp.api_url 指向其他服务器时生效)=====
|
||||||
|
// 「谁是中心站」由 config/ywxapp.php 的 api_url 决定(留空=本机即中心站,无需下面这些)。
|
||||||
|
'remote_token' => env('APPMARKET_REMOTE_TOKEN', ''), // 客户机↔中心站共享密钥,须与中心站一致
|
||||||
|
'ssl_verify' => env('APPMARKET_SSL_VERIFY', true), // 连接远程市场时 SSL 证书校验
|
||||||
|
|
||||||
|
// ===== 交易 / 安全 =====
|
||||||
|
'commission_rate' => env('APPMARKET_COMMISSION_RATE', 0.2), // 平台抽成 0~1
|
||||||
|
'security_scan' => env('APPMARKET_SECURITY_SCAN', true), // 发布前 PHP 语法+高危函数扫描
|
||||||
|
];
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\appmall\controller;
|
||||||
|
|
||||||
|
use think\facade\Db;
|
||||||
|
use ywxapp\controller\FrontendBase;
|
||||||
|
use ywxapp\library\Mailer;
|
||||||
|
use ywxapp\model\BaseModel;
|
||||||
|
use addon\appmall\library\AppmallSchema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发者中心(独立前台应用,非会员中心、非后台)
|
||||||
|
* 开发者自助注册 / 查询令牌 / 收益查询 / 提现;完全公开(noNeedLogin=*),
|
||||||
|
* 以开发者令牌(token)而非会员登录态认证。
|
||||||
|
* 路由前缀:/appmall/developer/*(由 AppService::loadAddonRoutes 在 boot 阶段包 Route::group('appmall') 注册到全局路由表,不隶属于任何 thinkphp 应用)。
|
||||||
|
* 视图:addon/appmall/view/frontend/developer/。
|
||||||
|
*
|
||||||
|
* 注:AddonFrontend 基类 _initialize 把 view_path 设成【主应用】view/frontend/(与插件目录不符),
|
||||||
|
* 故此处必须在 initialize() 显式把 view_path 锁回插件目录,否则 fetch 找不到模板。
|
||||||
|
*/
|
||||||
|
class Developer extends FrontendBase
|
||||||
|
{
|
||||||
|
protected $noNeedLogin = ['*'];
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 锁定视图路径到插件目录(修复 AddonFrontend 设成主应用目录的坑)
|
||||||
|
*/
|
||||||
|
protected function initialize()
|
||||||
|
{
|
||||||
|
// 确保 appmall 运营表存在(开发者中心只读开发者/收益/提现等表,避免全新安装 1146)
|
||||||
|
AppmallSchema::ensure();
|
||||||
|
|
||||||
|
// 仅中心站暴露:客户机(ywxapp.api_url 指向其他服务器,见 is_market_client())作为中心站客户端,
|
||||||
|
// 不持有 appmall_developers / appmall_addon_revenues / appmall_addon_withdrawals 等运营表,
|
||||||
|
// 访问开发者中心直接 404。即便路由层未拦住(如路由缓存陈旧、被直连)也在此兜底。
|
||||||
|
if (is_market_client()) {
|
||||||
|
abort(404, '开发者中心仅在中心站(市场服务端)可用');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->view->config([
|
||||||
|
'view_path' => ADDON_PATH . 'appmall' . DIRECTORY_SEPARATOR
|
||||||
|
. 'view' . DIRECTORY_SEPARATOR . 'frontend' . DIRECTORY_SEPARATOR,
|
||||||
|
]);
|
||||||
|
// 供前端 <script src=".../wxapp.js" module="{$site.app}"> 使用(前台应用目录名)
|
||||||
|
$this->view->assign('site', ['app' => 'frontend']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册页
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$this->view->assign('register_open', (bool) config('appmall.allow_register', true));
|
||||||
|
$this->view->assign('need_approval', (bool) config('appmall.need_approval', true));
|
||||||
|
return $this->view->fetch('developer/register');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交注册(生成令牌,按配置决定是否需要审核)
|
||||||
|
*/
|
||||||
|
public function save()
|
||||||
|
{
|
||||||
|
if (! $this->request->isPost()) {
|
||||||
|
$this->result->error('访问错误');
|
||||||
|
}
|
||||||
|
if (! (bool) config('appmall.allow_register', true)) {
|
||||||
|
$this->result->error('当前未开放自助注册');
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = trim($this->request->param('email', ''));
|
||||||
|
$name = trim($this->request->param('username', ''));
|
||||||
|
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$this->result->error('邮箱格式不正确');
|
||||||
|
}
|
||||||
|
if ($name === '') {
|
||||||
|
$this->result->error('请填写开发者名称');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Db::name('appmall_developers')->where('email', $email)->find()) {
|
||||||
|
$this->result->error('该邮箱已申请过开发者令牌');
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$needApproval = (bool) config('appmall.need_approval', true);
|
||||||
|
$status = $needApproval ? 0 : 1;
|
||||||
|
|
||||||
|
Db::name('appmall_developers')->insert([
|
||||||
|
'username' => $name,
|
||||||
|
'email' => $email,
|
||||||
|
'token' => $token,
|
||||||
|
'status' => $status,
|
||||||
|
'create_at' => time(),
|
||||||
|
'update_at' => time(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($needApproval) {
|
||||||
|
Mailer::send($email, '开发者入驻申请已收到', $this->pendingMail($name));
|
||||||
|
$this->result->success([], '注册成功,等待运营审核,通过后令牌将发送至您的邮箱');
|
||||||
|
}
|
||||||
|
|
||||||
|
Mailer::send($email, '您的开发者令牌', $this->tokenMail($name, $token));
|
||||||
|
$this->result->success(
|
||||||
|
['token' => $token],
|
||||||
|
'注册成功,请将下方令牌配置到开发者控制台的 APPMARKET_DEV_TOKEN'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询我的令牌
|
||||||
|
*/
|
||||||
|
public function mine()
|
||||||
|
{
|
||||||
|
return $this->view->fetch('developer/mine');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据邮箱取回令牌(已激活才返回,并重发邮件)
|
||||||
|
*/
|
||||||
|
public function lookup()
|
||||||
|
{
|
||||||
|
if (! $this->request->isPost()) {
|
||||||
|
$this->result->error('访问错误');
|
||||||
|
}
|
||||||
|
$email = trim($this->request->param('email', ''));
|
||||||
|
$dev = Db::name('appmall_developers')->where('email', $email)->find();
|
||||||
|
if (! $dev) {
|
||||||
|
$this->result->error('未找到该邮箱的开发者记录');
|
||||||
|
}
|
||||||
|
$status = (int) $dev['status'];
|
||||||
|
if ($status === 0) {
|
||||||
|
$this->result->error('您的申请正在审核中,请耐心等待');
|
||||||
|
}
|
||||||
|
if ($status === -1) {
|
||||||
|
$this->result->error('开发者账号已被禁用,请联系运营');
|
||||||
|
}
|
||||||
|
Mailer::send($email, '您的开发者令牌', $this->tokenMail($dev['username'], $dev['token']));
|
||||||
|
$this->result->success(
|
||||||
|
['token' => $dev['token'], 'username' => $dev['username']],
|
||||||
|
'令牌如下(已重新发送至邮箱)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的收益(令牌认证):返回余额与近期收益账本
|
||||||
|
*/
|
||||||
|
public function earnings()
|
||||||
|
{
|
||||||
|
$this->ensureTables();
|
||||||
|
$token = $this->request->param('token', '');
|
||||||
|
$dev = Db::name('appmall_developers')->where('token', $token)->where('status', 1)->find();
|
||||||
|
if (empty($dev)) {
|
||||||
|
return $this->result->error('令牌无效');
|
||||||
|
}
|
||||||
|
$revenues = Db::name('appmall_addon_revenues')->where('developer_id', $dev['id'])
|
||||||
|
->order('id', 'desc')->limit(20)->select()->toArray();
|
||||||
|
$this->result->success([
|
||||||
|
'balance' => $dev['balance'] ?? 0,
|
||||||
|
'frozen_balance' => $dev['frozen_balance'] ?? 0,
|
||||||
|
'revenues' => $revenues,
|
||||||
|
], '获取成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起提现(令牌认证):可提现余额 → 冻结,生成提现单待运营打款
|
||||||
|
*/
|
||||||
|
public function withdraw()
|
||||||
|
{
|
||||||
|
if (! $this->request->isPost()) {
|
||||||
|
return $this->result->error('访问错误');
|
||||||
|
}
|
||||||
|
$this->ensureTables();
|
||||||
|
$token = $this->request->param('token', '');
|
||||||
|
$amount = (float) $this->request->param('amount', 0);
|
||||||
|
$channel = $this->request->param('channel', 'alipay');
|
||||||
|
$account = trim($this->request->param('account', ''));
|
||||||
|
$dev = Db::name('appmall_developers')->where('token', $token)->where('status', 1)->find();
|
||||||
|
if (empty($dev)) {
|
||||||
|
return $this->result->error('令牌无效');
|
||||||
|
}
|
||||||
|
if ($amount <= 0) {
|
||||||
|
return $this->result->error('提现金额必须大于 0');
|
||||||
|
}
|
||||||
|
if ($amount > (float) ($dev['balance'] ?? 0)) {
|
||||||
|
return $this->result->error('可提现余额不足');
|
||||||
|
}
|
||||||
|
if ($account === '') {
|
||||||
|
return $this->result->error('请填写收款账号');
|
||||||
|
}
|
||||||
|
Db::transaction(function () use ($dev, $amount, $channel, $account) {
|
||||||
|
Db::name('appmall_developers')->where('id', $dev['id'])
|
||||||
|
->dec('balance', $amount)->inc('frozen_balance', $amount)->update();
|
||||||
|
Db::name('appmall_addon_withdrawals')->insert([
|
||||||
|
'developer_id' => $dev['id'],
|
||||||
|
'amount' => $amount,
|
||||||
|
'channel' => $channel,
|
||||||
|
'account' => $account,
|
||||||
|
'status' => 0,
|
||||||
|
'create_at' => time(),
|
||||||
|
'update_at' => time(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
return $this->result->success([], '提现申请已提交,等待运营打款');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发者控制台入口(统一 DZ 风格布局):令牌认证后渲染控制台
|
||||||
|
* 路由:GET /appmall/developer/console?token=xxx
|
||||||
|
*/
|
||||||
|
public function console()
|
||||||
|
{
|
||||||
|
$this->view->assign('token', $this->request->param('token', ''));
|
||||||
|
return $this->view->fetch('developer/console');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发者控制台(兼容旧入口,重定向到统一控制台)
|
||||||
|
*/
|
||||||
|
public function dashboard()
|
||||||
|
{
|
||||||
|
$this->view->assign('token', $this->request->param('token', ''));
|
||||||
|
return $this->view->fetch('developer/console');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 控制台概览数据(令牌认证):应用数 / 待审数 / 总下载 / 余额 / 累计收益 / 近期提交
|
||||||
|
* 路由:POST /appmall/developer/overview
|
||||||
|
*/
|
||||||
|
public function overview()
|
||||||
|
{
|
||||||
|
$this->ensureTables();
|
||||||
|
$dev = $this->authDev();
|
||||||
|
if (empty($dev)) {
|
||||||
|
return $this->result->error('令牌无效');
|
||||||
|
}
|
||||||
|
$devId = (int) $dev['id'];
|
||||||
|
$appCount = Db::name('appmall_addon_list')->where('developer_id', $devId)->where('status', 1)->count();
|
||||||
|
$pendingCount = Db::name('appmall_addon_submissions')->where('developer_id', $devId)->where('status', 0)->count();
|
||||||
|
$totalDownload = (int) Db::name('appmall_addon_list')->where('developer_id', $devId)->sum('download_count');
|
||||||
|
$totalIncome = (float) Db::name('appmall_addon_revenues')->where('developer_id', $devId)->where('status', 1)->sum('income');
|
||||||
|
$recent = Db::name('appmall_addon_submissions')->where('developer_id', $devId)
|
||||||
|
->field('title,version,status,update_at,type')->order('id', 'desc')->limit(5)->select()->toArray();
|
||||||
|
$this->result->success([
|
||||||
|
'username' => $dev['username'],
|
||||||
|
'email' => $dev['email'],
|
||||||
|
'balance' => $dev['balance'] ?? 0,
|
||||||
|
'frozen_balance' => $dev['frozen_balance'] ?? 0,
|
||||||
|
'app_count' => $appCount,
|
||||||
|
'pending_count' => $pendingCount,
|
||||||
|
'total_downloads' => $totalDownload,
|
||||||
|
'total_income' => $totalIncome,
|
||||||
|
'recent' => $recent,
|
||||||
|
], '获取成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的应用(令牌认证):已上架/下架应用 + 提交记录(待审/驳回)合并返回
|
||||||
|
* 路由:POST /appmall/developer/apps
|
||||||
|
*/
|
||||||
|
public function apps()
|
||||||
|
{
|
||||||
|
$this->ensureTables();
|
||||||
|
$dev = $this->authDev();
|
||||||
|
if (empty($dev)) {
|
||||||
|
return $this->result->error('令牌无效');
|
||||||
|
}
|
||||||
|
$devId = (int) $dev['id'];
|
||||||
|
$listed = Db::name('appmall_addon_list')->where('developer_id', $devId)
|
||||||
|
->field('id,name,title,version,price,logo,category,download_count,status,update_at,type')
|
||||||
|
->order('update_at', 'desc')->select()->toArray();
|
||||||
|
$subs = Db::name('appmall_addon_submissions')->where('developer_id', $devId)
|
||||||
|
->field('id,name,title,version,price,logo,category,status,reject_reason,update_at,type')
|
||||||
|
->order('update_at', 'desc')->select()->toArray();
|
||||||
|
$this->result->success(['listed' => $listed, 'submissions' => $subs], '获取成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改我的应用价格(令牌认证,仅能改自己名下的应用;无需重新打包/重新提审)
|
||||||
|
* 路由:POST /appmall/developer/price {token, name, price}
|
||||||
|
*/
|
||||||
|
public function price()
|
||||||
|
{
|
||||||
|
if (! $this->request->isPost()) {
|
||||||
|
return $this->result->error('访问错误');
|
||||||
|
}
|
||||||
|
$this->ensureTables();
|
||||||
|
$dev = $this->authDev();
|
||||||
|
if (empty($dev)) {
|
||||||
|
return $this->result->error('令牌无效');
|
||||||
|
}
|
||||||
|
$name = trim((string) $this->request->param('name', ''));
|
||||||
|
$price = (float) $this->request->param('price', -1);
|
||||||
|
try {
|
||||||
|
\addon\appmall\service\MarketService::instance()
|
||||||
|
->updatePrice($name, $price, (int) $dev['id']);
|
||||||
|
} catch (\addon\appmall\service\MarketException $e) {
|
||||||
|
return $this->result->error($e->getMessage());
|
||||||
|
}
|
||||||
|
return $this->result->success([], '价格已更新,市场即时生效');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 令牌鉴权:返回 status=1 的开发者记录,否则 null(沿用 earnings/withdraw 的契约)
|
||||||
|
*/
|
||||||
|
private function authDev()
|
||||||
|
{
|
||||||
|
$token = $this->request->param('token', '');
|
||||||
|
return Db::name('appmall_developers')->where('token', $token)->where('status', 1)->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行时自愈建表(仅当表不存在时创建),保证开发者收益/提现闭环在当前库即可跑通。
|
||||||
|
* 字段与 install.sql、中心站 Market::ensureTables 保持一致。
|
||||||
|
*/
|
||||||
|
private function ensureTables(): void
|
||||||
|
{
|
||||||
|
$prefix = Db::getConfig('prefix') ?: '';
|
||||||
|
$tables = [
|
||||||
|
'appmall_developers' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_developers` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`username` varchar(100) NOT NULL COMMENT '开发者名称',
|
||||||
|
`email` varchar(120) NOT NULL COMMENT '邮箱(接收令牌/通知)',
|
||||||
|
`token` varchar(64) NOT NULL COMMENT '开发者令牌(发布插件时校验)',
|
||||||
|
`status` tinyint DEFAULT 0 COMMENT '0=待审核 1=正常 -1=禁用',
|
||||||
|
`balance` decimal(10,2) DEFAULT '0.00' COMMENT '可提现余额',
|
||||||
|
`frozen_balance` decimal(10,2) DEFAULT '0.00' COMMENT '待结算/冻结金额',
|
||||||
|
`create_at` int DEFAULT 0,
|
||||||
|
`update_at` int DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_email` (`email`),
|
||||||
|
UNIQUE KEY `uk_token` (`token`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场开发者表';",
|
||||||
|
'appmall_addon_revenues' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_addon_revenues` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`developer_id` int unsigned NOT NULL DEFAULT '0' COMMENT '受益开发者',
|
||||||
|
`order_id` int unsigned NOT NULL DEFAULT '0' COMMENT 'appmall_addon_orders.id',
|
||||||
|
`aid` int unsigned NOT NULL DEFAULT '0' COMMENT 'appmall_addon_list.id',
|
||||||
|
`uid` int unsigned NOT NULL DEFAULT '0' COMMENT '购买用户',
|
||||||
|
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '订单金额',
|
||||||
|
`commission` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '平台抽成',
|
||||||
|
`income` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '开发者应得',
|
||||||
|
`status` tinyint DEFAULT 0 COMMENT '0=待结算 1=已结算',
|
||||||
|
`create_at` int DEFAULT 0,
|
||||||
|
`settle_at` int DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_developer` (`developer_id`),
|
||||||
|
KEY `idx_order` (`order_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件销售收益账本';",
|
||||||
|
'appmall_addon_withdrawals' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_addon_withdrawals` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`developer_id` int unsigned NOT NULL DEFAULT '0',
|
||||||
|
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '提现金额',
|
||||||
|
`channel` varchar(20) DEFAULT 'alipay' COMMENT '提现渠道',
|
||||||
|
`account` varchar(100) DEFAULT '' COMMENT '收款账号',
|
||||||
|
`status` tinyint DEFAULT 0 COMMENT '0=待处理 1=已打款 -1=驳回',
|
||||||
|
`remark` varchar(255) DEFAULT '',
|
||||||
|
`create_at` int DEFAULT 0,
|
||||||
|
`update_at` int DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_developer` (`developer_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开发者提现单';",
|
||||||
|
];
|
||||||
|
foreach ($tables as $t => $sql) {
|
||||||
|
BaseModel::ensureTable($prefix . $t, $sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发者令牌邮件正文
|
||||||
|
*/
|
||||||
|
private function tokenMail($name, $token): string
|
||||||
|
{
|
||||||
|
return "尊敬的 {$name}:<br>您的开发者令牌已生成:<br><br>"
|
||||||
|
. "<code style=\"background:#f4f4f4;padding:4px 8px;border-radius:4px;\">{$token}</code><br><br>"
|
||||||
|
. '请将其配置到开发者控制台的 <b>APPMARKET_DEV_TOKEN</b> 环境变量后,即可在「发布到官方市场」中使用。';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核中邮件正文
|
||||||
|
*/
|
||||||
|
private function pendingMail($name): string
|
||||||
|
{
|
||||||
|
return "尊敬的 {$name}:<br>我们已收到您的开发者入驻申请,将在 1-3 个工作日内完成审核,"
|
||||||
|
. '通过后令牌会发送至本邮箱,请注意查收。';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\appmall\controller;
|
||||||
|
|
||||||
|
use think\facade\Db;
|
||||||
|
use ywxapp\controller\FrontendBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主框架发布/下载展示页(公开,仅中心站)
|
||||||
|
*
|
||||||
|
* 列出已发布的主框架版本,提供「整包 / 增量补丁 / 完整安装包」下载入口。
|
||||||
|
* 下载走对外 API /appmall/api/framework/download(无需登录,可选签名)。
|
||||||
|
* 路由前缀:/appmall/framework/*(由 AppService::loadAddonRoutes 外层包 Route::group('appmall'))。
|
||||||
|
* 视图:addon/appmall/view/frontend/framework/。
|
||||||
|
*/
|
||||||
|
class Framework extends FrontendBase
|
||||||
|
{
|
||||||
|
protected $noNeedLogin = ['*'];
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 锁定视图路径到插件目录(AddonFrontend 默认指向主应用 view/frontend/,须覆盖)。
|
||||||
|
* 仅中心站暴露:客户机不持有 framework_list 发布数据,访问即 404。
|
||||||
|
*/
|
||||||
|
protected function initialize()
|
||||||
|
{
|
||||||
|
if (is_market_client()) {
|
||||||
|
abort(404, '框架下载页仅在中心站(市场服务端)可用');
|
||||||
|
}
|
||||||
|
$this->view->config([
|
||||||
|
'view_path' => ADDON_PATH . 'appmall' . DIRECTORY_SEPARATOR
|
||||||
|
. 'view' . DIRECTORY_SEPARATOR . 'frontend' . DIRECTORY_SEPARATOR,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布列表展示页
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$this->ensureColumns();
|
||||||
|
$rows = Db::name('appmall_framework_list')
|
||||||
|
->where('status', 1)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->field('id,version,title,changelog,file_path,patch_path,install_path,patch_from,from_version,download_count,create_at')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
foreach ($rows as &$r) {
|
||||||
|
$r['has_full'] = !empty($r['file_path']);
|
||||||
|
$r['has_patch'] = !empty($r['patch_path']);
|
||||||
|
$r['has_install'] = !empty($r['install_path']);
|
||||||
|
$r['patch_from'] = $r['patch_from'] ?: $r['from_version'];
|
||||||
|
$r['create_at_fmt'] = $r['create_at'] ? date('Y-m-d', (int) $r['create_at']) : '';
|
||||||
|
unset($r['file_path'], $r['patch_path'], $r['install_path']);
|
||||||
|
}
|
||||||
|
unset($r);
|
||||||
|
|
||||||
|
$this->view->assign('list', $rows);
|
||||||
|
$this->view->assign('market_name', config('appmall.appmall_name', 'YwxApp 应用市场'));
|
||||||
|
return $this->view->fetch('framework/index');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行时自愈:补齐 install_path / install_hash 列(早期安装的中心站可能缺失)。
|
||||||
|
*/
|
||||||
|
private function ensureColumns(): void
|
||||||
|
{
|
||||||
|
$table = \ywxapp\model\BaseModel::currentPrefix() . 'appmall_framework_list';
|
||||||
|
try {
|
||||||
|
$defs = [
|
||||||
|
'install_path' => "varchar(255) DEFAULT '' COMMENT '完整安装包 zip 物理路径'",
|
||||||
|
'install_hash' => "varchar(64) DEFAULT '' COMMENT '完整安装包 SHA256 校验值'",
|
||||||
|
];
|
||||||
|
foreach ($defs as $col => $def) {
|
||||||
|
$cols = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$col}'");
|
||||||
|
if (empty($cols)) {
|
||||||
|
Db::execute("ALTER TABLE `{$table}` ADD COLUMN `{$col}` {$def}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\appmall\controller;
|
||||||
|
|
||||||
|
use ywxapp\controller\FrontendBase;
|
||||||
|
use addon\appmall\service\MarketService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用商店前台展示页(公开,仅中心站)
|
||||||
|
*
|
||||||
|
* 列出应用市场上线的插件 / 模板,卡片展示 logo、价格、评分、下载量等。
|
||||||
|
* 数据来自中心站本地库(appmall_addon_list),复用 MarketService::catalog()。
|
||||||
|
* 路由前缀:/appmall/store/*(由 AppService::loadAddonRoutes 外层包 Route::group('appmall'))。
|
||||||
|
* 视图:addon/appmall/view/frontend/store/。
|
||||||
|
*/
|
||||||
|
class Store extends FrontendBase
|
||||||
|
{
|
||||||
|
protected $noNeedLogin = ['*'];
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 锁定视图路径到插件目录;仅中心站暴露(客户机不持有市场列表数据,访问即 404)。
|
||||||
|
*/
|
||||||
|
protected function initialize()
|
||||||
|
{
|
||||||
|
if (is_market_client()) {
|
||||||
|
abort(404, '应用商店仅在中心站(市场服务端)可用');
|
||||||
|
}
|
||||||
|
$this->view->config([
|
||||||
|
'view_path' => ADDON_PATH . 'appmall' . DIRECTORY_SEPARATOR
|
||||||
|
. 'view' . DIRECTORY_SEPARATOR . 'frontend' . DIRECTORY_SEPARATOR,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 商店首页:上线插件列表
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$catalog = MarketService::instance()->catalog();
|
||||||
|
$items = $catalog['list'] ?? [];
|
||||||
|
$categories = $catalog['categories'] ?? [];
|
||||||
|
|
||||||
|
$plugins = [];
|
||||||
|
foreach ($items as $it) {
|
||||||
|
$price = (float) ($it['price'] ?? 0);
|
||||||
|
$plugins[] = [
|
||||||
|
'id' => $it['id'] ?? 0,
|
||||||
|
'name' => $it['name'] ?? '',
|
||||||
|
'title' => $it['title'] ?? ($it['name'] ?? ''),
|
||||||
|
'desc' => $it['intro'] ?? '',
|
||||||
|
'author' => $it['author'] ?? '',
|
||||||
|
'logo' => $it['logo'] ?? '',
|
||||||
|
'version' => $it['version'] ?? '',
|
||||||
|
'price' => $price,
|
||||||
|
'price_text' => $price > 0 ? '¥' . number_format($price, 2) : '免费',
|
||||||
|
'rating' => (float) ($it['rating'] ?? 0),
|
||||||
|
'downloads' => (int) ($it['download_count'] ?? 0),
|
||||||
|
'category' => $it['category'] ?? '',
|
||||||
|
'type' => $it['type'] ?? 'addon',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->view->assign('list', $plugins);
|
||||||
|
$this->view->assign('categories', $categories);
|
||||||
|
$this->view->assign('market_name', config('appmall.appmall_name', 'YwxApp 应用市场'));
|
||||||
|
return $this->view->fetch('store/index');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件详情页(/appmall/store/detail/<name>)
|
||||||
|
* 参考 FastAdmin 插件市场详情版式:左封面+截图、右元信息+版本历史。
|
||||||
|
*/
|
||||||
|
public function detail()
|
||||||
|
{
|
||||||
|
$name = input('name', '', 'trim');
|
||||||
|
if ($name === '') {
|
||||||
|
abort(404, '插件标识为空');
|
||||||
|
}
|
||||||
|
$catalog = MarketService::instance()->catalog();
|
||||||
|
$hit = null;
|
||||||
|
foreach (($catalog['list'] ?? []) as $it) {
|
||||||
|
if (($it['name'] ?? '') === $name) {
|
||||||
|
$hit = $it;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$hit) {
|
||||||
|
abort(404, '插件不存在或未上架');
|
||||||
|
}
|
||||||
|
|
||||||
|
$price = (float) ($hit['price'] ?? 0);
|
||||||
|
$row = [
|
||||||
|
'id' => $hit['id'] ?? 0,
|
||||||
|
'name' => $hit['name'] ?? '',
|
||||||
|
'title' => $hit['title'] ?? ($hit['name'] ?? ''),
|
||||||
|
'intro' => $hit['intro'] ?? '',
|
||||||
|
'author' => $hit['author'] ?? '',
|
||||||
|
'logo' => $hit['logo'] ?? '',
|
||||||
|
'version' => $hit['version'] ?? '',
|
||||||
|
'price' => $price,
|
||||||
|
'price_text' => $price > 0 ? '¥' . number_format($price, 2) : '免费',
|
||||||
|
'rating' => (float) ($hit['rating'] ?? 0),
|
||||||
|
'downloads' => (int) ($hit['download_count'] ?? 0),
|
||||||
|
'category' => $hit['category'] ?? '',
|
||||||
|
'tags' => $hit['tags'] ?? '',
|
||||||
|
'type' => $hit['type'] ?? 'addon',
|
||||||
|
'update_at' => (int) ($hit['update_at'] ?? 0),
|
||||||
|
'update_fmt' => $hit['update_at'] ? date('Y-m-d', (int) $hit['update_at']) : '',
|
||||||
|
'screenshots'=> $this->parseScreenshots($hit['screenshots'] ?? ''),
|
||||||
|
'versions' => $this->normalizeVersions($hit['versions'] ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->view->assign('addon', $row);
|
||||||
|
$this->view->assign('market_name', config('appmall.appmall_name', 'YwxApp 应用市场'));
|
||||||
|
return $this->view->fetch('store/detail');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* screenshots 可能是逗号分隔字符串 / JSON 数组,归一化为数组。
|
||||||
|
*/
|
||||||
|
private function parseScreenshots($raw): array
|
||||||
|
{
|
||||||
|
if (is_array($raw)) {
|
||||||
|
return array_values($raw);
|
||||||
|
}
|
||||||
|
$raw = trim((string) $raw);
|
||||||
|
if ($raw === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (strpos($raw, '[') === 0) {
|
||||||
|
$dec = json_decode($raw, true);
|
||||||
|
return is_array($dec) ? array_values($dec) : [];
|
||||||
|
}
|
||||||
|
return array_filter(array_map('trim', explode(',', $raw)), fn ($x) => $x !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* versions 子版本归一化(version/price/update_fmt)。
|
||||||
|
*/
|
||||||
|
private function normalizeVersions(array $vers): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($vers as $v) {
|
||||||
|
$out[] = [
|
||||||
|
'version' => (string) ($v['version'] ?? ''),
|
||||||
|
'price' => (float) ($v['price'] ?? 0),
|
||||||
|
'price_text'=> ((float) ($v['price'] ?? 0)) > 0 ? '¥' . number_format((float) $v['price'], 2) : '免费',
|
||||||
|
'update_fmt'=> !empty($v['update_at']) ? date('Y-m-d', (int) $v['update_at']) : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\appmall\controller\api;
|
||||||
|
|
||||||
|
use think\facade\Config;
|
||||||
|
use think\facade\Db;
|
||||||
|
use think\facade\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主框架升级服务 API
|
||||||
|
*
|
||||||
|
* 契约对齐客户端 ywxapp\service\FrameworkService:
|
||||||
|
* - GET /api/framework/version 返回最新版本 + 历史版本(客户端检测更新)
|
||||||
|
* - GET /api/framework/download 下载核心包 zip(可选签名校验)
|
||||||
|
* 原 upgrade 插件功能,已合并进 market(应用服务中心)。
|
||||||
|
*/
|
||||||
|
class Framework
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 版本列表 / 最新版本
|
||||||
|
*/
|
||||||
|
public function version()
|
||||||
|
{
|
||||||
|
$this->ensureColumns();
|
||||||
|
try {
|
||||||
|
$rows = Db::name('appmall_framework_list')
|
||||||
|
->where('status', 1)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->limit(20)
|
||||||
|
->field('id,version,title,changelog,type,from_version,patch_from,file_path,patch_path,install_path,create_at')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return json(['code' => 0, 'msg' => '读取版本列表失败:' . $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$latest = $rows[0] ?? null;
|
||||||
|
$hasFull = !empty($latest['file_path']);
|
||||||
|
$hasPatch = !empty($latest['patch_path']);
|
||||||
|
$hasInstall = !empty($latest['install_path'] ?? '');
|
||||||
|
$patchFrom = $latest['patch_from'] ?? '';
|
||||||
|
if ($patchFrom === '' && $hasPatch) {
|
||||||
|
$patchFrom = $latest['from_version'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不向客户端泄露物理路径
|
||||||
|
foreach ($rows as &$r) {
|
||||||
|
$r['has_full'] = !empty($r['file_path']);
|
||||||
|
$r['has_patch'] = !empty($r['patch_path']);
|
||||||
|
$r['has_install'] = !empty($r['install_path']);
|
||||||
|
unset($r['file_path'], $r['patch_path'], $r['install_path']);
|
||||||
|
}
|
||||||
|
unset($r);
|
||||||
|
|
||||||
|
return json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => 'success',
|
||||||
|
'data' => [
|
||||||
|
'version' => $latest['version'] ?? '',
|
||||||
|
'title' => $latest['title'] ?? '',
|
||||||
|
'changelog' => $latest['changelog'] ?? '',
|
||||||
|
// 兼容旧客户端:type 只表达「整包缺失时才是纯补丁」
|
||||||
|
'type' => $hasFull ? 0 : ($hasPatch ? 1 : (int) ($latest['type'] ?? 0)),
|
||||||
|
'from_version'=> $patchFrom,
|
||||||
|
// 新协议:整包与补丁可同版本共存,客户端按自身版本择优
|
||||||
|
'has_full' => $hasFull,
|
||||||
|
'has_patch' => $hasPatch,
|
||||||
|
'has_install' => $hasInstall,
|
||||||
|
'patch_from' => $patchFrom,
|
||||||
|
'release_at' => $latest['create_at'] ?? 0,
|
||||||
|
'history' => $rows,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兼容已安装库:补齐 type / from_version / patch_* 列,并迁移旧「补丁行」数据
|
||||||
|
*/
|
||||||
|
private function ensureColumns(): void
|
||||||
|
{
|
||||||
|
$table = \ywxapp\model\BaseModel::currentPrefix() . 'appmall_framework_list';
|
||||||
|
try {
|
||||||
|
$defs = [
|
||||||
|
'type' => "tinyint DEFAULT 0 COMMENT '0=整包 1=补丁'",
|
||||||
|
'from_version' => "varchar(20) DEFAULT '' COMMENT '补丁基础版本'",
|
||||||
|
'patch_path' => "varchar(255) DEFAULT '' COMMENT '增量补丁 zip 物理路径'",
|
||||||
|
'patch_hash' => "varchar(64) DEFAULT '' COMMENT '补丁 SHA256 校验值'",
|
||||||
|
'patch_from' => "varchar(20) DEFAULT '' COMMENT '补丁适用的基础版本'",
|
||||||
|
'install_path' => "varchar(255) DEFAULT '' COMMENT '完整安装包 zip 物理路径'",
|
||||||
|
'install_hash' => "varchar(64) DEFAULT '' COMMENT '完整安装包 SHA256 校验值'",
|
||||||
|
];
|
||||||
|
foreach ($defs as $col => $def) {
|
||||||
|
$cols = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$col}'");
|
||||||
|
if (empty($cols)) {
|
||||||
|
Db::execute("ALTER TABLE `{$table}` ADD COLUMN `{$col}` {$def}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Db::execute(
|
||||||
|
"UPDATE `{$table}` SET patch_path = file_path, patch_hash = file_hash, patch_from = from_version,"
|
||||||
|
. " file_path = '', file_hash = ''"
|
||||||
|
. " WHERE type = 1 AND (patch_path = '' OR patch_path IS NULL) AND file_path <> ''"
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载核心包 zip
|
||||||
|
* 客户端 FrameworkService::download() GET ?version= :
|
||||||
|
* - 直接返回 zip 二进制(首字节非 '{')。
|
||||||
|
* 可选签名校验:config('appmall.addon_download_sign') = true 时,
|
||||||
|
* 请求需带 sign=md5('framework'.version.ts.secret) & ts(5 分钟有效期)。
|
||||||
|
*/
|
||||||
|
public function download()
|
||||||
|
{
|
||||||
|
$version = Request::param('version', '');
|
||||||
|
// 选包:type=install/2 下安装包;type=patch/1 下补丁;type=full/0 下整包;缺省整包(缺失时回退补丁/安装包)
|
||||||
|
$typeRaw = (string) Request::param('type', '');
|
||||||
|
$wantPatch = in_array($typeRaw, ['patch', '1'], true);
|
||||||
|
$wantInstall = in_array($typeRaw, ['install', '2'], true);
|
||||||
|
|
||||||
|
if (Config::get('ywxapp.addon_download_sign', false)) {
|
||||||
|
$sign = Request::param('sign', '');
|
||||||
|
$ts = (int) Request::param('ts', 0);
|
||||||
|
if (!$this->verifySign($version, $ts, $sign)) {
|
||||||
|
return json(['code' => 0, 'message' => '签名校验失败']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行时自愈:已部署的中心站若早于本版本安装,可能缺下载日志表
|
||||||
|
$this->ensureTables();
|
||||||
|
|
||||||
|
$query = Db::name('appmall_framework_list')->where('status', 1);
|
||||||
|
if ($version !== '') {
|
||||||
|
$query->where('version', $version);
|
||||||
|
}
|
||||||
|
$row = $query->order('id', 'desc')->find();
|
||||||
|
if (empty($row)) {
|
||||||
|
return json(['code' => 0, 'message' => '版本包不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($wantInstall) {
|
||||||
|
$path = $row['install_path'] ?? '';
|
||||||
|
} elseif ($wantPatch) {
|
||||||
|
$path = $row['patch_path'] ?? '';
|
||||||
|
} else {
|
||||||
|
$path = $row['file_path'] ?? '';
|
||||||
|
// 兼容「仅补丁/仅安装包」版本行:未显式指定时整包缺失则回退
|
||||||
|
if (($path === '' || !is_file($path)) && !empty($row['patch_path'])) {
|
||||||
|
$path = $row['patch_path'];
|
||||||
|
$wantPatch = true;
|
||||||
|
}
|
||||||
|
if (($path === '' || !is_file($path)) && !empty($row['install_path'])) {
|
||||||
|
$path = $row['install_path'];
|
||||||
|
$wantInstall = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (empty($path) || !is_file($path)) {
|
||||||
|
$msg = $wantInstall ? '该版本无完整安装包或文件缺失'
|
||||||
|
: ($wantPatch ? '该版本无增量补丁或文件缺失' : '版本包不存在或文件缺失');
|
||||||
|
return json(['code' => 0, 'message' => $msg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录下载次数与日志(列/表缺失时忽略,不影响下载)
|
||||||
|
try {
|
||||||
|
Db::name('appmall_framework_list')->where('id', $row['id'])->inc('download_count')->update();
|
||||||
|
Db::name('appmall_framework_download_log')->insert([
|
||||||
|
'version' => $row['version'],
|
||||||
|
'uid' => (int) Request::param('uid', 0),
|
||||||
|
'ip' => Request::ip(),
|
||||||
|
'create_at' => time(),
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
$downloadName = 'ywxapp-' . $row['version']
|
||||||
|
. ($wantInstall ? '-install' : ($wantPatch ? '-patch' : '')) . '.zip';
|
||||||
|
|
||||||
|
// 流式下载响应(复用公共类):避免 ThinkPHP download() 内部 file_get_contents 将整文件读入内存,
|
||||||
|
// 改用分块 fread 输出并支持 Range 续传;类内部已 set_time_limit(0)。
|
||||||
|
return new \ywxapp\library\StreamZipResponse($path, $downloadName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行时自愈建表:保证 appmall_framework_download_log 存在(早期安装的中心站可能缺失)。
|
||||||
|
*/
|
||||||
|
private function ensureTables(): void
|
||||||
|
{
|
||||||
|
$prefix = \ywxapp\model\BaseModel::currentPrefix();
|
||||||
|
$tables = [
|
||||||
|
'appmall_framework_download_log' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_framework_download_log` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`version` varchar(20) NOT NULL COMMENT '下载的版本号',
|
||||||
|
`uid` int DEFAULT 0 COMMENT '下载用户ID(来自客户端)',
|
||||||
|
`ip` varchar(45) DEFAULT '' COMMENT '下载来源IP',
|
||||||
|
`create_at` int DEFAULT 0 COMMENT '下载时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_version` (`version`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='主框架下载日志表';",
|
||||||
|
];
|
||||||
|
foreach ($tables as $t => $sql) {
|
||||||
|
try {
|
||||||
|
if (empty(Db::query("SHOW TABLES LIKE '{$prefix}{$t}'"))) {
|
||||||
|
Db::execute($sql);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// 忽略(如权限不足),由后续业务报错暴露
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载签名校验
|
||||||
|
*/
|
||||||
|
private function verifySign(string $version, int $ts, string $sign): bool
|
||||||
|
{
|
||||||
|
if ($sign === '' || abs(time() - $ts) > 300) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$secret = Config::get('ywxapp.addon_secret', 'ywxapp-addon-secret-change-me');
|
||||||
|
$expect = md5('framework' . $version . $ts . $secret);
|
||||||
|
return hash_equals($expect, $sign);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\appmall\controller\backend;
|
||||||
|
|
||||||
|
use think\facade\Db;
|
||||||
|
use think\facade\View;
|
||||||
|
use Exception;
|
||||||
|
use ywxapp\controller\BackendBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件审核(运营后台,中心站)
|
||||||
|
*
|
||||||
|
* 审核开发者通过 Market::submit 提交的插件上架申请(appmall_addon_submissions)。
|
||||||
|
* status: 0=待审核 1=已发布 2=已驳回。
|
||||||
|
*/
|
||||||
|
class AddonReview extends BackendBase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 审核列表 / 页面
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
if ($this->request->isAjax()) {
|
||||||
|
$status = $this->request->param('status', '');
|
||||||
|
$query = Db::name('appmall_addon_submissions')->order('id', 'desc');
|
||||||
|
if ($status !== '') {
|
||||||
|
$query->where('status', (int) $status);
|
||||||
|
}
|
||||||
|
$list = $query->paginate([
|
||||||
|
'page' => (int) $this->request->param('page', 1),
|
||||||
|
'list_rows' => (int) $this->request->param('limit', 10),
|
||||||
|
]);
|
||||||
|
$this->result->setCount($list->total())->success($list->items(), '获取成功');
|
||||||
|
}
|
||||||
|
return View::fetch('addonreview/index');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核通过:标记已发布(status=1)
|
||||||
|
* 注意:正式上架到 appmall_addon_list 由发布流程处理,此处仅切换审核态。
|
||||||
|
*/
|
||||||
|
public function audit()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$id = (int) input('id');
|
||||||
|
$row = Db::name('appmall_addon_submissions')->where('id', $id)->find();
|
||||||
|
if (empty($row)) {
|
||||||
|
return $this->result->error('提交记录不存在');
|
||||||
|
}
|
||||||
|
if ((int) $row['status'] === 1) {
|
||||||
|
return $this->result->error('该提交已发布');
|
||||||
|
}
|
||||||
|
Db::name('appmall_addon_submissions')->where('id', $id)
|
||||||
|
->update(['status' => 1, 'reject_reason' => '', 'update_at' => time()]);
|
||||||
|
return $this->result->success([], '已通过审核');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return $this->result->error($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驳回:标记已驳回(status=2)并记录原因
|
||||||
|
*/
|
||||||
|
public function reject()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$id = (int) input('id');
|
||||||
|
$reason = trim(input('reason', ''));
|
||||||
|
if ($reason === '') {
|
||||||
|
return $this->result->error('请填写驳回原因');
|
||||||
|
}
|
||||||
|
$row = Db::name('appmall_addon_submissions')->where('id', $id)->find();
|
||||||
|
if (empty($row)) {
|
||||||
|
return $this->result->error('提交记录不存在');
|
||||||
|
}
|
||||||
|
Db::name('appmall_addon_submissions')->where('id', $id)
|
||||||
|
->update(['status' => 2, 'reject_reason' => $reason, 'update_at' => time()]);
|
||||||
|
return $this->result->success([], '已驳回');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return $this->result->error($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\appmall\controller\backend;
|
||||||
|
|
||||||
|
use think\facade\Db;
|
||||||
|
use think\facade\View;
|
||||||
|
use Exception;
|
||||||
|
use ywxapp\controller\BackendBase;
|
||||||
|
use ywxapp\library\Mailer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发者管理(运营后台,含审核)
|
||||||
|
* 由原核心 app/backend/controller/Developer.php 剥离到 market 插件。
|
||||||
|
*/
|
||||||
|
class Developer extends BackendBase
|
||||||
|
{
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
if ($this->request->isAjax()) {
|
||||||
|
$status = $this->request->param('status', '');
|
||||||
|
$query = Db::name('appmall_developers');
|
||||||
|
if ($status !== '') {
|
||||||
|
$query->where('status', (int) $status);
|
||||||
|
}
|
||||||
|
$list = $query->order('status', 'asc')
|
||||||
|
->order('id', 'desc')
|
||||||
|
->paginate([
|
||||||
|
'page' => (int) $this->request->param('page', 1),
|
||||||
|
'list_rows' => (int) $this->request->param('limit', 10),
|
||||||
|
]);
|
||||||
|
$this->result->setCount($list->total())->success($list->items(), '获取成功');
|
||||||
|
}
|
||||||
|
return View::fetch('developer/index');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核通过:激活并邮件下发令牌
|
||||||
|
*/
|
||||||
|
public function approve()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$id = (int) input('id');
|
||||||
|
$dev = Db::name('appmall_developers')->where('id', $id)->find();
|
||||||
|
if (empty($dev)) {
|
||||||
|
return $this->result->error('记录不存在');
|
||||||
|
}
|
||||||
|
Db::name('appmall_developers')->where('id', $id)->update(['status' => 1, 'update_at' => time()]);
|
||||||
|
Mailer::send($dev['email'], '开发者申请已通过', $this->tokenMail($dev['username'], $dev['token']));
|
||||||
|
return $this->result->success([], '已通过并发送令牌邮件');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return $this->result->error($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁用
|
||||||
|
*/
|
||||||
|
public function disable()
|
||||||
|
{
|
||||||
|
$id = (int) input('id');
|
||||||
|
Db::name('appmall_developers')->where('id', $id)->update(['status' => -1, 'update_at' => time()]);
|
||||||
|
return $this->result->success([], '已禁用');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新发送令牌邮件(仅已激活)
|
||||||
|
*/
|
||||||
|
public function resend()
|
||||||
|
{
|
||||||
|
$id = (int) input('id');
|
||||||
|
$dev = Db::name('appmall_developers')->where('id', $id)->find();
|
||||||
|
if (empty($dev)) {
|
||||||
|
return $this->result->error('记录不存在');
|
||||||
|
}
|
||||||
|
if ((int) $dev['status'] !== 1) {
|
||||||
|
return $this->result->error('仅已激活账号可重发');
|
||||||
|
}
|
||||||
|
Mailer::send($dev['email'], '您的开发者令牌', $this->tokenMail($dev['username'], $dev['token']));
|
||||||
|
return $this->result->success([], '已重发令牌邮件');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置令牌(已激活则同步邮件通知)
|
||||||
|
*/
|
||||||
|
public function reset()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$id = (int) input('id');
|
||||||
|
$dev = Db::name('appmall_developers')->where('id', $id)->find();
|
||||||
|
if (empty($dev)) {
|
||||||
|
return $this->result->error('记录不存在');
|
||||||
|
}
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
Db::name('appmall_developers')->where('id', $id)->update(['token' => $token, 'update_at' => time()]);
|
||||||
|
if ((int) $dev['status'] === 1) {
|
||||||
|
Mailer::send($dev['email'], '您的开发者令牌已重置', $this->tokenMail($dev['username'], $token));
|
||||||
|
}
|
||||||
|
return $this->result->success(['token' => $token], '已重置令牌' . ((int) $dev['status'] === 1 ? '并邮件通知' : ''));
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return $this->result->error($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private function tokenMail($name, $token): string
|
||||||
|
{
|
||||||
|
return "尊敬的 {$name}:<br>您的开发者令牌如下:<br><br>"
|
||||||
|
. "<code style=\"background:#f4f4f4;padding:4px 8px;border-radius:4px;\">{$token}</code><br><br>"
|
||||||
|
. '请将其配置到开发者控制台的 <b>APPMARKET_DEV_TOKEN</b> 环境变量。';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\appmall\controller\backend;
|
||||||
|
|
||||||
|
use think\facade\Db;
|
||||||
|
use think\facade\View;
|
||||||
|
use Exception;
|
||||||
|
use ywxapp\controller\BackendBase;
|
||||||
|
use ywxapp\library\StreamZipResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主框架版本管理(运营后台,中心站)
|
||||||
|
*
|
||||||
|
* 上传核心包 zip → 入库(草稿)→ 发布(status=1)→ 客户端可见可下载。
|
||||||
|
* 原 upgrade 插件功能,已合并进 market(应用服务中心)。
|
||||||
|
*/
|
||||||
|
class Framework extends BackendBase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 版本列表 / 页面
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
if ($this->request->isAjax()) {
|
||||||
|
$this->ensurePatchColumns();
|
||||||
|
$status = $this->request->param('status', '');
|
||||||
|
$query = Db::name('appmall_framework_list');
|
||||||
|
if ($status !== '') {
|
||||||
|
$query->where('status', (int) $status);
|
||||||
|
}
|
||||||
|
$list = $query->order('id', 'desc')
|
||||||
|
->paginate([
|
||||||
|
'page' => (int) $this->request->param('page', 1),
|
||||||
|
'list_rows' => (int) $this->request->param('limit', 10),
|
||||||
|
]);
|
||||||
|
$this->result->setCount($list->total())->success($list->items(), '获取成功');
|
||||||
|
}
|
||||||
|
return View::fetch('framework/index');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传并登记一个主框架核心包(zip),默认草稿态
|
||||||
|
*
|
||||||
|
* 同一版本号对应一行记录:整包(type=0)写 file_path/file_hash,
|
||||||
|
* 增量补丁(type=1)写 patch_path/patch_hash/patch_from,二者可先后上传共存。
|
||||||
|
*/
|
||||||
|
public function upload()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->ensurePatchColumns();
|
||||||
|
$version = trim(input('version', ''));
|
||||||
|
$title = trim(input('title', ''));
|
||||||
|
$changelog = input('changelog', '');
|
||||||
|
$type = (int) input('type', 0); // 0=整包 1=补丁 2=完整安装包
|
||||||
|
$fromVer = trim(input('from_version', ''));
|
||||||
|
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
|
||||||
|
return $this->result->error('版本号格式不正确(需 x.y.z)');
|
||||||
|
}
|
||||||
|
if ($type === 1 && $fromVer === '') {
|
||||||
|
return $this->result->error('增量补丁必须填写「基础版本」');
|
||||||
|
}
|
||||||
|
if ($type === 1 && !preg_match('/^\d+\.\d+\.\d+$/', $fromVer)) {
|
||||||
|
return $this->result->error('基础版本号格式不正确(需 x.y.z)');
|
||||||
|
}
|
||||||
|
$file = $this->request->file('file');
|
||||||
|
if (empty($file)) {
|
||||||
|
return $this->result->error('请上传核心包 zip');
|
||||||
|
}
|
||||||
|
if (strtolower(substr($file->getOriginalName(), -4)) !== '.zip') {
|
||||||
|
return $this->result->error('仅支持 zip 格式');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 整包需校验 ywxapp 核心结构;补丁只校验为合法 zip(含变更文件即可)
|
||||||
|
$tmp = $file->getRealPath();
|
||||||
|
$zip = new \ZipArchive();
|
||||||
|
if ($zip->open($tmp) !== true) {
|
||||||
|
return $this->result->error('zip 包无法打开');
|
||||||
|
}
|
||||||
|
if ($type === 0) {
|
||||||
|
$hasCore = false;
|
||||||
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$nm = $zip->statIndex($i)['name'];
|
||||||
|
if (preg_match('#^(ywxapp/)?(service|controller|library|traits)/#', $nm)) {
|
||||||
|
$hasCore = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
if (!$hasCore) {
|
||||||
|
return $this->result->error('核心包结构不正确:应包含 ywxapp 核心目录(service/controller/...)');
|
||||||
|
}
|
||||||
|
} elseif ($type === 2) {
|
||||||
|
// 完整安装包:必须含 config/ 与占位符 .env,且不得夹带 runtime/vendor/data 或真实 .env.*
|
||||||
|
$hasConfig = false;
|
||||||
|
$hasEnv = false;
|
||||||
|
$bad = false;
|
||||||
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$nm = $zip->statIndex($i)['name'];
|
||||||
|
if ($nm === 'config' || strpos($nm, 'config/') === 0) {
|
||||||
|
$hasConfig = true;
|
||||||
|
}
|
||||||
|
if ($nm === '.env') {
|
||||||
|
$hasEnv = true;
|
||||||
|
}
|
||||||
|
if (preg_match('#^(runtime/|vendor/|data/|\.env\.)#', $nm)) {
|
||||||
|
$bad = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
if ($bad) {
|
||||||
|
return $this->result->error('安装包结构不正确:不应包含 runtime/vendor/data 或真实 .env.* 文件');
|
||||||
|
}
|
||||||
|
if (!$hasConfig || !$hasEnv) {
|
||||||
|
return $this->result->error('安装包结构不正确:必须包含 config/ 目录与 .env 占位符');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$zip->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'framework' . DIRECTORY_SEPARATOR;
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
$saveName = 'ywxapp-' . $version . ($type === 1 ? '-patch' : ($type === 2 ? '-install' : '')) . '.zip';
|
||||||
|
$file->move($dir, $saveName);
|
||||||
|
$absPath = realpath($dir . $saveName);
|
||||||
|
if (!$absPath || !is_file($absPath)) {
|
||||||
|
return $this->result->error('核心包保存失败');
|
||||||
|
}
|
||||||
|
$hash = hash_file('sha256', $absPath);
|
||||||
|
|
||||||
|
$exist = Db::name('appmall_framework_list')->where('version', $version)->find();
|
||||||
|
// 公共字段:标题/日志仅在填写时覆盖,避免后传的包把先前信息清空
|
||||||
|
$data = [
|
||||||
|
'version' => $version,
|
||||||
|
'status' => 0, // 重新上传任一包均回到草稿,需再次发布
|
||||||
|
'update_at' => time(),
|
||||||
|
];
|
||||||
|
if ($title !== '' || !$exist) {
|
||||||
|
$data['title'] = $title ?: ('主框架 ' . $version);
|
||||||
|
}
|
||||||
|
if (trim((string) $changelog) !== '' || !$exist) {
|
||||||
|
$data['changelog'] = $changelog;
|
||||||
|
}
|
||||||
|
if ($type === 1) {
|
||||||
|
// 增量补丁:只更新 patch 字段,不动整包
|
||||||
|
$data['patch_path'] = $absPath;
|
||||||
|
$data['patch_hash'] = $hash;
|
||||||
|
$data['patch_from'] = $fromVer;
|
||||||
|
$data['from_version'] = $fromVer; // 兼容旧客户端镜像
|
||||||
|
} elseif ($type === 2) {
|
||||||
|
// 完整安装包:只更新 install 字段,与整包/补丁独立共存
|
||||||
|
$data['install_path'] = $absPath;
|
||||||
|
$data['install_hash'] = $hash;
|
||||||
|
} else {
|
||||||
|
// 整包:只更新整包字段,不动补丁
|
||||||
|
$data['file_path'] = $absPath;
|
||||||
|
$data['file_hash'] = $hash;
|
||||||
|
}
|
||||||
|
if ($exist) {
|
||||||
|
Db::name('appmall_framework_list')->where('id', $exist['id'])->update($data);
|
||||||
|
$id = $exist['id'];
|
||||||
|
// type 兼容标志:有整包即 0,仅补丁为 1
|
||||||
|
$hasFull = ($type === 0) || !empty($exist['file_path']);
|
||||||
|
Db::name('appmall_framework_list')->where('id', $id)->update(['type' => $hasFull ? 0 : 1]);
|
||||||
|
} else {
|
||||||
|
$data['type'] = $type;
|
||||||
|
$data['create_at'] = time();
|
||||||
|
$id = Db::name('appmall_framework_list')->insertGetId($data);
|
||||||
|
}
|
||||||
|
$tip = $type === 1 ? '增量补丁已上传' : ($type === 2 ? '完整安装包已上传' : '整包已上传');
|
||||||
|
return $this->result->success(['id' => $id], $tip . ',请点击「发布」使其对外可下载');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return $this->result->error($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布:将某版本置为已发布(status=1),客户端即可检测到
|
||||||
|
*/
|
||||||
|
public function publish()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$id = (int) input('id');
|
||||||
|
if ($id <= 0) {
|
||||||
|
return $this->result->error('缺少版本ID');
|
||||||
|
}
|
||||||
|
$row = Db::name('appmall_framework_list')->where('id', $id)->find();
|
||||||
|
if (empty($row)) {
|
||||||
|
return $this->result->error('版本不存在');
|
||||||
|
}
|
||||||
|
if (empty($row['file_path']) && empty($row['patch_path'])) {
|
||||||
|
return $this->result->error('该版本尚未上传任何包,无法发布');
|
||||||
|
}
|
||||||
|
Db::name('appmall_framework_list')->where('id', $id)->update(['status' => 1, 'update_at' => time()]);
|
||||||
|
$tip = empty($row['file_path'])
|
||||||
|
? (empty($row['patch_path'])
|
||||||
|
? (empty($row['install_path']) ? '已发布' : '已发布(完整安装包,可供全新部署下载)')
|
||||||
|
: '已发布(仅增量补丁:只有停在 ' . ($row['patch_from'] ?: $row['from_version']) . ' 的客户端可升级,建议补传整包)')
|
||||||
|
: (empty($row['patch_path']) ? '已发布(整包)' : '已发布(整包 + 增量补丁)');
|
||||||
|
return $this->result->success([], $tip);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return $this->result->error($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除版本(同时删除整包与补丁物理文件)
|
||||||
|
*/
|
||||||
|
public function delete()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$id = (int) input('id');
|
||||||
|
if ($id <= 0) {
|
||||||
|
return $this->result->error('缺少版本ID');
|
||||||
|
}
|
||||||
|
$row = Db::name('appmall_framework_list')->where('id', $id)->find();
|
||||||
|
foreach (['file_path', 'patch_path', 'install_path'] as $col) {
|
||||||
|
if ($row && !empty($row[$col]) && is_file($row[$col])) {
|
||||||
|
@unlink($row[$col]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Db::name('appmall_framework_list')->where('id', $id)->delete();
|
||||||
|
return $this->result->success([], '已删除');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return $this->result->error($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载核心包(供运营本地校验)
|
||||||
|
* ?type=patch 下载增量补丁,默认整包
|
||||||
|
*/
|
||||||
|
public function download()
|
||||||
|
{
|
||||||
|
$id = (int) input('id');
|
||||||
|
$kind = input('type', 'full');
|
||||||
|
$kind = in_array($kind, ['patch', 'install'], true) ? $kind : 'full';
|
||||||
|
$row = Db::name('appmall_framework_list')->where('id', $id)->find();
|
||||||
|
if ($kind === 'patch') {
|
||||||
|
$path = $row['patch_path'] ?? '';
|
||||||
|
$suffix = '-patch';
|
||||||
|
} elseif ($kind === 'install') {
|
||||||
|
$path = $row['install_path'] ?? '';
|
||||||
|
$suffix = '-install';
|
||||||
|
} else {
|
||||||
|
$path = $row['file_path'] ?? '';
|
||||||
|
$suffix = '';
|
||||||
|
}
|
||||||
|
if (empty($row) || empty($path) || !is_file($path)) {
|
||||||
|
return json(['code' => 0, 'msg' => '文件不存在']);
|
||||||
|
}
|
||||||
|
return new StreamZipResponse($path, 'ywxapp-' . $row['version'] . $suffix . '.zip');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兼容已安装库:在线补齐 patch_path / patch_hash / patch_from 列
|
||||||
|
*/
|
||||||
|
private function ensurePatchColumns(): void
|
||||||
|
{
|
||||||
|
$table = \ywxapp\model\BaseModel::currentPrefix() . 'appmall_framework_list';
|
||||||
|
try {
|
||||||
|
$defs = [
|
||||||
|
'patch_path' => "varchar(255) DEFAULT '' COMMENT '增量补丁 zip 物理路径'",
|
||||||
|
'patch_hash' => "varchar(64) DEFAULT '' COMMENT '补丁 SHA256 校验值'",
|
||||||
|
'patch_from' => "varchar(20) DEFAULT '' COMMENT '补丁适用的基础版本'",
|
||||||
|
'install_path' => "varchar(255) DEFAULT '' COMMENT '完整安装包 zip 物理路径'",
|
||||||
|
'install_hash' => "varchar(64) DEFAULT '' COMMENT '完整安装包 SHA256 校验值'",
|
||||||
|
];
|
||||||
|
foreach ($defs as $col => $def) {
|
||||||
|
$cols = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$col}'");
|
||||||
|
if (empty($cols)) {
|
||||||
|
Db::execute("ALTER TABLE `{$table}` ADD COLUMN `{$col}` {$def}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 历史数据迁移:旧「补丁行」(type=1 且 patch_path 为空) 把整包字段挪到补丁字段
|
||||||
|
Db::execute(
|
||||||
|
"UPDATE `{$table}` SET patch_path = file_path, patch_hash = file_hash, patch_from = from_version,"
|
||||||
|
. " file_path = '', file_hash = ''"
|
||||||
|
. " WHERE type = 1 AND (patch_path = '' OR patch_path IS NULL) AND file_path <> ''"
|
||||||
|
);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\appmall\controller\backend;
|
||||||
|
|
||||||
|
use think\facade\Db;
|
||||||
|
use think\facade\View;
|
||||||
|
use Exception;
|
||||||
|
use ywxapp\controller\BackendBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收益与结算管理(运营后台,中心站)
|
||||||
|
*
|
||||||
|
* - 收益账本:每笔已支付订单按平台抽成记账,归属对应开发者;
|
||||||
|
* - 提现单:开发者发起(或运营代发)后在此审核打款/驳回。
|
||||||
|
*/
|
||||||
|
class Revenue extends BackendBase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 收益账本列表
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
if ($this->request->isAjax()) {
|
||||||
|
$devId = $this->request->param('developer_id', '');
|
||||||
|
$query = Db::name('appmall_addon_revenues')->order('id', 'desc');
|
||||||
|
if ($devId !== '') {
|
||||||
|
$query->where('developer_id', (int) $devId);
|
||||||
|
}
|
||||||
|
$list = $query->paginate([
|
||||||
|
'page' => (int) $this->request->param('page', 1),
|
||||||
|
'list_rows' => (int) $this->request->param('limit', 10),
|
||||||
|
]);
|
||||||
|
// 汇总:总销售额 / 总抽成 / 总开发者收益
|
||||||
|
$summary = Db::name('appmall_addon_revenues')
|
||||||
|
->field('SUM(amount) as total_amount, SUM(commission) as total_commission, SUM(income) as total_income')
|
||||||
|
->find();
|
||||||
|
$this->result->setCount($list->total())->success([
|
||||||
|
'list' => $list->items(),
|
||||||
|
'summary' => [
|
||||||
|
'total_amount' => $summary['total_amount'] ?? 0,
|
||||||
|
'total_commission' => $summary['total_commission'] ?? 0,
|
||||||
|
'total_income' => $summary['total_income'] ?? 0,
|
||||||
|
],
|
||||||
|
], '获取成功');
|
||||||
|
}
|
||||||
|
return View::fetch('revenue/index');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提现单列表
|
||||||
|
*/
|
||||||
|
public function withdrawals()
|
||||||
|
{
|
||||||
|
if ($this->request->isAjax()) {
|
||||||
|
$status = $this->request->param('status', '');
|
||||||
|
$query = Db::name('appmall_addon_withdrawals')->order('id', 'desc');
|
||||||
|
if ($status !== '') {
|
||||||
|
$query->where('status', (int) $status);
|
||||||
|
}
|
||||||
|
$list = $query->paginate([
|
||||||
|
'page' => (int) $this->request->param('page', 1),
|
||||||
|
'list_rows' => (int) $this->request->param('limit', 10),
|
||||||
|
]);
|
||||||
|
$this->result->setCount($list->total())->success($list->items(), '获取成功');
|
||||||
|
}
|
||||||
|
return View::fetch('revenue/withdrawal');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核通过:标记已打款,释放冻结金额,并将对应收益置为已结算
|
||||||
|
*/
|
||||||
|
public function settle()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$id = (int) input('id');
|
||||||
|
$w = Db::name('appmall_addon_withdrawals')->where('id', $id)->find();
|
||||||
|
if (empty($w)) {
|
||||||
|
return $this->result->error('提现单不存在');
|
||||||
|
}
|
||||||
|
if ((int) $w['status'] !== 0) {
|
||||||
|
return $this->result->error('该单已处理');
|
||||||
|
}
|
||||||
|
Db::transaction(function () use ($w) {
|
||||||
|
// 释放冻结(实际打款为线下/对接支付,此处仅结算记账)
|
||||||
|
Db::name('appmall_developers')->where('id', $w['developer_id'])
|
||||||
|
->dec('frozen_balance', $w['amount'])->update();
|
||||||
|
Db::name('appmall_addon_withdrawals')->where('id', $w['id'])
|
||||||
|
->update(['status' => 1, 'update_at' => time()]);
|
||||||
|
// 同步将该开发者待结算收益置为已结算
|
||||||
|
Db::name('appmall_addon_revenues')->where('developer_id', $w['developer_id'])
|
||||||
|
->where('status', 0)
|
||||||
|
->update(['status' => 1, 'settle_at' => time()]);
|
||||||
|
});
|
||||||
|
return $this->result->success([], '已结算打款');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return $this->result->error($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驳回提现:冻结金额退回开发者可提现余额
|
||||||
|
*/
|
||||||
|
public function rejectWithdrawal()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$id = (int) input('id');
|
||||||
|
$reason = input('reason', '');
|
||||||
|
$w = Db::name('appmall_addon_withdrawals')->where('id', $id)->find();
|
||||||
|
if (empty($w)) {
|
||||||
|
return $this->result->error('提现单不存在');
|
||||||
|
}
|
||||||
|
if ((int) $w['status'] !== 0) {
|
||||||
|
return $this->result->error('该单已处理');
|
||||||
|
}
|
||||||
|
Db::transaction(function () use ($w, $reason) {
|
||||||
|
Db::name('appmall_developers')->where('id', $w['developer_id'])
|
||||||
|
->dec('frozen_balance', $w['amount'])
|
||||||
|
->inc('balance', $w['amount'])->update();
|
||||||
|
Db::name('appmall_addon_withdrawals')->where('id', $w['id'])
|
||||||
|
->update(['status' => -1, 'remark' => $reason, 'update_at' => time()]);
|
||||||
|
});
|
||||||
|
return $this->result->success([], '已驳回,金额已退回');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return $this->result->error($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => 'appmall',
|
||||||
|
'title' => '插件应用市场(服务端)',
|
||||||
|
'intro' => '应用服务中心(插件商店 + 主框架在线升级)服务端:插件市场列表 / 下载 / 授权校验 / 开发者提交与审核,以及主框架核心包版本发布与升级。部署在「中心站」,客户端通过 config ywxapp.api_url 连接。',
|
||||||
|
'author' => 'YwxApp',
|
||||||
|
'website' => 'https://www.ywxapp.cn',
|
||||||
|
'version' => '1.0.19',
|
||||||
|
'state' => 1,
|
||||||
|
'license' => false,
|
||||||
|
'events' => [
|
||||||
|
],
|
||||||
|
'middleware' => [
|
||||||
|
],
|
||||||
|
'services' => [
|
||||||
|
],
|
||||||
|
'install_time' => 1785173040,
|
||||||
|
];
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => 'appmall',
|
||||||
|
'title' => '插件应用市场(服务端)',
|
||||||
|
'intro' => '应用服务中心(插件商店 + 主框架在线升级)服务端:插件市场列表 / 下载 / 授权校验 / 开发者提交与审核,以及主框架核心包版本发布与升级。部署在「中心站」,客户端通过 config ywxapp.api_url 连接。',
|
||||||
|
'author' => 'YwxApp',
|
||||||
|
'website' => 'https://www.ywxapp.cn',
|
||||||
|
'version' => '1.0.19',
|
||||||
|
'state' => 1,
|
||||||
|
'license' => false,
|
||||||
|
'events' => [
|
||||||
|
],
|
||||||
|
'middleware' => [
|
||||||
|
],
|
||||||
|
'services' => [
|
||||||
|
],
|
||||||
|
'install_time' => 1785173040,
|
||||||
|
];
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 应用市场(appmall)插件安装表结构
|
||||||
|
-- 事实源:addon/appmall/library/AppmallSchema.php::ensure()
|
||||||
|
-- 与运行时自愈 DDL 一致;扩展列已合并进建表,全新安装即完整。
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `appmall_addon_orders` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`uid` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`aid` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_addon_list.id',
|
||||||
|
`site_id` int unsigned NOT NULL DEFAULT 0 COMMENT '下单站点',
|
||||||
|
`amount` decimal(10,2) NOT NULL DEFAULT 0,
|
||||||
|
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0=待支付 1=已支付 2=已退款',
|
||||||
|
`trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '商户订单号',
|
||||||
|
`pay_time` int unsigned DEFAULT 0,
|
||||||
|
`create_at` int unsigned DEFAULT 0,
|
||||||
|
`update_at` int unsigned DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_uid` (`uid`),
|
||||||
|
KEY `idx_trade` (`trade_no`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件购买订单表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `appmall_addon_licenses` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`uid` int unsigned NOT NULL,
|
||||||
|
`aid` int unsigned NOT NULL,
|
||||||
|
`license_key` varchar(64) NOT NULL COMMENT '授权码',
|
||||||
|
`site_id` int unsigned NOT NULL DEFAULT 0 COMMENT '绑定站点',
|
||||||
|
`domain` varchar(255) DEFAULT NULL COMMENT '绑定域名(站点授权)',
|
||||||
|
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1=有效 0=已吊销',
|
||||||
|
`expire_time` int unsigned DEFAULT 0 COMMENT '0为永久',
|
||||||
|
`download_count` int DEFAULT 0 COMMENT '下载次数限制',
|
||||||
|
`create_at` int DEFAULT 0,
|
||||||
|
`update_at` int DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `license_key` (`license_key`),
|
||||||
|
UNIQUE KEY `uk_uid_aid` (`uid`, `aid`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件授权表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `appmall_addon_download_logs` (
|
||||||
|
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`license_id` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`uid` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`aid` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '运营后台安装者(特权放行审计)',
|
||||||
|
`ip` varchar(45) DEFAULT '',
|
||||||
|
`create_at` int unsigned DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_license` (`license_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件下载日志表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `appmall_throttle` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`action` varchar(30) NOT NULL COMMENT '限流动作(buy/notify/download/submit)',
|
||||||
|
`ident` varchar(80) NOT NULL COMMENT '限流标识(uid 或 IP)',
|
||||||
|
`count` int unsigned NOT NULL DEFAULT 0 COMMENT '窗口内已请求次数',
|
||||||
|
`expire_at` int unsigned NOT NULL DEFAULT 0 COMMENT '窗口过期时间戳',
|
||||||
|
`create_at` int unsigned DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_action_ident` (`action`, `ident`),
|
||||||
|
KEY `idx_expire` (`expire_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='接口频率限制表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `appmall_addon_review_log` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`submission_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_addon_submissions.id',
|
||||||
|
`operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '操作管理员ID',
|
||||||
|
`action` varchar(20) NOT NULL COMMENT '操作:submit/approve/reject',
|
||||||
|
`from_status` tinyint NOT NULL DEFAULT 0 COMMENT '变更前状态',
|
||||||
|
`to_status` tinyint NOT NULL DEFAULT 0 COMMENT '变更后状态',
|
||||||
|
`reason` varchar(255) NOT NULL DEFAULT '' COMMENT '审核意见/驳回理由',
|
||||||
|
`create_at` int unsigned NOT NULL DEFAULT 0 COMMENT '操作时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_submission` (`submission_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件审核历史日志(可视化时间线数据源)';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `appmall_developers` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`username` varchar(100) NOT NULL COMMENT '开发者名称',
|
||||||
|
`email` varchar(120) NOT NULL COMMENT '邮箱(接收令牌/通知)',
|
||||||
|
`token` varchar(64) NOT NULL COMMENT '开发者令牌(发布插件时校验)',
|
||||||
|
`status` tinyint DEFAULT 0 COMMENT '0=待审核 1=正常 -1=禁用',
|
||||||
|
`balance` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '可提现余额',
|
||||||
|
`frozen_balance` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '待结算/冻结金额',
|
||||||
|
`create_at` int DEFAULT 0,
|
||||||
|
`update_at` int DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_email` (`email`),
|
||||||
|
UNIQUE KEY `uk_token` (`token`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场开发者表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `appmall_addon_revenues` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`developer_id` int unsigned NOT NULL DEFAULT '0' COMMENT '受益开发者',
|
||||||
|
`order_id` int unsigned NOT NULL DEFAULT '0' COMMENT 'appmall_addon_orders.id',
|
||||||
|
`aid` int unsigned NOT NULL DEFAULT '0' COMMENT 'appmall_addon_list.id',
|
||||||
|
`uid` int unsigned NOT NULL DEFAULT '0' COMMENT '购买用户',
|
||||||
|
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '订单金额',
|
||||||
|
`commission` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '平台抽成',
|
||||||
|
`income` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '开发者应得',
|
||||||
|
`status` tinyint DEFAULT 0 COMMENT '0=待结算 1=已结算',
|
||||||
|
`create_at` int DEFAULT 0,
|
||||||
|
`settle_at` int DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_developer` (`developer_id`),
|
||||||
|
KEY `idx_order` (`order_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件销售收益账本';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `appmall_addon_withdrawals` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`developer_id` int unsigned NOT NULL DEFAULT '0',
|
||||||
|
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '提现金额',
|
||||||
|
`channel` varchar(20) DEFAULT 'alipay' COMMENT '提现渠道',
|
||||||
|
`account` varchar(100) DEFAULT '' COMMENT '收款账号',
|
||||||
|
`status` tinyint DEFAULT 0 COMMENT '0=待处理 1=已打款 -1=驳回',
|
||||||
|
`remark` varchar(255) DEFAULT '',
|
||||||
|
`create_at` int DEFAULT 0,
|
||||||
|
`update_at` int DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_developer` (`developer_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开发者提现单';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `appmall_addon_submissions` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`developer_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_developers.id',
|
||||||
|
`name` varchar(50) NOT NULL COMMENT '插件标识符',
|
||||||
|
`type` varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板',
|
||||||
|
`title` varchar(100) NOT NULL DEFAULT '' COMMENT '名称',
|
||||||
|
`author` varchar(100) DEFAULT '' COMMENT '作者',
|
||||||
|
`description` text COMMENT '简介',
|
||||||
|
`version` varchar(20) NOT NULL COMMENT '版本号',
|
||||||
|
`price` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '价格',
|
||||||
|
`logo` varchar(255) DEFAULT '' COMMENT '封面',
|
||||||
|
`category` varchar(50) DEFAULT '' COMMENT '分类',
|
||||||
|
`tags` varchar(255) DEFAULT '' COMMENT '标签',
|
||||||
|
`file_path` varchar(255) NOT NULL COMMENT '待审包路径',
|
||||||
|
`file_hash` varchar(64) NOT NULL COMMENT 'SHA256',
|
||||||
|
`changelog` text COMMENT '更新日志',
|
||||||
|
`require_framework` varchar(20) DEFAULT '' COMMENT '最低兼容框架版本,如 1.2.0',
|
||||||
|
`status` tinyint DEFAULT 0 COMMENT '0待审 1通过 2驳回',
|
||||||
|
`reject_reason` varchar(255) DEFAULT '' COMMENT '驳回原因',
|
||||||
|
`create_at` int DEFAULT 0,
|
||||||
|
`update_at` int DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_name_ver` (`name`, `version`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件提交审核表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `appmall_addon_list` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` varchar(50) NOT NULL COMMENT '标识符',
|
||||||
|
`type` varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板',
|
||||||
|
`developer_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_developers.id',
|
||||||
|
`title` varchar(100) NOT NULL COMMENT '名称',
|
||||||
|
`version` varchar(20) NOT NULL COMMENT '版本号',
|
||||||
|
`price` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '价格',
|
||||||
|
`author` varchar(100) DEFAULT '' COMMENT '作者',
|
||||||
|
`description` text COMMENT '简介/描述',
|
||||||
|
`logo` varchar(255) DEFAULT '' COMMENT '封面图 URL',
|
||||||
|
`file_path` varchar(255) NOT NULL COMMENT '存储路径(非公开)',
|
||||||
|
`file_hash` varchar(64) NOT NULL COMMENT 'SHA256校验值',
|
||||||
|
`category` varchar(50) DEFAULT '' COMMENT '分类',
|
||||||
|
`tags` varchar(255) DEFAULT '' COMMENT '标签(逗号分隔)',
|
||||||
|
`screenshots` text COMMENT '截图URL(逗号分隔或JSON)',
|
||||||
|
`rating` decimal(2,1) DEFAULT '0.0' COMMENT '评分(0.0~5.0)',
|
||||||
|
`status` tinyint DEFAULT 1 COMMENT '1上架 0下架',
|
||||||
|
`download_count` int DEFAULT 0 COMMENT '下载次数',
|
||||||
|
`changelog` text COMMENT '更新日志',
|
||||||
|
`require_framework` varchar(20) DEFAULT '' COMMENT '最低兼容框架版本,如 1.2.0',
|
||||||
|
`create_at` int DEFAULT 0 COMMENT '创建时间',
|
||||||
|
`update_at` int DEFAULT 0 COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场插件列表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `appmall_sites` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`site_token` varchar(64) NOT NULL DEFAULT '' COMMENT '站点令牌(客户端 install 时生成/上报)',
|
||||||
|
`domain` varchar(255) NOT NULL DEFAULT '' COMMENT '主域名',
|
||||||
|
`owner_uid` int unsigned NOT NULL DEFAULT '0' COMMENT '站点所有者用户ID',
|
||||||
|
`status` tinyint NOT NULL DEFAULT '1' COMMENT '1=正常 0=禁用',
|
||||||
|
`create_at` int DEFAULT 0,
|
||||||
|
`update_at` int DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_domain` (`domain`),
|
||||||
|
KEY `idx_token` (`site_token`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场客户端站点(授权绑定维度)';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `appmall_framework_list` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`version` varchar(20) NOT NULL COMMENT '框架版本号',
|
||||||
|
`title` varchar(100) DEFAULT '' COMMENT '版本标题',
|
||||||
|
`changelog` text COMMENT '更新日志',
|
||||||
|
`file_path` varchar(255) DEFAULT '' COMMENT '整包 zip 物理路径(非 Web 可访问目录)',
|
||||||
|
`file_hash` varchar(64) DEFAULT '' COMMENT '整包 SHA256 校验值',
|
||||||
|
`patch_path` varchar(255) DEFAULT '' COMMENT '增量补丁 zip 物理路径(与整包同版本共存)',
|
||||||
|
`patch_hash` varchar(64) DEFAULT '' COMMENT '补丁 SHA256 校验值',
|
||||||
|
`patch_from` varchar(20) DEFAULT '' COMMENT '补丁适用的基础版本',
|
||||||
|
`type` tinyint DEFAULT 0 COMMENT '兼容旧客户端:0=含整包 1=仅补丁',
|
||||||
|
`from_version` varchar(20) DEFAULT '' COMMENT '兼容旧客户端:补丁基础版本镜像',
|
||||||
|
`status` tinyint DEFAULT 0 COMMENT '0=草稿 1=已发布(可下载)',
|
||||||
|
`download_count` int DEFAULT 0 COMMENT '下载次数',
|
||||||
|
`create_at` int DEFAULT 0 COMMENT '创建时间',
|
||||||
|
`update_at` int DEFAULT 0 COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_version` (`version`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='主框架版本表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `appmall_framework_download_log` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`version` varchar(20) NOT NULL COMMENT '下载的版本号',
|
||||||
|
`uid` int DEFAULT 0 COMMENT '下载用户ID(来自客户端)',
|
||||||
|
`ip` varchar(45) DEFAULT '' COMMENT '下载来源IP',
|
||||||
|
`create_at` int DEFAULT 0 COMMENT '下载时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_version` (`version`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='主框架下载日志表';
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<?php
|
||||||
|
namespace addon\appmall\library;
|
||||||
|
|
||||||
|
use think\facade\Db;
|
||||||
|
use ywxapp\model\BaseModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用市场(appmall)专属表结构自愈。
|
||||||
|
*
|
||||||
|
* 归属原则:appmall_* 表属于 appmall 插件私有表,其建表/补列逻辑内聚在本插件,
|
||||||
|
* 不污染核心框架(核心只在 BaseModel 保留通用自愈引擎)。
|
||||||
|
*
|
||||||
|
* 建表 DDL 为运行时版事实源(与核心 install.sql 口径一致),列兜底幂等。
|
||||||
|
*/
|
||||||
|
class AppmallSchema
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 集中创建 appmall 全部业务表(缺则建),并补齐老库扩展列。
|
||||||
|
*
|
||||||
|
* 中心站 / 客户端所有查询 appmall_* 表的入口(api/Market、backend/AddonReview、
|
||||||
|
* developer/Developer、service/MarketService)都应先调用本方法,避免全新安装后
|
||||||
|
* 走到未自愈的查询路径而触发 1146。
|
||||||
|
*/
|
||||||
|
public static function ensure(): void
|
||||||
|
{
|
||||||
|
$prefix = BaseModel::currentPrefix();
|
||||||
|
$p = $prefix;
|
||||||
|
$tables = [
|
||||||
|
'appmall_addon_orders' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_orders` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`uid` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`aid` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_addon_list.id',
|
||||||
|
`site_id` int unsigned NOT NULL DEFAULT 0 COMMENT '下单站点',
|
||||||
|
`amount` decimal(10,2) NOT NULL DEFAULT 0,
|
||||||
|
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0=待支付 1=已支付 2=已退款',
|
||||||
|
`trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '商户订单号',
|
||||||
|
`pay_time` int unsigned DEFAULT 0,
|
||||||
|
`create_at` int unsigned DEFAULT 0,
|
||||||
|
`update_at` int unsigned DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_uid` (`uid`),
|
||||||
|
KEY `idx_trade` (`trade_no`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件购买订单表';",
|
||||||
|
'appmall_addon_licenses' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_licenses` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`uid` int unsigned NOT NULL,
|
||||||
|
`aid` int unsigned NOT NULL,
|
||||||
|
`license_key` varchar(64) NOT NULL COMMENT '授权码',
|
||||||
|
`site_id` int unsigned NOT NULL DEFAULT 0 COMMENT '绑定站点',
|
||||||
|
`domain` varchar(255) DEFAULT NULL COMMENT '绑定域名(站点授权)',
|
||||||
|
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1=有效 0=已吊销',
|
||||||
|
`expire_time` int unsigned DEFAULT 0 COMMENT '0为永久',
|
||||||
|
`download_count` int DEFAULT 0 COMMENT '下载次数限制',
|
||||||
|
`create_at` int DEFAULT 0,
|
||||||
|
`update_at` int DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `license_key` (`license_key`),
|
||||||
|
UNIQUE KEY `uk_uid_aid` (`uid`, `aid`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件授权表';",
|
||||||
|
'appmall_addon_download_logs' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_download_logs` (
|
||||||
|
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`license_id` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`uid` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`aid` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '运营后台安装者(特权放行审计)',
|
||||||
|
`ip` varchar(45) DEFAULT '',
|
||||||
|
`create_at` int unsigned DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_license` (`license_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件下载日志表';",
|
||||||
|
'appmall_throttle' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_throttle` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`action` varchar(30) NOT NULL COMMENT '限流动作(buy/notify/download/submit)',
|
||||||
|
`ident` varchar(80) NOT NULL COMMENT '限流标识(uid 或 IP)',
|
||||||
|
`count` int unsigned NOT NULL DEFAULT 0 COMMENT '窗口内已请求次数',
|
||||||
|
`expire_at` int unsigned NOT NULL DEFAULT 0 COMMENT '窗口过期时间戳',
|
||||||
|
`create_at` int unsigned DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_action_ident` (`action`, `ident`),
|
||||||
|
KEY `idx_expire` (`expire_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='接口频率限制表';",
|
||||||
|
'appmall_addon_review_log' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_review_log` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`submission_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_addon_submissions.id',
|
||||||
|
`operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '操作管理员ID',
|
||||||
|
`action` varchar(20) NOT NULL COMMENT '操作:submit/approve/reject',
|
||||||
|
`from_status` tinyint NOT NULL DEFAULT 0 COMMENT '变更前状态',
|
||||||
|
`to_status` tinyint NOT NULL DEFAULT 0 COMMENT '变更后状态',
|
||||||
|
`reason` varchar(255) NOT NULL DEFAULT '' COMMENT '审核意见/驳回理由',
|
||||||
|
`create_at` int unsigned NOT NULL DEFAULT 0 COMMENT '操作时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_submission` (`submission_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件审核历史日志(可视化时间线数据源)';",
|
||||||
|
'appmall_developers' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_developers` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`username` varchar(100) NOT NULL COMMENT '开发者名称',
|
||||||
|
`email` varchar(120) NOT NULL COMMENT '邮箱(接收令牌/通知)',
|
||||||
|
`token` varchar(64) NOT NULL COMMENT '开发者令牌(发布插件时校验)',
|
||||||
|
`status` tinyint DEFAULT 0 COMMENT '0=待审核 1=正常 -1=禁用',
|
||||||
|
`balance` decimal(10,2) DEFAULT '0.00' COMMENT '可提现余额',
|
||||||
|
`frozen_balance` decimal(10,2) DEFAULT '0.00' COMMENT '待结算/冻结金额',
|
||||||
|
`create_at` int DEFAULT 0,
|
||||||
|
`update_at` int DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_email` (`email`),
|
||||||
|
UNIQUE KEY `uk_token` (`token`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场开发者表';",
|
||||||
|
'appmall_addon_revenues' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_revenues` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`developer_id` int unsigned NOT NULL DEFAULT '0' COMMENT '受益开发者',
|
||||||
|
`order_id` int unsigned NOT NULL DEFAULT '0' COMMENT 'appmall_addon_orders.id',
|
||||||
|
`aid` int unsigned NOT NULL DEFAULT '0' COMMENT 'appmall_addon_list.id',
|
||||||
|
`uid` int unsigned NOT NULL DEFAULT '0' COMMENT '购买用户',
|
||||||
|
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '订单金额',
|
||||||
|
`commission` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '平台抽成',
|
||||||
|
`income` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '开发者应得',
|
||||||
|
`status` tinyint DEFAULT 0 COMMENT '0=待结算 1=已结算',
|
||||||
|
`create_at` int DEFAULT 0,
|
||||||
|
`settle_at` int DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_developer` (`developer_id`),
|
||||||
|
KEY `idx_order` (`order_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件销售收益账本';",
|
||||||
|
'appmall_addon_withdrawals' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_withdrawals` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`developer_id` int unsigned NOT NULL DEFAULT '0',
|
||||||
|
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '提现金额',
|
||||||
|
`channel` varchar(20) DEFAULT 'alipay' COMMENT '提现渠道',
|
||||||
|
`account` varchar(100) DEFAULT '' COMMENT '收款账号',
|
||||||
|
`status` tinyint DEFAULT 0 COMMENT '0=待处理 1=已打款 -1=驳回',
|
||||||
|
`remark` varchar(255) DEFAULT '',
|
||||||
|
`create_at` int DEFAULT 0,
|
||||||
|
`update_at` int DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_developer` (`developer_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开发者提现单';",
|
||||||
|
'appmall_addon_submissions' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_submissions` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`developer_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_developers.id',
|
||||||
|
`name` varchar(50) NOT NULL COMMENT '插件标识符',
|
||||||
|
`type` varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板',
|
||||||
|
`title` varchar(100) NOT NULL DEFAULT '' COMMENT '名称',
|
||||||
|
`author` varchar(100) DEFAULT '' COMMENT '作者',
|
||||||
|
`description` text COMMENT '简介',
|
||||||
|
`version` varchar(20) NOT NULL COMMENT '版本号',
|
||||||
|
`price` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '价格',
|
||||||
|
`logo` varchar(255) DEFAULT '' COMMENT '封面',
|
||||||
|
`category` varchar(50) DEFAULT '' COMMENT '分类',
|
||||||
|
`tags` varchar(255) DEFAULT '' COMMENT '标签',
|
||||||
|
`file_path` varchar(255) NOT NULL COMMENT '待审包路径',
|
||||||
|
`file_hash` varchar(64) NOT NULL COMMENT 'SHA256',
|
||||||
|
`status` tinyint DEFAULT 0 COMMENT '0待审 1通过 2驳回',
|
||||||
|
`reject_reason` varchar(255) DEFAULT '' COMMENT '驳回原因',
|
||||||
|
`create_at` int DEFAULT 0,
|
||||||
|
`update_at` int DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_name_ver` (`name`, `version`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件提交审核表';",
|
||||||
|
'appmall_addon_list' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_list` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` varchar(50) NOT NULL COMMENT '标识符',
|
||||||
|
`type` varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板',
|
||||||
|
`developer_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_developers.id',
|
||||||
|
`title` varchar(100) NOT NULL COMMENT '名称',
|
||||||
|
`version` varchar(20) NOT NULL COMMENT '版本号',
|
||||||
|
`price` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '价格',
|
||||||
|
`author` varchar(100) DEFAULT '' COMMENT '作者',
|
||||||
|
`description` text COMMENT '简介/描述',
|
||||||
|
`logo` varchar(255) DEFAULT '' COMMENT '封面图 URL',
|
||||||
|
`file_path` varchar(255) NOT NULL COMMENT '存储路径(非公开)',
|
||||||
|
`file_hash` varchar(64) NOT NULL COMMENT 'SHA256校验值',
|
||||||
|
`category` varchar(50) DEFAULT '' COMMENT '分类',
|
||||||
|
`tags` varchar(255) DEFAULT '' COMMENT '标签',
|
||||||
|
`screenshots` text COMMENT '截图URL(逗号分隔或JSON)',
|
||||||
|
`rating` decimal(2,1) DEFAULT '0.0' COMMENT '评分(0.0~5.0)',
|
||||||
|
`status` tinyint DEFAULT 1 COMMENT '1上架 0下架',
|
||||||
|
`download_count` int DEFAULT 0 COMMENT '下载次数',
|
||||||
|
`create_at` int DEFAULT 0 COMMENT '创建时间',
|
||||||
|
`update_at` int DEFAULT 0 COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场插件列表';",
|
||||||
|
'appmall_sites' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_sites` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`site_token` varchar(64) NOT NULL DEFAULT '' COMMENT '站点令牌(客户端 install 时生成/上报)',
|
||||||
|
`domain` varchar(255) NOT NULL DEFAULT '' COMMENT '主域名',
|
||||||
|
`owner_uid` int unsigned NOT NULL DEFAULT '0' COMMENT '站点所有者用户ID',
|
||||||
|
`status` tinyint NOT NULL DEFAULT '1' COMMENT '1=正常 0=禁用',
|
||||||
|
`create_at` int DEFAULT 0,
|
||||||
|
`update_at` int DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_domain` (`domain`),
|
||||||
|
KEY `idx_token` (`site_token`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场客户端站点(授权绑定维度)';",
|
||||||
|
'appmall_framework_list' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_framework_list` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`version` varchar(20) NOT NULL COMMENT '框架版本号',
|
||||||
|
`title` varchar(100) DEFAULT '' COMMENT '版本标题',
|
||||||
|
`changelog` text COMMENT '更新日志',
|
||||||
|
`file_path` varchar(255) DEFAULT '' COMMENT '整包 zip 物理路径(非 Web 可访问目录)',
|
||||||
|
`file_hash` varchar(64) DEFAULT '' COMMENT '整包 SHA256 校验值',
|
||||||
|
`patch_path` varchar(255) DEFAULT '' COMMENT '增量补丁 zip 物理路径(与整包同版本共存)',
|
||||||
|
`patch_hash` varchar(64) DEFAULT '' COMMENT '补丁 SHA256 校验值',
|
||||||
|
`patch_from` varchar(20) DEFAULT '' COMMENT '补丁适用的基础版本',
|
||||||
|
`type` tinyint DEFAULT 0 COMMENT '兼容旧客户端:0=含整包 1=仅补丁',
|
||||||
|
`from_version` varchar(20) DEFAULT '' COMMENT '兼容旧客户端:补丁基础版本镜像',
|
||||||
|
`status` tinyint DEFAULT 0 COMMENT '0=草稿 1=已发布(可下载)',
|
||||||
|
`download_count` int DEFAULT 0 COMMENT '下载次数',
|
||||||
|
`create_at` int DEFAULT 0 COMMENT '创建时间',
|
||||||
|
`update_at` int DEFAULT 0 COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_version` (`version`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='主框架版本表';",
|
||||||
|
'appmall_framework_download_log' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_framework_download_log` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`version` varchar(20) NOT NULL COMMENT '下载的版本号',
|
||||||
|
`uid` int DEFAULT 0 COMMENT '下载用户ID(来自客户端)',
|
||||||
|
`ip` varchar(45) DEFAULT '' COMMENT '下载来源IP',
|
||||||
|
`create_at` int DEFAULT 0 COMMENT '下载时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_version` (`version`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='主框架下载日志表';",
|
||||||
|
];
|
||||||
|
foreach ($tables as $t => $sql) {
|
||||||
|
BaseModel::ensureTable($prefix . $t, $sql);
|
||||||
|
}
|
||||||
|
// 老库扩展列兜底(幂等)
|
||||||
|
$list = $prefix . 'appmall_addon_list';
|
||||||
|
BaseModel::ensureColumn($list, 'category', "varchar(50) DEFAULT '' COMMENT '分类'");
|
||||||
|
BaseModel::ensureColumn($list, 'tags', "varchar(255) DEFAULT '' COMMENT '标签(逗号分隔)'");
|
||||||
|
BaseModel::ensureColumn($list, 'screenshots', "text COMMENT '截图URL(逗号分隔或JSON)'");
|
||||||
|
BaseModel::ensureColumn($list, 'rating', "decimal(2,1) DEFAULT '0.0' COMMENT '评分(0.0~5.0)'");
|
||||||
|
BaseModel::ensureColumn($list, 'changelog', "text COMMENT '更新日志'");
|
||||||
|
BaseModel::ensureColumn($list, 'require_framework', "varchar(20) DEFAULT '' COMMENT '最低兼容框架版本,如 1.2.0'");
|
||||||
|
BaseModel::ensureColumn($list, 'type', "varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板'");
|
||||||
|
$sub = $prefix . 'appmall_addon_submissions';
|
||||||
|
BaseModel::ensureColumn($sub, 'changelog', "text COMMENT '更新日志'");
|
||||||
|
BaseModel::ensureColumn($sub, 'require_framework', "varchar(20) DEFAULT '' COMMENT '最低兼容框架版本,如 1.2.0'");
|
||||||
|
BaseModel::ensureColumn($sub, 'type', "varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板'");
|
||||||
|
BaseModel::ensureColumn($prefix . 'appmall_addon_download_logs', 'operator_id', "int unsigned NOT NULL DEFAULT 0 COMMENT '运营后台安装者(特权放行审计)'");
|
||||||
|
BaseModel::ensureColumn($prefix . 'appmall_developers', 'balance', "decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '可提现余额'");
|
||||||
|
BaseModel::ensureColumn($prefix . 'appmall_developers', 'frozen_balance', "decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '待结算/冻结金额'");
|
||||||
|
BaseModel::ensureColumn($prefix . 'appmall_addon_licenses', 'status', "tinyint NOT NULL DEFAULT '1' COMMENT '1=有效 0=已吊销'");
|
||||||
|
BaseModel::ensureColumn($prefix . 'appmall_addon_licenses', 'site_id', "int unsigned NOT NULL DEFAULT '0' COMMENT '绑定站点'");
|
||||||
|
BaseModel::ensureColumn($prefix . 'appmall_addon_licenses', 'domain', "varchar(255) DEFAULT NULL COMMENT '绑定域名(站点授权)'");
|
||||||
|
BaseModel::ensureColumn($prefix . 'appmall_addon_licenses', 'expire_time', "int unsigned DEFAULT 0 COMMENT '0为永久'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"top_title": "应用中心",
|
||||||
|
"superior": "service_center",
|
||||||
|
"backend": [
|
||||||
|
{
|
||||||
|
"name": "appmall",
|
||||||
|
"title": "应用市场",
|
||||||
|
"icon": "fa fa-shopping-cart",
|
||||||
|
"centerOnly": true,
|
||||||
|
"sublist": [
|
||||||
|
{
|
||||||
|
"name": "addonreview",
|
||||||
|
"title": "插件审核",
|
||||||
|
"route": "/appmall/backend/addonreview/index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "developer",
|
||||||
|
"title": "开发者管理",
|
||||||
|
"route": "/appmall/backend/developer/index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "revenuelist",
|
||||||
|
"title": "收益账本",
|
||||||
|
"route": "/appmall/backend/revenue/index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "withdrawallist",
|
||||||
|
"title": "提现管理",
|
||||||
|
"route": "/appmall/backend/revenue/withdrawals"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "fa fa-cloud-download",
|
||||||
|
"centerOnly": true,
|
||||||
|
"name": "frameworklist",
|
||||||
|
"title": "框架管理",
|
||||||
|
"route": "/appmall/backend/framework/index"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"member": [],
|
||||||
|
"frontend": [
|
||||||
|
{
|
||||||
|
"name": "frameworkdisplay",
|
||||||
|
"title": "框架下载",
|
||||||
|
"route": "/appmall/frontend/framework/index"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\appmall\model;
|
||||||
|
|
||||||
|
use ywxapp\model\BaseModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件下载日志(中心站,风控用)
|
||||||
|
* 表:appmall_addon_download_logs
|
||||||
|
* @mixin \think\Model
|
||||||
|
*/
|
||||||
|
class AddonDownloadLog extends BaseModel
|
||||||
|
{
|
||||||
|
protected function getOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'strict' => true,
|
||||||
|
'name' => 'appmall_addon_download_logs',
|
||||||
|
'autoWriteTimestamp' => 'int',
|
||||||
|
'createTime' => 'create_at',
|
||||||
|
'updateTime' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\appmall\model;
|
||||||
|
|
||||||
|
use ywxapp\model\BaseModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件授权(中心站)
|
||||||
|
* 表:appmall_addon_licenses
|
||||||
|
* @mixin \think\Model
|
||||||
|
*/
|
||||||
|
class AddonLicense extends BaseModel
|
||||||
|
{
|
||||||
|
protected function getOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'strict' => true,
|
||||||
|
'name' => 'appmall_addon_licenses',
|
||||||
|
'autoWriteTimestamp' => 'int',
|
||||||
|
'readonly' => ['uid'],
|
||||||
|
'createTime' => 'create_at',
|
||||||
|
'updateTime' => 'update_at',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联插件信息(核心插件注册表)
|
||||||
|
* @return \think\model\relation\BelongsTo
|
||||||
|
*/
|
||||||
|
public function addon()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\ywxapp\model\AddonModel::class, 'aid', 'id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成安全签名(防链接泄漏)
|
||||||
|
* @param string $licenseKey 授权码
|
||||||
|
* @param int $aid 插件ID
|
||||||
|
* @param int $timestamp 时间戳
|
||||||
|
* @param string $secret 签名密钥(仅作 HMAC key,不要拼进消息)
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function generateSign(string $licenseKey, int $aid, int $timestamp, string $secret): string
|
||||||
|
{
|
||||||
|
$str = $licenseKey . $aid . $timestamp;
|
||||||
|
return hash_hmac('sha256', $str, $secret);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\appmall\model;
|
||||||
|
|
||||||
|
use ywxapp\model\BaseModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件订单(中心站)
|
||||||
|
* 表:appmall_addon_orders
|
||||||
|
* @mixin \think\Model
|
||||||
|
*/
|
||||||
|
class AddonOrder extends BaseModel
|
||||||
|
{
|
||||||
|
protected function getOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'strict' => true,
|
||||||
|
'name' => 'appmall_addon_orders',
|
||||||
|
'autoWriteTimestamp' => 'int',
|
||||||
|
'readonly' => ['uid'],
|
||||||
|
'createTime' => 'create_at',
|
||||||
|
'updateTime' => 'update_at',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\appmall\model;
|
||||||
|
|
||||||
|
use ywxapp\model\BaseModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发者(中心站)
|
||||||
|
* 表:appmall_developers
|
||||||
|
* @mixin \think\Model
|
||||||
|
*/
|
||||||
|
class Developer extends BaseModel
|
||||||
|
{
|
||||||
|
protected function getOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'strict' => true,
|
||||||
|
'name' => 'appmall_developers',
|
||||||
|
'autoWriteTimestamp' => 'int',
|
||||||
|
'createTime' => 'create_at',
|
||||||
|
'updateTime' => 'update_at',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联收益记录
|
||||||
|
* @return \think\model\relation\HasMany
|
||||||
|
*/
|
||||||
|
public function revenues()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Revenue::class, 'developer_id', 'id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联提现单
|
||||||
|
* @return \think\model\relation\HasMany
|
||||||
|
*/
|
||||||
|
public function withdrawals()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Withdrawal::class, 'developer_id', 'id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\appmall\model;
|
||||||
|
|
||||||
|
use ywxapp\model\BaseModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件销售收益账本(中心站)
|
||||||
|
* 表:appmall_addon_revenues(无 update_at,结算时间用 settle_at 业务字段)
|
||||||
|
* @mixin \think\Model
|
||||||
|
*/
|
||||||
|
class Revenue extends BaseModel
|
||||||
|
{
|
||||||
|
protected function getOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'strict' => true,
|
||||||
|
'name' => 'appmall_addon_revenues',
|
||||||
|
'autoWriteTimestamp' => 'int',
|
||||||
|
'createTime' => 'create_at',
|
||||||
|
'updateTime' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联开发者
|
||||||
|
* @return \think\model\relation\BelongsTo
|
||||||
|
*/
|
||||||
|
public function developer()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Developer::class, 'developer_id', 'id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\appmall\model;
|
||||||
|
|
||||||
|
use ywxapp\model\BaseModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发者提现单(中心站)
|
||||||
|
* 表:appmall_addon_withdrawals
|
||||||
|
* @mixin \think\Model
|
||||||
|
*/
|
||||||
|
class Withdrawal extends BaseModel
|
||||||
|
{
|
||||||
|
protected function getOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'strict' => true,
|
||||||
|
'name' => 'appmall_addon_withdrawals',
|
||||||
|
'autoWriteTimestamp' => 'int',
|
||||||
|
'createTime' => 'create_at',
|
||||||
|
'updateTime' => 'update_at',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联开发者
|
||||||
|
* @return \think\model\relation\BelongsTo
|
||||||
|
*/
|
||||||
|
public function developer()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Developer::class, 'developer_id', 'id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @Date: 2026-07-21 13:36:46
|
||||||
|
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @LastEditTime: 2026-08-16 11:31:01
|
||||||
|
* @Description: 应用市场插件统一路由(对外 API + 主应用业务路由)
|
||||||
|
* @FilePath: \ywxapp_dev\addon\appmall\route\app.php
|
||||||
|
* @CustomString: Copyright (c) 2026 YwxApp
|
||||||
|
*/
|
||||||
|
// ============================================================
|
||||||
|
// 应用市场插件「统一路由入口」(替代原散落的 route/api.php)。
|
||||||
|
//
|
||||||
|
// 插件名前缀(/appmall)由外层加载器统一补全,本文件【不要】自行写前缀:
|
||||||
|
// 主应用:ywxapp/service/AppService::loadAddonRoutes() 包 Route::group('appmall')
|
||||||
|
//
|
||||||
|
// 全部路由(业务 + 对外 API)都在主应用 boot 阶段注册,形式为「相对组名」,
|
||||||
|
// 由外层 Route::group('appmall') 补全 → /appmall/backend、/appmall/developer、
|
||||||
|
// /appmall/api(与「插件名开头」约定一致,彻底避免多插件路由冲突)。
|
||||||
|
// 注意:group 前缀是累加的,文件内【不要】再写 /appmall/ 或 /api/ 这样的前缀(会双重前缀)。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
use think\facade\Route;
|
||||||
|
use addon\appmall\controller\api\Market;
|
||||||
|
use addon\appmall\controller\backend\AddonReview;
|
||||||
|
use addon\appmall\controller\backend\Developer;
|
||||||
|
use addon\appmall\controller\backend\Revenue as BackendRevenue;
|
||||||
|
use addon\appmall\controller\Developer as DeveloperCenter;
|
||||||
|
use addon\appmall\controller\backend\Framework;
|
||||||
|
use addon\appmall\controller\api\Framework as ApiFramework;
|
||||||
|
use addon\appmall\controller\Framework as FrontendFramework;
|
||||||
|
use addon\appmall\controller\Store;
|
||||||
|
|
||||||
|
// ===================== 业务路由 + 对外 API(/appmall/*) =====================
|
||||||
|
// 插件名前缀 /appmall 由 AppService::loadAddonRoutes() 的 Route::group('appmall') 补全。
|
||||||
|
|
||||||
|
// ---------- 后台:插件审核 / 开发者管理 / 收益与结算(统一前缀 /appmall/backend) ----------
|
||||||
|
Route::group('backend', function () {
|
||||||
|
// 插件审核
|
||||||
|
Route::get('addonreview', [AddonReview::class, 'index']);
|
||||||
|
Route::get('addonreview/index', [AddonReview::class, 'index']);
|
||||||
|
Route::post('addonreview/approve', [AddonReview::class, 'approve']);
|
||||||
|
Route::post('addonreview/reject', [AddonReview::class, 'reject']);
|
||||||
|
Route::post('addonreview/updatePrice', [AddonReview::class, 'updatePrice']);
|
||||||
|
Route::get('addonreview/download', [AddonReview::class, 'download']);
|
||||||
|
Route::get('addonreview/detail', [AddonReview::class, 'detail']);
|
||||||
|
Route::get('addonreview/detailview', [AddonReview::class, 'detailview']);
|
||||||
|
Route::get('addonreview/history', [AddonReview::class, 'history']);
|
||||||
|
|
||||||
|
// 开发者管理
|
||||||
|
Route::get('developer', [Developer::class, 'index']);
|
||||||
|
Route::get('developer/index', [Developer::class, 'index']);
|
||||||
|
Route::post('developer/approve', [Developer::class, 'approve']);
|
||||||
|
Route::post('developer/disable', [Developer::class, 'disable']);
|
||||||
|
Route::post('developer/resend', [Developer::class, 'resend']);
|
||||||
|
Route::post('developer/reset', [Developer::class, 'reset']);
|
||||||
|
|
||||||
|
// 收益与结算
|
||||||
|
Route::get('revenue', [BackendRevenue::class, 'index']);
|
||||||
|
Route::get('revenue/index', [BackendRevenue::class, 'index']);
|
||||||
|
Route::get('revenue/withdrawals', [BackendRevenue::class, 'withdrawals']);
|
||||||
|
Route::post('revenue/settle', [BackendRevenue::class, 'settle']);
|
||||||
|
Route::post('revenue/reject', [BackendRevenue::class, 'rejectWithdrawal']);
|
||||||
|
|
||||||
|
// 主框架升级(原 upgrade 插件,已合并进 market)
|
||||||
|
Route::get('framework', [Framework::class, 'index']);
|
||||||
|
Route::get('framework/index', [Framework::class, 'index']);
|
||||||
|
Route::post('framework/upload', [Framework::class, 'upload']);
|
||||||
|
Route::post('framework/publish', [Framework::class, 'publish']);
|
||||||
|
Route::post('framework/delete', [Framework::class, 'delete']);
|
||||||
|
Route::get('framework/download', [Framework::class, 'download']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 开发者中心(独立前台应用,公开注册/令牌/收益/提现,非会员中心):统一前缀 /appmall/developer ----------
|
||||||
|
// 仅【中心站】暴露:开发者自助注册/收益/提现依赖 appmall_developers / appmall_addon_revenues / appmall_addon_withdrawals 等
|
||||||
|
// 运营表,客户机(ywxapp.api_url 指向其他服务器,见 is_market_client())作为中心站客户端不持有这些表,故不在路由层注册,
|
||||||
|
// 访问即 404。配合 Developer 控制器 initialize() 的运行时兜底(即便路由缓存陈旧或被直连也能拦截)。
|
||||||
|
// ---------- 开发者中心(独立前台,令牌认证):/appmall/Developer/* ----------
|
||||||
|
Route::group('Developer', function () {
|
||||||
|
Route::get('index', [DeveloperCenter::class, 'index']);
|
||||||
|
Route::post('save', [DeveloperCenter::class, 'save']);
|
||||||
|
Route::get('mine', [DeveloperCenter::class, 'mine']);
|
||||||
|
Route::post('lookup', [DeveloperCenter::class, 'lookup']);
|
||||||
|
Route::get('dashboard', [DeveloperCenter::class, 'dashboard']);
|
||||||
|
Route::get('console', [DeveloperCenter::class, 'console']);
|
||||||
|
Route::post('overview', [DeveloperCenter::class, 'overview']);
|
||||||
|
Route::post('apps', [DeveloperCenter::class, 'apps']);
|
||||||
|
Route::post('price', [DeveloperCenter::class, 'price']);
|
||||||
|
Route::post('earnings', [DeveloperCenter::class, 'earnings']);
|
||||||
|
Route::post('withdraw', [DeveloperCenter::class, 'withdraw']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 对外 API(统一前缀 /appmall/api):远程客户机经 Remote/Addon/FrameworkService 调用 ----------
|
||||||
|
// 由 AppService::loadAddonRoutes() 的 Route::group('appmall') 补全插件名前缀 → /appmall/api/*
|
||||||
|
//(与 /appmall/backend、/appmall/developer 同源同构,均为「插件名开头」约定)。
|
||||||
|
// 调用方契约(勿单侧改动):
|
||||||
|
// RemoteAppmallService → /appmall/api/addon/*、/appmall/api/index
|
||||||
|
// AddonService → /appmall/api/addon/valid、/appmall/api/index/index
|
||||||
|
// FrameworkService → /appmall/api/framework/version、/appmall/api/framework/download
|
||||||
|
Route::group('api', function () {
|
||||||
|
Route::get('addon/lists', [Market::class, 'lists']);
|
||||||
|
Route::get('addon/info', [Market::class, 'info']);
|
||||||
|
Route::post('addon/valid', [Market::class, 'valid']);
|
||||||
|
Route::post('addon/submit', [Market::class, 'submit']);
|
||||||
|
Route::post('addon/buy', [Market::class, 'buy']);
|
||||||
|
Route::get('addon/orderStatus', [Market::class, 'orderStatus']);
|
||||||
|
Route::post('addon/notify', [Market::class, 'notify']);
|
||||||
|
Route::get('addon/payResult', [Market::class, 'payResult']);
|
||||||
|
Route::post('addon/refund', [Market::class, 'refund']);
|
||||||
|
Route::get('addon/my', [Market::class, 'my']);
|
||||||
|
// 插件 zip 下载(RemoteAppmallService 走 /appmall/api/index,AddonService 走 /appmall/api/index/index)
|
||||||
|
Route::get('index', [Market::class, 'index']);
|
||||||
|
Route::get('index/index', [Market::class, 'index']);
|
||||||
|
// 主框架升级分发(FrameworkService 走 /appmall/api/framework/*)
|
||||||
|
Route::get('framework/version', [ApiFramework::class, 'version']);
|
||||||
|
Route::get('framework/download', [ApiFramework::class, 'download']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 前台展示(公开,仅中心站):主框架发布/下载页 + 应用商店 ----------
|
||||||
|
// 前台控制器直接置于 addon\appmall\controller 下(如 ForumFrontend 惯例),
|
||||||
|
// 不套 frontend 子目录;URL 前缀为 /appmall/framework、/appmall/store。
|
||||||
|
Route::get('framework', [FrontendFramework::class, 'index']);
|
||||||
|
Route::get('framework/index', [FrontendFramework::class, 'index']);
|
||||||
|
// 应用商店:上线插件列表(/appmall/store)
|
||||||
|
Route::get('store', [Store::class, 'index'])->completeMatch();
|
||||||
|
Route::get('store/index', [Store::class, 'index'])->completeMatch();
|
||||||
|
// 插件详情页(/appmall/store/detail/<name>)
|
||||||
|
Route::get('store/detail/:name', [Store::class, 'detail'])
|
||||||
|
->pattern(['name' => '[\w\-]+']);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\appmall\service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 市场领域业务异常:code 携带 HTTP 语义(403/404/500 等),
|
||||||
|
* 由调用方(核心 v1/Addon 等)转换为对应的 Result 错误响应。
|
||||||
|
*/
|
||||||
|
class MarketException extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,701 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\appmall\service;
|
||||||
|
|
||||||
|
use addon\appmall\model\AddonDownloadLog;
|
||||||
|
use addon\appmall\model\AddonLicense;
|
||||||
|
use addon\appmall\model\AddonOrder;
|
||||||
|
use think\facade\Cache;
|
||||||
|
use think\facade\Db;
|
||||||
|
use think\facade\Log;
|
||||||
|
use think\Request;
|
||||||
|
use ywxapp\model\BaseModel;
|
||||||
|
use Yansongda\Pay\Pay;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 市场中心站本地成交域服务(会员直购 / 授权 / 收益 / 下载)。
|
||||||
|
*
|
||||||
|
* 由核心 app/api/controller/v1/Addon.php 在「本机即中心站」模式下进程内委托调用;
|
||||||
|
* 所有中心站领域逻辑(订单、授权、收益账本、支付回调)收敛于插件,核心不再持有。
|
||||||
|
*
|
||||||
|
* 约定:业务失败抛 MarketException(携带 HTTP 语义 code),由调用方转成 Result 响应。
|
||||||
|
*/
|
||||||
|
class MarketService
|
||||||
|
{
|
||||||
|
protected static ?MarketService $instance = null;
|
||||||
|
|
||||||
|
public static function instance(): static
|
||||||
|
{
|
||||||
|
if (static::$instance === null) {
|
||||||
|
static::$instance = new static();
|
||||||
|
// 中心站成交域首次被委托时,确保全部 appmall 表存在(覆盖 v1/Addon 等直接调用方)
|
||||||
|
\addon\appmall\library\AppmallSchema::ensure();
|
||||||
|
}
|
||||||
|
return static::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行时自愈建表(仅当表不存在时创建),保证购买闭环在当前库即可跑通。
|
||||||
|
* 已收敛到 \addon\appmall\library\AppmallSchema::ensure()(建全部表 + 扩展列),此处委托。
|
||||||
|
*/
|
||||||
|
public function ensureTables(): void
|
||||||
|
{
|
||||||
|
\addon\appmall\library\AppmallSchema::ensure();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 商品查询(appmall_addon_list)
|
||||||
|
*/
|
||||||
|
public function product(int $id): ?array
|
||||||
|
{
|
||||||
|
return Db::name('appmall_addon_list')->where('id', $id)->where('status', 1)->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件详情(含当前用户已购状态)
|
||||||
|
* @throws MarketException 404 插件不存在或已下架
|
||||||
|
*/
|
||||||
|
public function info(int $id, int $uid = 0): array
|
||||||
|
{
|
||||||
|
$addon = $this->product($id);
|
||||||
|
if (!$addon) {
|
||||||
|
throw new MarketException('插件不存在或已下架', 404);
|
||||||
|
}
|
||||||
|
$purchased = false;
|
||||||
|
if ($uid > 0) {
|
||||||
|
$purchased = AddonLicense::where('uid', $uid)->where('aid', $id)->count() > 0;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'addon' => [
|
||||||
|
'id' => $addon['id'],
|
||||||
|
'name' => $addon['name'],
|
||||||
|
'title' => $addon['title'],
|
||||||
|
'version' => $addon['version'],
|
||||||
|
'price' => $addon['price'],
|
||||||
|
'logo' => $addon['logo'],
|
||||||
|
'intro' => $addon['description'] ?? '',
|
||||||
|
'author' => $addon['author'] ?? '',
|
||||||
|
],
|
||||||
|
'purchased' => $purchased,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上架插件分页列表
|
||||||
|
* @return array{items: array, total: int}
|
||||||
|
*/
|
||||||
|
public function lists(int $page = 1, int $perPage = 15): array
|
||||||
|
{
|
||||||
|
$perPage = min(max($perPage, 1), 50);
|
||||||
|
$page = max($page, 1);
|
||||||
|
$query = Db::name('appmall_addon_list')->where('status', 1);
|
||||||
|
$total = (clone $query)->count();
|
||||||
|
$items = $query->field('id,name,title,version,price,logo,description as intro,author')
|
||||||
|
->order('id', 'desc')
|
||||||
|
->page($page, $perPage)
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
return ['items' => $items, 'total' => $total];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起购买:创建/复用待支付订单并拉起支付(或模拟支付直接签发授权)。
|
||||||
|
* @param string $baseUrl 客户端根地址(用于拼支付回调)
|
||||||
|
* @return array{data: array, message: string}
|
||||||
|
* @throws MarketException
|
||||||
|
*/
|
||||||
|
public function buy(int $uid, int $addonId, string $method, string $baseUrl): array
|
||||||
|
{
|
||||||
|
$addon = $this->product($addonId);
|
||||||
|
if (!$addon) {
|
||||||
|
throw new MarketException('插件不存在或已下架', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 免费插件无需购买(服务端 market valid 也直接放行)
|
||||||
|
if ((float) $addon['price'] <= 0) {
|
||||||
|
return ['data' => ['free' => true], 'message' => '该插件免费,可直接安装'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂等:已购直接返回授权
|
||||||
|
$existLicense = AddonLicense::where('uid', $uid)->where('aid', $addonId)->find();
|
||||||
|
if ($existLicense) {
|
||||||
|
return ['data' => ['license_key' => $existLicense->license_key], 'message' => '您已购买该插件'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防重复下单:复用 15 分钟内待支付订单
|
||||||
|
$order = AddonOrder::where('uid', $uid)
|
||||||
|
->where('aid', $addonId)
|
||||||
|
->where('status', 0)
|
||||||
|
->where('create_at', '>', time() - 900)
|
||||||
|
->find();
|
||||||
|
if (!$order) {
|
||||||
|
$order = AddonOrder::create([
|
||||||
|
'uid' => $uid,
|
||||||
|
'aid' => $addonId,
|
||||||
|
'amount' => $addon['price'],
|
||||||
|
'status' => 0,
|
||||||
|
'trade_no' => date('YmdHis') . $uid . $addonId . random_int(100000, 999999),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟支付:无真实商户号时直接完成并签发授权(仅本地/自托管调试用)
|
||||||
|
if (config('pay.mock_enable')
|
||||||
|
&& empty(config('pay.alipay.app_id'))
|
||||||
|
&& empty(config('pay.wechat.mch_id'))) {
|
||||||
|
$this->completeOrder($order, $addonId, $uid);
|
||||||
|
$license = AddonLicense::where('uid', $uid)->where('aid', $addonId)->find();
|
||||||
|
return [
|
||||||
|
'data' => ['license_key' => $license?->license_key, 'mock' => true],
|
||||||
|
'message' => '(模拟支付)购买成功',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$base = rtrim($baseUrl, '/');
|
||||||
|
$payParams = [
|
||||||
|
'out_trade_no' => $order->trade_no,
|
||||||
|
'total_amount' => $order->amount,
|
||||||
|
'subject' => '购买插件:' . $addon['title'],
|
||||||
|
];
|
||||||
|
if ($method === 'wechat') {
|
||||||
|
$wconfig = config('pay.wechat');
|
||||||
|
if (empty($wconfig['mch_id']) || empty($wconfig['app_id'])) {
|
||||||
|
throw new \Exception('微信支付未配置,请联系管理员');
|
||||||
|
}
|
||||||
|
$wconfig['notify_url'] = $base . '/v1/addon/notify';
|
||||||
|
$result = Pay::wechat($wconfig)->h5($payParams);
|
||||||
|
return [
|
||||||
|
'data' => ['order_no' => $order->trade_no, 'pay_params' => $result->getBody()->getContents()],
|
||||||
|
'message' => '支付发起成功',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$aconfig = config('pay.alipay');
|
||||||
|
if (empty($aconfig['app_id']) || empty($aconfig['private_key'])) {
|
||||||
|
throw new \Exception('支付未配置,请联系管理员');
|
||||||
|
}
|
||||||
|
$aconfig['notify_url'] = $base . '/v1/addon/notify';
|
||||||
|
$aconfig['return_url'] = $base . '/v1/addon/payResult';
|
||||||
|
$result = Pay::alipay($aconfig)->web($payParams);
|
||||||
|
return [
|
||||||
|
'data' => ['order_no' => $order->trade_no, 'pay_params' => $result->getBody()->getContents()],
|
||||||
|
'message' => '支付发起成功',
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
throw new MarketException('支付发起失败: ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单支付成功后:置已付 + 幂等签发授权 + 写入开发者收益账本(分红)
|
||||||
|
*/
|
||||||
|
public function completeOrder($order, int $aid, int $uid): void
|
||||||
|
{
|
||||||
|
if ($order->status == 0) {
|
||||||
|
$order->status = 1;
|
||||||
|
$order->pay_time = time();
|
||||||
|
$order->save();
|
||||||
|
}
|
||||||
|
$exist = AddonLicense::where('uid', $uid)->where('aid', $aid)->find();
|
||||||
|
if (!$exist) {
|
||||||
|
try {
|
||||||
|
AddonLicense::create([
|
||||||
|
'uid' => $uid,
|
||||||
|
'aid' => $aid,
|
||||||
|
'license_key' => $this->generateLicenseKey($uid, $aid),
|
||||||
|
'expire_time' => 0,
|
||||||
|
'download_count' => 0,
|
||||||
|
]);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
if (stripos($e->getMessage(), 'duplicate') === false
|
||||||
|
&& stripos($e->getMessage(), '1062') === false) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 写入收益账本(仅当该商品归属某开发者)
|
||||||
|
try {
|
||||||
|
$product = Db::name('appmall_addon_list')->where('id', $aid)->find();
|
||||||
|
$devId = (int) ($product['developer_id'] ?? 0);
|
||||||
|
if ($devId > 0 && !Db::name('appmall_addon_revenues')->where('order_id', $order->id)->find()) {
|
||||||
|
$rate = (float) config('appmall.commission_rate', 0.2);
|
||||||
|
$commission = round((float) $order->amount * $rate, 2);
|
||||||
|
$income = round((float) $order->amount - $commission, 2);
|
||||||
|
Db::name('appmall_addon_revenues')->insert([
|
||||||
|
'developer_id' => $devId,
|
||||||
|
'order_id' => $order->id,
|
||||||
|
'aid' => $aid,
|
||||||
|
'uid' => $uid,
|
||||||
|
'amount' => $order->amount,
|
||||||
|
'commission' => $commission,
|
||||||
|
'income' => $income,
|
||||||
|
'status' => 0,
|
||||||
|
'create_at' => time(),
|
||||||
|
]);
|
||||||
|
// 计入开发者可提现余额(提现申请时再转入冻结待打款)
|
||||||
|
Db::name('appmall_developers')->where('id', $devId)->inc('balance', $income)->update();
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// 收益记账失败不影响已完成的购买
|
||||||
|
Log::warning('收益记账失败:' . $e->getMessage());
|
||||||
|
}
|
||||||
|
// 钩子点:订单支付完成(已签发授权 + 写入分红账本)后触发,
|
||||||
|
// 插件可在 info.php['events']['listen']['order_paid'] 监听,用于发货 / 通知等联动。
|
||||||
|
event('order_paid', ['order' => $order, 'aid' => $aid, 'uid' => $uid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付结果查询(前端轮询用)
|
||||||
|
* @throws MarketException 404 订单不存在
|
||||||
|
*/
|
||||||
|
public function orderStatus(int $uid, string $tradeNo): array
|
||||||
|
{
|
||||||
|
$order = AddonOrder::where('trade_no', $tradeNo)->where('uid', $uid)->find();
|
||||||
|
if (!$order) {
|
||||||
|
throw new MarketException('订单不存在', 404);
|
||||||
|
}
|
||||||
|
$data = ['status' => $order->status];
|
||||||
|
if ($order->status === 1) {
|
||||||
|
$license = AddonLicense::where('uid', $uid)->where('aid', $order->aid)->find();
|
||||||
|
$data['license_key'] = $license?->license_key;
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付完成落地页状态(支付宝 return_url 跳转,无需登录,仅展示状态)
|
||||||
|
* @return array{data: array, message: string}
|
||||||
|
*/
|
||||||
|
public function payResult(string $tradeNo): array
|
||||||
|
{
|
||||||
|
if ($tradeNo === '') {
|
||||||
|
return ['data' => [], 'message' => '支付流程结束'];
|
||||||
|
}
|
||||||
|
$order = AddonOrder::where('trade_no', $tradeNo)->find();
|
||||||
|
if (!$order) {
|
||||||
|
return ['data' => ['status' => 0], 'message' => '订单不存在'];
|
||||||
|
}
|
||||||
|
return ['data' => ['status' => $order->status], 'message' => '获取成功'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退款 / 吊销授权(买家或运营)
|
||||||
|
* @return array{data: array, message: string}
|
||||||
|
* @throws MarketException
|
||||||
|
*/
|
||||||
|
public function refund(int $uid, string $tradeNo, int $orderId): array
|
||||||
|
{
|
||||||
|
$order = null;
|
||||||
|
if ($tradeNo !== '') {
|
||||||
|
$order = AddonOrder::where('trade_no', $tradeNo)->where('uid', $uid)->find();
|
||||||
|
} elseif ($orderId > 0) {
|
||||||
|
$order = AddonOrder::where('id', $orderId)->where('uid', $uid)->find();
|
||||||
|
}
|
||||||
|
if (!$order) {
|
||||||
|
throw new MarketException('订单不存在', 404);
|
||||||
|
}
|
||||||
|
if ($order->status === 2) {
|
||||||
|
return ['data' => ['status' => 2], 'message' => '该订单已退款'];
|
||||||
|
}
|
||||||
|
if ($order->status !== 1) {
|
||||||
|
throw new MarketException('仅已支付订单可申请退款', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::transaction(function () use ($order, $uid) {
|
||||||
|
$order->status = 2;
|
||||||
|
$order->save();
|
||||||
|
// 吊销授权(status=0 且过期,download/valid 双重拦截)
|
||||||
|
AddonLicense::where('uid', $uid)->where('aid', $order->aid)
|
||||||
|
->update(['status' => 0, 'expire_time' => time()]);
|
||||||
|
// 收益冲正
|
||||||
|
$rev = Db::name('appmall_addon_revenues')->where('order_id', $order->id)->find();
|
||||||
|
if ($rev) {
|
||||||
|
if ((int) $rev['status'] === 0) {
|
||||||
|
Db::name('appmall_developers')->where('id', $rev['developer_id'])
|
||||||
|
->dec('balance', (float) $rev['income'])->update();
|
||||||
|
}
|
||||||
|
Db::name('appmall_addon_revenues')->where('id', $rev['id'])->update(['status' => 2]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ['data' => ['status' => 2], 'message' => '退款成功,授权已吊销'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的已购插件(买家视角):列出已购付费插件 + 授权状态 + 是否可退款
|
||||||
|
*/
|
||||||
|
public function mine(int $uid): array
|
||||||
|
{
|
||||||
|
$rows = Db::name('appmall_addon_licenses')
|
||||||
|
->alias('l')
|
||||||
|
->join('appmall_addon_list a', 'a.id = l.aid')
|
||||||
|
->where('l.uid', $uid)
|
||||||
|
->where('a.price', '>', 0)
|
||||||
|
->field('a.name,a.title,a.version,a.price,l.status as license_status,l.expire_time,l.domain,l.aid')
|
||||||
|
->order('l.id', 'desc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
$list = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$order = Db::name('appmall_addon_orders')->where('uid', $uid)->where('aid', $r['aid'])
|
||||||
|
->order('id', 'desc')->find();
|
||||||
|
$orderStatus = $order ? (int) $order['status'] : 0;
|
||||||
|
$refundable = ((int) $r['license_status'] === 1) && $orderStatus === 1;
|
||||||
|
$list[] = [
|
||||||
|
'name' => $r['name'],
|
||||||
|
'title' => $r['title'],
|
||||||
|
'version' => $r['version'],
|
||||||
|
'price' => $r['price'],
|
||||||
|
'license_status' => (int) $r['license_status'],
|
||||||
|
'expire_time' => (int) $r['expire_time'],
|
||||||
|
'domain' => $r['domain'] ?? '',
|
||||||
|
'order_id' => $order ? (int) $order['id'] : 0,
|
||||||
|
'trade_no' => $order['trade_no'] ?? '',
|
||||||
|
'refundable' => $refundable,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return ['list' => $list];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付宝/微信异步回调:验签 -> 校验金额 -> 订单置已付 -> 生成授权
|
||||||
|
* @return \think\Response
|
||||||
|
*/
|
||||||
|
public function notify(string $method)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if ($method === 'wechat') {
|
||||||
|
$pay = Pay::wechat(config('pay.wechat'));
|
||||||
|
$data = $pay->callback();
|
||||||
|
$tradeNo = $data->get('out_trade_no');
|
||||||
|
$amount = (float) $data->get('amount') ?: (float) $data->get('total');
|
||||||
|
} else {
|
||||||
|
$pay = Pay::alipay(config('pay.alipay'));
|
||||||
|
$data = $pay->verify();
|
||||||
|
$tradeNo = $data->get('out_trade_no');
|
||||||
|
$amount = (float) $data->get('total_amount');
|
||||||
|
$tradeStatus = (string) $data->get('trade_status');
|
||||||
|
if (!in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true)) {
|
||||||
|
return $pay->success();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return response('fail');
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = AddonOrder::where('trade_no', $tradeNo)->find();
|
||||||
|
if ($order && $order->status == 0) {
|
||||||
|
// 防金额篡改:回调金额必须与订单一致
|
||||||
|
if (abs((float) $order->amount - $amount) < 0.01) {
|
||||||
|
Db::transaction(function () use ($order) {
|
||||||
|
$order->refresh();
|
||||||
|
if ($order->status != 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->completeOrder($order, $order->aid, $order->uid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $method === 'wechat' ? response('SUCCESS') : Pay::alipay(config('pay.alipay'))->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户下载已购插件(需有效授权 + 域名/签名/次数校验)。
|
||||||
|
* @return array{file: string, filename: string} 通过校验后返回文件路径与下载名
|
||||||
|
* @throws MarketException
|
||||||
|
*/
|
||||||
|
public function download(int $uid, int $addonId, Request $request): array
|
||||||
|
{
|
||||||
|
$license = AddonLicense::where('uid', $uid)->where('aid', $addonId)->find();
|
||||||
|
if (!$license) {
|
||||||
|
throw new MarketException('您尚未购买该插件', 403);
|
||||||
|
}
|
||||||
|
// 授权已吊销(退款):拒绝下载
|
||||||
|
if (isset($license->status) && (int) $license->status !== 1) {
|
||||||
|
throw new MarketException('授权已被吊销,无法下载', 403);
|
||||||
|
}
|
||||||
|
if ($license->expire_time > 0 && $license->expire_time < time()) {
|
||||||
|
throw new MarketException('授权已过期', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 域名绑定:license.domain 非空时,仅允许绑定的域名下载
|
||||||
|
if (!empty($license->domain)) {
|
||||||
|
$host = $request->host();
|
||||||
|
$hostNoPort = preg_replace('/:\d+$/', '', $host);
|
||||||
|
$allowed = array_filter(array_map('trim', preg_split('/[,\s]+/', (string) $license->domain)));
|
||||||
|
if (!in_array($host, $allowed, true) && !in_array($hostNoPort, $allowed, true)) {
|
||||||
|
throw new MarketException('该授权未绑定当前域名,无法下载', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载签名校验(开关 addon_download_sign)
|
||||||
|
$signSecret = config('ywxapp.addon_secret');
|
||||||
|
if (config('ywxapp.addon_download_sign') && $signSecret) {
|
||||||
|
$ts = (int) $request->get('ts', 0);
|
||||||
|
$sign = (string) $request->get('sign', '');
|
||||||
|
if ($ts <= 0 || time() - $ts > 600) {
|
||||||
|
throw new MarketException('下载授权已过期,请重新获取', 403);
|
||||||
|
}
|
||||||
|
$expected = $license->generateSign($license->license_key, $addonId, $ts, $signSecret);
|
||||||
|
if (!hash_equals($expected, $sign)) {
|
||||||
|
throw new MarketException('下载授权校验失败', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$addon = Db::name('appmall_addon_list')->where('id', $addonId)->where('status', 1)->find();
|
||||||
|
if (!$addon) {
|
||||||
|
throw new MarketException('插件不存在或已下架', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载次数限制:0 表示不限
|
||||||
|
if ($license->download_count > 0) {
|
||||||
|
$used = AddonDownloadLog::where('license_id', $license->id)->count();
|
||||||
|
if ($used >= $license->download_count) {
|
||||||
|
throw new MarketException('下载次数已用完', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = realpath($addon['file_path']);
|
||||||
|
$root = realpath(root_path()) ?: root_path();
|
||||||
|
if ($file === false || !is_file($file) || strpos($file, $root) !== 0) {
|
||||||
|
throw new MarketException('插件文件暂不可用,请联系管理员', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddonDownloadLog::create([
|
||||||
|
'license_id' => $license->id,
|
||||||
|
'uid' => $uid,
|
||||||
|
'aid' => $addonId,
|
||||||
|
'ip' => $request->ip(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['file' => $file, 'filename' => $addon['name'] . '-' . $addon['version'] . '.zip'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台市场目录(中心站本机浏览):关键词/分类筛选 + 排序 + 分类下拉数据源。
|
||||||
|
* 自愈元数据列(category/tags/screenshots/rating),与 api/Market::ensureAddonListColumns 对齐。
|
||||||
|
* @return array{list: array, categories: array}
|
||||||
|
*/
|
||||||
|
public function catalog(string $keyword = '', string $category = '', string $order = 'new', string $type = ''): array
|
||||||
|
{
|
||||||
|
// 自愈元数据列:旧库缺 category/tags/screenshots/rating 时自动补齐
|
||||||
|
$listTable = Db::name('appmall_addon_list')->getTable();
|
||||||
|
$colDefs = [
|
||||||
|
'category' => "varchar(50) DEFAULT '' COMMENT '分类'",
|
||||||
|
'tags' => "varchar(255) DEFAULT '' COMMENT '标签(逗号分隔)'",
|
||||||
|
'screenshots' => "text COMMENT '截图URL(逗号分隔或JSON)'",
|
||||||
|
'rating' => "decimal(2,1) DEFAULT '0.0' COMMENT '评分(0.0~5.0)'",
|
||||||
|
'changelog' => "text COMMENT '更新日志'",
|
||||||
|
'require_framework' => "varchar(20) DEFAULT '' COMMENT '最低兼容框架版本,如 1.2.0'",
|
||||||
|
'type' => "varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板'",
|
||||||
|
];
|
||||||
|
foreach ($colDefs as $col => $def) {
|
||||||
|
BaseModel::ensureColumn($listTable, $col, $def);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 探测元数据列是否存在,存在才选,保证兼容
|
||||||
|
$hasCols = [];
|
||||||
|
try {
|
||||||
|
$cols = Db::query("SHOW COLUMNS FROM `{$listTable}`");
|
||||||
|
$hasCols = array_column($cols, 'Field');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
}
|
||||||
|
$has = fn (string $c): bool => in_array($c, $hasCols, true);
|
||||||
|
|
||||||
|
$q = Db::name('appmall_addon_list')->where('status', 1);
|
||||||
|
if ($keyword !== '') {
|
||||||
|
$q->where(function ($w) use ($keyword) {
|
||||||
|
$w->where('name', 'like', '%' . $keyword . '%')
|
||||||
|
->whereOr('title', 'like', '%' . $keyword . '%')
|
||||||
|
->whereOr('author', 'like', '%' . $keyword . '%')
|
||||||
|
->whereOr('description', 'like', '%' . $keyword . '%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ($category !== '' && $has('category')) {
|
||||||
|
$q->where('category', $category);
|
||||||
|
}
|
||||||
|
// 商品类型筛选(addon=插件 / template=模板;空=全部)
|
||||||
|
if ($type !== '' && $has('type')) {
|
||||||
|
$q->where('type', $type);
|
||||||
|
}
|
||||||
|
$field = 'id,name,title,description as intro,author,version,logo,price,download_count,update_at';
|
||||||
|
foreach (['category', 'tags', 'screenshots', 'rating', 'changelog', 'require_framework', 'type'] as $mf) {
|
||||||
|
if ($has($mf)) {
|
||||||
|
$field .= ',' . $mf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 排序:对齐 api/Market::lists 的 orderMap(download_count 为商品表内置列)
|
||||||
|
$orderMap = [
|
||||||
|
'new' => ['id', 'desc'],
|
||||||
|
'hot' => ['download_count', 'desc'],
|
||||||
|
'price_asc' => ['price', 'asc'],
|
||||||
|
'price_desc' => ['price', 'desc'],
|
||||||
|
];
|
||||||
|
[$of, $od] = $orderMap[$order] ?? $orderMap['new'];
|
||||||
|
$list = $q->field($field)->order($of, $od)->select()->toArray();
|
||||||
|
// 同名插件多版本聚合:只展示最新版,其余版本收进 versions(详情弹窗版本下拉用)
|
||||||
|
$list = self::aggregateVersions($list);
|
||||||
|
|
||||||
|
// 分类下拉数据源:本机全部非空分类
|
||||||
|
$categories = [];
|
||||||
|
if ($has('category')) {
|
||||||
|
$categories = Db::name('appmall_addon_list')
|
||||||
|
->where('status', 1)->where('category', '<>', '')
|
||||||
|
->distinct(true)->order('category', 'asc')->column('category');
|
||||||
|
}
|
||||||
|
return ['list' => $list, 'categories' => $categories];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运营退款(后台特权):按插件名退最新「已支付」订单,
|
||||||
|
* 吊销授权 + 订单置已退款 + 收益冲正。
|
||||||
|
* @throws MarketException
|
||||||
|
*/
|
||||||
|
public function operatorRefund(string $name): void
|
||||||
|
{
|
||||||
|
$addon = Db::name('appmall_addon_list')->where('name', $name)->find();
|
||||||
|
if (empty($addon)) {
|
||||||
|
throw new MarketException('插件不存在', 404);
|
||||||
|
}
|
||||||
|
$order = Db::name('appmall_addon_orders')->where('aid', $addon['id'])
|
||||||
|
->where('status', 1)->order('id', 'desc')->find();
|
||||||
|
if (empty($order)) {
|
||||||
|
throw new MarketException('未找到该插件的已支付订单', 404);
|
||||||
|
}
|
||||||
|
Db::transaction(function () use ($order, $addon) {
|
||||||
|
Db::name('appmall_addon_orders')->where('id', $order['id'])
|
||||||
|
->update(['status' => 2, 'update_at' => time()]);
|
||||||
|
Db::name('appmall_addon_licenses')->where('uid', $order['uid'])
|
||||||
|
->where('aid', $addon['id'])
|
||||||
|
->update(['status' => 0, 'expire_time' => time(), 'update_at' => time()]);
|
||||||
|
$rev = Db::name('appmall_addon_revenues')->where('order_id', $order['id'])->find();
|
||||||
|
if ($rev) {
|
||||||
|
if ((int) $rev['status'] === 0) {
|
||||||
|
Db::name('appmall_developers')->where('id', $rev['developer_id'])
|
||||||
|
->dec('balance', (float) $rev['income'])->update();
|
||||||
|
}
|
||||||
|
Db::name('appmall_addon_revenues')->where('id', $rev['id'])->update(['status' => 2]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同名插件多版本聚合:每个插件只保留最新版一条作代表行,
|
||||||
|
* 其余版本收进代表行的 versions 数组(含最新版自身,按版本号降序),
|
||||||
|
* 供市场详情页/弹窗做「版本下拉选择」,列表不再平铺展示多个版本。
|
||||||
|
* 输出顺序 = 各插件在原列表中的首次出现顺序(尽量保持原排序语义)。
|
||||||
|
*/
|
||||||
|
public static function aggregateVersions(array $rows): array
|
||||||
|
{
|
||||||
|
$groups = [];
|
||||||
|
$names = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$n = (string) ($r['name'] ?? '');
|
||||||
|
if (!isset($groups[$n])) {
|
||||||
|
$groups[$n] = [];
|
||||||
|
$names[] = $n;
|
||||||
|
}
|
||||||
|
$groups[$n][] = $r;
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach ($names as $n) {
|
||||||
|
$vers = $groups[$n];
|
||||||
|
usort($vers, fn ($a, $b) => version_compare((string) ($b['version'] ?? '0'), (string) ($a['version'] ?? '0')));
|
||||||
|
$main = $vers[0];
|
||||||
|
$main['versions'] = array_map(fn ($v) => [
|
||||||
|
'id' => $v['id'] ?? 0,
|
||||||
|
'version' => (string) ($v['version'] ?? ''),
|
||||||
|
'price' => $v['price'] ?? 0,
|
||||||
|
'update_at' => (int) ($v['update_at'] ?? 0),
|
||||||
|
], $vers);
|
||||||
|
$out[] = $main;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改已上架插件价格(按插件名维度,同步全部版本行 + 提交记录)。
|
||||||
|
* 后台改价传 developerId=0(不限归属);开发者中心改价须传本人 developerId(仅能改自己的应用)。
|
||||||
|
* 改价后 bump 目录版本号,市场列表缓存立即失效。
|
||||||
|
* @return int 受影响的版本行数
|
||||||
|
* @throws MarketException
|
||||||
|
*/
|
||||||
|
public function updatePrice(string $name, float $price, int $developerId = 0): int
|
||||||
|
{
|
||||||
|
if ($name === '') {
|
||||||
|
throw new MarketException('缺少插件标识', 400);
|
||||||
|
}
|
||||||
|
if ($price < 0 || $price > 999999) {
|
||||||
|
throw new MarketException('价格必须在 0 ~ 999999 之间', 400);
|
||||||
|
}
|
||||||
|
$q = Db::name('appmall_addon_list')->where('name', $name);
|
||||||
|
if ($developerId > 0) {
|
||||||
|
$q->where('developer_id', $developerId);
|
||||||
|
}
|
||||||
|
$rows = $q->select()->toArray();
|
||||||
|
if (empty($rows)) {
|
||||||
|
// 尚未上架(仅有提交记录)也允许改价:只同步 submissions
|
||||||
|
$subExist = Db::name('appmall_addon_submissions')->where('name', $name)
|
||||||
|
->when($developerId > 0, fn ($w) => $w->where('developer_id', $developerId))
|
||||||
|
->count();
|
||||||
|
if ($subExist <= 0) {
|
||||||
|
throw new MarketException($developerId > 0 ? '未找到你名下的该插件' : '插件不存在', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$ids = array_column($rows, 'id');
|
||||||
|
if ($ids) {
|
||||||
|
Db::name('appmall_addon_list')->whereIn('id', $ids)
|
||||||
|
->update(['price' => $price, 'update_at' => time()]);
|
||||||
|
}
|
||||||
|
// 同步提交记录价格(保持审核台/开发者中心口径一致;仅同步同归属记录)
|
||||||
|
try {
|
||||||
|
$sq = Db::name('appmall_addon_submissions')->where('name', $name);
|
||||||
|
if ($developerId > 0) {
|
||||||
|
$sq->where('developer_id', $developerId);
|
||||||
|
}
|
||||||
|
$sq->update(['price' => $price, 'update_at' => time()]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// 提交记录同步失败不影响商品改价
|
||||||
|
}
|
||||||
|
self::bumpCatalogVersion();
|
||||||
|
return count($ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目录版本号 bump:使 api/Market::lists 的 60s 缓存立即失效(审核发布/改价后调用)。
|
||||||
|
*/
|
||||||
|
public static function bumpCatalogVersion(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
Cache::set('appmall:catalog:ver', (string) microtime(true));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// 缓存不可用时静默(列表最多延迟 60s 更新)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成唯一授权码
|
||||||
|
*/
|
||||||
|
protected function generateLicenseKey(int $uid, int $aid): string
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
$key = strtoupper(sprintf(
|
||||||
|
'%s-%s-%s',
|
||||||
|
substr(md5($uid . $aid . random_bytes(8)), 0, 8),
|
||||||
|
bin2hex(random_bytes(4)),
|
||||||
|
bin2hex(random_bytes(4))
|
||||||
|
));
|
||||||
|
} while (AddonLicense::where('license_key', $key)->find());
|
||||||
|
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
|
||||||
|
<div class="layui-card" style="margin:20px;">
|
||||||
|
<div class="layui-card-header">插件审核(开发者上架申请)</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<div class="layui-form layui-row" style="margin-bottom:12px;">
|
||||||
|
<div class="layui-inline">
|
||||||
|
<select id="statusFilter">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="0">待审核</option>
|
||||||
|
<option value="1">已发布</option>
|
||||||
|
<option value="2">已驳回</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<button class="layui-btn" id="searchBtn">搜索</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/html" id="toolTpl">
|
||||||
|
{{# if(d.status == 0){ }}
|
||||||
|
<a class="layui-btn layui-btn-xs" lay-event="audit">通过</a>
|
||||||
|
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="reject">驳回</a>
|
||||||
|
{{# } else { }}
|
||||||
|
<span class="layui-badge {{ d.status == 1 ? 'layui-bg-green' : '' }}">
|
||||||
|
{{ d.status == 1 ? '已发布' : '已驳回' }}
|
||||||
|
</span>
|
||||||
|
{{# } }}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
layui.use(['table', 'jquery', 'layer'], function () {
|
||||||
|
var table = layui.table, $ = layui.jquery, layer = layui.layer;
|
||||||
|
table.render({
|
||||||
|
elem: '#dataTable',
|
||||||
|
url: '{:url("/appmall/backend/addonreview/index")}',
|
||||||
|
page: true,
|
||||||
|
response: { statusName: 'code', msgName: 'message', countName: 'count', dataName: 'data' },
|
||||||
|
parseData: function (res) {
|
||||||
|
return {
|
||||||
|
code: res.code,
|
||||||
|
msg: res.message,
|
||||||
|
count: res.count || 0,
|
||||||
|
data: res.data || []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
where: { status: $('#statusFilter').val() },
|
||||||
|
cols: [[
|
||||||
|
{field: 'id', title: 'ID', width: 70},
|
||||||
|
{field: 'title', title: '插件名称', width: 160},
|
||||||
|
{field: 'name', title: '标识', width: 140},
|
||||||
|
{field: 'version', title: '版本', width: 90},
|
||||||
|
{field: 'developer_id', title: '开发者ID', width: 100},
|
||||||
|
{field: 'price', title: '价格', width: 90, templet: function(d){return d.price ? d.price : '免费';}},
|
||||||
|
{field: 'status', title: '状态', width: 90, templet: function(d){
|
||||||
|
if(d.status==0) return '<span class="layui-badge">待审核</span>';
|
||||||
|
if(d.status==1) return '<span class="layui-badge layui-bg-green">已发布</span>';
|
||||||
|
return '<span class="layui-badge layui-bg-red">已驳回</span>';
|
||||||
|
}},
|
||||||
|
{field: 'reject_reason', title: '驳回原因', width: 160},
|
||||||
|
{field: 'create_at', title: '提交时间', width: 170, templet: function(d){return layui.util.toDateString(d.create_at*1000);}},
|
||||||
|
{title: '操作', width: 160, templet: '#toolTpl', fixed: 'right'}
|
||||||
|
]]
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#searchBtn').on('click', function () {
|
||||||
|
table.reload('dataTable', { where: { status: $('#statusFilter').val() }, page: { curr: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
table.on('tool(dataTable)', function (obj) {
|
||||||
|
var d = obj.data;
|
||||||
|
if (obj.event === 'audit') {
|
||||||
|
layer.confirm('确认通过该插件上架申请?', function (index) {
|
||||||
|
$.post('{:url("/appmall/backend/addonreview/audit")}', { id: d.id }, function (r) {
|
||||||
|
layer.msg(r.msg || '操作完成');
|
||||||
|
if (r.code === 0) { table.reload('dataTable'); }
|
||||||
|
layer.close(index);
|
||||||
|
}, 'json');
|
||||||
|
});
|
||||||
|
} else if (obj.event === 'reject') {
|
||||||
|
layer.prompt({ title: '填写驳回原因', formType: 2 }, function (val, index) {
|
||||||
|
$.post('{:url("/appmall/backend/addonreview/reject")}', { id: d.id, reason: val }, function (r) {
|
||||||
|
layer.msg(r.msg || '操作完成');
|
||||||
|
if (r.code === 0) { table.reload('dataTable'); }
|
||||||
|
layer.close(index);
|
||||||
|
}, 'json');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
{block name="title"}<title>{$title | default='YwxApp'}</title>{/block}
|
||||||
|
<meta name="renderer" content="webkit">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||||
|
<link href="/assets/layui/css/layui.css" rel="stylesheet">
|
||||||
|
<link href="/assets/ywxapp/css/wxapp.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding-bottom: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layui-card {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layui-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layui-table-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layui-table-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layui-table-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="/assets/layui/layui.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
// 全局路径配置:仅注入部署参数,路由方法来自 layui 的 route 模块。
|
||||||
|
// app 为 ThinkPHP 真实应用目录名(backend/member/frontend/home),对应 public/static/<app> 目录。
|
||||||
|
layui.app= window.APP = {
|
||||||
|
root: "{$site.root|default=''}",
|
||||||
|
assetUrl: "/assets",
|
||||||
|
module: "{$site.module}",
|
||||||
|
realModule: "{$site.app}",
|
||||||
|
routeBase: "{$route_base}",
|
||||||
|
controller: "{$site.controller}",
|
||||||
|
action: "{$site.action}"
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="/assets/ywxapp/ywxapp.js" module="{$site.app}"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
{block name="body"}{/block}
|
||||||
|
|
||||||
|
{block name="script"}{/block}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>开发者管理 - 应用市场管理</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link href="/assets/layui/css/layui.css" rel="stylesheet">
|
||||||
|
<link href="/assets/ywxapp/css/wxapp.css" rel="stylesheet">
|
||||||
|
<style>body{background:#f2f3f5;padding:15px;}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form class="layui-form" lay-filter="filterForm">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">状态</label>
|
||||||
|
<div class="layui-input-inline" style="width:140px;">
|
||||||
|
<select name="status">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option value="0">待审核</option>
|
||||||
|
<option value="1">正常</option>
|
||||||
|
<option value="-1">已禁用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="layui-btn" lay-submit lay-filter="search">搜索</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table id="dataTable" lay-filter="dataTable"></table>
|
||||||
|
|
||||||
|
<script type="text/html" id="statusTpl">
|
||||||
|
{{# if(d.status == 0){ }}<span class="layui-badge layui-bg-orange">待审核</span>
|
||||||
|
{{# } else if(d.status == 1){ }}<span class="layui-badge layui-bg-green">正常</span>
|
||||||
|
{{# } else { }}<span class="layui-badge">已禁用</span>{{# } }}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/html" id="operateTpl">
|
||||||
|
{{# if(d.status == 0){ }}
|
||||||
|
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="approve">通过</a>
|
||||||
|
{{# } }}
|
||||||
|
{{# if(d.status == 1){ }}
|
||||||
|
<a class="layui-btn layui-btn-xs" lay-event="resend">重发令牌</a>
|
||||||
|
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="reset">重置令牌</a>
|
||||||
|
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="disable">禁用</a>
|
||||||
|
{{# } }}
|
||||||
|
{{# if(d.status == -1){ }}
|
||||||
|
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="approve">启用</a>
|
||||||
|
{{# } }}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script src="/assets/layui/layui.js"></script>
|
||||||
|
<script src="/assets/ywxapp/ywxapp.js" module="{$site.app}"></script>
|
||||||
|
<script>
|
||||||
|
layui.use(['table', 'form', 'layer', 'jquery', 'http'], function () {
|
||||||
|
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.jquery, http = layui.http;
|
||||||
|
|
||||||
|
table.render({
|
||||||
|
elem: '#dataTable',
|
||||||
|
url: 'index',
|
||||||
|
page: true,
|
||||||
|
limits: [10, 20, 50],
|
||||||
|
limit: 10,
|
||||||
|
cols: [[
|
||||||
|
{ field: 'id', title: 'ID', width: 70 },
|
||||||
|
{ field: 'username', title: '开发者', minWidth: 140 },
|
||||||
|
{ field: 'email', title: '邮箱', minWidth: 200 },
|
||||||
|
{ field: 'token', title: '令牌', minWidth: 320, templet: function (d) { return '<span style="word-break:break-all;">' + d.token + '</span>'; } },
|
||||||
|
{ field: 'status', title: '状态', width: 90, templet: '#statusTpl' },
|
||||||
|
{ field: 'created_at', title: '创建时间', width: 170, templet: function (d) { return d.created_at ? new Date(d.created_at * 1000).toLocaleString() : ''; } },
|
||||||
|
{ title: '操作', width: 260, toolbar: '#operateTpl', fixed: 'right' }
|
||||||
|
]],
|
||||||
|
response: { statusName: 'code', statusCode: 0, msgName: 'message', countName: 'count', dataName: 'data' }
|
||||||
|
});
|
||||||
|
|
||||||
|
form.on('submit(search)', function (data) {
|
||||||
|
table.reload('dataTable', { where: data.field, page: { curr: 1 } });
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
table.on('tool(dataTable)', function (elem) {
|
||||||
|
var d = elem.data, id = d.id;
|
||||||
|
if (elem.event === 'approve') {
|
||||||
|
layer.confirm('确认' + (d.status == 0 ? '通过该开发者并邮件下发令牌?' : '启用该开发者?'), function () {
|
||||||
|
http.post('approve', { id: id }).then(function (r) {
|
||||||
|
if (r.code === 0) { layer.msg('操作成功', { icon: 1 }); table.reload('dataTable'); }
|
||||||
|
else { layer.msg(r.message || '操作失败', { icon: 2 }); }
|
||||||
|
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
|
||||||
|
});
|
||||||
|
} else if (elem.event === 'disable') {
|
||||||
|
layer.confirm('确认禁用该开发者?其令牌将立即失效。', function () {
|
||||||
|
http.post('disable', { id: id }).then(function (r) {
|
||||||
|
if (r.code === 0) { layer.msg('已禁用', { icon: 1 }); table.reload('dataTable'); }
|
||||||
|
else { layer.msg(r.message || '操作失败', { icon: 2 }); }
|
||||||
|
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
|
||||||
|
});
|
||||||
|
} else if (elem.event === 'resend') {
|
||||||
|
http.post('resend', { id: id }).then(function (r) {
|
||||||
|
layer.msg(r.message || (r.code === 0 ? '已重发' : '失败'), { icon: r.code === 0 ? 1 : 2 });
|
||||||
|
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
|
||||||
|
} else if (elem.event === 'reset') {
|
||||||
|
layer.confirm('确认重置令牌?旧令牌将失效。', function () {
|
||||||
|
http.post('reset', { id: id }).then(function (r) {
|
||||||
|
if (r.code === 0) { layer.msg('已重置' + (r.data && r.data.token ? ',新令牌:' + r.data.token : ''), { icon: 1, time: 4000 }); table.reload('dataTable'); }
|
||||||
|
else { layer.msg(r.message || '操作失败', { icon: 2 }); }
|
||||||
|
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">主框架版本管理(升级服务器)</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<blockquote class="layui-elem-quote layui-quote-nm" style="margin-bottom:12px;">
|
||||||
|
同一版本号可先后上传「整包」「增量补丁」「完整安装包」,三者共存于同一条版本记录;
|
||||||
|
客户端升级时自动择优(正好停在补丁基础版本走补丁,否则走整包),完整安装包供全新部署下载。重新上传任一包后需再次「发布」。
|
||||||
|
</blockquote>
|
||||||
|
<form class="layui-form" id="uploadForm">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">版本号</label>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<input type="text" name="version" placeholder="x.y.z" class="layui-input" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">标题</label>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<input type="text" name="title" placeholder="可选,如 安全修复" class="layui-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">类型</label>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<select name="type" id="pkgType" class="layui-input">
|
||||||
|
<option value="0">整包(full)</option>
|
||||||
|
<option value="1">增量补丁(patch)</option>
|
||||||
|
<option value="2">完整安装包(install)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">基础版本</label>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<input type="text" name="from_version" id="fromVersion" placeholder="仅增量补丁需要,如 1.2.0" class="layui-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<button type="button" class="layui-btn" id="btnUpload">
|
||||||
|
<i class="layui-icon layui-icon-upload"></i> 上传核心包
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">更新日志</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<textarea name="changelog" class="layui-textarea" placeholder="更新内容..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
layui.use(['table', 'upload', 'jquery', 'layer'], function () {
|
||||||
|
var table = layui.table, upload = layui.upload, $ = layui.jquery, layer = layui.layer;
|
||||||
|
table.render({
|
||||||
|
elem: '#dataTable',
|
||||||
|
url: '{:url("/appmall/backend/framework/index")}',
|
||||||
|
page: true,
|
||||||
|
cols: [[
|
||||||
|
{field: 'id', title: 'ID', width: 70},
|
||||||
|
{field: 'version', title: '版本号', width: 120},
|
||||||
|
{title: '包', width: 200, templet: function (d) {
|
||||||
|
var h = '';
|
||||||
|
h += d.file_path ? '<span class="layui-badge layui-bg-blue">整包</span> '
|
||||||
|
: '<span class="layui-badge layui-bg-gray">无整包</span> ';
|
||||||
|
h += d.patch_path ? '<span class="layui-badge layui-bg-orange">补丁</span> ' : '';
|
||||||
|
h += d.install_path ? '<span class="layui-badge layui-bg-cyan">安装包</span>' : '';
|
||||||
|
return h;
|
||||||
|
}},
|
||||||
|
{field: 'patch_from', title: '补丁基础版本', width: 110, templet: function (d) {
|
||||||
|
return d.patch_from || d.from_version || '';
|
||||||
|
}},
|
||||||
|
{field: 'title', title: '标题'},
|
||||||
|
{field: 'changelog', title: '更新日志'},
|
||||||
|
{field: 'status', title: '状态', width: 90, templet: function (d) {
|
||||||
|
return d.status == 1
|
||||||
|
? '<span class="layui-badge layui-bg-green">已发布</span>'
|
||||||
|
: '<span class="layui-badge">草稿</span>';
|
||||||
|
}},
|
||||||
|
{field: 'download_count', title: '下载数', width: 90},
|
||||||
|
{title: '操作', width: 360, templet: function (d) {
|
||||||
|
var h = '';
|
||||||
|
if (d.status != 1) h += '<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="publish">发布</a>';
|
||||||
|
if (d.file_path) h += '<a class="layui-btn layui-btn-xs" lay-event="download">下载整包</a>';
|
||||||
|
if (d.patch_path) h += '<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="downloadPatch">下载补丁</a>';
|
||||||
|
if (d.install_path) h += '<a class="layui-btn layui-btn-xs layui-bg-cyan" lay-event="downloadInstall">下载安装包</a>';
|
||||||
|
h += '<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="del">删除</a>';
|
||||||
|
return h;
|
||||||
|
}}
|
||||||
|
]]
|
||||||
|
});
|
||||||
|
upload.render({
|
||||||
|
elem: '#btnUpload',
|
||||||
|
url: '{:url("/appmall/backend/framework/upload")}',
|
||||||
|
accept: 'file',
|
||||||
|
exts: 'zip',
|
||||||
|
before: function () {
|
||||||
|
if (!$('input[name="version"]').val()) {
|
||||||
|
layer.msg('请先填写版本号'); return false;
|
||||||
|
}
|
||||||
|
if ($('#pkgType').val() == '1' && !$('#fromVersion').val()) {
|
||||||
|
layer.msg('增量补丁必须填写基础版本'); return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
version: function () { return $('input[name="version"]').val(); },
|
||||||
|
title: function () { return $('input[name="title"]').val(); },
|
||||||
|
changelog: function () { return $('textarea[name="changelog"]').val(); },
|
||||||
|
type: function () { return $('#pkgType').val(); },
|
||||||
|
from_version: function () { return $('#fromVersion').val(); }
|
||||||
|
},
|
||||||
|
done: function (res) {
|
||||||
|
layer.msg(res.message || '完成');
|
||||||
|
if (res.code === 0) table.reload('dataTable');
|
||||||
|
},
|
||||||
|
error: function () { layer.msg('上传失败'); }
|
||||||
|
});
|
||||||
|
table.on('tool(dataTable)', function (obj) {
|
||||||
|
var d = obj.data;
|
||||||
|
if (obj.event === 'publish') {
|
||||||
|
$.post('{:url("/appmall/backend/framework/publish")}', {id: d.id}, function (r) {
|
||||||
|
layer.msg(r.message); if (r.code === 0) table.reload('dataTable');
|
||||||
|
});
|
||||||
|
} else if (obj.event === 'download') {
|
||||||
|
location.href = '{:url("/appmall/backend/framework/download")}?id=' + d.id + '&type=full';
|
||||||
|
} else if (obj.event === 'downloadPatch') {
|
||||||
|
location.href = '{:url("/appmall/backend/framework/download")}?id=' + d.id + '&type=patch';
|
||||||
|
} else if (obj.event === 'downloadInstall') {
|
||||||
|
location.href = '{:url("/appmall/backend/framework/download")}?id=' + d.id + '&type=install';
|
||||||
|
} else if (obj.event === 'del') {
|
||||||
|
layer.confirm('确定删除该版本?', function () {
|
||||||
|
$.post('{:url("/appmall/backend/framework/delete")}', {id: d.id}, function (r) {
|
||||||
|
layer.msg(r.message); if (r.code === 0) table.reload('dataTable');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
|
||||||
|
<div class="layui-card" style="margin:20px;">
|
||||||
|
<div class="layui-card-header">收益账本(开发者分红)</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<div class="layui-row" id="summary" style="margin-bottom:12px;"></div>
|
||||||
|
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
layui.use(['table', 'jquery'], function () {
|
||||||
|
var table = layui.table, $ = layui.jquery;
|
||||||
|
var summaryData = null;
|
||||||
|
table.render({
|
||||||
|
elem: '#dataTable',
|
||||||
|
url: '{:url("/appmall/backend/revenue/index")}',
|
||||||
|
page: true,
|
||||||
|
// Result::success 返回 code:0 / message / data:{list,summary} / count
|
||||||
|
response: { statusName: 'code', msgName: 'message', countName: 'count', dataName: 'data' },
|
||||||
|
parseData: function (res) {
|
||||||
|
summaryData = (res.data && res.data.summary) ? res.data.summary : null;
|
||||||
|
return {
|
||||||
|
code: res.code,
|
||||||
|
msg: res.message,
|
||||||
|
count: res.count || 0,
|
||||||
|
data: (res.data && res.data.list) ? res.data.list : []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
cols: [[
|
||||||
|
{field: 'id', title: 'ID', width: 70},
|
||||||
|
{field: 'developer_id', title: '开发者ID', width: 100},
|
||||||
|
{field: 'aid', title: '商品ID', width: 90},
|
||||||
|
{field: 'uid', title: '购买用户', width: 90},
|
||||||
|
{field: 'amount', title: '订单金额', width: 100},
|
||||||
|
{field: 'commission', title: '平台抽成', width: 100},
|
||||||
|
{field: 'income', title: '开发者收益', width: 110, templet: function(d){return '<b>'+d.income+'</b>';}},
|
||||||
|
{field: 'status', title: '结算', width: 90, templet: function(d){return d.status==1?'<span class="layui-badge layui-bg-green">已结算</span>':'<span class="layui-badge">待结算</span>';}},
|
||||||
|
{field: 'create_at', title: '时间', width: 170, templet: function(d){return layui.util.toDateString(d.create_at*1000);}}
|
||||||
|
]],
|
||||||
|
done: function () {
|
||||||
|
if (summaryData) {
|
||||||
|
$('#summary').html(
|
||||||
|
'总销售额:<b>' + summaryData.total_amount + '</b> | ' +
|
||||||
|
'平台抽成:<b>' + summaryData.total_commission + '</b> | ' +
|
||||||
|
'开发者收益:<b style="color:#16a34a;">' + summaryData.total_income + '</b>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
|
||||||
|
<div class="layui-card" style="margin:20px;">
|
||||||
|
<div class="layui-card-header">提现管理(开发者分红结算)</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
layui.use(['table', 'jquery', 'layer'], function () {
|
||||||
|
var table = layui.table, $ = layui.jquery, layer = layui.layer;
|
||||||
|
table.render({
|
||||||
|
elem: '#dataTable',
|
||||||
|
url: '{:url("/appmall/backend/revenue/withdrawals")}',
|
||||||
|
page: true,
|
||||||
|
cols: [[
|
||||||
|
{field: 'id', title: 'ID', width: 70},
|
||||||
|
{field: 'developer_id', title: '开发者ID', width: 100},
|
||||||
|
{field: 'amount', title: '提现金额', width: 110},
|
||||||
|
{field: 'channel', title: '渠道', width: 90},
|
||||||
|
{field: 'account', title: '收款账号', width: 180},
|
||||||
|
{field: 'status', title: '状态', width: 100, templet: function(d){
|
||||||
|
return d.status==1?'<span class="layui-badge layui-bg-green">已打款</span>'
|
||||||
|
: (d.status==-1?'<span class="layui-badge layui-bg-orange">已驳回</span>'
|
||||||
|
:'<span class="layui-badge">待处理</span>');
|
||||||
|
}},
|
||||||
|
{field: 'create_at', title: '申请时间', width: 170, templet: function(d){return layui.util.toDateString(d.create_at*1000);}},
|
||||||
|
{title: '操作', width: 160, templet: function(d){
|
||||||
|
if(d.status!=0) return '-';
|
||||||
|
return '<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="settle">打款</a>'
|
||||||
|
+ '<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="reject">驳回</a>';
|
||||||
|
}}
|
||||||
|
]]
|
||||||
|
});
|
||||||
|
table.on('tool(dataTable)', function(obj){
|
||||||
|
var d = obj.data;
|
||||||
|
if(obj.event==='settle'){
|
||||||
|
layer.confirm('确认已向开发者打款?', function(){
|
||||||
|
$.post('{:url("revenue/settle")}', {id:d.id}, function(r){layer.msg(r.message||r.msg);if(r.code===0)table.reload('dataTable');});
|
||||||
|
});
|
||||||
|
} else if(obj.event==='reject'){
|
||||||
|
layer.prompt({title:'驳回原因'}, function(val, index){
|
||||||
|
$.post('{:url("revenue/reject")}', {id:d.id, reason:val}, function(r){layer.msg(r.message||r.msg);if(r.code===0)table.reload('dataTable');layer.close(index);});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>开发者控制台 - YwxApp 应用市场</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link href="/assets/layui/css/layui.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { background: #f2f3f5; color: #333; }
|
||||||
|
.dev-box { max-width: 720px; margin: 40px auto; background: #fff; padding: 28px 32px; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,.08); }
|
||||||
|
.dev-box h2 { margin-bottom: 4px; }
|
||||||
|
.dev-tip { color: #999; font-size: 13px; margin-bottom: 18px; }
|
||||||
|
.balance-row { display: flex; gap: 16px; margin: 18px 0; }
|
||||||
|
.balance-card { flex: 1; background: #f7f9fc; border-radius: 8px; padding: 16px; text-align: center; }
|
||||||
|
.balance-card .label { color: #888; font-size: 13px; }
|
||||||
|
.balance-card .val { font-size: 24px; font-weight: 700; margin-top: 6px; }
|
||||||
|
.balance-card .val.ok { color: #18a058; }
|
||||||
|
.balance-card .val.free { color: #d48806; }
|
||||||
|
.rev-table { margin-top: 10px; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="dev-box">
|
||||||
|
<h2>开发者控制台</h2>
|
||||||
|
<p class="dev-tip">使用开发者令牌查看收益并发起提现(令牌可在「我的令牌」页查询)</p>
|
||||||
|
|
||||||
|
<form class="layui-form" id="authForm">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">开发者令牌</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="token" id="tokenInput" value="{$token|default=''}" required placeholder="粘贴你的开发者令牌" class="layui-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<button class="layui-btn" lay-submit lay-filter="load">加载我的收益</button>
|
||||||
|
<a href="{:url('index')}" class="layui-btn layui-btn-primary">返回注册</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="panel" class="hidden">
|
||||||
|
<div class="balance-row">
|
||||||
|
<div class="balance-card">
|
||||||
|
<div class="label">可提现余额</div>
|
||||||
|
<div class="val ok" id="balance">¥0.00</div>
|
||||||
|
</div>
|
||||||
|
<div class="balance-card">
|
||||||
|
<div class="label">冻结中(提现待打款)</div>
|
||||||
|
<div class="val free" id="frozen">¥0.00</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="layui-elem-field layui-field-title" style="margin-top:18px;"><legend>发起提现</legend></fieldset>
|
||||||
|
<form class="layui-form" id="withdrawForm">
|
||||||
|
<input type="hidden" name="token" id="wToken">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">提现金额</label>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<input type="number" name="amount" step="0.01" min="0.01" required placeholder="0.00" class="layui-input">
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-mid layui-word-aux">不可超过可提现余额</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">提现渠道</label>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<select name="channel">
|
||||||
|
<option value="alipay">支付宝</option>
|
||||||
|
<option value="wechat">微信</option>
|
||||||
|
<option value="bank">银行卡</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">收款账号</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="account" required placeholder="支付宝/微信账号或银行卡号" class="layui-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<button class="layui-btn layui-btn-normal" lay-submit lay-filter="doWithdraw">提交提现申请</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<fieldset class="layui-elem-field layui-field-title" style="margin-top:24px;"><legend>近期收益</legend></fieldset>
|
||||||
|
<table class="layui-table rev-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>订单号</th><th>销售额</th><th>抽成</th><th>应得</th><th>状态</th><th>时间</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="revBody">
|
||||||
|
<tr><td colspan="6" style="text-align:center;color:#999;">暂无收益记录</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/assets/layui/layui.js"></script>
|
||||||
|
<script>
|
||||||
|
layui.use(['form', 'jquery', 'layer', 'http'], function () {
|
||||||
|
var form = layui.form, $ = layui.jquery, layer = layui.layer, http = layui.http;
|
||||||
|
|
||||||
|
form.on('submit(load)', function (data) {
|
||||||
|
http.post('{:url("/appmall/developer/earnings")}', data.field).then(function (r) {
|
||||||
|
if (r.code !== 0) { layer.msg(r.message || '加载失败', { icon: 2 }); return; }
|
||||||
|
var d = r.data || {};
|
||||||
|
$('#balance').text('¥' + parseFloat(d.balance || 0).toFixed(2));
|
||||||
|
$('#frozen').text('¥' + parseFloat(d.frozen_balance || 0).toFixed(2));
|
||||||
|
$('#wToken').val(data.field.token);
|
||||||
|
renderRevenues(d.revenues || []);
|
||||||
|
$('#panel').removeClass('hidden');
|
||||||
|
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
form.on('submit(doWithdraw)', function (data) {
|
||||||
|
var amount = parseFloat(data.field.amount);
|
||||||
|
if (!(amount > 0)) { layer.msg('请输入正确的提现金额', { icon: 2 }); return false; }
|
||||||
|
var avail = parseFloat($('#balance').text().replace(/[^\d.]/g, '')) || 0;
|
||||||
|
if (amount > avail) { layer.msg('提现金额超过可提现余额', { icon: 2 }); return false; }
|
||||||
|
http.post('{:url("/appmall/developer/withdraw")}', data.field).then(function (r) {
|
||||||
|
layer.msg(r.message || '提交完成', { icon: r.code === 0 ? 1 : 2 });
|
||||||
|
if (r.code === 0) { $('#withdrawForm')[0].reset(); form.render(); }
|
||||||
|
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderRevenues(list) {
|
||||||
|
var body = $('#revBody');
|
||||||
|
if (!list.length) {
|
||||||
|
body.html('<tr><td colspan="6" style="text-align:center;color:#999;">暂无收益记录</td></tr>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '';
|
||||||
|
list.forEach(function (it) {
|
||||||
|
var st = it.status == 1 ? '<span style="color:#18a058;">已结算</span>' : '<span style="color:#d48806;">待结算</span>';
|
||||||
|
html += '<tr>'
|
||||||
|
+ '<td>' + (it.order_id || '-') + '</td>'
|
||||||
|
+ '<td>¥' + parseFloat(it.amount || 0).toFixed(2) + '</td>'
|
||||||
|
+ '<td>¥' + parseFloat(it.commission || 0).toFixed(2) + '</td>'
|
||||||
|
+ '<td>¥' + parseFloat(it.income || 0).toFixed(2) + '</td>'
|
||||||
|
+ '<td>' + st + '</td>'
|
||||||
|
+ '<td>' + (it.create_at ? new Date(it.create_at * 1000).toLocaleString() : '-') + '</td>'
|
||||||
|
+ '</tr>';
|
||||||
|
});
|
||||||
|
body.html(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若 URL 携带 token,自动加载
|
||||||
|
var tok = $('#tokenInput').val();
|
||||||
|
if (tok) { $('#authForm').find('button[lay-filter="load"]').click(); }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<!--
|
||||||
|
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @Description: 主框架发布/下载展示页(公开,仅中心站)
|
||||||
|
-->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>主框架下载 - {$market_name}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link href="/assets/layui/css/layui.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--brand: #4f46e5; --brand-2: #7c3aed; --ink: #1f2329;
|
||||||
|
--muted: #8a8f99; --line: #eceef2; --bg: #f5f6fa;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; background: var(--bg); color: var(--ink);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
.brandbar {
|
||||||
|
height: 56px; display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 0 24px; background: #fff; border-bottom: 1px solid var(--line);
|
||||||
|
position: sticky; top: 0; z-index: 20;
|
||||||
|
}
|
||||||
|
.brandbar .logo {
|
||||||
|
width: 30px; height: 30px; border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, var(--brand), var(--brand-2));
|
||||||
|
display: grid; place-items: center; color: #fff; font-weight: 700;
|
||||||
|
}
|
||||||
|
.brandbar .name { font-weight: 600; font-size: 16px; }
|
||||||
|
.brandbar .nav { margin-left: auto; display: flex; gap: 22px; font-size: 14px; }
|
||||||
|
.brandbar .nav a { color: var(--muted); text-decoration: none; transition: color .2s; }
|
||||||
|
.brandbar .nav a:hover, .brandbar .nav a.on { color: var(--brand); }
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 54px 24px 46px; text-align: center; color: #fff;
|
||||||
|
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 55%, #9333ea 100%);
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.hero::after {
|
||||||
|
content: ""; position: absolute; inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(420px 220px at 12% 0%, rgba(255,255,255,.18), transparent 60%),
|
||||||
|
radial-gradient(380px 200px at 88% 100%, rgba(255,255,255,.12), transparent 60%);
|
||||||
|
}
|
||||||
|
.hero h1 { margin: 0 0 10px; font-size: 32px; position: relative; }
|
||||||
|
.hero p { margin: 0; opacity: .92; font-size: 15px; position: relative; }
|
||||||
|
|
||||||
|
.wrap { max-width: 960px; margin: 36px auto 60px; padding: 0 24px; }
|
||||||
|
.ver-card {
|
||||||
|
background: #fff; border: 1px solid var(--line); border-radius: 16px;
|
||||||
|
padding: 22px 26px; margin-bottom: 18px;
|
||||||
|
transition: transform .22s, box-shadow .22s, border-color .22s;
|
||||||
|
}
|
||||||
|
.ver-card:hover { transform: translateY(-4px); box-shadow: 0 16px 36px rgba(79,70,229,.14); border-color: transparent; }
|
||||||
|
.ver-top { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.ver-title { font-size: 20px; font-weight: 600; }
|
||||||
|
.ver-title .v { color: #16a34a; }
|
||||||
|
.ver-date { color: var(--muted); font-size: 13px; }
|
||||||
|
.ver-sub { color: #6b7280; font-size: 14px; margin: 10px 0 14px; }
|
||||||
|
.ver-sub b { color: var(--ink); }
|
||||||
|
.ver-log {
|
||||||
|
background: #f7f8fa; border: 1px solid var(--line); border-radius: 10px;
|
||||||
|
padding: 12px 14px; font-size: 13px; color: #555; white-space: pre-wrap;
|
||||||
|
margin-bottom: 16px; max-height: 160px; overflow: auto;
|
||||||
|
}
|
||||||
|
.dl-btns { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.dl-btns a {
|
||||||
|
text-decoration: none; font-size: 13px; color: #fff; padding: 9px 16px; border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, var(--brand), var(--brand-2)); transition: opacity .2s, transform .1s;
|
||||||
|
}
|
||||||
|
.dl-btns a:hover { opacity: .9; }
|
||||||
|
.dl-btns a:active { transform: scale(.97); }
|
||||||
|
.dl-btns a.install { background: linear-gradient(135deg, #722ed1, #9254de); }
|
||||||
|
.empty { text-align: center; color: var(--muted); padding: 60px 0; }
|
||||||
|
footer { text-align: center; color: var(--muted); font-size: 12px; padding: 26px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="brandbar">
|
||||||
|
<div class="logo">Y</div>
|
||||||
|
<div class="name">{$market_name}</div>
|
||||||
|
<div class="nav">
|
||||||
|
<a href="/appmall/store">应用市场</a>
|
||||||
|
<a href="/appmall/framework" class="on">主框架</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h1>YwxApp 主框架下载</h1>
|
||||||
|
<p>完整安装包用于全新部署,整包 / 补丁用于已安装站点的在线升级。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
{if empty($list)}
|
||||||
|
<div class="empty">暂未发布任何框架版本。</div>
|
||||||
|
{else}
|
||||||
|
{foreach $list as $item}
|
||||||
|
<div class="ver-card">
|
||||||
|
<div class="ver-top">
|
||||||
|
<div class="ver-title">{$item.title|default='主框架'} <span class="v">v{$item.version}</span></div>
|
||||||
|
<div class="ver-date">发布于 {$item.create_at_fmt} · 下载 {$item.download_count} 次</div>
|
||||||
|
</div>
|
||||||
|
{if $item.patch_from}
|
||||||
|
<div class="ver-sub">增量补丁适用基础版本:<b>{$item.patch_from}</b></div>
|
||||||
|
{/if}
|
||||||
|
{if $item.changelog}
|
||||||
|
<div class="ver-log">{$item.changelog}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="dl-btns">
|
||||||
|
{if $item.has_full}
|
||||||
|
<a href="/appmall/api/framework/download?version={$item.version}&type=full">
|
||||||
|
<i class="layui-icon layui-icon-download-circle"></i> 下载整包(升级)
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{if $item.has_patch}
|
||||||
|
<a href="/appmall/api/framework/download?version={$item.version}&type=patch">
|
||||||
|
<i class="layui-icon layui-icon-download-circle"></i> 下载补丁(升级)
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{if $item.has_install}
|
||||||
|
<a class="install" href="/appmall/api/framework/download?version={$item.version}&type=install">
|
||||||
|
<i class="layui-icon layui-icon-download-circle"></i> 下载完整安装包(全新部署)
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/foreach}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>© {:date('Y')} {$market_name} · 由 YwxApp 框架强力驱动</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
<!--
|
||||||
|
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @Description: 插件详情页(公开,仅中心站)· 参考 FastAdmin 插件市场详情版式
|
||||||
|
* 布局:Hero 标题区 → 左主内容(功能介绍/预览截图/更新日志) + 右 sticky 信息栏(价格/目录)
|
||||||
|
-->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{$addon.title} - 应用市场</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link href="/assets/layui/css/layui.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--brand: #4f46e5; --brand-2: #7c3aed; --ink: #1f2329;
|
||||||
|
--muted: #8a8f99; --line: #eceef2; --bg: #f5f6fa;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--bg); color: var(--ink);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased; }
|
||||||
|
a { text-decoration: none; }
|
||||||
|
.brandbar { height: 56px; display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 0 24px; background: #fff; border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 30; }
|
||||||
|
.brandbar .logo { width: 30px; height: 30px; border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, var(--brand), var(--brand-2)); display: grid; place-items: center; color: #fff; font-weight: 700; }
|
||||||
|
.brandbar .name { font-weight: 600; font-size: 16px; }
|
||||||
|
.brandbar .nav { margin-left: auto; display: flex; gap: 22px; font-size: 14px; }
|
||||||
|
.brandbar .nav a { color: var(--muted); transition: color .2s; }
|
||||||
|
.brandbar .nav a:hover, .brandbar .nav a.on { color: var(--brand); }
|
||||||
|
|
||||||
|
.crumb { max-width: 1120px; margin: 16px auto 0; padding: 0 24px; font-size: 13px; color: var(--muted); }
|
||||||
|
.crumb a { color: var(--muted); }
|
||||||
|
.crumb a:hover { color: var(--brand); }
|
||||||
|
|
||||||
|
/* Hero */
|
||||||
|
.hero { max-width: 1120px; margin: 14px auto 0; padding: 30px 36px; background: #fff; border: 1px solid var(--line); border-radius: 18px;
|
||||||
|
display: flex; gap: 24px; align-items: center; position: relative; overflow: hidden; }
|
||||||
|
.hero::before { content: ""; position: absolute; right: -60px; top: -60px; width: 240px; height: 240px; border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(124,58,237,.10), transparent 70%); }
|
||||||
|
.hero .ava { width: 88px; height: 88px; border-radius: 20px; flex: none; overflow: hidden; background: #f0f1f5;
|
||||||
|
display: grid; place-items: center; color: var(--brand); font-size: 34px; font-weight: 700; box-shadow: 0 8px 22px rgba(79,70,229,.18); }
|
||||||
|
.hero .ava img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.hero .h-main { min-width: 0; flex: 1; }
|
||||||
|
.hero .h-title { font-size: 26px; font-weight: 700; display: flex; align-items: center; gap: 12px; }
|
||||||
|
.hero .h-title .ver { font-size: 13px; color: #16a34a; background: rgba(22,163,74,.1); border-radius: 6px; padding: 3px 9px; font-weight: 500; }
|
||||||
|
.hero .h-desc { color: #5b606b; font-size: 14px; margin-top: 8px; line-height: 1.7; }
|
||||||
|
.hero .h-actions { display: flex; gap: 12px; margin-top: 16px; flex-wrap: wrap; }
|
||||||
|
.btn-primary { display: inline-flex; align-items: center; gap: 6px; color: #fff; font-size: 14px; font-weight: 600;
|
||||||
|
padding: 11px 22px; border-radius: 11px; background: linear-gradient(135deg, var(--brand), var(--brand-2)); transition: .2s, transform .1s; }
|
||||||
|
.btn-primary:hover { opacity: .92; }
|
||||||
|
.btn-primary:active { transform: scale(.98); }
|
||||||
|
.btn-ghost { display: inline-flex; align-items: center; gap: 6px; color: var(--brand); font-size: 14px; font-weight: 600;
|
||||||
|
padding: 11px 22px; border-radius: 11px; background: #fff; border: 1px solid var(--brand); transition: .2s; }
|
||||||
|
.btn-ghost:hover { background: rgba(79,70,229,.06); }
|
||||||
|
|
||||||
|
/* 主体双栏 */
|
||||||
|
.main { max-width: 1120px; margin: 24px auto 60px; padding: 0 24px; display: grid; gap: 24px;
|
||||||
|
grid-template-columns: 1fr 320px; align-items: start; }
|
||||||
|
.col-right { position: sticky; top: 76px; display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
|
||||||
|
.panel { background: #fff; border: 1px solid var(--line); border-radius: 16px; padding: 24px; }
|
||||||
|
.panel + .panel { margin-top: 24px; }
|
||||||
|
.panel h3 { margin: 0 0 16px; font-size: 17px; display: flex; align-items: center; gap: 9px; }
|
||||||
|
.panel h3::before { content: ""; width: 4px; height: 17px; border-radius: 2px; background: linear-gradient(var(--brand), var(--brand-2)); }
|
||||||
|
.prose { color: #4b515c; font-size: 14px; line-height: 1.9; white-space: pre-wrap; }
|
||||||
|
|
||||||
|
/* 截图 */
|
||||||
|
.cover { width: 100%; aspect-ratio: 16/9; border-radius: 14px; overflow: hidden; background: #f0f1f5;
|
||||||
|
display: grid; place-items: center; color: var(--brand); font-size: 36px; font-weight: 700; }
|
||||||
|
.cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.thumbs { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
|
||||||
|
.thumbs img { width: 104px; height: 64px; object-fit: cover; border-radius: 9px; border: 2px solid transparent; cursor: pointer; transition: .18s; }
|
||||||
|
.thumbs img:hover, .thumbs img.on { border-color: var(--brand); }
|
||||||
|
|
||||||
|
/* 更新日志时间线 */
|
||||||
|
.timeline { position: relative; padding-left: 22px; }
|
||||||
|
.timeline::before { content: ""; position: absolute; left: 5px; top: 6px; bottom: 6px; width: 2px; background: var(--line); }
|
||||||
|
.tl-item { position: relative; padding-bottom: 22px; }
|
||||||
|
.tl-item:last-child { padding-bottom: 0; }
|
||||||
|
.tl-item::before { content: ""; position: absolute; left: -21px; top: 5px; width: 12px; height: 12px; border-radius: 50%;
|
||||||
|
background: #fff; border: 3px solid var(--brand); }
|
||||||
|
.tl-item.first::before { background: var(--brand); box-shadow: 0 0 0 4px rgba(79,70,229,.18); }
|
||||||
|
.tl-head { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.tl-ver { font-weight: 700; font-size: 14px; }
|
||||||
|
.tl-badge { font-size: 11px; color: #16a34a; background: rgba(22,163,74,.1); border-radius: 6px; padding: 2px 8px; }
|
||||||
|
.tl-date { color: var(--muted); font-size: 12px; margin-left: auto; }
|
||||||
|
.tl-note { color: #5b606b; font-size: 13px; line-height: 1.7; margin-top: 6px; white-space: pre-wrap; }
|
||||||
|
|
||||||
|
/* 右栏:价格卡 + 信息 + 目录 */
|
||||||
|
.price-card { background: linear-gradient(135deg, rgba(79,70,229,.07), rgba(124,58,237,.07));
|
||||||
|
border: 1px solid rgba(124,58,237,.18); border-radius: 14px; padding: 18px 20px; }
|
||||||
|
.price-card .price { font-size: 30px; font-weight: 800; color: var(--brand); }
|
||||||
|
.price-card .price.free { color: #16a34a; }
|
||||||
|
.price-card .sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||||
|
.buy-btn { display: block; text-align: center; color: #fff; font-weight: 600; font-size: 15px; padding: 13px;
|
||||||
|
border-radius: 11px; background: linear-gradient(135deg, var(--brand), var(--brand-2)); margin-top: 14px; transition: .2s, transform .1s; }
|
||||||
|
.buy-btn:hover { opacity: .92; }
|
||||||
|
.buy-btn:active { transform: scale(.98); }
|
||||||
|
.buy-btn.ghost { background: #fff; color: var(--brand); border: 1px solid var(--brand); margin-top: 10px; }
|
||||||
|
|
||||||
|
.kv { font-size: 13px; }
|
||||||
|
.kv .row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px dashed var(--line); }
|
||||||
|
.kv .row:last-child { border-bottom: 0; }
|
||||||
|
.kv .k { color: var(--muted); }
|
||||||
|
.kv .v { color: var(--ink); text-align: right; }
|
||||||
|
.stars { color: #f5a623; letter-spacing: 1px; }
|
||||||
|
.stars .off { color: #e2e4ea; }
|
||||||
|
.tags { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
|
||||||
|
.tags span { font-size: 12px; color: var(--brand); background: rgba(79,70,229,.1); border-radius: 6px; padding: 4px 10px; }
|
||||||
|
|
||||||
|
.toc { font-size: 13px; }
|
||||||
|
.toc .t { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
|
||||||
|
.toc a { display: block; color: #5b606b; padding: 7px 0; border-left: 2px solid transparent; padding-left: 12px; transition: .18s; }
|
||||||
|
.toc a:hover { color: var(--brand); border-left-color: var(--brand); }
|
||||||
|
|
||||||
|
footer { text-align: center; color: var(--muted); font-size: 12px; padding: 26px; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.main { grid-template-columns: 1fr; }
|
||||||
|
.col-right { position: static; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="brandbar">
|
||||||
|
<div class="logo">Y</div>
|
||||||
|
<div class="name">{$market_name}</div>
|
||||||
|
<div class="nav">
|
||||||
|
<a href="/appmall/store" class="on">应用市场</a>
|
||||||
|
<a href="/appmall/framework">主框架</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="crumb">
|
||||||
|
<a href="/appmall/store">应用市场</a> / <span>{$addon.title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hero -->
|
||||||
|
<div class="hero">
|
||||||
|
<div class="ava">
|
||||||
|
{if $addon.logo}<img src="{$addon.logo}" alt="{$addon.title}">{else}{$addon.title|substr=0,1}{/if}
|
||||||
|
</div>
|
||||||
|
<div class="h-main">
|
||||||
|
<div class="h-title">{$addon.title}<span class="ver">v{$addon.version}</span></div>
|
||||||
|
<div class="h-desc">{if $addon.author}by {$addon.author} · {/if}{$addon.category|default='未分类'}</div>
|
||||||
|
<div class="h-actions">
|
||||||
|
<a class="btn-primary" href="#"><i class="layui-icon layui-icon-play"></i> 立即体验</a>
|
||||||
|
<a class="btn-ghost" href="#"><i class="layui-icon layui-icon-cart"></i> 立即购买</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<!-- 左主内容 -->
|
||||||
|
<div class="col-left">
|
||||||
|
<div class="panel" id="intro">
|
||||||
|
<h3>功能介绍</h3>
|
||||||
|
<div class="prose">{$addon.intro|default='暂无介绍'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{if $addon.screenshots}
|
||||||
|
<div class="panel" id="shots">
|
||||||
|
<h3>预览截图</h3>
|
||||||
|
<div class="cover" id="cover">
|
||||||
|
<img src="{$addon.screenshots.0}" alt="{$addon.title}">
|
||||||
|
</div>
|
||||||
|
{if count($addon.screenshots) > 1}
|
||||||
|
<div class="thumbs" id="thumbs">
|
||||||
|
{foreach $addon.screenshots as $s}
|
||||||
|
<img src="{$s}" class="{if $s == $addon.screenshots.0}on{/if}" onclick="swap('{$s}', this)">
|
||||||
|
{/foreach}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="panel" id="log">
|
||||||
|
<h3>更新日志</h3>
|
||||||
|
{if empty($addon.versions)}
|
||||||
|
<div class="prose" style="color:var(--muted)">暂无版本记录</div>
|
||||||
|
{else}
|
||||||
|
<div class="timeline">
|
||||||
|
{foreach $addon.versions as $k=>$v}
|
||||||
|
<div class="tl-item {if $k == 0}first{/if}">
|
||||||
|
<div class="tl-head">
|
||||||
|
<span class="tl-ver">v{$v.version}</span>
|
||||||
|
{if $k == 0}<span class="tl-badge">最新版本</span>{/if}
|
||||||
|
<span class="tl-date">{$v.update_fmt}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tl-note">本版本定价:{$v.price_text}</div>
|
||||||
|
</div>
|
||||||
|
{/foreach}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右 sticky 信息栏 -->
|
||||||
|
<div class="col-right">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="kv">
|
||||||
|
<div class="row"><span class="k">当前版本</span><span class="v">v{$addon.version}</span></div>
|
||||||
|
<div class="row"><span class="k">更新时间</span><span class="v">{$addon.update_fmt}</span></div>
|
||||||
|
<div class="row"><span class="k">下载量</span><span class="v">{$addon.downloads}</span></div>
|
||||||
|
<div class="row"><span class="k">评分</span>
|
||||||
|
<span class="v">
|
||||||
|
<span class="stars">
|
||||||
|
{for start="1" end="6" name="i"}
|
||||||
|
{if $addon.rating >= $i}<i class="layui-icon layui-icon-rate-solid"></i>{else/}<i class="layui-icon layui-icon-rate off"></i>{/if}
|
||||||
|
{/for}
|
||||||
|
</span>
|
||||||
|
{$addon.rating}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row"><span class="k">开发者</span><span class="v">{$addon.author|default='官方'}</span></div>
|
||||||
|
<div class="row"><span class="k">分类</span><span class="v">{$addon.category|default='未分类'}</span></div>
|
||||||
|
</div>
|
||||||
|
{if $addon.tags}
|
||||||
|
<div class="tags">
|
||||||
|
{foreach explode(',', $addon.tags) as $t}
|
||||||
|
<span>{$t}</span>
|
||||||
|
{/foreach}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="toc">
|
||||||
|
<div class="t">本页目录</div>
|
||||||
|
<a href="#intro">功能介绍</a>
|
||||||
|
{if $addon.screenshots}<a href="#shots">预览截图</a>{/if}
|
||||||
|
<a href="#log">更新日志</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>© {:date('Y')} {$market_name} · 由 YwxApp 框架强力驱动</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function swap(src, el) {
|
||||||
|
document.querySelector('#cover img').src = src;
|
||||||
|
document.querySelectorAll('#thumbs img').forEach(function (im) { im.classList.remove('on'); });
|
||||||
|
el.classList.add('on');
|
||||||
|
}
|
||||||
|
// 锚点平滑滚动 + 目录高亮
|
||||||
|
document.querySelectorAll('.toc a').forEach(function (a) {
|
||||||
|
a.addEventListener('click', function (e) {
|
||||||
|
var id = this.getAttribute('href');
|
||||||
|
var target = document.querySelector(id);
|
||||||
|
if (target) { e.preventDefault(); target.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<!--
|
||||||
|
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @Description: 应用商店前台(公开,仅中心站)
|
||||||
|
-->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>应用市场 - {$market_name}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link href="/assets/layui/css/layui.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--brand: #4f46e5;
|
||||||
|
--brand-2: #7c3aed;
|
||||||
|
--ink: #1f2329;
|
||||||
|
--muted: #8a8f99;
|
||||||
|
--line: #eceef2;
|
||||||
|
--bg: #f5f6fa;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; background: var(--bg); color: var(--ink);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
.brandbar {
|
||||||
|
height: 56px; display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 0 24px; background: #fff; border-bottom: 1px solid var(--line);
|
||||||
|
position: sticky; top: 0; z-index: 20;
|
||||||
|
}
|
||||||
|
.brandbar .logo {
|
||||||
|
width: 30px; height: 30px; border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, var(--brand), var(--brand-2));
|
||||||
|
display: grid; place-items: center; color: #fff; font-weight: 700;
|
||||||
|
}
|
||||||
|
.brandbar .name { font-weight: 600; font-size: 16px; }
|
||||||
|
.brandbar .nav { margin-left: auto; display: flex; gap: 22px; font-size: 14px; }
|
||||||
|
.brandbar .nav a { color: var(--muted); text-decoration: none; transition: color .2s; }
|
||||||
|
.brandbar .nav a:hover, .brandbar .nav a.on { color: var(--brand); }
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 54px 24px 46px; text-align: center; color: #fff;
|
||||||
|
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 55%, #9333ea 100%);
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.hero::after {
|
||||||
|
content: ""; position: absolute; inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(420px 220px at 12% 0%, rgba(255,255,255,.18), transparent 60%),
|
||||||
|
radial-gradient(380px 200px at 88% 100%, rgba(255,255,255,.12), transparent 60%);
|
||||||
|
}
|
||||||
|
.hero h1 { margin: 0 0 10px; font-size: 32px; letter-spacing: .5px; position: relative; }
|
||||||
|
.hero p { margin: 0; opacity: .92; font-size: 15px; position: relative; }
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
max-width: 1180px; margin: -26px auto 0; padding: 0 24px; position: relative; z-index: 5;
|
||||||
|
}
|
||||||
|
.toolbar .inner {
|
||||||
|
background: #fff; border-radius: 14px; box-shadow: 0 8px 30px rgba(31,35,41,.08);
|
||||||
|
padding: 14px 18px; display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
flex: 1; min-width: 220px; display: flex; align-items: center; gap: 8px;
|
||||||
|
background: #f5f6fa; border-radius: 10px; padding: 9px 14px; color: var(--muted);
|
||||||
|
}
|
||||||
|
.search input {
|
||||||
|
border: 0; background: transparent; outline: none; flex: 1; font-size: 14px; color: var(--ink);
|
||||||
|
}
|
||||||
|
.cats { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.cat {
|
||||||
|
border: 1px solid var(--line); background: #fff; color: var(--muted);
|
||||||
|
border-radius: 999px; padding: 6px 14px; font-size: 13px; cursor: pointer; transition: .18s;
|
||||||
|
}
|
||||||
|
.cat:hover { border-color: var(--brand); color: var(--brand); }
|
||||||
|
.cat.on { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
max-width: 1180px; margin: 28px auto 60px; padding: 0 24px;
|
||||||
|
display: grid; gap: 20px;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #fff; border-radius: 16px; border: 1px solid var(--line);
|
||||||
|
padding: 20px; display: flex; flex-direction: column; gap: 12px;
|
||||||
|
transition: transform .22s ease, box-shadow .22s ease, border-color .22s;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
box-shadow: 0 18px 40px rgba(79,70,229,.16);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.card .head { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.card .ava {
|
||||||
|
width: 52px; height: 52px; border-radius: 14px; flex: none;
|
||||||
|
background: #f0f1f5; object-fit: cover; display: grid; place-items: center;
|
||||||
|
font-weight: 700; color: var(--brand); font-size: 20px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.card .ava img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.card .titles { min-width: 0; }
|
||||||
|
.card .titles .t { font-weight: 600; font-size: 16px; line-height: 1.3; }
|
||||||
|
.card .titles .a { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||||
|
.card .desc {
|
||||||
|
color: #5b606b; font-size: 13px; line-height: 1.6; min-height: 42px;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
.card .meta { display: flex; align-items: center; gap: 14px; font-size: 12px; color: var(--muted); }
|
||||||
|
.stars { color: #f5a623; letter-spacing: 1px; }
|
||||||
|
.stars .off { color: #e2e4ea; }
|
||||||
|
.dl { display: flex; align-items: center; gap: 5px; }
|
||||||
|
.card .foot { display: flex; align-items: center; justify-content: space-between; margin-top: auto; }
|
||||||
|
.price {
|
||||||
|
font-weight: 700; font-size: 16px; color: var(--brand);
|
||||||
|
}
|
||||||
|
.price.free { color: #16a34a; }
|
||||||
|
.tag {
|
||||||
|
font-size: 11px; color: var(--brand); background: rgba(79,70,229,.1);
|
||||||
|
border-radius: 6px; padding: 3px 8px;
|
||||||
|
}
|
||||||
|
.card .btn {
|
||||||
|
text-decoration: none; font-size: 13px; color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--brand), var(--brand-2));
|
||||||
|
padding: 8px 16px; border-radius: 9px; transition: opacity .2s, transform .1s;
|
||||||
|
}
|
||||||
|
.card .btn:hover { opacity: .9; }
|
||||||
|
.card .btn:active { transform: scale(.97); }
|
||||||
|
|
||||||
|
.empty { grid-column: 1 / -1; text-align: center; padding: 70px 0; color: var(--muted); }
|
||||||
|
.empty .ic { font-size: 48px; opacity: .5; }
|
||||||
|
.empty p { margin-top: 12px; }
|
||||||
|
|
||||||
|
footer { text-align: center; color: var(--muted); font-size: 12px; padding: 26px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="brandbar">
|
||||||
|
<div class="logo">Y</div>
|
||||||
|
<div class="name">{$market_name}</div>
|
||||||
|
<div class="nav">
|
||||||
|
<a href="/appmall/store" class="on">应用市场</a>
|
||||||
|
<a href="/appmall/framework">主框架</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h1>发现优质扩展,一键点亮你的站点</h1>
|
||||||
|
<p>插件、模板、工具,覆盖建站全场景 · 由 {$market_name} 官方精选</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="inner">
|
||||||
|
<div class="search">
|
||||||
|
<i class="layui-icon layui-icon-search"></i>
|
||||||
|
<input type="text" placeholder="搜索插件名称、作者或功能…" oninput="filterCards(this.value)">
|
||||||
|
</div>
|
||||||
|
<div class="cats" id="cats">
|
||||||
|
<span class="cat on" data-cat="">全部</span>
|
||||||
|
{foreach $categories as $c}
|
||||||
|
<span class="cat" data-cat="{$c}">{$c}</span>
|
||||||
|
{/foreach}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid" id="grid">
|
||||||
|
{if empty($list)}
|
||||||
|
<div class="empty">
|
||||||
|
<div class="ic"><i class="layui-icon layui-icon-template-1"></i></div>
|
||||||
|
<p>市场暂无插件,敬请期待。</p>
|
||||||
|
</div>
|
||||||
|
{else}
|
||||||
|
{foreach $list as $p}
|
||||||
|
<div class="card" data-name="{$p.name|lower}" data-author="{$p.author|lower}" data-cat="{$p.category}">
|
||||||
|
<div class="head">
|
||||||
|
<div class="ava">
|
||||||
|
{if $p.logo}
|
||||||
|
<img src="{$p.logo}" alt="{$p.title}">
|
||||||
|
{else}
|
||||||
|
{$p.title|substr=0,1}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="titles">
|
||||||
|
<div class="t">{$p.title}{if $p.version} <span style="font-weight:400;color:#aab;font-size:12px;">v{$p.version}</span>{/if}</div>
|
||||||
|
<div class="a">by {$p.author|default='官方'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="desc">{$p.desc|default='暂无描述'}</div>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="stars" title="评分 {$p.rating}">
|
||||||
|
{for start="1" end="6" name="i"}
|
||||||
|
{if $p.rating >= $i}<i class="layui-icon layui-icon-rate-solid"></i>{else/}<i class="layui-icon layui-icon-rate off"></i>{/if}
|
||||||
|
{/for}
|
||||||
|
</span>
|
||||||
|
<span class="dl"><i class="layui-icon layui-icon-download-circle"></i>{$p.downloads} 下载</span>
|
||||||
|
</div>
|
||||||
|
<div class="foot">
|
||||||
|
<div>
|
||||||
|
<span class="price {if $p.price==0}free{/if}">{$p.price_text}</span>
|
||||||
|
{if $p.category}<span class="tag">{$p.category}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<a class="btn" href="/appmall/store/detail/{$p.name}">查看详情</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/foreach}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>© {:date('Y')} {$market_name} · 由 YwxApp 框架强力驱动</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function filterCards(kw) {
|
||||||
|
kw = (kw || '').toLowerCase().trim();
|
||||||
|
document.querySelectorAll('#grid .card').forEach(function (el) {
|
||||||
|
var hit = !kw || el.dataset.name.indexOf(kw) > -1 || el.dataset.author.indexOf(kw) > -1;
|
||||||
|
el.style.display = hit ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('cats').addEventListener('click', function (e) {
|
||||||
|
var cat = e.target.closest('.cat');
|
||||||
|
if (!cat) return;
|
||||||
|
document.querySelectorAll('#cats .cat').forEach(function (c) { c.classList.remove('on'); });
|
||||||
|
cat.classList.add('on');
|
||||||
|
var v = cat.dataset.cat;
|
||||||
|
document.querySelectorAll('#grid .card').forEach(function (el) {
|
||||||
|
el.style.display = (!v || el.dataset.cat === v) ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\articles;
|
||||||
|
|
||||||
|
use ywxapp\AddonBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文章CMS插件启动类
|
||||||
|
*/
|
||||||
|
class Addon extends addon
|
||||||
|
{
|
||||||
|
// 插件基本信息
|
||||||
|
public $info = [
|
||||||
|
'name' => 'articles',
|
||||||
|
'title' => '文章CMS',
|
||||||
|
'description' => '完整文章类 CMS 插件:文章、分类、标签、评论',
|
||||||
|
'status' => 1,
|
||||||
|
'author' => 'ywxapp',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'type' => 1,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 插件安装
|
||||||
|
public function install()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插件卸载
|
||||||
|
public function uninstall()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插件启用
|
||||||
|
public function enable()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插件禁用
|
||||||
|
public function disable()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @Date: 2026-08-10 15:31:07
|
||||||
|
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @LastEditTime: 2026-08-13 17:07:28
|
||||||
|
* @Description:
|
||||||
|
* @FilePath: \ywxapp_dev\addon\articles\controller\Article.php
|
||||||
|
* @CustomString: Copyright (c) 2026 YwxApp
|
||||||
|
*/
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 文章CMS前台文章详情 / 搜索 / 评论
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\articles\controller;
|
||||||
|
|
||||||
|
use think\facade\Db;
|
||||||
|
use addon\articles\model\Article as ArticleModel;
|
||||||
|
use addon\articles\model\ArticleComment;
|
||||||
|
|
||||||
|
class Article extends ArticlesFrontend
|
||||||
|
{
|
||||||
|
public function read($id)
|
||||||
|
{
|
||||||
|
$article = ArticleModel::getDetail((int) $id);
|
||||||
|
if (!$article) {
|
||||||
|
$this->result->error('文章不存在或已删除');
|
||||||
|
}
|
||||||
|
// 浏览量 +1
|
||||||
|
ArticleModel::where('id', $id)->inc('view_count')->update();
|
||||||
|
|
||||||
|
$commentsPage = ArticleComment::getByArticle((int) $id, (int) input('page', 1), 10);
|
||||||
|
$comments = [
|
||||||
|
'data' => $commentsPage->items(),
|
||||||
|
'render' => $commentsPage->render(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->view->assign([
|
||||||
|
'article' => $article,
|
||||||
|
'prev' => ArticleModel::prev($article->id, $article->cid),
|
||||||
|
'next' => ArticleModel::next($article->id, $article->cid),
|
||||||
|
'related' => ArticleModel::related($article->id, $article->cid, 5),
|
||||||
|
'hot' => ArticleModel::hot(10),
|
||||||
|
'comments'=> $comments,
|
||||||
|
'nav_active' => $article->cid,
|
||||||
|
]);
|
||||||
|
return $this->fetch('article/detail');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容 route: articles/article/detail/:id
|
||||||
|
public function detail($id)
|
||||||
|
{
|
||||||
|
return $this->read($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search()
|
||||||
|
{
|
||||||
|
$keyword = trim(input('keyword', ''));
|
||||||
|
$page = (int) input('page', 1);
|
||||||
|
$list = ArticleModel::listArticles(['keyword' => $keyword], $page, 10);
|
||||||
|
|
||||||
|
$this->view->assign([
|
||||||
|
'keyword' => $keyword,
|
||||||
|
'list' => $list->items(),
|
||||||
|
'total' => $list->total(),
|
||||||
|
'page' => $list->render(),
|
||||||
|
'nav_active' => '',
|
||||||
|
]);
|
||||||
|
return $this->fetch('search/index');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function comment()
|
||||||
|
{
|
||||||
|
if (!$this->auth || !$this->auth->isLogin) {
|
||||||
|
$this->result->error('请先登录后再评论');
|
||||||
|
}
|
||||||
|
$aid = (int) input('aid', 0);
|
||||||
|
$content = trim(input('content', ''));
|
||||||
|
if (!$aid || !$content) {
|
||||||
|
$this->result->error('参数错误');
|
||||||
|
}
|
||||||
|
$uid = $this->auth->info['uid'] ?? 0;
|
||||||
|
ArticleComment::create([
|
||||||
|
'uid' => $uid,
|
||||||
|
'aid' => $aid,
|
||||||
|
'pid' => 0,
|
||||||
|
'content' => $content,
|
||||||
|
'status' => 1,
|
||||||
|
'create_at' => time(),
|
||||||
|
'update_at' => time(),
|
||||||
|
]);
|
||||||
|
ArticleModel::where('id', $aid)->inc('comment_count')->update();
|
||||||
|
$this->result->success('评论成功');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @Date: 2026-08-10 15:30:57
|
||||||
|
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @LastEditTime: 2026-08-13 17:38:27
|
||||||
|
* @Description:
|
||||||
|
* @FilePath: \ywxapp_dev\addon\articles\controller\ArticlesFrontend.php
|
||||||
|
* @CustomString: Copyright (c) 2026 YwxApp
|
||||||
|
*/
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 文章CMS前台基类:统一注入侧边栏/分类/标签等共享数据
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\articles\controller;
|
||||||
|
|
||||||
|
use ywxapp\controller\FrontendBase;
|
||||||
|
use addon\articles\model\Article;
|
||||||
|
use addon\articles\model\ArticleCategory;
|
||||||
|
use addon\articles\model\ArticleTag;
|
||||||
|
use addon\articles\library\ArticleSchema;
|
||||||
|
|
||||||
|
class ArticlesFrontend extends FrontendBase
|
||||||
|
{
|
||||||
|
protected $noNeedLogin = ['*'];
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
protected function initialize()
|
||||||
|
{
|
||||||
|
parent::initialize();
|
||||||
|
// articles 前台页面自带完整 <!DOCTYPE html> 结构,清空 layout_name,
|
||||||
|
// 避免被 common/layout 包裹导致双层 <html>、标签外露、排版错乱。
|
||||||
|
//$this->view->config(['layout_name' => '']);
|
||||||
|
ArticleSchema::ensure();
|
||||||
|
$isLogin = $this->auth && $this->auth->isLogin;
|
||||||
|
$loginUser = $isLogin ? $this->auth->info : null;
|
||||||
|
|
||||||
|
$this->view->assign([
|
||||||
|
'categories' => ArticleCategory::allEnable(),
|
||||||
|
'tags' => ArticleTag::where('delete_at', 0)->order('id', 'desc')->limit(20)->select(),
|
||||||
|
'hot' => Article::hot(10),
|
||||||
|
'is_login' => $isLogin,
|
||||||
|
'member' => $loginUser,
|
||||||
|
'nav_active' => '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 文章CMS前台分类列表
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\articles\controller;
|
||||||
|
|
||||||
|
use addon\articles\model\Article;
|
||||||
|
use addon\articles\model\ArticleCategory;
|
||||||
|
|
||||||
|
class Category extends ArticlesFrontend
|
||||||
|
{
|
||||||
|
public function index($id)
|
||||||
|
{
|
||||||
|
$category = ArticleCategory::getById((int) $id);
|
||||||
|
if (!$category) {
|
||||||
|
$this->result->error('分类不存在');
|
||||||
|
}
|
||||||
|
$page = (int) input('page', 1);
|
||||||
|
$list = Article::listArticles(['cid' => $id], $page, 10);
|
||||||
|
|
||||||
|
$this->view->assign([
|
||||||
|
'category' => $category,
|
||||||
|
'list' => $list->items(),
|
||||||
|
'page' => $list->render(),
|
||||||
|
'nav_active' => $id,
|
||||||
|
]);
|
||||||
|
return $this->fetch('category/index');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @Date: 2026-08-10 15:31:01
|
||||||
|
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @LastEditTime: 2026-08-10 20:38:46
|
||||||
|
* @Description:
|
||||||
|
* @FilePath: \ywxapp_dev\addon\articles\controller\Index.php
|
||||||
|
* @CustomString: Copyright (c) 2026 YwxApp
|
||||||
|
*/
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 文章CMS前台首页
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\articles\controller;
|
||||||
|
|
||||||
|
use addon\articles\model\Article;
|
||||||
|
use addon\articles\model\ArticleCategory;
|
||||||
|
|
||||||
|
class Index extends ArticlesFrontend
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
|
||||||
|
// dump($this->app->route);die;
|
||||||
|
$page = (int) input('page', 1);
|
||||||
|
$list = Article::listArticles([], $page, 10);
|
||||||
|
|
||||||
|
// 轮播:取推荐或最新的 5 篇
|
||||||
|
$slides = Article::scope('normal')
|
||||||
|
->order('is_top desc, is_recommend desc, create_at desc')
|
||||||
|
->limit(5)
|
||||||
|
->field('id,title,cover_image')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$this->view->assign([
|
||||||
|
'list' => $list->items(),
|
||||||
|
'page' => $list->render(),
|
||||||
|
'slides' => $slides,
|
||||||
|
'nav_active' => 'index',
|
||||||
|
]);
|
||||||
|
return $this->fetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 文章CMS前台标签列表
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\articles\controller;
|
||||||
|
|
||||||
|
use addon\articles\model\Article;
|
||||||
|
use addon\articles\model\ArticleTag;
|
||||||
|
|
||||||
|
class Tag extends ArticlesFrontend
|
||||||
|
{
|
||||||
|
public function index($id)
|
||||||
|
{
|
||||||
|
$tag = ArticleTag::where('delete_at', 0)->find($id);
|
||||||
|
if (!$tag) {
|
||||||
|
$this->result->error('标签不存在');
|
||||||
|
}
|
||||||
|
$page = (int) input('page', 1);
|
||||||
|
$list = Article::listArticles(['tid' => $id], $page, 10);
|
||||||
|
|
||||||
|
$this->view->assign([
|
||||||
|
'tag' => $tag,
|
||||||
|
'list' => $list->items(),
|
||||||
|
'page' => $list->render(),
|
||||||
|
]);
|
||||||
|
return $this->fetch('tag/index');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 文章CMS后台文章管理
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\articles\controller\backend;
|
||||||
|
|
||||||
|
use think\facade\Request;
|
||||||
|
use think\facade\View;
|
||||||
|
use think\Validate;
|
||||||
|
use ywxapp\controller\BackendBase;
|
||||||
|
use addon\articles\model\Article as ArticleModel;
|
||||||
|
use addon\articles\model\ArticleCategory;
|
||||||
|
use addon\articles\model\ArticleTag;
|
||||||
|
use addon\articles\model\ArticleArticleTag;
|
||||||
|
use addon\articles\library\ArticleSchema;
|
||||||
|
|
||||||
|
class Article extends BackendBase
|
||||||
|
{
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
protected function initialize()
|
||||||
|
{
|
||||||
|
parent::initialize();
|
||||||
|
ArticleSchema::ensure();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$status = Request::param('status', 'all');
|
||||||
|
$keyword = Request::param('keyword', '');
|
||||||
|
$page = Request::param('page', 1);
|
||||||
|
$limit = Request::param('limit', 15);
|
||||||
|
|
||||||
|
if (Request::isAjax()) {
|
||||||
|
$query = ArticleModel::with(['category']);
|
||||||
|
if ($status !== 'all') {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
if ($keyword) {
|
||||||
|
$query->where('title', 'like', "%{$keyword}%");
|
||||||
|
}
|
||||||
|
$paginator = $query->order('is_top', 'desc')
|
||||||
|
->order('create_at', 'desc')
|
||||||
|
->paginate(['list_rows' => $limit, 'page' => $page]);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($paginator->items() as $a) {
|
||||||
|
$rows[] = [
|
||||||
|
'id' => $a->id,
|
||||||
|
'title' => $a->title,
|
||||||
|
'is_top' => $a->is_top,
|
||||||
|
'is_recommend'=> $a->is_recommend,
|
||||||
|
'category' => $a->category->name ?? '-',
|
||||||
|
'status' => $a->status,
|
||||||
|
'view_count' => (int) $a->view_count,
|
||||||
|
'comment_count' => (int) $a->comment_count,
|
||||||
|
'create_at' => $a->create_at,
|
||||||
|
'edit_url' => (string) url('articles/backend.article/edit', ['id' => $a->id]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return json(['code' => 0, 'msg' => '', 'count' => $paginator->total(), 'data' => $rows]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return View::fetch('article/index', ['status' => $status, 'keyword' => $keyword]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save()
|
||||||
|
{
|
||||||
|
if (Request::isPost()) {
|
||||||
|
return $this->update(0);
|
||||||
|
}
|
||||||
|
$categories = ArticleCategory::where('delete_at', 0)->where('status', 1)->select();
|
||||||
|
$tags = ArticleTag::where('delete_at', 0)->select();
|
||||||
|
return View::fetch('article/edit', [
|
||||||
|
'article' => null,
|
||||||
|
'categories' => $categories,
|
||||||
|
'tags' => $tags,
|
||||||
|
'articleTags' => [],
|
||||||
|
'tagNamesStr' => '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit($id = null)
|
||||||
|
{
|
||||||
|
$article = ArticleModel::with(['tags'])->find($id);
|
||||||
|
if (!$article) {
|
||||||
|
return redirect('/articles/backend/article');
|
||||||
|
}
|
||||||
|
$categories = ArticleCategory::where('delete_at', 0)->where('status', 1)->select();
|
||||||
|
$tags = ArticleTag::where('delete_at', 0)->select();
|
||||||
|
$articleTags = $article->tags->column('id');
|
||||||
|
$tagNamesStr = implode(',', $article->tags->column('name'));
|
||||||
|
|
||||||
|
return View::fetch('article/edit', [
|
||||||
|
'article' => $article,
|
||||||
|
'categories' => $categories,
|
||||||
|
'tags' => $tags,
|
||||||
|
'articleTags' => $articleTags,
|
||||||
|
'tagNamesStr' => $tagNamesStr,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update($id = 0)
|
||||||
|
{
|
||||||
|
$data = Request::post();
|
||||||
|
$id = $id ?: (int) ($data['id'] ?? 0);
|
||||||
|
$data['update_at'] = time();
|
||||||
|
$data['is_top'] = isset($data['is_top']) ? 1 : 0;
|
||||||
|
$data['is_recommend']= isset($data['is_recommend']) ? 1 : 0;
|
||||||
|
|
||||||
|
$validate = new Validate([
|
||||||
|
'title|标题' => 'require|max:255',
|
||||||
|
'content|内容' => 'require',
|
||||||
|
'cid|分类' => 'require|number',
|
||||||
|
]);
|
||||||
|
if (!$validate->check($data)) {
|
||||||
|
return json(['code' => 0, 'msg' => $validate->getError()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理标签
|
||||||
|
$tagIds = [];
|
||||||
|
if (!empty($data['tags'])) {
|
||||||
|
$tagNames = is_array($data['tags']) ? $data['tags'] : explode(',', $data['tags']);
|
||||||
|
foreach ($tagNames as $name) {
|
||||||
|
$name = trim($name);
|
||||||
|
if ($name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$tag = ArticleTag::where('delete_at', 0)->where('name', $name)->find()
|
||||||
|
?: ArticleTag::create(['name' => $name, 'alias' => $name, 'create_at' => time(), 'update_at' => time()]);
|
||||||
|
$tagIds[] = $tag->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
$article = ArticleModel::find($id);
|
||||||
|
if (!$article) {
|
||||||
|
return json(['code' => 0, 'msg' => '文章不存在']);
|
||||||
|
}
|
||||||
|
$article->save($data);
|
||||||
|
} else {
|
||||||
|
if (empty($data['uid'])) {
|
||||||
|
$data['uid'] = $this->auth->info['uid'] ?? 0;
|
||||||
|
}
|
||||||
|
if (empty($data['author'])) {
|
||||||
|
$data['author'] = $this->auth->info['nickname'] ?? '';
|
||||||
|
}
|
||||||
|
$data['create_at'] = time();
|
||||||
|
$article = ArticleModel::create($data);
|
||||||
|
$id = $article->id;
|
||||||
|
}
|
||||||
|
ArticleArticleTag::syncTags($id, $tagIds);
|
||||||
|
return json(['code' => 1, 'msg' => '保存成功', 'url' => '/articles/backend/article']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete($id = 0)
|
||||||
|
{
|
||||||
|
$id = $id ?: (int) Request::post('id', 0);
|
||||||
|
$article = ArticleModel::find($id);
|
||||||
|
if ($article) {
|
||||||
|
$article->delete();
|
||||||
|
return json(['code' => 1, 'msg' => '已删除']);
|
||||||
|
}
|
||||||
|
return json(['code' => 0, 'msg' => '文章不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recyclebin()
|
||||||
|
{
|
||||||
|
$page = Request::param('page', 1);
|
||||||
|
$limit = Request::param('limit', 15);
|
||||||
|
if (Request::isAjax()) {
|
||||||
|
$list = ArticleModel::onlyTrashed()
|
||||||
|
->order('delete_at', 'desc')
|
||||||
|
->paginate(['list_rows' => $limit, 'page' => $page]);
|
||||||
|
$rows = array_map(function ($a) {
|
||||||
|
return [
|
||||||
|
'id' => $a->id,
|
||||||
|
'title' => $a->title,
|
||||||
|
'delete_at' => $a->delete_at,
|
||||||
|
];
|
||||||
|
}, $list->items());
|
||||||
|
return json(['code' => 0, 'msg' => '', 'count' => $list->total(), 'data' => $rows]);
|
||||||
|
}
|
||||||
|
return View::fetch('article/recyclebin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore($id = 0)
|
||||||
|
{
|
||||||
|
$id = $id ?: (int) Request::post('id', 0);
|
||||||
|
$article = ArticleModel::onlyTrashed()->find($id);
|
||||||
|
if ($article) {
|
||||||
|
$article->restore();
|
||||||
|
return json(['code' => 1, 'msg' => '已恢复']);
|
||||||
|
}
|
||||||
|
return json(['code' => 0, 'msg' => '记录不存在']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 文章CMS后台分类管理
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\articles\controller\backend;
|
||||||
|
|
||||||
|
use think\facade\Request;
|
||||||
|
use think\facade\View;
|
||||||
|
use think\Validate;
|
||||||
|
use ywxapp\controller\BackendBase;
|
||||||
|
use addon\articles\model\ArticleCategory;
|
||||||
|
use addon\articles\library\ArticleSchema;
|
||||||
|
|
||||||
|
class Category extends BackendBase
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary of needLogin
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $noNeedLogin = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary of needRight
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $noNeedVerify = [];
|
||||||
|
|
||||||
|
protected function initialize()
|
||||||
|
{
|
||||||
|
parent::initialize();
|
||||||
|
ArticleSchema::ensure();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示资源列表
|
||||||
|
*
|
||||||
|
* @param \think\Request $request
|
||||||
|
* @return \think\Response
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$page = $this->request->param('page', 1);
|
||||||
|
$size = $this->request->param('size', 15);
|
||||||
|
if ($this->request->isAjax()) {
|
||||||
|
$data = ArticleCategory::paginate([
|
||||||
|
'page' => $page,
|
||||||
|
'list_rows' => $size,
|
||||||
|
]);
|
||||||
|
$this->result->success($data->items());
|
||||||
|
}
|
||||||
|
$this->view->assign('title', '登录');
|
||||||
|
return $this->view->fetch('category/index');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save()
|
||||||
|
{
|
||||||
|
if (Request::isPost()) {
|
||||||
|
$data = Request::post();
|
||||||
|
$data['status'] = isset($data['status']) ? 1 : 0;
|
||||||
|
|
||||||
|
$validate = new Validate([
|
||||||
|
'name|名称' => 'require|max:50',
|
||||||
|
'alias|别名' => 'require|max:50',
|
||||||
|
]);
|
||||||
|
if (!$validate->check($data)) {
|
||||||
|
$this->result->error($validate->getError());
|
||||||
|
}
|
||||||
|
ArticleCategory::create($data);
|
||||||
|
$this->result->success();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit($id = null)
|
||||||
|
{
|
||||||
|
$category = ArticleCategory::find($id);
|
||||||
|
if (!$category) {
|
||||||
|
return redirect('/articles/backend/category');
|
||||||
|
}
|
||||||
|
return View::fetch('category/edit', ['category' => $category]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update($id = 0)
|
||||||
|
{
|
||||||
|
$data = Request::post();
|
||||||
|
$id = $id ?: (int) ($data['id'] ?? 0);
|
||||||
|
$data['update_at'] = time();
|
||||||
|
$data['status'] = isset($data['status']) ? 1 : 0;
|
||||||
|
|
||||||
|
$validate = new Validate([
|
||||||
|
'name|名称' => 'require|max:50',
|
||||||
|
'alias|别名' => 'require|max:50',
|
||||||
|
]);
|
||||||
|
if (!$validate->check($data)) {
|
||||||
|
return json(['code' => 0, 'msg' => $validate->getError()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
$category = ArticleCategory::find($id);
|
||||||
|
if (!$category) {
|
||||||
|
return json(['code' => 0, 'msg' => '分类不存在']);
|
||||||
|
}
|
||||||
|
$category->save($data);
|
||||||
|
} else {
|
||||||
|
$data['create_at'] = time();
|
||||||
|
ArticleCategory::create($data);
|
||||||
|
}
|
||||||
|
return json(['code' => 0, 'msg' => '保存成功', 'url' => '/articles/backend/category']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete($id = 0)
|
||||||
|
{
|
||||||
|
$id = $id ?: (int) Request::post('id', 0);
|
||||||
|
$category = ArticleCategory::find($id);
|
||||||
|
if ($category) {
|
||||||
|
if (ArticleCategory::hasArticles($id)) {
|
||||||
|
return json(['code' => 0, 'msg' => '该分类下还有文章,无法删除']);
|
||||||
|
}
|
||||||
|
$category->delete();
|
||||||
|
return json(['code' => 0, 'msg' => '已删除']);
|
||||||
|
}
|
||||||
|
return json(['code' => 0, 'msg' => '分类不存在']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 文章CMS后台评论管理
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\articles\controller\backend;
|
||||||
|
|
||||||
|
use think\facade\Request;
|
||||||
|
use think\facade\View;
|
||||||
|
use ywxapp\controller\BackendBase;
|
||||||
|
use addon\articles\model\ArticleComment;
|
||||||
|
use addon\articles\model\Article;
|
||||||
|
use addon\articles\library\ArticleSchema;
|
||||||
|
|
||||||
|
class Comment extends BackendBase
|
||||||
|
{
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
protected function initialize()
|
||||||
|
{
|
||||||
|
parent::initialize();
|
||||||
|
ArticleSchema::ensure();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$status = Request::param('status', 'all');
|
||||||
|
$keyword = Request::param('keyword', '');
|
||||||
|
$page = Request::param('page', 1);
|
||||||
|
$limit = Request::param('limit', 15);
|
||||||
|
|
||||||
|
if (Request::isAjax()) {
|
||||||
|
$query = ArticleComment::with(['user', 'article']);
|
||||||
|
if ($status !== 'all') {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
if ($keyword) {
|
||||||
|
$query->where('content', 'like', "%{$keyword}%");
|
||||||
|
}
|
||||||
|
$paginator = $query->order('create_at', 'desc')
|
||||||
|
->paginate(['list_rows' => $limit, 'page' => $page]);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($paginator->items() as $c) {
|
||||||
|
$rows[] = [
|
||||||
|
'id' => $c->id,
|
||||||
|
'nickname' => $c->user->nickname ?? '游客',
|
||||||
|
'article' => $c->article->title ?? '-',
|
||||||
|
'content' => $c->content,
|
||||||
|
'status' => $c->status,
|
||||||
|
'create_at' => $c->create_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return json(['code' => 0, 'msg' => '', 'count' => $paginator->total(), 'data' => $rows]);
|
||||||
|
}
|
||||||
|
return View::fetch('comment/index', ['status' => $status, 'keyword' => $keyword]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function audit($id = 0)
|
||||||
|
{
|
||||||
|
$id = $id ?: (int) Request::post('id', 0);
|
||||||
|
$comment = ArticleComment::find($id);
|
||||||
|
if ($comment) {
|
||||||
|
$comment->status = 1;
|
||||||
|
$comment->save();
|
||||||
|
Article::where('id', $comment->aid)->inc('comment_count')->update();
|
||||||
|
return json(['code' => 1, 'msg' => '审核通过']);
|
||||||
|
}
|
||||||
|
return json(['code' => 0, 'msg' => '评论不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete($id = 0)
|
||||||
|
{
|
||||||
|
$id = $id ?: (int) Request::post('id', 0);
|
||||||
|
$comment = ArticleComment::find($id);
|
||||||
|
if ($comment) {
|
||||||
|
$comment->delete();
|
||||||
|
return json(['code' => 1, 'msg' => '已删除']);
|
||||||
|
}
|
||||||
|
return json(['code' => 0, 'msg' => '评论不存在']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 文章CMS后台标签管理
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\articles\controller\backend;
|
||||||
|
|
||||||
|
use think\facade\Request;
|
||||||
|
use think\facade\View;
|
||||||
|
use think\Validate;
|
||||||
|
use ywxapp\controller\BackendBase;
|
||||||
|
use addon\articles\model\ArticleTag;
|
||||||
|
use addon\articles\library\ArticleSchema;
|
||||||
|
|
||||||
|
class Tag extends BackendBase
|
||||||
|
{
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
protected function initialize()
|
||||||
|
{
|
||||||
|
parent::initialize();
|
||||||
|
ArticleSchema::ensure();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
if (Request::isAjax()) {
|
||||||
|
$list = ArticleTag::where('delete_at', 0)->order('id', 'desc')->select();
|
||||||
|
$rows = array_map(function ($t) {
|
||||||
|
return [
|
||||||
|
'id' => $t->id,
|
||||||
|
'name' => $t->name,
|
||||||
|
'alias' => $t->alias,
|
||||||
|
'description' => $t->description,
|
||||||
|
'create_at' => $t->create_at,
|
||||||
|
'edit_url' => (string) url('articles/backend.tag/edit', ['id' => $t->id]),
|
||||||
|
];
|
||||||
|
}, $list->toArray());
|
||||||
|
return json(['code' => 0, 'msg' => '', 'count' => count($rows), 'data' => $rows]);
|
||||||
|
}
|
||||||
|
return View::fetch('tag/index');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save()
|
||||||
|
{
|
||||||
|
if (Request::isPost()) {
|
||||||
|
return $this->update(0);
|
||||||
|
}
|
||||||
|
return View::fetch('tag/edit', ['tag' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit($id = null)
|
||||||
|
{
|
||||||
|
$tag = ArticleTag::find($id);
|
||||||
|
if (!$tag) {
|
||||||
|
return redirect('/articles/backend/tag');
|
||||||
|
}
|
||||||
|
return View::fetch('tag/edit', ['tag' => $tag]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update($id = 0)
|
||||||
|
{
|
||||||
|
$data = Request::post();
|
||||||
|
$id = $id ?: (int) ($data['id'] ?? 0);
|
||||||
|
$data['update_at'] = time();
|
||||||
|
$data['alias'] = $data['alias'] ?? $data['name'];
|
||||||
|
|
||||||
|
$validate = new Validate([
|
||||||
|
'name|名称' => 'require|max:50',
|
||||||
|
]);
|
||||||
|
if (!$validate->check($data)) {
|
||||||
|
return json(['code' => 0, 'msg' => $validate->getError()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
$tag = ArticleTag::find($id);
|
||||||
|
if (!$tag) {
|
||||||
|
return json(['code' => 0, 'msg' => '标签不存在']);
|
||||||
|
}
|
||||||
|
$tag->save($data);
|
||||||
|
} else {
|
||||||
|
$data['create_at'] = time();
|
||||||
|
ArticleTag::create($data);
|
||||||
|
}
|
||||||
|
return json(['code' => 1, 'msg' => '保存成功', 'url' => '/articles/backend/tag']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete($id = 0)
|
||||||
|
{
|
||||||
|
$id = $id ?: (int) Request::post('id', 0);
|
||||||
|
$tag = ArticleTag::find($id);
|
||||||
|
if ($tag) {
|
||||||
|
$tag->delete();
|
||||||
|
return json(['code' => 1, 'msg' => '已删除']);
|
||||||
|
}
|
||||||
|
return json(['code' => 0, 'msg' => '标签不存在']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => 'articles',
|
||||||
|
'title' => '文章CMS',
|
||||||
|
'description' => '完整文章类 CMS 插件:文章、分类、标签、评论,前台套用 layuiSimpleNews 模板',
|
||||||
|
'status' => 1,
|
||||||
|
'author' => 'ywxapp',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'type' => 1,
|
||||||
|
'state' => 0,
|
||||||
|
'install_time' => 1786348115,
|
||||||
|
'update_time' => 1786366229,
|
||||||
|
];
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- addon/articles/install.sql —— 文章CMS插件数据表
|
||||||
|
-- 框架约定:插件安装时由 ywxapp\service\AddonService 执行本文件
|
||||||
|
-- (仅允许 CREATE TABLE / INSERT,见 importsql 白名单)。
|
||||||
|
-- 表名须为 __PREFIX__articles_*,与插件名一致。
|
||||||
|
-- 时间字段统一:create_at / update_at / delete_at(int 时间戳,0=未删)
|
||||||
|
-- ============================================================
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `__PREFIX__articles_article` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '标题',
|
||||||
|
`content` longtext NOT NULL COMMENT '正文',
|
||||||
|
`summary` varchar(500) DEFAULT '' COMMENT '摘要',
|
||||||
|
`cover_image` varchar(255) DEFAULT '' COMMENT '封面图',
|
||||||
|
`uid` int NOT NULL DEFAULT 0 COMMENT '作者ID(member_user.uid)',
|
||||||
|
`author` varchar(50) DEFAULT '' COMMENT '作者名(冗余)',
|
||||||
|
`cid` int NOT NULL DEFAULT 0 COMMENT '分类ID',
|
||||||
|
`status` tinyint(1) DEFAULT 1 COMMENT '状态:1发布 0草稿',
|
||||||
|
`is_top` tinyint(1) DEFAULT 0 COMMENT '是否置顶',
|
||||||
|
`is_recommend` tinyint(1) DEFAULT 0 COMMENT '是否推荐',
|
||||||
|
`view_count` int DEFAULT 0 COMMENT '浏览次数',
|
||||||
|
`comment_count` int DEFAULT 0 COMMENT '评论数',
|
||||||
|
`like_count` int DEFAULT 0 COMMENT '点赞数',
|
||||||
|
`create_at` int NOT NULL DEFAULT 0 COMMENT '创建时间',
|
||||||
|
`update_at` int NOT NULL DEFAULT 0 COMMENT '更新时间',
|
||||||
|
`delete_at` int NOT NULL DEFAULT 0 COMMENT '删除时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_uid` (`uid`),
|
||||||
|
KEY `idx_cid` (`cid`),
|
||||||
|
KEY `idx_status_create` (`status`,`create_at`),
|
||||||
|
KEY `idx_delete` (`delete_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `__PREFIX__articles_category` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '分类名称',
|
||||||
|
`alias` varchar(50) NOT NULL DEFAULT '' COMMENT '分类别名(URL标识)',
|
||||||
|
`description` varchar(255) DEFAULT '' COMMENT '分类描述',
|
||||||
|
`cover` varchar(255) DEFAULT '' COMMENT '分类封面',
|
||||||
|
`sort` int DEFAULT 0 COMMENT '排序权重',
|
||||||
|
`status` tinyint(1) DEFAULT 1 COMMENT '状态',
|
||||||
|
`create_at` int NOT NULL DEFAULT 0 COMMENT '创建时间',
|
||||||
|
`update_at` int NOT NULL DEFAULT 0 COMMENT '更新时间',
|
||||||
|
`delete_at` int NOT NULL DEFAULT 0 COMMENT '删除时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_alias` (`alias`),
|
||||||
|
KEY `idx_delete` (`delete_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章分类表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `__PREFIX__articles_tag` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '标签名称',
|
||||||
|
`alias` varchar(50) NOT NULL DEFAULT '' COMMENT '标签别名',
|
||||||
|
`description` varchar(255) DEFAULT '' COMMENT '标签描述',
|
||||||
|
`create_at` int NOT NULL DEFAULT 0 COMMENT '创建时间',
|
||||||
|
`update_at` int NOT NULL DEFAULT 0 COMMENT '更新时间',
|
||||||
|
`delete_at` int NOT NULL DEFAULT 0 COMMENT '删除时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_name` (`name`),
|
||||||
|
KEY `idx_delete` (`delete_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章标签表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `__PREFIX__articles_article_tag` (
|
||||||
|
`aid` int NOT NULL COMMENT '文章ID',
|
||||||
|
`tid` int NOT NULL COMMENT '标签ID',
|
||||||
|
PRIMARY KEY (`aid`,`tid`) USING BTREE,
|
||||||
|
KEY `idx_tid` (`tid`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章标签关联表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `__PREFIX__articles_comment` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`uid` int NOT NULL DEFAULT 0 COMMENT '用户ID(member_user.uid)',
|
||||||
|
`aid` int NOT NULL DEFAULT 0 COMMENT '文章ID',
|
||||||
|
`pid` int NOT NULL DEFAULT 0 COMMENT '父评论ID',
|
||||||
|
`content` text NOT NULL COMMENT '评论内容',
|
||||||
|
`status` tinyint(1) DEFAULT 1 COMMENT '状态:1正常 0待审核',
|
||||||
|
`like_count` int DEFAULT 0 COMMENT '点赞数',
|
||||||
|
`ip` varchar(45) DEFAULT '' COMMENT 'IP地址',
|
||||||
|
`user_agent` varchar(255) DEFAULT '' COMMENT '用户代理',
|
||||||
|
`create_at` int NOT NULL DEFAULT 0 COMMENT '创建时间',
|
||||||
|
`update_at` int NOT NULL DEFAULT 0 COMMENT '更新时间',
|
||||||
|
`delete_at` int NOT NULL DEFAULT 0 COMMENT '删除时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_aid` (`aid`),
|
||||||
|
KEY `idx_pid` (`pid`),
|
||||||
|
KEY `idx_delete` (`delete_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章评论表';
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 文章CMS插件数据表自愈聚合入口
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\articles\library;
|
||||||
|
|
||||||
|
use addon\articles\model\Article;
|
||||||
|
use addon\articles\model\ArticleCategory;
|
||||||
|
use addon\articles\model\ArticleTag;
|
||||||
|
use addon\articles\model\ArticleComment;
|
||||||
|
|
||||||
|
class ArticleSchema
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 插件启用/首访时调用,确保全部私有表存在
|
||||||
|
*/
|
||||||
|
public static function ensure(): void
|
||||||
|
{
|
||||||
|
Article::ensureSchema();
|
||||||
|
ArticleCategory::ensureSchema();
|
||||||
|
ArticleTag::ensureSchema();
|
||||||
|
ArticleComment::ensureSchema();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"backend": [
|
||||||
|
{
|
||||||
|
"name": "articles",
|
||||||
|
"title": "文章CMS",
|
||||||
|
"icon": "layui-icon layui-icon-list",
|
||||||
|
|
||||||
|
"sublist": [
|
||||||
|
{
|
||||||
|
"name": "article/index",
|
||||||
|
"title": "文章列表",
|
||||||
|
"route": "articles/backend/article/index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "article/index/index",
|
||||||
|
"title": "分类管理",
|
||||||
|
"route": "articles/backend/category/index",
|
||||||
|
"child": [
|
||||||
|
{ "name": "category:add", "title": "新增分类", "type": 4 },
|
||||||
|
{ "name": "category:edit", "title": "编辑分类", "type": 4 },
|
||||||
|
{ "name": "category:delete", "title": "删除分类", "type": 4 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tag/index",
|
||||||
|
"title": "标签管理",
|
||||||
|
"route": "articles/backend/tag/index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment/index",
|
||||||
|
"title": "评论管理",
|
||||||
|
"route": "articles/backend/comment/index"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 文章模型
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\articles\model;
|
||||||
|
|
||||||
|
use think\model\concern\SoftDelete;
|
||||||
|
use ywxapp\model\BaseModel;
|
||||||
|
|
||||||
|
class Article extends BaseModel
|
||||||
|
{
|
||||||
|
use SoftDelete;
|
||||||
|
|
||||||
|
protected $name = 'articles_article';
|
||||||
|
protected $deleteTime = 'delete_at';
|
||||||
|
protected $defaultSoftDelete = 0;
|
||||||
|
|
||||||
|
// 时间字段为 int 时间戳(与 install.sql 一致),开启自动维护
|
||||||
|
protected $autoWriteTimestamp = true;
|
||||||
|
protected $createTime = 'create_at';
|
||||||
|
protected $updateTime = 'update_at';
|
||||||
|
|
||||||
|
protected $type = [
|
||||||
|
'status' => 'integer',
|
||||||
|
'is_top' => 'integer',
|
||||||
|
'is_recommend' => 'integer',
|
||||||
|
'view_count' => 'integer',
|
||||||
|
'comment_count'=> 'integer',
|
||||||
|
'like_count' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 发布中(未删除、已发布)
|
||||||
|
public function scopeNormal($query)
|
||||||
|
{
|
||||||
|
$query->where('delete_at', 0)->where('status', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function category()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ArticleCategory::class, 'cid', 'id')
|
||||||
|
->bind(['category_name' => 'name', 'category_alias' => 'alias']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function author()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\ywxapp\model\MemberUser::class, 'uid', 'uid')
|
||||||
|
->bind(['author_name' => 'nickname']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tags()
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(ArticleTag::class, ArticleArticleTag::class, 'tid', 'aid');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表查询(支持分类/标签/关键词)
|
||||||
|
*/
|
||||||
|
public static function listArticles(array $where = [], int $page = 1, int $size = 10, string $order = 'is_top desc, create_at desc')
|
||||||
|
{
|
||||||
|
$query = self::with(['category'])->scope('normal');
|
||||||
|
|
||||||
|
if (!empty($where['cid'])) {
|
||||||
|
$query->where('cid', $where['cid']);
|
||||||
|
}
|
||||||
|
if (!empty($where['tid'])) {
|
||||||
|
$ids = ArticleArticleTag::where('tid', $where['tid'])->column('aid');
|
||||||
|
$query->whereIn('id', $ids ?: [0]);
|
||||||
|
}
|
||||||
|
if (!empty($where['keyword'])) {
|
||||||
|
$kw = $where['keyword'];
|
||||||
|
$query->where(function ($q) use ($kw) {
|
||||||
|
$q->whereLike('title', "%{$kw}%")->whereOr('summary', 'like', "%{$kw}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!empty($where['is_recommend'])) {
|
||||||
|
$query->where('is_recommend', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->orderRaw($order)->paginate([
|
||||||
|
'list_rows' => $size,
|
||||||
|
'page' => $page,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getDetail(int $id)
|
||||||
|
{
|
||||||
|
return self::with(['category', 'tags', 'author'])
|
||||||
|
->scope('normal')
|
||||||
|
->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function prev(int $id, int $cid = 0)
|
||||||
|
{
|
||||||
|
$query = self::scope('normal')->where('id', '<', $id);
|
||||||
|
if ($cid) {
|
||||||
|
$query->where('cid', $cid);
|
||||||
|
}
|
||||||
|
return $query->order('id', 'desc')->field('id,title')->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function next(int $id, int $cid = 0)
|
||||||
|
{
|
||||||
|
$query = self::scope('normal')->where('id', '>', $id);
|
||||||
|
if ($cid) {
|
||||||
|
$query->where('cid', $cid);
|
||||||
|
}
|
||||||
|
return $query->order('id', 'asc')->field('id,title')->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hot(int $limit = 10)
|
||||||
|
{
|
||||||
|
return self::scope('normal')
|
||||||
|
->order('view_count', 'desc')
|
||||||
|
->limit($limit)
|
||||||
|
->field('id,title,cid,view_count')
|
||||||
|
->select();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function related(int $id, int $cid = 0, int $limit = 5)
|
||||||
|
{
|
||||||
|
$query = self::scope('normal')->where('id', '<>', $id);
|
||||||
|
if ($cid) {
|
||||||
|
$query->where('cid', $cid);
|
||||||
|
}
|
||||||
|
return $query->order('create_at', 'desc')
|
||||||
|
->limit($limit)
|
||||||
|
->field('id,title,cid')
|
||||||
|
->select();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ensureSchema(): void
|
||||||
|
{
|
||||||
|
$prefix = self::currentPrefix();
|
||||||
|
self::ensureTableFromInstall($prefix, 'articles_article');
|
||||||
|
self::ensureTableFromInstall($prefix, 'articles_article_tag');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @Date: 2026-08-10 15:28:22
|
||||||
|
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @LastEditTime: 2026-08-12 09:42:29
|
||||||
|
* @Description:
|
||||||
|
* @FilePath: \ywxapp_dev\addon\articles\model\ArticleArticleTag.php
|
||||||
|
* @CustomString: Copyright (c) 2026 YwxApp
|
||||||
|
*/
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 文章标签关联模型
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\articles\model;
|
||||||
|
|
||||||
|
use ywxapp\model\BaseModel;
|
||||||
|
use think\model\Pivot;
|
||||||
|
class ArticleArticleTag extends Pivot
|
||||||
|
{
|
||||||
|
protected $name = 'articles_article_tag';
|
||||||
|
public $autoWriteTimestamp = false;
|
||||||
|
|
||||||
|
public static function syncTags(int $aid, array $tagIds): void
|
||||||
|
{
|
||||||
|
self::where('aid', $aid)->delete();
|
||||||
|
$data = array_map(function ($tid) use ($aid) {
|
||||||
|
return ['aid' => $aid, 'tid' => $tid];
|
||||||
|
}, $tagIds);
|
||||||
|
if ($data) {
|
||||||
|
self::insertAll($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @Date: 2026-08-10 15:28:14
|
||||||
|
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @LastEditTime: 2026-08-11 00:39:57
|
||||||
|
* @Description:
|
||||||
|
* @FilePath: \ywxapp_dev\addon\articles\model\ArticleCategory.php
|
||||||
|
* @CustomString: Copyright (c) 2026 YwxApp
|
||||||
|
*/
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 文章分类模型
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\articles\model;
|
||||||
|
|
||||||
|
use think\model\concern\SoftDelete;
|
||||||
|
use ywxapp\model\BaseModel;
|
||||||
|
|
||||||
|
class ArticleCategory extends BaseModel
|
||||||
|
{
|
||||||
|
use SoftDelete;
|
||||||
|
|
||||||
|
protected $name = 'articles_category';
|
||||||
|
protected $deleteTime = 'delete_at';
|
||||||
|
protected $defaultSoftDelete = 0;
|
||||||
|
|
||||||
|
protected $autoWriteTimestamp = true;
|
||||||
|
protected $createTime = 'create_at';
|
||||||
|
protected $updateTime = 'update_at';
|
||||||
|
|
||||||
|
protected $type = [
|
||||||
|
'sort' => 'integer',
|
||||||
|
'status' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function articles()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Article::class, 'cid', 'id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function allEnable()
|
||||||
|
{
|
||||||
|
return self::where('delete_at', 0)
|
||||||
|
->where('status', 1)
|
||||||
|
->order('sort', 'asc')
|
||||||
|
->order('id', 'asc')
|
||||||
|
->select();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getByAlias(string $alias)
|
||||||
|
{
|
||||||
|
return self::where('delete_at', 0)->where('alias', $alias)->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getById(int $id)
|
||||||
|
{
|
||||||
|
return self::where('delete_at', 0)->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hasArticles(int $id): bool
|
||||||
|
{
|
||||||
|
return Article::where('delete_at', 0)->where('cid', $id)->count() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ensureSchema(): void
|
||||||
|
{
|
||||||
|
$prefix = self::currentPrefix();
|
||||||
|
self::ensureTableFromInstall($prefix, 'articles_category');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兼容旧框架 ArticleCategory::tabTree 的树形展开输出
|
||||||
|
* 插件分类为扁平结构(无 parent_id),此处直接按 sort/id 顺序输出带层级前缀的列表。
|
||||||
|
* @param iterable $arr 分类数据集
|
||||||
|
* @param int $pid 兼容参数,保留原签名
|
||||||
|
* @param int $lv 兼容参数,保留原签名
|
||||||
|
*/
|
||||||
|
public static function tabTree($arr, $pid = 0, $lv = 0)
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($arr as $item) {
|
||||||
|
$data = is_array($item) ? $item : $item->toArray();
|
||||||
|
$data['lv'] = $lv + 1;
|
||||||
|
$data['title'] = str_repeat('|——', $lv + 1) . ($data['name'] ?? ($data['title'] ?? ''));
|
||||||
|
$result[] = $data;
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @Date: 2026-08-10 15:28:26
|
||||||
|
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @LastEditTime: 2026-08-15 10:17:49
|
||||||
|
* @Description:
|
||||||
|
* @FilePath: \ywxapp_dev\addon\articles\model\ArticleComment.php
|
||||||
|
* @CustomString: Copyright (c) 2026 YwxApp
|
||||||
|
*/
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 文章评论模型
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\articles\model;
|
||||||
|
|
||||||
|
use think\model\concern\SoftDelete;
|
||||||
|
use ywxapp\model\BaseModel;
|
||||||
|
|
||||||
|
class ArticleComment extends BaseModel
|
||||||
|
{
|
||||||
|
use SoftDelete;
|
||||||
|
|
||||||
|
protected $name = 'articles_comment';
|
||||||
|
protected $deleteTime = 'delete_at';
|
||||||
|
protected $defaultSoftDelete = 0;
|
||||||
|
|
||||||
|
protected $autoWriteTimestamp = true;
|
||||||
|
protected $createTime = 'create_at';
|
||||||
|
protected $updateTime = 'update_at';
|
||||||
|
|
||||||
|
protected $type = [
|
||||||
|
'pid' => 'integer',
|
||||||
|
'aid' => 'integer',
|
||||||
|
'uid' => 'integer',
|
||||||
|
'status' => 'integer',
|
||||||
|
'like_count'=> 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\ywxapp\model\MemberUser::class, 'uid', 'uid')
|
||||||
|
->bind(['nickname', 'avatar']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getByArticle(int $aid, int $page = 1, int $size = 10)
|
||||||
|
{
|
||||||
|
return self::with(['user'])
|
||||||
|
->where('delete_at', 0)
|
||||||
|
->where('aid', $aid)
|
||||||
|
->where('status', 1)
|
||||||
|
->order('id', 'asc')
|
||||||
|
->paginate(['list_rows' => $size, 'page' => $page]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ensureSchema(): void
|
||||||
|
{
|
||||||
|
$prefix = self::currentPrefix();
|
||||||
|
self::ensureTableFromInstall($prefix, 'articles_comment');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 文章标签模型
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\articles\model;
|
||||||
|
|
||||||
|
use think\model\concern\SoftDelete;
|
||||||
|
use ywxapp\model\BaseModel;
|
||||||
|
|
||||||
|
class ArticleTag extends BaseModel
|
||||||
|
{
|
||||||
|
use SoftDelete;
|
||||||
|
|
||||||
|
protected $name = 'articles_tag';
|
||||||
|
protected $deleteTime = 'delete_at';
|
||||||
|
protected $defaultSoftDelete = 0;
|
||||||
|
|
||||||
|
protected $autoWriteTimestamp = true;
|
||||||
|
protected $createTime = 'create_at';
|
||||||
|
protected $updateTime = 'update_at';
|
||||||
|
|
||||||
|
protected $type = [];
|
||||||
|
|
||||||
|
public static function getByAlias(string $alias)
|
||||||
|
{
|
||||||
|
return self::where('delete_at', 0)->where('alias', $alias)->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ensureSchema(): void
|
||||||
|
{
|
||||||
|
$prefix = self::currentPrefix();
|
||||||
|
self::ensureTableFromInstall($prefix, 'articles_tag');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @Date: 2026-08-10 15:26:18
|
||||||
|
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||||
|
* @LastEditTime: 2026-08-15 10:14:11
|
||||||
|
* @Description:
|
||||||
|
* @FilePath: \ywxapp_dev\addon\articles\route\app.php
|
||||||
|
* @CustomString: Copyright (c) 2026 YwxApp
|
||||||
|
*/
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 文章CMS插件路由:相对路由,由 AppService::loadAddonRoutes 统一补 articles 前缀
|
||||||
|
// | 约定:不要用 Route::group('articles') 再包一层(外层已补前缀,会双重前缀)
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
use think\facade\Route;
|
||||||
|
|
||||||
|
// 前台(相对规则,由外层 Route::group('articles') 统一补全前缀,url() 用 articles/xxx/index 生成)
|
||||||
|
Route::get('index/index', 'addon\articles\controller\Index@index');
|
||||||
|
Route::get('read/:id', 'addon\articles\controller\Article@read');
|
||||||
|
Route::get('article/detail/:id', 'addon\articles\controller\Article@detail');
|
||||||
|
Route::get('category/index', 'addon\articles\controller\Category@index');
|
||||||
|
Route::get('category/:id', 'addon\articles\controller\Category@index');
|
||||||
|
Route::get('tag/index', 'addon\articles\controller\Tag@index');
|
||||||
|
Route::get('tag/:id', 'addon\articles\controller\Tag@index');
|
||||||
|
Route::get('search/index', 'addon\articles\controller\Article@search');
|
||||||
|
Route::post('article/comment', 'addon\articles\controller\Article@comment');
|
||||||
|
|
||||||
|
// 后台(直接把 backend/ 写进 rule 路径,避免嵌套 Route::group('backend') 产生的歧义)
|
||||||
|
// 实测结论:无论用嵌套 Route::group('backend') 还是把 backend 写进 rule 路径,
|
||||||
|
// loadAddonRoutes 的外层 Route::group('articles') 都会把 backend 层级拼成「带点」形式
|
||||||
|
// → 真实 URL 固定为 /articles/backend.category/index.html(斜杠形式恒 404)。
|
||||||
|
// 故后台 URL 统一用带点,menu.json 的 jump 与视图 {:url('articles/backend.xxx/yyy')} 均须用带点。
|
||||||
|
// 注意:不能用 wxchat 的 ->prefix('backend/') 写法——articles 后台控制器在 controller/backend/ 子目录,
|
||||||
|
// prefix 会让 TP 解析成 backend/backend/Category 导致 500(实测)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Route::group('backend', function () {
|
||||||
|
Route::get('article/index', 'Article/index');
|
||||||
|
Route::any('article/save', 'Article/save');
|
||||||
|
Route::get('article/edit', 'Article/edit');
|
||||||
|
Route::post('article/update', 'Article/update');
|
||||||
|
Route::post('article/delete', 'Article/delete');
|
||||||
|
Route::post('article/recyclebin', 'Article/recyclebin');
|
||||||
|
Route::post('article/restore', 'Article/restore');
|
||||||
|
|
||||||
|
Route::get('tag/index', 'Tag/index');
|
||||||
|
Route::any('tag/save', 'Tag/save');
|
||||||
|
Route::get('tag/edit', 'Tag/edit');
|
||||||
|
Route::post('tag/update', 'Tag/update');
|
||||||
|
Route::post('tag/delete', 'Tag/delete');
|
||||||
|
|
||||||
|
Route::get('comment/index', 'Comment/index');
|
||||||
|
Route::post('comment/audit', 'Comment/audit');
|
||||||
|
Route::post('comment/delete', 'Comment/delete');
|
||||||
|
|
||||||
|
Route::get('category/index', 'Category/index');
|
||||||
|
Route::any('category/save', 'Category/save');
|
||||||
|
Route::get('category/edit', 'Category/edit');
|
||||||
|
Route::post('category/update', 'Category/update');
|
||||||
|
Route::post('category/delete', 'Category/delete');
|
||||||
|
|
||||||
|
})->layer('backend');;
|
||||||
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<div class="layui-fluid">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">编辑文章</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<form class="layui-form" id="form">
|
||||||
|
<input type="hidden" name="id" value="{$article.id|default=0}">
|
||||||
|
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">标题</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="title" value="{$article.title|default=''}" class="layui-input" lay-verify="required">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">分类</label>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<select name="cid" lay-verify="required">
|
||||||
|
<option value="">请选择分类</option>
|
||||||
|
{volist name='categories' id='c'}
|
||||||
|
<option value="{$c.id}" {if isset($article['cid']) && $article['cid'] == $c['id']}selected{/if}>{$c.name}</option>
|
||||||
|
{/volist}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label class="layui-form-label">标签</label>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<input type="text" name="tags" value="{$tagNamesStr|default=''}" class="layui-input" placeholder="逗号分隔">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">封面图</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="cover_image" id="cover_image" value="{$article.cover_image|default=''}" class="layui-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">摘要</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<textarea name="summary" class="layui-textarea">{$article.summary|default=''}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">正文</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<textarea id="content" name="content" style="display:none;">{$article.content|default=''}</textarea>
|
||||||
|
<script id="editor" type="text/plain" style="width:100%;height:420px;"></script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">状态</label>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<input type="checkbox" name="status" value="1" {if isset($article['status']) ? $article['status'] : 1}checked{/if} title="发布" lay-skin="switch">
|
||||||
|
</div>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<input type="checkbox" name="is_top" value="1" {if isset($article['is_top']) ? $article['is_top'] : 0}checked{/if} title="置顶">
|
||||||
|
</div>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<input type="checkbox" name="is_recommend" value="1" {if isset($article['is_recommend']) ? $article['is_recommend'] : 0}checked{/if} title="推荐">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<button class="layui-btn" lay-submit lay-filter="save">保存</button>
|
||||||
|
<a href="{:url('articles/backend.article/index')}" class="layui-btn layui-btn-primary">返回</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/assets/library/ueditor/ueditor.config.js"></script>
|
||||||
|
<script src="/assets/library/ueditor/ueditor.all.js"></script>
|
||||||
|
<script>
|
||||||
|
layui.use(['form', 'jquery'], function () {
|
||||||
|
var form = layui.form, $ = layui.jquery;
|
||||||
|
|
||||||
|
var ue = UE.getEditor('editor', {
|
||||||
|
UEDITOR_HOME_URL: '/assets/library/ueditor/',
|
||||||
|
serverUrl: '/ueditor'
|
||||||
|
});
|
||||||
|
ue.ready(function () {
|
||||||
|
var c = $('#content').val();
|
||||||
|
if (c) { ue.setContent(c); }
|
||||||
|
});
|
||||||
|
|
||||||
|
form.on('submit(save)', function (data) {
|
||||||
|
data.field.content = ue.getContent();
|
||||||
|
$.ajax({
|
||||||
|
url: "{:url('articles/backend.article/update')}",
|
||||||
|
type: 'post',
|
||||||
|
data: data.field,
|
||||||
|
dataType: 'json',
|
||||||
|
success: function (r) {
|
||||||
|
layer.msg(r.msg);
|
||||||
|
if (r.code) { setTimeout(function () { location.href = r.url || "{:url('articles/backend.article/index')}"; }, 800); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<div class="layui-fluid">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">文章管理</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<form class="layui-form" lay-filter="search">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-inline">
|
||||||
|
<select name="status">
|
||||||
|
<option value="all">全部状态</option>
|
||||||
|
<option value="1">已发布</option>
|
||||||
|
<option value="0">草稿</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<input type="text" name="keyword" placeholder="标题关键词" class="layui-input">
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<button class="layui-btn" lay-submit lay-filter="search">搜索</button>
|
||||||
|
<a href="{:url('articles/backend.article/save')}" class="layui-btn layui-btn-normal">新建文章</a>
|
||||||
|
<a href="{:url('articles/backend.article/recyclebin')}" class="layui-btn layui-btn-primary">回收站</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table class="layui-table" id="table" lay-filter="table"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/html" id="toolbar">
|
||||||
|
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
|
||||||
|
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="del">删除</a>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
layui.use(['table', 'form'], function () {
|
||||||
|
var table = layui.table, form = layui.form, $ = layui.jquery;
|
||||||
|
var tableIns = table.render({
|
||||||
|
elem: '#table',
|
||||||
|
url: "{:url('articles/backend.article/index')}",
|
||||||
|
method: 'post',
|
||||||
|
where: { status: 'all' },
|
||||||
|
page: true,
|
||||||
|
cols: [[
|
||||||
|
{ field: 'id', title: 'ID', width: 70 },
|
||||||
|
{ field: 'title', title: '标题', minWidth: 200 },
|
||||||
|
{ field: 'category', title: '分类', width: 100 },
|
||||||
|
{ field: 'is_top', title: '置顶', width: 70, templet: function (d) { return d.is_top ? '是' : ''; } },
|
||||||
|
{ field: 'is_recommend', title: '推荐', width: 70, templet: function (d) { return d.is_recommend ? '是' : ''; } },
|
||||||
|
{ field: 'status', title: '状态', width: 80, templet: function (d) { return d.status ? '<span class="layui-badge layui-bg-green">发布</span>' : '<span class="layui-badge">草稿</span>'; } },
|
||||||
|
{ field: 'view_count', title: '浏览', width: 80 },
|
||||||
|
{ field: 'comment_count', title: '评论', width: 80 },
|
||||||
|
{ field: 'create_at', title: '创建时间', width: 170 },
|
||||||
|
{ title: '操作', width: 140, toolbar: '#toolbar' }
|
||||||
|
]]
|
||||||
|
});
|
||||||
|
|
||||||
|
form.on('submit(search)', function (data) {
|
||||||
|
tableIns.reload({ where: data.field, page: { curr: 1 } });
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
table.on('tool(table)', function (obj) {
|
||||||
|
if (obj.event === 'edit') {
|
||||||
|
location.href = obj.data.edit_url;
|
||||||
|
} else if (obj.event === 'del') {
|
||||||
|
layer.confirm('确认删除该文章?', function () {
|
||||||
|
$.post("{:url('articles/backend.article/delete')}", { id: obj.data.id }, function (r) {
|
||||||
|
layer.msg(r.msg);
|
||||||
|
if (r.code) { obj.del(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<div class="layui-fluid">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">回收站</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<table class="layui-table" id="table" lay-filter="table"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/html" id="toolbar">
|
||||||
|
<a class="layui-btn layui-btn-xs" lay-event="restore">恢复</a>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
layui.use(['table'], function () {
|
||||||
|
var table = layui.table;
|
||||||
|
var tableIns = table.render({
|
||||||
|
elem: '#table',
|
||||||
|
url: "{:url('articles/backend.article/recyclebin')}",
|
||||||
|
method: 'post',
|
||||||
|
page: true,
|
||||||
|
cols: [[
|
||||||
|
{ field: 'id', title: 'ID', width: 70 },
|
||||||
|
{ field: 'title', title: '标题', minWidth: 200 },
|
||||||
|
{ field: 'delete_at', title: '删除时间', width: 170 },
|
||||||
|
{ title: '操作', width: 90, toolbar: '#toolbar' }
|
||||||
|
]]
|
||||||
|
});
|
||||||
|
table.on('tool(table)', function (obj) {
|
||||||
|
if (obj.event === 'restore') {
|
||||||
|
$.post("{:url('articles/backend.article/restore')}", { id: obj.data.id }, function (r) {
|
||||||
|
layer.msg(r.msg); if (r.code) { obj.del(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<div class="layui-fluid">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">编辑分类</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<form class="layui-form" id="form">
|
||||||
|
<input type="hidden" name="id" value="{$category.id|default=0}">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">名称</label>
|
||||||
|
<div class="layui-input-block"><input type="text" name="name" value="{$category.name|default=''}" class="layui-input" lay-verify="required"></div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">别名</label>
|
||||||
|
<div class="layui-input-block"><input type="text" name="alias" value="{$category.alias|default=''}" class="layui-input" lay-verify="required"></div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">描述</label>
|
||||||
|
<div class="layui-input-block"><input type="text" name="description" value="{$category.description|default=''}" 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" value="{$category.cover|default=''}" class="layui-input"></div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">排序</label>
|
||||||
|
<div class="layui-input-inline"><input type="number" name="sort" value="{$category.sort|default=0}" class="layui-input"></div>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<input type="checkbox" name="status" value="1" {if isset($category) && $category.status}checked{/if} title="启用" lay-skin="switch">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<button class="layui-btn" lay-submit lay-filter="save">保存</button>
|
||||||
|
<a href="{:url('articles/backend.category/index')}" class="layui-btn layui-btn-primary">返回</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
layui.use(['form', 'jquery'], function () {
|
||||||
|
var form = layui.form, $ = layui.jquery;
|
||||||
|
form.on('submit(save)', function (data) {
|
||||||
|
$.post("{:url('articles/backend.category/update')}", data.field, function (r) {
|
||||||
|
layer.msg(r.msg); if (r.code) { setTimeout(function () { location.href = r.url; }, 800); }
|
||||||
|
}, 'json');
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<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="toolbar">
|
||||||
|
<div class="layui-btn-group">
|
||||||
|
<a
|
||||||
|
class="layui-btn layui-btn-sm layui-btn-primary"
|
||||||
|
title="新建分类"
|
||||||
|
lay-event="create"
|
||||||
|
data-perm="add">
|
||||||
|
<i class="layui-icon layui-icon-add-1"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- 表格数据工具条 -->
|
||||||
|
<script type="text/html" id="action">
|
||||||
|
<div class="layui-btn-group">
|
||||||
|
<a
|
||||||
|
class="layui-btn layui-btn-sm layui-btn-primary"
|
||||||
|
title="编辑分类"
|
||||||
|
lay-event="edit"
|
||||||
|
data-perm="edit"
|
||||||
|
><i class="layui-icon layui-icon-edit"></i>
|
||||||
|
</a>
|
||||||
|
{{# if(d.id > 1) { }}
|
||||||
|
<a
|
||||||
|
class="layui-btn layui-btn-sm layui-btn-primary"
|
||||||
|
title="删除分类"
|
||||||
|
lay-event="delete"
|
||||||
|
data-perm="delete"
|
||||||
|
><i class="layui-icon layui-icon-delete"></i>
|
||||||
|
</a>
|
||||||
|
{{# } }}
|
||||||
|
</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="dataFormTpl">
|
||||||
|
<form class="layui-form layui-form-pane" lay-filter="category-form" id="category-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="name" required lay-verify="required" placeholder="请输入分类名称" autocomplete="off" class="layui-input" value="{{d.name||''}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">别名</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="alias" required lay-verify="required" placeholder="请输入分类别名(英文标识)" autocomplete="off" class="layui-input" value="{{d.alias||''}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">描述</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<textarea name="description" placeholder="请输入分类描述" class="layui-textarea">{{d.description||''}}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">排序</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="number" name="sort" placeholder="数字越小越靠前" autocomplete="off" class="layui-input" value="{{d.sort||0}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">状态</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="radio" name="status" value="1" title="启用" {{ d.status==undefined || d.status==1 ? 'checked' : '' }}>
|
||||||
|
<input type="radio" name="status" value="0" title="禁用" {{ d.status==0 ? 'checked' : '' }}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item layui-hide">
|
||||||
|
<button class="layui-btn" lay-submit lay-filter="category-form-submit" id="category-form-submit">提交</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
layui.use(['category'], function () {});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<div class="layui-fluid">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">评论管理</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<form class="layui-form" lay-filter="search">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-inline">
|
||||||
|
<select name="status">
|
||||||
|
<option value="all">全部</option>
|
||||||
|
<option value="1">已审核</option>
|
||||||
|
<option value="0">待审核</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<input type="text" name="keyword" placeholder="评论内容" class="layui-input">
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<button class="layui-btn" lay-submit lay-filter="search">搜索</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<table class="layui-table" id="table" lay-filter="table"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/html" id="toolbar">
|
||||||
|
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="audit">审核</a>
|
||||||
|
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="del">删除</a>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
layui.use(['table', 'form'], function () {
|
||||||
|
var table = layui.table, form = layui.form;
|
||||||
|
var tableIns = table.render({
|
||||||
|
elem: '#table',
|
||||||
|
url: "{:url('articles/backend.comment/index')}",
|
||||||
|
method: 'post',
|
||||||
|
where: { status: 'all' },
|
||||||
|
page: true,
|
||||||
|
cols: [[
|
||||||
|
{ field: 'id', title: 'ID', width: 70 },
|
||||||
|
{ field: 'nickname', title: '用户', width: 120 },
|
||||||
|
{ field: 'article', title: '文章', minWidth: 160 },
|
||||||
|
{ field: 'content', title: '内容', minWidth: 200 },
|
||||||
|
{ field: 'status', title: '状态', width: 90, templet: function (d) { return d.status ? '已审核' : '待审'; } },
|
||||||
|
{ field: 'create_at', title: '时间', width: 170 },
|
||||||
|
{ title: '操作', width: 140, toolbar: '#toolbar' }
|
||||||
|
]]
|
||||||
|
});
|
||||||
|
form.on('submit(search)', function (data) {
|
||||||
|
tableIns.reload({ where: data.field, page: { curr: 1 } });
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
table.on('tool(table)', function (obj) {
|
||||||
|
if (obj.event === 'audit') {
|
||||||
|
$.post("{:url('articles/backend.comment/audit')}", { id: obj.data.id }, function (r) { layer.msg(r.msg); if (r.code) { obj.update({ status: '已审核' }); } });
|
||||||
|
} else if (obj.event === 'del') {
|
||||||
|
layer.confirm('确认删除该评论?', function () {
|
||||||
|
$.post("{:url('articles/backend.comment/delete')}", { id: obj.data.id }, function (r) { layer.msg(r.msg); if (r.code) obj.del(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<div class="layui-fluid">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">编辑标签</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<form class="layui-form" id="form">
|
||||||
|
<input type="hidden" name="id" value="{$tag.id|default=0}">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">名称</label>
|
||||||
|
<div class="layui-input-block"><input type="text" name="name" value="{$tag.name|default=''}" class="layui-input" lay-verify="required"></div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">别名</label>
|
||||||
|
<div class="layui-input-block"><input type="text" name="alias" value="{$tag.alias|default=''}" 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="description" value="{$tag.description|default=''}" class="layui-input"></div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<button class="layui-btn" lay-submit lay-filter="save">保存</button>
|
||||||
|
<a href="{:url('articles/backend.tag/index')}" class="layui-btn layui-btn-primary">返回</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
layui.use(['form', 'jquery'], function () {
|
||||||
|
var form = layui.form, $ = layui.jquery;
|
||||||
|
form.on('submit(save)', function (data) {
|
||||||
|
$.post("{:url('articles/backend.tag/update')}", data.field, function (r) {
|
||||||
|
layer.msg(r.msg); if (r.code) { setTimeout(function () { location.href = r.url; }, 800); }
|
||||||
|
}, 'json');
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<div class="layui-fluid">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">标签管理 <a href="{:url('articles/backend.tag/save')}" class="layui-btn layui-btn-sm layui-btn-normal" style="float:right;margin-top:8px;">新建标签</a></div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<table class="layui-table" id="table" lay-filter="table"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/html" id="toolbar">
|
||||||
|
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
|
||||||
|
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="del">删除</a>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
layui.use(['table'], function () {
|
||||||
|
var table = layui.table;
|
||||||
|
table.render({
|
||||||
|
elem: '#table',
|
||||||
|
url: "{:url('articles/backend.tag/index')}",
|
||||||
|
method: 'post',
|
||||||
|
page: false,
|
||||||
|
cols: [[
|
||||||
|
{ field: 'id', title: 'ID', width: 70 },
|
||||||
|
{ field: 'name', title: '名称', width: 150 },
|
||||||
|
{ field: 'alias', title: '别名', width: 150 },
|
||||||
|
{ field: 'description', title: '描述', minWidth: 180 },
|
||||||
|
{ field: 'create_at', title: '创建时间', width: 170 },
|
||||||
|
{ title: '操作', width: 140, toolbar: '#toolbar' }
|
||||||
|
]]
|
||||||
|
});
|
||||||
|
table.on('tool(table)', function (obj) {
|
||||||
|
if (obj.event === 'edit') { location.href = obj.data.edit_url; }
|
||||||
|
else if (obj.event === 'del') {
|
||||||
|
layer.confirm('确认删除该标签?', function () {
|
||||||
|
$.post("{:url('articles/backend.tag/delete')}", { id: obj.data.id }, function (r) {
|
||||||
|
layer.msg(r.msg); if (r.code) { obj.del(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{$article.title} - 文章CMS</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=0">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/assets/layui/css/layui.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/articles/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body class="micronews">
|
||||||
|
<div class="micronews-header-wrap">
|
||||||
|
<div class="micronews-header w1000 layui-clear">
|
||||||
|
<h1 class="logo">
|
||||||
|
<a href="{:url('articles/index/index')}">
|
||||||
|
<img src="/static/articles/img/LOGO.png" alt="logo">
|
||||||
|
<span class="layui-hide">LOGO</span>
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
<p class="nav">
|
||||||
|
<a href="{:url('articles/index/index')}" {eq name='nav_active' value='index'}class="active"{/eq}>最新</a>
|
||||||
|
{volist name='categories' id='c'}
|
||||||
|
<a href="{:url('articles/category/index', ['id'=>$c.id])}" {eq name='nav_active' value='$c.id'}class="active"{/eq}>{$c.name}</a>
|
||||||
|
{/volist}
|
||||||
|
</p>
|
||||||
|
<div class="search-bar">
|
||||||
|
<form class="layui-form" action="{:url('articles/search/index')}">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="keyword" placeholder="搜索你要的内容" autocomplete="off" class="layui-input">
|
||||||
|
<button class="layui-btn search-btn" formnovalidate><i class="layui-icon layui-icon-search"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>-
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="login">
|
||||||
|
{if $is_login}
|
||||||
|
<a href="{:url('member/index/index')}"><img src="{:isset($member['avatar']) && $member['avatar'] ? $member['avatar'] : '/static/articles/img/header.png'}" style="width: 36px; height: 36px;"></a>
|
||||||
|
{else/}
|
||||||
|
<a href="{:url('member/login/index')}">登录</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="menu-icon">
|
||||||
|
<i class="layui-icon layui-icon-more-vertical"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-nav">
|
||||||
|
<ul class="layui-nav" lay-filter="">
|
||||||
|
<li class="layui-nav-item {eq name='nav_active' value='index'}layui-this{/eq}"><a href="{:url('articles/index/index')}">最新</a></li>
|
||||||
|
{volist name='categories' id='c'}
|
||||||
|
<li class="layui-nav-item {eq name='nav_active' value='$c.id'}layui-this{/eq}"><a href="{:url('articles/category/index', ['id'=>$c.id])}">{$c.name}</a></li>
|
||||||
|
{/volist}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="micronews-container micronews-details-container w1000">
|
||||||
|
<div class="layui-fluid">
|
||||||
|
<div class="layui-row">
|
||||||
|
<div class="layui-col-xs12 layui-col-sm12 layui-col-md8">
|
||||||
|
<div class="main">
|
||||||
|
<div class="title">
|
||||||
|
<h3>{$article.title}</h3>
|
||||||
|
<div class="b-txt">
|
||||||
|
<span class="label">{$article.category_name|default='未分类'}</span>
|
||||||
|
<span class="icon">
|
||||||
|
<i class="layui-icon layui-icon-radio"></i>
|
||||||
|
<b>{$article.view_count}</b>人
|
||||||
|
</span>
|
||||||
|
<a href="#message">
|
||||||
|
<span class="icon message">
|
||||||
|
<i class="layui-icon layui-icon-dialogue"></i>
|
||||||
|
<b>{$article.comment_count}</b>条
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<span class="icon time">
|
||||||
|
<i class="layui-icon layui-icon-log"></i>
|
||||||
|
{$article.create_at}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="article">
|
||||||
|
{$article.content|raw}
|
||||||
|
</div>
|
||||||
|
<div class="leave-message" id="message">
|
||||||
|
<div class="tit-box">
|
||||||
|
<span class="tit">网友跟帖</span>
|
||||||
|
<span class="num"><b>{$article.comment_count}</b>条</span>
|
||||||
|
</div>
|
||||||
|
<div class="content-box">
|
||||||
|
<div class="tear-box">
|
||||||
|
<a href="#"><img src="/static/articles/img/header_img1.png"></a>
|
||||||
|
<form class="layui-form">
|
||||||
|
<div class="layui-form-item layui-form-text">
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<textarea id="onInput" placeholder="请输入内容" class="layui-textarea"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-input-block" style="text-align: right;">
|
||||||
|
<div class="message-text">
|
||||||
|
<div class="txt"></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="layui-btn micronews-details-Publish">发表</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="ulCommentList">
|
||||||
|
{volist name='comments.data' id='cm'}
|
||||||
|
<div class="liCont">
|
||||||
|
<a href="#"><img src="/static/articles/img/header_img1.png"></a>
|
||||||
|
<div class="item-cont">
|
||||||
|
<div class="cont">
|
||||||
|
<p><span class="name">{$cm.nickname|default='游客'}</span><span class="time">{$cm.create_at}</span></p>
|
||||||
|
<p class="text">{$cm.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/volist}
|
||||||
|
</div>
|
||||||
|
<div class="page-wrap">{$comments.render|raw}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layui-col-xs12 layui-col-sm12 layui-col-md4">
|
||||||
|
<div class="popular-info popular-info-tog">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">
|
||||||
|
<h3>资讯推荐</h3>
|
||||||
|
</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<ul class="list-box">
|
||||||
|
{volist name='related' id='r'}
|
||||||
|
<li class="list">
|
||||||
|
<a href="{:url('articles/Article/read', ['id'=>$r.id])}">{$r.title}</a>
|
||||||
|
</li>
|
||||||
|
{/volist}
|
||||||
|
{volist name='hot' id='h'}
|
||||||
|
<li class="list">
|
||||||
|
<a href="{:url('articles/Article/read', ['id'=>$h.id])}">{$h.title}</a>
|
||||||
|
</li>
|
||||||
|
{/volist}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="micronews-footer-wrap">
|
||||||
|
<div class="micronews-footer w1000">
|
||||||
|
<div class="ft-nav">
|
||||||
|
<a href="#">关于我们</a>
|
||||||
|
<a href="#">合作伙伴</a>
|
||||||
|
<a href="#">广告服务</a>
|
||||||
|
<a href="#">常见问题</a>
|
||||||
|
</div>
|
||||||
|
<div class="Copyright">
|
||||||
|
<span>Copyright </span> ©<span>文章CMS </span><span>Powered by YwxApp</span>
|
||||||
|
</div>
|
||||||
|
<div class="f-icon">
|
||||||
|
<a href="#" class="w-icon">
|
||||||
|
<img src="/static/articles/img/wechat_ic.png">
|
||||||
|
</a>
|
||||||
|
<a href="#" class="wb-icon">
|
||||||
|
<img src="/static/articles/img/qq_ic.png">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/assets/layui/layui.js"></script>
|
||||||
|
<script type="text/javascript" src="/assets/ywxapp/ywxapp.js"></script>
|
||||||
|
<script>
|
||||||
|
layui.config({
|
||||||
|
base: '/assets/layui/js/'
|
||||||
|
}).use('index', function () {
|
||||||
|
var index = layui.index;
|
||||||
|
index.EnterMessage();
|
||||||
|
index.seachBtn();
|
||||||
|
index.onInput();
|
||||||
|
index.arrowutil();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{$category.name|default='分类'} - 文章CMS</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=0">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/assets/layui/css/layui.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/articles/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body class="micronews">
|
||||||
|
<div class="micronews-header-wrap">
|
||||||
|
<div class="micronews-header w1000 layui-clear">
|
||||||
|
<h1 class="logo">
|
||||||
|
<a href="{:url('articles/index/index')}">
|
||||||
|
<img src="/static/articles/img/LOGO.png" alt="logo">
|
||||||
|
<span class="layui-hide">LOGO</span>
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
<p class="nav">
|
||||||
|
<a href="{:url('articles/index/index')}" {eq name='nav_active' value='index'}class="active"{/eq}>最新</a>
|
||||||
|
{volist name='categories' id='c'}
|
||||||
|
<a href="{:url('articles/category/index', ['id'=>$c.id])}" {eq name='nav_active' value='$c.id'}class="active"{/eq}>{$c.name}</a>
|
||||||
|
{/volist}
|
||||||
|
</p>
|
||||||
|
<div class="search-bar">
|
||||||
|
<form class="layui-form" action="{:url('articles/search/index')}">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="keyword" placeholder="搜索你要的内容" autocomplete="off" class="layui-input">
|
||||||
|
<button class="layui-btn search-btn" formnovalidate><i class="layui-icon layui-icon-search"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="login">
|
||||||
|
{if $is_login}
|
||||||
|
<a href="{:url('member/index/index')}"><img src="{:isset($member['avatar']) && $member['avatar'] ? $member['avatar'] : '/static/articles/img/header.png'}" style="width: 36px; height: 36px;"></a>
|
||||||
|
{else/}
|
||||||
|
<a href="{:url('member/login/index')}">登录</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="menu-icon">
|
||||||
|
<i class="layui-icon layui-icon-more-vertical"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-nav">
|
||||||
|
<ul class="layui-nav" lay-filter="">
|
||||||
|
<li class="layui-nav-item {eq name='nav_active' value='index'}layui-this{/eq}"><a href="{:url('articles/index/index')}">最新</a></li>
|
||||||
|
{volist name='categories' id='c'}
|
||||||
|
<li class="layui-nav-item {eq name='nav_active' value='$c.id'}layui-this{/eq}"><a href="{:url('articles/category/index', ['id'=>$c.id])}">{$c.name}</a></li>
|
||||||
|
{/volist}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="micronews-container w1000">
|
||||||
|
<div class="layui-fluid">
|
||||||
|
<div class="layui-row">
|
||||||
|
<div class="layui-col-xs12 layui-col-sm12 layui-col-md8">
|
||||||
|
<div class="main">
|
||||||
|
<div class="list-item" id="LAY_demo2">
|
||||||
|
{volist name='list' id='vo'}
|
||||||
|
<div class="item">
|
||||||
|
{if isset($vo['cover_image']) && $vo['cover_image']}
|
||||||
|
<a href="{:url('articles/Article/read', ['id'=>$vo.id])}">
|
||||||
|
<img src="{$vo.cover_image}">
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<div class="item-info">
|
||||||
|
<h4><a href="{:url('articles/Article/read', ['id'=>$vo.id])}">{$vo.title}</a></h4>
|
||||||
|
<div class="b-txt">
|
||||||
|
<span class="label">{$vo.category_name|default='未分类'}</span>
|
||||||
|
<span class="icon message">
|
||||||
|
<i class="layui-icon layui-icon-dialogue"></i>
|
||||||
|
{$vo.comment_count|default=0}条
|
||||||
|
</span>
|
||||||
|
<span class="icon time">
|
||||||
|
<i class="layui-icon layui-icon-log"></i>
|
||||||
|
{$vo.create_at}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/volist}
|
||||||
|
</div>
|
||||||
|
<div class="page-wrap">{$page|raw}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-col-xs12 layui-col-sm12 layui-col-md4">
|
||||||
|
<div class="popular-info">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">
|
||||||
|
<h3>热门资讯</h3>
|
||||||
|
</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<ul class="list-box">
|
||||||
|
{volist name='hot' id='h'}
|
||||||
|
<li class="list">
|
||||||
|
<a href="{:url('articles/Article/read', ['id'=>$h.id])}">{$h.title}</a><i class="heat-icon"></i>
|
||||||
|
</li>
|
||||||
|
{/volist}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="micronews-footer-wrap">
|
||||||
|
<div class="micronews-footer w1000">
|
||||||
|
<div class="ft-nav">
|
||||||
|
<a href="#">关于我们</a>
|
||||||
|
<a href="#">合作伙伴</a>
|
||||||
|
<a href="#">广告服务</a>
|
||||||
|
<a href="#">常见问题</a>
|
||||||
|
</div>
|
||||||
|
<div class="Copyright">
|
||||||
|
<span>Copyright </span> ©<span>文章CMS </span><span>Powered by YwxApp</span>
|
||||||
|
</div>
|
||||||
|
<div class="f-icon">
|
||||||
|
<a href="#" class="w-icon">
|
||||||
|
<img src="/static/articles/img/wechat_ic.png">
|
||||||
|
</a>
|
||||||
|
<a href="#" class="wb-icon">
|
||||||
|
<img src="/static/articles/img/qq_ic.png">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/assets/layui/layui.js"></script>
|
||||||
|
<script type="text/javascript" src="/assets/ywxapp/ywxapp.js"></script>
|
||||||
|
<script>
|
||||||
|
layui.config({
|
||||||
|
base: '/assets/layui/js/'
|
||||||
|
}).use('index', function () {
|
||||||
|
var index = layui.index;
|
||||||
|
index.seachBtn();
|
||||||
|
index.arrowutil();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>文章CMS - 资讯中心</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=0">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/assets/layui/css/layui.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/articles/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body class="micronews">
|
||||||
|
<div class="micronews-header-wrap">
|
||||||
|
<div class="micronews-header w1000 layui-clear">
|
||||||
|
<h1 class="logo">
|
||||||
|
<a href="{:url('articles/index/index')}">
|
||||||
|
<img src="/static/articles/img/LOGO.png" alt="logo">
|
||||||
|
<span class="layui-hide">LOGO</span>
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
<p class="nav">
|
||||||
|
<a href="{:url('articles/index/index')}" {eq name='nav_active' value='index'}class="active"{/eq}>最新</a>
|
||||||
|
{volist name='categories' id='c'}
|
||||||
|
<a href="{:url('articles/category/index', ['id'=>$c.id])}" {eq name='nav_active' value='$c.id'}class="active"{/eq}>{$c.name}</a>
|
||||||
|
{/volist}
|
||||||
|
</p>
|
||||||
|
<div class="search-bar">
|
||||||
|
<form class="layui-form" action="{:url('articles/search/index')}">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="keyword" placeholder="搜索你要的内容" autocomplete="off" class="layui-input">
|
||||||
|
<button class="layui-btn search-btn" formnovalidate><i class="layui-icon layui-icon-search"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="login">
|
||||||
|
{if $is_login}
|
||||||
|
<a href="{:url('member/index/index')}"><img src="{:isset($member['avatar']) && $member['avatar'] ? $member['avatar'] : '/static/articles/img/header.png'}" style="width: 36px; height: 36px;"></a>
|
||||||
|
{else/}
|
||||||
|
<a href="{:url('member/login/index')}">登录</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="menu-icon">
|
||||||
|
<i class="layui-icon layui-icon-more-vertical"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-nav">
|
||||||
|
<ul class="layui-nav" lay-filter="">
|
||||||
|
<li class="layui-nav-item {eq name='nav_active' value='index'}layui-this{/eq}"><a href="{:url('articles/index/index')}">最新</a></li>
|
||||||
|
{volist name='categories' id='c'}
|
||||||
|
<li class="layui-nav-item {eq name='nav_active' value='$c.id'}layui-this{/eq}"><a href="{:url('articles/category/index', ['id'=>$c.id])}">{$c.name}</a></li>
|
||||||
|
{/volist}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layui-fluid micronews-banner w1000">
|
||||||
|
<div class="layui-carousel imgbox" id="micronews-carouse">
|
||||||
|
<div carousel-item>
|
||||||
|
{volist name='slides' id='s'}
|
||||||
|
<div>
|
||||||
|
<p class="title">{$s.title}</p>
|
||||||
|
<a href="{:url('articles/Article/read', ['id'=>$s.id])}"><img src="{$s.cover_image}"></a>
|
||||||
|
</div>
|
||||||
|
{/volist}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="micronews-container w1000">
|
||||||
|
<div class="layui-fluid">
|
||||||
|
<div class="layui-row">
|
||||||
|
<div class="layui-col-xs12 layui-col-sm12 layui-col-md8">
|
||||||
|
<div class="main">
|
||||||
|
<div class="list-item" id="LAY_demo2">
|
||||||
|
{volist name='list' id='vo'}
|
||||||
|
<div class="item">
|
||||||
|
{if isset($vo['cover_image']) && $vo['cover_image']}
|
||||||
|
<a href="{:url('articles/Article/read', ['id'=>$vo.id])}">
|
||||||
|
<img src="{$vo.cover_image}">
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<div class="item-info">
|
||||||
|
<h4><a href="{:url('articles/Article/read', ['id'=>$vo.id])}">{$vo.title}</a></h4>
|
||||||
|
<div class="b-txt">
|
||||||
|
<span class="label">{$vo.category_name|default='未分类'}</span>
|
||||||
|
<span class="icon message">
|
||||||
|
<i class="layui-icon layui-icon-dialogue"></i>
|
||||||
|
{$vo.comment_count|default=0}条
|
||||||
|
</span>
|
||||||
|
<span class="icon time">
|
||||||
|
<i class="layui-icon layui-icon-log"></i>
|
||||||
|
{$vo.create_at}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/volist}
|
||||||
|
</div>
|
||||||
|
<div class="page-wrap">{$page|raw}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-col-xs12 layui-col-sm12 layui-col-md4">
|
||||||
|
<div class="popular-info">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">
|
||||||
|
<h3>热门资讯</h3>
|
||||||
|
</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<ul class="list-box">
|
||||||
|
{volist name='hot' id='h'}
|
||||||
|
<li class="list">
|
||||||
|
<a href="{:url('articles/Article/read', ['id'=>$h.id])}">{$h.title}</a><i class="heat-icon"></i>
|
||||||
|
</li>
|
||||||
|
{/volist}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="micronews-footer-wrap">
|
||||||
|
<div class="micronews-footer w1000">
|
||||||
|
<div class="ft-nav">
|
||||||
|
<a href="#">关于我们</a>
|
||||||
|
<a href="#">合作伙伴</a>
|
||||||
|
<a href="#">广告服务</a>
|
||||||
|
<a href="#">常见问题</a>
|
||||||
|
</div>
|
||||||
|
<div class="Copyright">
|
||||||
|
<span>Copyright </span> ©<span>文章CMS </span><span>Powered by YwxApp</span>
|
||||||
|
</div>
|
||||||
|
<div class="f-icon">
|
||||||
|
<a href="#" class="w-icon">
|
||||||
|
<img src="/static/articles/img/wechat_ic.png">
|
||||||
|
</a>
|
||||||
|
<a href="#" class="wb-icon">
|
||||||
|
<img src="/static/articles/img/qq_ic.png">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/assets/layui/layui.js"></script>
|
||||||
|
<script type="text/javascript" src="/assets/ywxapp/ywxapp.js"></script>
|
||||||
|
<script>
|
||||||
|
layui.config({
|
||||||
|
base: '/assets/layui/js/'
|
||||||
|
}).use('index', function () {
|
||||||
|
var index = layui.index;
|
||||||
|
index.banner();
|
||||||
|
index.seachBtn();
|
||||||
|
index.arrowutil();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>搜索:{$keyword|default=''} - 文章CMS</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=0">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/assets/layui/css/layui.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/articles/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body class="micronews">
|
||||||
|
<div class="micronews-header-wrap">
|
||||||
|
<div class="micronews-header w1000 layui-clear">
|
||||||
|
<h1 class="logo">
|
||||||
|
<a href="{:url('articles/index/index')}">
|
||||||
|
<img src="/static/articles/img/LOGO.png" alt="logo">
|
||||||
|
<span class="layui-hide">LOGO</span>
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
<p class="nav">
|
||||||
|
<a href="{:url('articles/index/index')}" {eq name='nav_active' value='index'}class="active"{/eq}>最新</a>
|
||||||
|
{volist name='categories' id='c'}
|
||||||
|
<a href="{:url('articles/category/index', ['id'=>$c.id])}" {eq name='nav_active' value='$c.id'}class="active"{/eq}>{$c.name}</a>
|
||||||
|
{/volist}
|
||||||
|
</p>
|
||||||
|
<div class="search-bar">
|
||||||
|
<form class="layui-form" action="{:url('articles/search/index')}">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="keyword" value="{$keyword|default=''}" placeholder="搜索你要的内容" autocomplete="off" class="layui-input">
|
||||||
|
<button class="layui-btn search-btn" formnovalidate><i class="layui-icon layui-icon-search"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="login">
|
||||||
|
{if $is_login}
|
||||||
|
<a href="{:url('member/index/index')}"><img src="{:isset($member['avatar']) && $member['avatar'] ? $member['avatar'] : '/static/articles/img/header.png'}" style="width: 36px; height: 36px;"></a>
|
||||||
|
{else/}
|
||||||
|
<a href="{:url('member/index/login')}">登录</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="menu-icon">
|
||||||
|
<i class="layui-icon layui-icon-more-vertical"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-nav">
|
||||||
|
<ul class="layui-nav" lay-filter="">
|
||||||
|
<li class="layui-nav-item {eq name='nav_active' value='index'}layui-this{/eq}"><a href="{:url('articles/index/index')}">最新</a></li>
|
||||||
|
{volist name='categories' id='c'}
|
||||||
|
<li class="layui-nav-item {eq name='nav_active' value='$c.id'}layui-this{/eq}"><a href="{:url('articles/category/index', ['id'=>$c.id])}">{$c.name}</a></li>
|
||||||
|
{/volist}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="micronews-container w1000">
|
||||||
|
<div class="layui-fluid">
|
||||||
|
<div class="layui-row">
|
||||||
|
<div class="layui-col-xs12 layui-col-sm12 layui-col-md8">
|
||||||
|
<div class="main micronews-search-container">
|
||||||
|
<h3>“{$keyword|default=''}” 共有 <b>{$total|default=0}</b> 条搜索结果</h3>
|
||||||
|
<div class="list-item" id="LAY_demo2">
|
||||||
|
{volist name='list' id='vo'}
|
||||||
|
<div class="item">
|
||||||
|
{if isset($vo['cover_image']) && $vo['cover_image']}
|
||||||
|
<a href="{:url('articles/Article/read', ['id'=>$vo.id])}">
|
||||||
|
<img src="{$vo.cover_image}">
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<div class="item-info">
|
||||||
|
<h4><a href="{:url('articles/Article/read', ['id'=>$vo.id])}">{$vo.title}</a></h4>
|
||||||
|
<div class="b-txt">
|
||||||
|
<span class="label">{$vo.category_name|default='未分类'}</span>
|
||||||
|
<span class="icon message">
|
||||||
|
<i class="layui-icon layui-icon-dialogue"></i>
|
||||||
|
{$vo.comment_count|default=0}条
|
||||||
|
</span>
|
||||||
|
<span class="icon time">
|
||||||
|
<i class="layui-icon layui-icon-log"></i>
|
||||||
|
{$vo.create_at}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/volist}
|
||||||
|
</div>
|
||||||
|
<div class="page-wrap">{$page|raw}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-col-xs12 layui-col-sm12 layui-col-md4">
|
||||||
|
<div class="popular-info">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">
|
||||||
|
<h3>热门资讯</h3>
|
||||||
|
</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<ul class="list-box">
|
||||||
|
{volist name='hot' id='h'}
|
||||||
|
<li class="list">
|
||||||
|
<a href="{:url('articles/Article/read', ['id'=>$h.id])}">{$h.title}</a><i class="heat-icon"></i>
|
||||||
|
</li>
|
||||||
|
{/volist}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="micronews-footer-wrap">
|
||||||
|
<div class="micronews-footer w1000">
|
||||||
|
<div class="ft-nav">
|
||||||
|
<a href="#">关于我们</a>
|
||||||
|
<a href="#">合作伙伴</a>
|
||||||
|
<a href="#">广告服务</a>
|
||||||
|
<a href="#">常见问题</a>
|
||||||
|
</div>
|
||||||
|
<div class="Copyright">
|
||||||
|
<span>Copyright </span> ©<span>文章CMS </span><span>Powered by YwxApp</span>
|
||||||
|
</div>
|
||||||
|
<div class="f-icon">
|
||||||
|
<a href="#" class="w-icon">
|
||||||
|
<img src="/static/articles/img/wechat_ic.png">
|
||||||
|
</a>
|
||||||
|
<a href="#" class="wb-icon">
|
||||||
|
<img src="/static/articles/img/qq_ic.png">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/assets/layui/layui.js"></script>
|
||||||
|
<script type="text/javascript" src="/assets/ywxapp/ywxapp.js"></script>
|
||||||
|
<script>
|
||||||
|
layui.config({
|
||||||
|
base: '/assets/layui/js/'
|
||||||
|
}).use('index', function () {
|
||||||
|
var index = layui.index;
|
||||||
|
index.seachBtn();
|
||||||
|
index.arrowutil();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{$tag.name|default='标签'} - 文章CMS</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=0">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/assets/layui/css/layui.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/articles/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body class="micronews">
|
||||||
|
<div class="micronews-header-wrap">
|
||||||
|
<div class="micronews-header w1000 layui-clear">
|
||||||
|
<h1 class="logo">
|
||||||
|
<a href="{:url('articles/index/index')}">
|
||||||
|
<img src="/static/articles/img/LOGO.png" alt="logo">
|
||||||
|
<span class="layui-hide">LOGO</span>
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
<p class="nav">
|
||||||
|
<a href="{:url('articles/index/index')}" {eq name='nav_active' value='index'}class="active"{/eq}>最新</a>
|
||||||
|
{volist name='categories' id='c'}
|
||||||
|
<a href="{:url('articles/category/index', ['id'=>$c.id])}" {eq name='nav_active' value='$c.id'}class="active"{/eq}>{$c.name}</a>
|
||||||
|
{/volist}
|
||||||
|
</p>
|
||||||
|
<div class="search-bar">
|
||||||
|
<form class="layui-form" action="{:url('articles/search/index')}">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="keyword" placeholder="搜索你要的内容" autocomplete="off" class="layui-input">
|
||||||
|
<button class="layui-btn search-btn" formnovalidate><i class="layui-icon layui-icon-search"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="login">g
|
||||||
|
{if $is_login}
|
||||||
|
<a href="{:url('member/index/index')}"><img src="{:isset($member['avatar']) && $member['avatar'] ? $member['avatar'] : '/static/articles/img/header.png'}" style="width: 36px; height: 36px;"></a>
|
||||||
|
{else/}
|
||||||
|
<a href="{:url('member/login/index')}">登录</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="menu-icon">
|
||||||
|
<i class="layui-icon layui-icon-more-vertical"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-nav">
|
||||||
|
<ul class="layui-nav" lay-filter="">
|
||||||
|
<li class="layui-nav-item {eq name='nav_active' value='index'}layui-this{/eq}"><a href="{:url('articles/index/index')}">最新</a></li>
|
||||||
|
{volist name='categories' id='c'}
|
||||||
|
<li class="layui-nav-item {eq name='nav_active' value='$c.id'}layui-this{/eq}"><a href="{:url('articles/category/index', ['id'=>$c.id])}">{$c.name}</a></li>
|
||||||
|
{/volist}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="micronews-container w1000">
|
||||||
|
<div class="layui-fluid">
|
||||||
|
<div class="layui-row">
|
||||||
|
<div class="layui-col-xs12 layui-col-sm12 layui-col-md8">
|
||||||
|
<div class="main">
|
||||||
|
<div class="list-item" id="LAY_demo2">
|
||||||
|
{volist name='list' id='vo'}
|
||||||
|
<div class="item">
|
||||||
|
{if isset($vo['cover_image']) && $vo['cover_image']}
|
||||||
|
<a href="{:url('articles/Article/read', ['id'=>$vo.id])}">
|
||||||
|
<img src="{$vo.cover_image}">
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<div class="item-info">
|
||||||
|
<h4><a href="{:url('articles/Article/read', ['id'=>$vo.id])}">{$vo.title}</a></h4>
|
||||||
|
<div class="b-txt">
|
||||||
|
<span class="label">{$vo.category_name|default='未分类'}</span>
|
||||||
|
<span class="icon message">
|
||||||
|
<i class="layui-icon layui-icon-dialogue"></i>
|
||||||
|
{$vo.comment_count|default=0}条
|
||||||
|
</span>
|
||||||
|
<span class="icon time">
|
||||||
|
<i class="layui-icon layui-icon-log"></i>
|
||||||
|
{$vo.create_at}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/volist}
|
||||||
|
</div>
|
||||||
|
<div class="page-wrap">{$page|raw}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-col-xs12 layui-col-sm12 layui-col-md4">
|
||||||
|
<div class="popular-info">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">
|
||||||
|
<h3>热门资讯</h3>
|
||||||
|
</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<ul class="list-box">
|
||||||
|
{volist name='hot' id='h'}
|
||||||
|
<li class="list">
|
||||||
|
<a href="{:url('articles/Article/read', ['id'=>$h.id])}">{$h.title}</a><i class="heat-icon"></i>
|
||||||
|
</li>
|
||||||
|
{/volist}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="micronews-footer-wrap">
|
||||||
|
<div class="micronews-footer w1000">
|
||||||
|
<div class="ft-nav">
|
||||||
|
<a href="#">关于我们</a>
|
||||||
|
<a href="#">合作伙伴</a>
|
||||||
|
<a href="#">广告服务</a>
|
||||||
|
<a href="#">常见问题</a>
|
||||||
|
</div>
|
||||||
|
<div class="Copyright">
|
||||||
|
<span>Copyright </span> ©<span>文章CMS </span><span>Powered by YwxApp</span>
|
||||||
|
</div>
|
||||||
|
<div class="f-icon">
|
||||||
|
<a href="#" class="w-icon">
|
||||||
|
<img src="/static/articles/img/wechat_ic.png">
|
||||||
|
</a>
|
||||||
|
<a href="#" class="wb-icon">
|
||||||
|
<img src="/static/articles/img/qq_ic.png">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/assets/layui/layui.js"></script>
|
||||||
|
<script type="text/javascript" src="/assets/ywxapp/ywxapp.js"></script>
|
||||||
|
<script>
|
||||||
|
layui.config({
|
||||||
|
base: '/assets/layui/js/'
|
||||||
|
}).use('index', function () {
|
||||||
|
var index = layui.index;
|
||||||
|
index.seachBtn();
|
||||||
|
index.arrowutil();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog;
|
||||||
|
|
||||||
|
use think\facade\Db;
|
||||||
|
use ywxapp\AddonBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Addon 类
|
||||||
|
*
|
||||||
|
* @author ywxapp <admin@ywxapp.cn>
|
||||||
|
*/
|
||||||
|
class Addon extends addon
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 安装钩子
|
||||||
|
*
|
||||||
|
* 菜单与建表/种子数据由框架统一处理,无需在此手写:
|
||||||
|
* - 菜单:addon/blog/menu.json
|
||||||
|
* backend → wxapp_admin_power(后台权限/菜单,含 addon 归属)
|
||||||
|
* member → wxapp_user_rule(module='member',会员中心)
|
||||||
|
* frontend → wxapp_user_rule(module='frontend',前台)
|
||||||
|
* - 数据:addon/blog/install.sql(建表,框架 importsql 白名单执行)
|
||||||
|
*
|
||||||
|
* 这里仅保留插件自身的额外初始化钩子(无需可留空)。
|
||||||
|
*/
|
||||||
|
public function install()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卸载钩子
|
||||||
|
*
|
||||||
|
* 框架 AddonService::deleteMenu() 只清理 admin_power,
|
||||||
|
* 这里补清 user_rule 中本插件的前端/会员菜单(按 name 前缀),避免残留。
|
||||||
|
* 采用物理删除,防止软删除后唯一键 name 冲突导致重装失败。
|
||||||
|
*/
|
||||||
|
public function uninstall()
|
||||||
|
{
|
||||||
|
Db::execute("DELETE FROM wxapp_user_rule WHERE name LIKE 'blog:%'");
|
||||||
|
Db::execute("DELETE FROM wxapp_admin_power WHERE addon='blog'");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
# ThinkPHP 8 博客应用 - 说明文档与应用目录
|
||||||
|
|
||||||
|
|
||||||
|
## 一、说明文档
|
||||||
|
|
||||||
|
|
||||||
|
### 1. 项目概述
|
||||||
|
**项目名称**:TP-Blog(基于ThinkPHP 8的现代化博客系统)
|
||||||
|
**项目定位**:轻量级、可扩展的博客平台,支持用户管理、文章发布、分类标签、评论互动、后台管理等核心功能,适合个人博客或小型社区使用。
|
||||||
|
**技术栈**:
|
||||||
|
- 后端:ThinkPHP 8(PHP 8.0+)
|
||||||
|
- 前端:Layui 2.6+、HTML5、CSS3、JavaScript
|
||||||
|
- 数据库:MySQL 5.7+(支持MariaDB 10.3+)
|
||||||
|
- 缓存:Redis(可选,用于提升性能)
|
||||||
|
- 富文本编辑器:WangEditor
|
||||||
|
- 其他:QRCode(分享功能)、ECharts(统计图表,可选)
|
||||||
|
|
||||||
|
|
||||||
|
### 2. 功能模块
|
||||||
|
#### 2.1 前台功能
|
||||||
|
- **用户系统**:注册、登录、退出、个人资料管理
|
||||||
|
- **文章系统**:文章列表(分页/排序)、文章详情(含点赞/收藏/分享)、文章搜索
|
||||||
|
- **分类标签**:按分类/标签筛选文章、分类/标签云展示
|
||||||
|
- **互动功能**:评论(多级回复)、点赞、收藏、文章分享(二维码/链接)
|
||||||
|
- **其他**:RSS订阅、访客统计(PV/UV)
|
||||||
|
|
||||||
|
#### 2.2 后台功能
|
||||||
|
- **仪表盘**:数据统计(文章/用户/评论/访问量)、最新动态、热门文章
|
||||||
|
- **内容管理**:文章发布/编辑/删除、分类/标签管理、评论审核/删除
|
||||||
|
- **用户管理**:用户列表、添加/编辑/删除用户、角色权限(admin/editor/user)
|
||||||
|
- **统计分析**:访客记录、热门页面、访问趋势(需集成ECharts)
|
||||||
|
- **系统设置**:站点信息、SEO配置、邮件设置、权限管理
|
||||||
|
|
||||||
|
|
||||||
|
### 3. 技术架构
|
||||||
|
#### 3.1 架构图
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[前端] --> B[ThinkPHP 8]
|
||||||
|
B --> C[MySQL]
|
||||||
|
B --> D[Redis缓存]
|
||||||
|
B --> E[文件存储]
|
||||||
|
|
||||||
|
subgraph 前端技术栈
|
||||||
|
A1[Layui 2.6+]
|
||||||
|
A2[HTML5/CSS3]
|
||||||
|
A3[JavaScript]
|
||||||
|
A4[WangEditor]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 后端技术栈
|
||||||
|
B1[ThinkPHP 8 MVC]
|
||||||
|
B2[Blade模板引擎]
|
||||||
|
B3[RESTful API]
|
||||||
|
B4[中间件]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 数据存储
|
||||||
|
C1[用户表(tp_user)]
|
||||||
|
C2[文章表(tp_article)]
|
||||||
|
C3[分类表(tp_category)]
|
||||||
|
C4[标签表(tp_tag)]
|
||||||
|
C5[评论表(tp_comment)]
|
||||||
|
C6[点赞/收藏表(tp_like/tp_favorite)]
|
||||||
|
C7[访客表(tp_visit)]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 核心技术点
|
||||||
|
- **MVC分层**:控制器(Controller)、模型(Model)、视图(View)分离
|
||||||
|
- **模型关联**:一对一(用户-文章)、一对多(文章-评论)、多对多(文章-标签)
|
||||||
|
- **中间件**:用户认证(`Auth`)、访客统计(`Visit`)、管理员权限(`AdminAuth`)
|
||||||
|
- **富文本编辑**:集成WangEditor,支持图片上传、代码高亮
|
||||||
|
- **权限控制**:基于角色的访问控制(RBAC),区分普通用户/管理员
|
||||||
|
|
||||||
|
|
||||||
|
### 4. 安装部署
|
||||||
|
#### 4.1 环境准备
|
||||||
|
- **服务器**:Linux(推荐CentOS/Ubuntu)或Windows
|
||||||
|
- **运行环境**:
|
||||||
|
- PHP 8.0+(需开启`fileinfo`、`redis`扩展)
|
||||||
|
- Composer 2.0+
|
||||||
|
- MySQL 5.7+ 或 MariaDB 10.3+
|
||||||
|
- Nginx 1.18+ 或 Apache 2.4+
|
||||||
|
- Redis 6.0+(可选,用于缓存)
|
||||||
|
- **工具**:Git(代码拉取)、Xshell(远程连接)、FileZilla(文件传输)
|
||||||
|
|
||||||
|
#### 4.2 安装步骤
|
||||||
|
##### 4.2.1 本地开发环境
|
||||||
|
```bash
|
||||||
|
# 1. 克隆代码
|
||||||
|
git clone https://github.com/yourname/tp-blog.git
|
||||||
|
cd tp-blog
|
||||||
|
|
||||||
|
# 2. 安装依赖
|
||||||
|
composer install
|
||||||
|
|
||||||
|
# 3. 配置环境
|
||||||
|
cp .env.example .env
|
||||||
|
php think key:generate # 生成应用密钥
|
||||||
|
|
||||||
|
# 4. 创建数据库(导入SQL文件)
|
||||||
|
mysql -u root -p
|
||||||
|
CREATE DATABASE IF NOT EXISTS `tp_blog` DEFAULT CHARACTER SET utf8mb4;
|
||||||
|
USE `tp_blog`;
|
||||||
|
source database/tp_blog.sql; # 导入数据库表结构
|
||||||
|
|
||||||
|
# 5. 配置.env文件
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_NAME=tp_blog
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASS=your_mysql_password
|
||||||
|
APP_DEBUG=true # 开发环境开启调试
|
||||||
|
|
||||||
|
# 6. 启动服务
|
||||||
|
php think run # 访问 http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 4.2.2 生产环境部署(Nginx)
|
||||||
|
```bash
|
||||||
|
# 1. 上传代码到服务器
|
||||||
|
scp -r tp-blog root@your_server_ip:/var/www/
|
||||||
|
|
||||||
|
# 2. 安装依赖(生产环境关闭dev依赖)
|
||||||
|
composer install --no-dev
|
||||||
|
|
||||||
|
# 3. 配置Nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name yourdomain.com;
|
||||||
|
root /var/www/tp-blog/public;
|
||||||
|
index index.php;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
if (!-e $request_filename) {
|
||||||
|
rewrite ^(.*)$ /index.php?s=$1 last;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
fastcgi_pass unix:/run/php/php8.0-fpm.sock;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 静态资源缓存
|
||||||
|
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
|
||||||
|
expires 365d;
|
||||||
|
add_header Cache-Control "public, no-transform";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. 设置文件权限
|
||||||
|
chown -R www-data:www-data /var/www/tp-blog
|
||||||
|
chmod -R 755 /var/www/tp-blog/storage
|
||||||
|
|
||||||
|
# 5. 重启服务
|
||||||
|
systemctl restart nginx
|
||||||
|
systemctl restart php8.0-fpm
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.3 配置说明
|
||||||
|
- **数据库配置**:修改`.env`文件中的`DATABASE`部分
|
||||||
|
- **缓存配置**:开启Redis缓存(`.env`中`CACHE_TYPE=redis`,配置Redis连接)
|
||||||
|
- **站点信息**:修改`config/site.php`中的站点名称、描述、URL等
|
||||||
|
- **邮件配置**:配置SMTP信息(用于用户注册验证、找回密码)
|
||||||
|
|
||||||
|
|
||||||
|
### 5. 使用说明
|
||||||
|
#### 5.1 前台使用
|
||||||
|
- **首页**:展示最新文章、热门文章、分类/标签云
|
||||||
|
- **文章详情**:阅读全文、发表评论、点赞/收藏、分享
|
||||||
|
- **分类/标签**:点击分类/标签查看相关文章列表
|
||||||
|
- **搜索**:输入关键词搜索文章标题/内容
|
||||||
|
- **用户中心**:登录后查看个人资料、我的文章、我的收藏
|
||||||
|
|
||||||
|
#### 5.2 后台使用
|
||||||
|
- **登录**:访问`/admin`,使用管理员账号(默认`admin/admin123`)
|
||||||
|
- **仪表盘**:查看数据概览、最新文章/评论、热门文章
|
||||||
|
- **内容管理**:
|
||||||
|
- 文章:发布/编辑(支持Markdown/WangEditor)、删除、置顶
|
||||||
|
- 分类/标签:添加/编辑/删除,排序分类权重
|
||||||
|
- 评论:审核(通过/拒绝)、删除、批量操作
|
||||||
|
- **用户管理**:添加/编辑用户、分配角色(管理员/编辑/普通用户)
|
||||||
|
- **统计分析**:查看访客记录、热门页面、访问趋势图表
|
||||||
|
|
||||||
|
|
||||||
|
### 6. 数据库设计
|
||||||
|
详见前文《数据库表结构整理》,包含10张核心表(用户、文章、分类、标签、评论、点赞、收藏、访客等),支持完整的博客功能和数据关联。
|
||||||
|
|
||||||
|
|
||||||
|
### 7. API接口(核心)
|
||||||
|
| 接口路径 | 方法 | 参数 | 说明 |
|
||||||
|
|------------------------|------|-------------------------------|--------------------------|
|
||||||
|
| `/login` | POST | username, password | 用户登录 |
|
||||||
|
| `/register` | POST | username, password, email | 用户注册 |
|
||||||
|
| `/article/save` | POST | title, content, category_id | 发布文章 |
|
||||||
|
| `/comment/add` | POST | article_id, content, parent_id | 发表评论 |
|
||||||
|
| `/interaction/like/:id` | POST | - | 点赞/取消点赞文章 |
|
||||||
|
| `/interaction/favorite/:id` | POST | - | 收藏/取消收藏文章 |
|
||||||
|
| `/admin/comment/approve/:id` | POST | - | 审核评论(后台) |
|
||||||
|
|
||||||
|
|
||||||
|
### 8. 常见问题
|
||||||
|
#### 8.1 安装问题
|
||||||
|
- **Composer安装慢**:配置国内镜像 `composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/`
|
||||||
|
- **数据库连接失败**:检查`.env`文件中的数据库账号密码是否正确,MySQL是否允许远程连接
|
||||||
|
|
||||||
|
#### 8.2 功能问题
|
||||||
|
- **评论不显示**:检查评论状态是否为“已通过”(`status=1`),评论是否需要审核
|
||||||
|
- **图片上传失败**:确认`public/upload`目录权限(755),PHP配置`upload_max_filesize`是否足够
|
||||||
|
|
||||||
|
#### 8.3 性能问题
|
||||||
|
- **页面加载慢**:开启Redis缓存(热点数据缓存),压缩静态资源(CSS/JS合并压缩)
|
||||||
|
|
||||||
|
|
||||||
|
### 9. 附录
|
||||||
|
- **参考文档**:
|
||||||
|
- https://www.kancloud.cn/manual/thinkphp8_0
|
||||||
|
- https://layui.dev/
|
||||||
|
- https://www.wangeditor.com/
|
||||||
|
- **鸣谢**:感谢开源社区提供的框架和工具支持
|
||||||
|
|
||||||
|
|
||||||
|
## 二、应用目录结构
|
||||||
|
|
||||||
|
|
||||||
|
### 完整目录树
|
||||||
|
```
|
||||||
|
|
||||||
|
blog/ # 应用目录(核心代码)
|
||||||
|
├─ controller/ # 控制器目录
|
||||||
|
│ ├─ Admin/ # 后台控制器
|
||||||
|
│ │ ├─ Dashboard.php # 仪表盘控制器
|
||||||
|
│ │ ├─ Article.php # 文章管理控制器
|
||||||
|
│ │ ├─ Category.php # 分类管理控制器
|
||||||
|
│ │ ├─ Tag.php # 标签管理控制器
|
||||||
|
│ │ ├─ Comment.php # 评论管理控制器
|
||||||
|
│ │ ├─ User.php # 用户管理控制器
|
||||||
|
│ │ ├─ Stat.php # 统计分析控制器
|
||||||
|
│ │ └─ Setting.php # 系统设置控制器
|
||||||
|
│ ├─ Auth.php # 认证控制器(登录/注册/退出)
|
||||||
|
│ ├─ Articler.php # 前台文章控制器(详情/列表)
|
||||||
|
│ ├─ Comment.php # 前台评论控制器(发表/删除)
|
||||||
|
│ ├─ Interaction.php # 互动控制器(点赞/收藏)
|
||||||
|
│ ├─ Index.php # 前台首页控制器
|
||||||
|
│ └─ Rss.php # RSS订阅控制器
|
||||||
|
├─ model/ # 模型目录(数据库映射)
|
||||||
|
│ ├─ User.php # 用户模型
|
||||||
|
│ ├─ BlogArticle.php # 文章模型
|
||||||
|
│ ├─ BlogCategory.php # 分类模型
|
||||||
|
│ ├─ BlogTag.php # 标签模型
|
||||||
|
│ ├─ BlogComment.php # 评论模型
|
||||||
|
│ ├─ BlogLike.php # 点赞模型
|
||||||
|
│ ├─ BlogFavorite.php # 收藏模型
|
||||||
|
│ └─ BlogVisit.php # 访客模型
|
||||||
|
├─ view/ # 视图目录(模板文件)
|
||||||
|
│ ├─ admin/ # 后台视图
|
||||||
|
│ │ ├─ dashboard/ # 仪表盘视图
|
||||||
|
│ │ ├─ article/ # 文章管理视图
|
||||||
|
│ │ ├─ category/ # 分类管理视图
|
||||||
|
│ │ ├─ tag/ # 标签管理视图
|
||||||
|
│ │ ├─ comment/ # 评论管理视图
|
||||||
|
│ │ ├─ user/ # 用户管理视图
|
||||||
|
│ │ ├─ stat/ # 统计分析视图
|
||||||
|
│ │ ├─ setting/ # 系统设置视图
|
||||||
|
│ │ └─ layout/ # 后台布局模板
|
||||||
|
│ ├─ auth/ # 认证视图(登录/注册)
|
||||||
|
│ ├─ index/ # 前台视图
|
||||||
|
│ │ ├─ article/ # 文章详情/列表视图
|
||||||
|
│ │ ├─ category/ # 分类文章列表视图
|
||||||
|
│ │ ├─ tag/ # 标签文章列表视图
|
||||||
|
│ │ ├─ search/ # 搜索结果视图
|
||||||
|
│ │ ├─ _comment.html # 评论组件视图
|
||||||
|
│ │ └─ _sidebar.html # 侧边栏组件视图
|
||||||
|
│ └─ layout/ # 前台布局模板(base.html)
|
||||||
|
├─ middleware/ # 中间件目录
|
||||||
|
│ │ ├─ Auth.php # 用户认证中间件
|
||||||
|
│ │ ├─ AdminAuth.php # 管理员权限中间件
|
||||||
|
│ │ └─ Visit.php # 访客统计中间件
|
||||||
|
└─ service/ # 服务层(可选,封装业务逻辑)
|
||||||
|
│ ├─ UserService.php # 用户服务
|
||||||
|
│ └─ ArticleService.php # 文章服务
|
||||||
|
├─ config/ # 配置文件目录
|
||||||
|
│ ├─ app.php # 应用配置(调试模式、时区等)
|
||||||
|
│ ├─ database.php # 数据库配置
|
||||||
|
│ ├─ cache.php # 缓存配置(Redis)
|
||||||
|
│ ├─ route.php # 路由配置(可选)
|
||||||
|
│ └─ site.php # 站点信息配置(名称、描述等)
|
||||||
|
├─ public/ # 公共资源目录(Web根目录)
|
||||||
|
│ ├─ static/ # 静态资源
|
||||||
|
│ │ ├─ layui/ # Layui框架文件
|
||||||
|
│ │ ├─ wangEditor/ # 富文本编辑器
|
||||||
|
│ │ ├─ css/ # 自定义CSS
|
||||||
|
│ │ │ ├─ main.css # 前台样式
|
||||||
|
│ │ │ └─ admin.css # 后台样式
|
||||||
|
│ │ ├─ js/ # 自定义JS
|
||||||
|
│ │ │ ├─ main.js # 前台脚本
|
||||||
|
│ │ │ └─ admin.js # 后台脚本
|
||||||
|
│ │ └─ images/ # 图片资源(头像、默认封面等)
|
||||||
|
│ ├─ upload/ # 上传文件目录(图片、附件)
|
||||||
|
│ ├─ index.php # 入口文件(单应用模式)
|
||||||
|
│ └─ router.php # 路由入口(可选)
|
||||||
|
├─ route/ # 路由目录
|
||||||
|
│ ├─ app.php # 路由定义(分组路由、资源路由)
|
||||||
|
│ └─ api.php # API路由(可选)
|
||||||
|
├─ database/ # 数据库相关
|
||||||
|
│ ├─ migrations/ # 数据库迁移文件(可选)
|
||||||
|
│ ├─ seeds/ # 数据填充文件(可选)
|
||||||
|
│ └─ tp_blog.sql # 数据库表结构SQL文件
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 核心目录说明
|
||||||
|
- **app/controller**:存放所有控制器,按前台/后台分组,处理HTTP请求和业务逻辑分发
|
||||||
|
- **app/model**:模型类,映射数据库表,封装数据操作(关联查询、数据验证等)
|
||||||
|
- **app/view**:模板文件,使用Layui组件构建界面,支持模板继承和组件复用
|
||||||
|
- **public/static**:静态资源集中存放,便于Nginx配置缓存策略
|
||||||
|
- **route/app.php**:路由定义文件,配置URL与控制器方法的映射关系
|
||||||
|
- **storage**:运行时生成的文件(日志、缓存),需确保写入权限
|
||||||
|
|
||||||
|
|
||||||
|
以上文档和目录结构覆盖了项目的全生命周期,可作为开发、部署和维护的参考手册。如需扩展功能(如第三方登录、Markdown支持),可在对应目录下新增模块。
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// 博客插件应用引导
|
||||||
|
// 前台默认使用会员(Member)鉴权;后台 /blog/backend/* 由 AdminAuth 中间件切换为管理员(Backend)鉴权。
|
||||||
|
app()->bind('auth', \ywxapp\library\Auth::class);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// 这是系统自动生成的config定义文件
|
||||||
|
return [
|
||||||
|
// 发帖是否需要审核
|
||||||
|
'need_audit' => 0,
|
||||||
|
/* ============ 全文检索(ES 可选增强,默认 LIKE) ============ */
|
||||||
|
// 搜索引擎:like=原生模糊检索(默认);es=ElasticSearch
|
||||||
|
'search_engine' => 'like',
|
||||||
|
// ES 节点地址
|
||||||
|
'es_host' => 'http://127.0.0.1:9200',
|
||||||
|
// ES 索引名
|
||||||
|
'es_index' => 'ywxapp_blog',
|
||||||
|
];
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller;
|
||||||
|
|
||||||
|
use addon\blog\model\BlogArticle as ArticleModel;
|
||||||
|
use addon\blog\model\BlogCategory as CategoryModel;
|
||||||
|
use addon\blog\model\BlogComment as CommentModel;
|
||||||
|
use addon\blog\model\BlogTag as TagModel;
|
||||||
|
use addon\blog\model\BlogLike as LikeModel;
|
||||||
|
use addon\blog\model\BlogFavorite as FavoriteModel;
|
||||||
|
use ywxapp\model\MemberUser;
|
||||||
|
use think\facade\Cache;
|
||||||
|
use think\facade\Request;
|
||||||
|
use think\facade\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Article 类
|
||||||
|
*
|
||||||
|
* @author ywxapp <admin@ywxapp.cn>
|
||||||
|
*/
|
||||||
|
class Article extends BlogFrontend
|
||||||
|
{
|
||||||
|
|
||||||
|
public function initialize()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $noNeedLogin = ['*'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网站首页
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$page = Request::param('page', 1);
|
||||||
|
|
||||||
|
$articles = ArticleModel::with(['user', 'category', 'tags'])
|
||||||
|
->where('status', 1)
|
||||||
|
->order('create_at', 'desc')
|
||||||
|
->paginate(['list_rows' => 10, 'page' => $page]);
|
||||||
|
|
||||||
|
$hotArticles = ArticleModel::with(['user', 'category'])
|
||||||
|
->where('status', 1)
|
||||||
|
->order('view_count', 'desc')
|
||||||
|
->limit(5)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$recommendedArticles = ArticleModel::with(['user', 'category'])
|
||||||
|
->where('status', 1)
|
||||||
|
->where('is_top', 1)
|
||||||
|
->order('like_count', 'desc')
|
||||||
|
->limit(5)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$categories = CategoryModel::withCount(['articles' =>
|
||||||
|
function ($query, &$alias) {
|
||||||
|
$query->where('status', 1);
|
||||||
|
$alias = 'card_count';
|
||||||
|
}])
|
||||||
|
->order('sort', 'asc')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$tags = TagModel::withCount('articles')
|
||||||
|
->order('create_at', 'desc')
|
||||||
|
->limit(20)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$latestComments = CommentModel::with(['user', 'article'])
|
||||||
|
->where('status', 1)
|
||||||
|
->order('create_at', 'desc')
|
||||||
|
->limit(5)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$this->view->assign('articles', $articles);
|
||||||
|
$this->view->assign('hotArticles', $hotArticles);
|
||||||
|
$this->view->assign('recommendedArticles', $recommendedArticles);
|
||||||
|
$this->view->assign('categories', $categories);
|
||||||
|
$this->view->assign('tags', $tags);
|
||||||
|
$this->view->assign('latestComments', $latestComments);
|
||||||
|
return $this->view->fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文章详情页
|
||||||
|
*/
|
||||||
|
public function detail($id)
|
||||||
|
{
|
||||||
|
$article = ArticleModel::with(['user', 'category', 'tags'])
|
||||||
|
->where('id', $id)
|
||||||
|
->where('status', 1)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if (!$article) {
|
||||||
|
return $this->error('文章不存在或已被删除');
|
||||||
|
}
|
||||||
|
|
||||||
|
$article->view_count += 1;
|
||||||
|
$article->save();
|
||||||
|
|
||||||
|
$comments = CommentModel::with([
|
||||||
|
'member',
|
||||||
|
'children' =>
|
||||||
|
function ($q) {
|
||||||
|
$q->with(['user'])->where('status', 1)->order('create_at', 'asc');
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->where('aid', $id)
|
||||||
|
->where('pid', 0)
|
||||||
|
->where('status', 1)
|
||||||
|
->order('create_at', 'desc')
|
||||||
|
->paginate(10);
|
||||||
|
|
||||||
|
$relatedArticles = \addon\blog\service\BlogSearchService::related($article->id, 5);
|
||||||
|
|
||||||
|
return View::fetch('article/detail', [
|
||||||
|
'article' => $article,
|
||||||
|
'comments' => $comments,
|
||||||
|
'relatedArticles' => $relatedArticles
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文章点赞
|
||||||
|
*/
|
||||||
|
public function like($id)
|
||||||
|
{
|
||||||
|
if (! $this->auth->isLogin) {
|
||||||
|
return json(['code' => 0, 'msg' => '请先登录']);
|
||||||
|
}
|
||||||
|
$uid = $this->auth->model->uid;
|
||||||
|
|
||||||
|
$article = ArticleModel::find($id);
|
||||||
|
if (!$article) {
|
||||||
|
return json(['code' => 0, 'msg' => '文章不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$like = LikeModel::where('uid', $uid)
|
||||||
|
->where('aid', $id)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if ($like) {
|
||||||
|
$like->delete();
|
||||||
|
$article->like_count = max(0, $article->like_count - 1);
|
||||||
|
$action = 'cancel';
|
||||||
|
} else {
|
||||||
|
$like = new LikeModel();
|
||||||
|
$like->uid = $uid;
|
||||||
|
$like->aid = $id;
|
||||||
|
$like->save();
|
||||||
|
$article->like_count += 1;
|
||||||
|
$action = 'add';
|
||||||
|
}
|
||||||
|
|
||||||
|
$article->save();
|
||||||
|
|
||||||
|
return json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => $action == 'add' ? '点赞成功' : '已取消点赞',
|
||||||
|
'count' => $article->like_count,
|
||||||
|
'action' => $action
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文章收藏
|
||||||
|
*/
|
||||||
|
public function favorite($id)
|
||||||
|
{
|
||||||
|
if (! $this->auth->isLogin) {
|
||||||
|
return json(['code' => 0, 'msg' => '请先登录']);
|
||||||
|
}
|
||||||
|
$uid = $this->auth->model->uid;
|
||||||
|
|
||||||
|
$article = ArticleModel::find($id);
|
||||||
|
if (!$article) {
|
||||||
|
return json(['code' => 0, 'msg' => '文章不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$favorite = FavoriteModel::where('uid', $uid)
|
||||||
|
->where('aid', $id)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if ($favorite) {
|
||||||
|
$favorite->delete();
|
||||||
|
$article->favorite_count = max(0, $article->favorite_count - 1);
|
||||||
|
$action = 'cancel';
|
||||||
|
} else {
|
||||||
|
$favorite = new FavoriteModel();
|
||||||
|
$favorite->uid = $uid;
|
||||||
|
$favorite->aid = $id;
|
||||||
|
$favorite->save();
|
||||||
|
$article->favorite_count += 1;
|
||||||
|
$action = 'add';
|
||||||
|
}
|
||||||
|
|
||||||
|
$article->save();
|
||||||
|
|
||||||
|
return json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => $action == 'add' ? '收藏成功' : '已取消收藏',
|
||||||
|
'count' => $article->favorite_count,
|
||||||
|
'action' => $action
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文章评论
|
||||||
|
*/
|
||||||
|
public function getComments($id)
|
||||||
|
{
|
||||||
|
$page = Request::param('page', 1);
|
||||||
|
$pageSize = Request::param('page_size', 10);
|
||||||
|
|
||||||
|
$comments = CommentModel::with(['user'])
|
||||||
|
->where('aid', $id)
|
||||||
|
->where('pid', 0)
|
||||||
|
->where('status', 1)
|
||||||
|
->order('create_at', 'desc')
|
||||||
|
->page($page, $pageSize)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$total = CommentModel::where('aid', $id)
|
||||||
|
->where('pid', 0)
|
||||||
|
->where('status', 1)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$commentList = [];
|
||||||
|
foreach ($comments as $comment) {
|
||||||
|
$commentList[] = [
|
||||||
|
'id' => $comment->id,
|
||||||
|
'content' => $comment->content,
|
||||||
|
'create_at' => date('Y-m-d H:i', $comment->create_at),
|
||||||
|
'member' => [
|
||||||
|
'uid' => $comment->user->uid,
|
||||||
|
'nickname' => $comment->user->nickname,
|
||||||
|
'avatar' => $comment->user->avatar ?? '/static/images/avatar.png'
|
||||||
|
],
|
||||||
|
'like_count' => $comment->like_count
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return json([
|
||||||
|
'code' => 1,
|
||||||
|
'data' => [
|
||||||
|
'list' => $commentList,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'page_size' => $pageSize
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 个人博客主页:展示该用户的资料 + 已发布文章(多用户门户)
|
||||||
|
*/
|
||||||
|
public function author($id)
|
||||||
|
{
|
||||||
|
$author = MemberUser::find($id);
|
||||||
|
if (!$author) {
|
||||||
|
return $this->error('用户不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'articles' => ArticleModel::where('uid', $id)->where('status', 1)->count(),
|
||||||
|
'views' => (int) ArticleModel::where('uid', $id)->where('status', 1)->sum('view_count'),
|
||||||
|
'likes' => (int) ArticleModel::where('uid', $id)->where('status', 1)->sum('like_count'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$join = $author->create_at;
|
||||||
|
if (is_numeric($join)) {
|
||||||
|
$join = date('Y-m-d', (int) $join);
|
||||||
|
} else {
|
||||||
|
$ts = strtotime((string) $join);
|
||||||
|
$join = $ts !== false ? date('Y-m-d', $ts) : (string) $join;
|
||||||
|
}
|
||||||
|
|
||||||
|
$avatar = $author->avatar ?: '/static/blog/img/portrait.jpg';
|
||||||
|
|
||||||
|
$articles = ArticleModel::with(['user', 'category', 'tags'])
|
||||||
|
->where('uid', $id)
|
||||||
|
->where('status', 1)
|
||||||
|
->order('create_at', 'desc')
|
||||||
|
->paginate(10);
|
||||||
|
|
||||||
|
return View::fetch('article/author', [
|
||||||
|
'author' => $author,
|
||||||
|
'avatar' => $avatar,
|
||||||
|
'author_join' => $join,
|
||||||
|
'stats' => $stats,
|
||||||
|
'articles' => $articles,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller;
|
||||||
|
use ywxapp\controller\FrontendBase;
|
||||||
|
|
||||||
|
use addon\blog\model\BlogArticle;
|
||||||
|
use addon\blog\model\BlogCategory;
|
||||||
|
use addon\blog\model\BlogTag;
|
||||||
|
use addon\blog\model\BlogComment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 博客前台基类:统一注入侧边栏/统计等共享数据
|
||||||
|
*/
|
||||||
|
class BlogFrontend extends FrontendBase
|
||||||
|
{
|
||||||
|
// 博客前台为公开页面,不做强制登录拦截
|
||||||
|
protected $noNeedLogin = ['*'];
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
|
||||||
|
public function _initialize()
|
||||||
|
{
|
||||||
|
parent::_initialize();
|
||||||
|
|
||||||
|
$articleModel = new BlogArticle();
|
||||||
|
$categoryModel = new BlogCategory();
|
||||||
|
$tagModel = new BlogTag();
|
||||||
|
$commentModel = new BlogComment();
|
||||||
|
|
||||||
|
// 主动解析登录态:verifyAuth 因 noNeedLogin='*' 会跳过 tokenParse,
|
||||||
|
// 必须显式 checkLogin() 从 session(access_token) 还原 isLogin。
|
||||||
|
$isLogin = $this->auth && $this->auth->isLogin;
|
||||||
|
$currentUid = 0;
|
||||||
|
$loginUser = null;
|
||||||
|
if ($isLogin) {
|
||||||
|
$u = $this->auth->info;
|
||||||
|
$loginUser = $u;
|
||||||
|
$currentUid = is_array($u) ? (int) ($u['uid'] ?? 0) : (int) ($u->uid ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->view->assign([
|
||||||
|
'total_articles' => $articleModel->where('status', 1)->count(),
|
||||||
|
'total_categories' => $categoryModel->count(),
|
||||||
|
'total_views' => (int) $articleModel->where('status', 1)->sum('view_count'),
|
||||||
|
'hotArticles' => $articleModel->with(['user', 'category'])
|
||||||
|
->where('status', 1)->order('view_count', 'desc')->limit(5)->select(),
|
||||||
|
'categories' => $categoryModel->withCount(['articles' =>
|
||||||
|
function ($query, &$alias) {
|
||||||
|
$query->where('status', 1);
|
||||||
|
$alias = 'card_count';
|
||||||
|
}])->order('sort', 'asc')->select(),
|
||||||
|
'tags' => $tagModel->withCount('articles')->order('create_at', 'desc')->limit(20)->select(),
|
||||||
|
'latestComments' => $commentModel->with(['user', 'article'])
|
||||||
|
->where('status', 1)->order('create_at', 'desc')->limit(5)->select(),
|
||||||
|
'nav_active' => '',
|
||||||
|
'is_login' => $isLogin,
|
||||||
|
'member' => $loginUser,
|
||||||
|
'current_uid' => $currentUid,
|
||||||
|
// 主题色:后台「基本设置」可配置,默认 #2d6cdf
|
||||||
|
'theme_color' => \think\facade\Config::get('site.theme_color', '#2d6cdf'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller;
|
||||||
|
|
||||||
|
use addon\blog\model\BlogArticle as ArticleModel;
|
||||||
|
use addon\blog\model\BlogComment as CommentModel;
|
||||||
|
use think\facade\Request;
|
||||||
|
use think\facade\View;
|
||||||
|
use think\facade\Session;
|
||||||
|
use think\facade\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comment 类
|
||||||
|
*
|
||||||
|
* @author ywxapp <admin@ywxapp.cn>
|
||||||
|
*/
|
||||||
|
class Comment extends BlogFrontend
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 公开访问(登录校验由方法内部通过 $this->auth 处理,会员统一以 uid 标识)
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $noNeedLogin = ['*'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发表评论
|
||||||
|
*/
|
||||||
|
public function add()
|
||||||
|
{
|
||||||
|
if (! $this->auth->isLogin) {
|
||||||
|
return json(['code' => 0, 'msg' => '请先登录']);
|
||||||
|
}
|
||||||
|
$uid = $this->auth->model->uid;
|
||||||
|
|
||||||
|
$data = Request::post();
|
||||||
|
|
||||||
|
$validate = new \think\Validate([
|
||||||
|
'content|评论内容' => 'require|min:5|max:500',
|
||||||
|
'article_id|文章ID' => 'require|number',
|
||||||
|
'parent_id|父评论ID' => 'number'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$validate->check($data)) {
|
||||||
|
return json(['code' => 0, 'msg' => $validate->getError()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$article = ArticleModel::find($data['article_id']);
|
||||||
|
if (!$article) {
|
||||||
|
return json(['code' => 0, 'msg' => '文章不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data['parent_id'] > 0) {
|
||||||
|
$parentComment = CommentModel::find($data['parent_id']);
|
||||||
|
if (!$parentComment || $parentComment->aid != $data['article_id']) {
|
||||||
|
return json(['code' => 0, 'msg' => '回复的评论不存在']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$sensitiveWords = ['垃圾', '广告', '违法', '政治'];
|
||||||
|
$content = str_replace($sensitiveWords, '***', $data['content']);
|
||||||
|
|
||||||
|
$comment = new CommentModel();
|
||||||
|
$comment->uid = $uid;
|
||||||
|
$comment->aid = $data['article_id'];
|
||||||
|
$comment->pid = $data['parent_id'] ?? 0;
|
||||||
|
$comment->content = $content;
|
||||||
|
$comment->status = 1;
|
||||||
|
$comment->ip = Request::ip();
|
||||||
|
$comment->user_agent = Request::server('HTTP_USER_AGENT');
|
||||||
|
$comment->save();
|
||||||
|
|
||||||
|
$article->comment_count = Db::raw('comment_count + 1');
|
||||||
|
$article->save();
|
||||||
|
|
||||||
|
return json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => '评论发表成功',
|
||||||
|
'data' => [
|
||||||
|
'id' => $comment->id,
|
||||||
|
'content' => $comment->content,
|
||||||
|
'create_at' => $comment->create_text,
|
||||||
|
'member' => [
|
||||||
|
'uid' => $uid,
|
||||||
|
'nickname' => $this->auth->model->nickname,
|
||||||
|
'avatar' => $this->auth->model->avatar ?? '/static/images/avatar.png'
|
||||||
|
],
|
||||||
|
'parent_id' => $comment->pid
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除评论
|
||||||
|
*/
|
||||||
|
public function delete($id)
|
||||||
|
{
|
||||||
|
if (! $this->auth->isLogin) {
|
||||||
|
return json(['code' => 0, 'msg' => '请先登录']);
|
||||||
|
}
|
||||||
|
$uid = $this->auth->model->uid;
|
||||||
|
|
||||||
|
$comment = CommentModel::find($id);
|
||||||
|
if (!$comment) {
|
||||||
|
return json(['code' => 0, 'msg' => '评论不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($comment->uid != $uid) {
|
||||||
|
return json(['code' => 0, 'msg' => '无权限操作']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->deleteCommentWithChildren($id);
|
||||||
|
|
||||||
|
$article = ArticleModel::find($comment->aid);
|
||||||
|
if ($article) {
|
||||||
|
$article->comment_count = Db::raw('comment_count - 1');
|
||||||
|
$article->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(['code' => 1, 'msg' => '评论已删除']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归删除评论及其子评论
|
||||||
|
*/
|
||||||
|
private function deleteCommentWithChildren($commentId)
|
||||||
|
{
|
||||||
|
$comment = CommentModel::find($commentId);
|
||||||
|
if ($comment) {
|
||||||
|
$children = CommentModel::where('pid', $commentId)->select();
|
||||||
|
foreach ($children as $child) {
|
||||||
|
$this->deleteCommentWithChildren($child->id);
|
||||||
|
}
|
||||||
|
$comment->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评论点赞
|
||||||
|
*/
|
||||||
|
public function like($id)
|
||||||
|
{
|
||||||
|
if (! $this->auth->isLogin) {
|
||||||
|
return json(['code' => 0, 'msg' => '请先登录']);
|
||||||
|
}
|
||||||
|
$uid = $this->auth->model->uid;
|
||||||
|
|
||||||
|
$comment = CommentModel::find($id);
|
||||||
|
if (!$comment) {
|
||||||
|
return json(['code' => 0, 'msg' => '评论不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$likeKey = "comment_like_{$id}_{$uid}";
|
||||||
|
if (Session::get($likeKey)) {
|
||||||
|
Session::delete($likeKey);
|
||||||
|
$comment->like_count = max(0, $comment->like_count - 1);
|
||||||
|
$action = 'cancel';
|
||||||
|
} else {
|
||||||
|
Session::set($likeKey, true);
|
||||||
|
$comment->like_count += 1;
|
||||||
|
$action = 'add';
|
||||||
|
}
|
||||||
|
|
||||||
|
$comment->save();
|
||||||
|
|
||||||
|
return json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => $action == 'add' ? '点赞成功' : '已取消点赞',
|
||||||
|
'count' => $comment->like_count,
|
||||||
|
'action' => $action
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller;
|
||||||
|
|
||||||
|
use addon\blog\model\BlogArticle as ArticleModel;
|
||||||
|
use addon\blog\model\BlogCategory as CategoryModel;
|
||||||
|
use addon\blog\model\BlogComment as CommentModel;
|
||||||
|
use addon\blog\model\BlogTag as TagModel;
|
||||||
|
use think\facade\Cache;
|
||||||
|
use think\facade\View;
|
||||||
|
use think\facade\Request;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index 类
|
||||||
|
*
|
||||||
|
* @author ywxapp <admin@ywxapp.cn>
|
||||||
|
*/
|
||||||
|
class Index extends BlogFrontend
|
||||||
|
{
|
||||||
|
|
||||||
|
public function initialize() {}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary of needLogin
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $noNeedLogin = ['*'];
|
||||||
|
/**
|
||||||
|
* 网站首页
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
// 尝试从缓存获取首页数据(v2 键:绕过旧版本可能残留的损坏缓存)
|
||||||
|
$cacheKey = 'homepage_data_v2';
|
||||||
|
$data = Cache::get($cacheKey);
|
||||||
|
// 判断缓存中的文章是否为空(兼容数组与模型集合对象)
|
||||||
|
$articlesEmpty = true;
|
||||||
|
if (! empty($data) && ! empty($data['articles'])) {
|
||||||
|
$cachedArticles = $data['articles'];
|
||||||
|
$articlesEmpty = (is_object($cachedArticles) && method_exists($cachedArticles, 'isEmpty'))
|
||||||
|
? $cachedArticles->isEmpty()
|
||||||
|
: (count($cachedArticles) === 0);
|
||||||
|
}
|
||||||
|
if (empty($data) || $articlesEmpty) {
|
||||||
|
// 获取最新文章
|
||||||
|
$articles = ArticleModel::with(['user', 'category', 'tags'])
|
||||||
|
->where('status', 1)
|
||||||
|
->order('create_at', 'desc')
|
||||||
|
->limit(10)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
// 获取热门文章
|
||||||
|
$hotArticles = ArticleModel::with(['user', 'category'])
|
||||||
|
->where('status', 1)
|
||||||
|
->order('view_count', 'desc')
|
||||||
|
->limit(5)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
// 获取推荐文章(置顶+高赞)
|
||||||
|
$recommendedArticles = ArticleModel::with(['user', 'category'])
|
||||||
|
->where('status', 1)
|
||||||
|
->where('is_top', 1)
|
||||||
|
->order('like_count', 'desc')
|
||||||
|
->limit(5)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
// 获取分类(公开站点展示全部分类)
|
||||||
|
$categories = CategoryModel::withCount(['articles' =>
|
||||||
|
function ($query, &$alias) {
|
||||||
|
$query->where('status', 1);
|
||||||
|
$alias = 'card_count';
|
||||||
|
}])
|
||||||
|
->order('sort', 'asc')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
// 获取标签
|
||||||
|
$tags = TagModel::withCount('articles')
|
||||||
|
->order('create_at', 'desc')
|
||||||
|
->limit(20)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
// 获取最新评论
|
||||||
|
$latestComments = CommentModel::with(['user', 'article'])
|
||||||
|
->where('status', 1)
|
||||||
|
->order('create_at', 'desc')
|
||||||
|
->limit(5)
|
||||||
|
->select();
|
||||||
|
$data = [
|
||||||
|
'articles' => $articles,
|
||||||
|
'hotArticles' => $hotArticles,
|
||||||
|
'recommendedArticles' => $recommendedArticles,
|
||||||
|
'categories' => $categories,
|
||||||
|
'tags' => $tags,
|
||||||
|
'latestComments' => $latestComments,
|
||||||
|
];
|
||||||
|
// 仅在存在文章时缓存,避免空结果被长期缓存挡住新数据
|
||||||
|
if (! empty($articles) && ! $articles->isEmpty()) {
|
||||||
|
Cache::set($cacheKey, $data, 3600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->view->assign('articles', $data['articles']);
|
||||||
|
$this->view->assign('hotArticles', $data['hotArticles']);
|
||||||
|
$this->view->assign('recommendedArticles', $data['recommendedArticles']);
|
||||||
|
$this->view->assign('categories', $data['categories']);
|
||||||
|
$this->view->assign('tags', $data['tags']);
|
||||||
|
$this->view->assign('latestComments', $data['latestComments']);
|
||||||
|
return $this->view->fetch('index/index');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分类文章列表(无 $id 时展示全部分类)
|
||||||
|
*/
|
||||||
|
public function category($id = null)
|
||||||
|
{
|
||||||
|
if ($id === null) {
|
||||||
|
$categories = CategoryModel::withCount(['articles' =>
|
||||||
|
function ($query, &$alias) {
|
||||||
|
$query->where('status', 1);
|
||||||
|
$alias = 'card_count';
|
||||||
|
}])->order('sort', 'asc')->select();
|
||||||
|
return View::fetch('category/index', [
|
||||||
|
'category' => null,
|
||||||
|
'categories' => $categories,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$category = CategoryModel::find($id);
|
||||||
|
if (! $category) {
|
||||||
|
return $this->result->error('分类不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = Request::param('page', 1);
|
||||||
|
$pageSize = 10;
|
||||||
|
|
||||||
|
$articles = ArticleModel::with(['user', 'tags'])
|
||||||
|
->where('cid', $id)
|
||||||
|
->where('status', 1)
|
||||||
|
->order('create_at', 'desc')
|
||||||
|
->paginate([
|
||||||
|
'list_rows' => $pageSize,
|
||||||
|
'page' => $page,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return View::fetch('category/index', [
|
||||||
|
'category' => $category,
|
||||||
|
'articles' => $articles,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标签文章列表(无 $id 时展示全部标签)
|
||||||
|
*/
|
||||||
|
public function tag($id = null)
|
||||||
|
{
|
||||||
|
if ($id === null) {
|
||||||
|
$tags = TagModel::withCount('articles')->order('create_at', 'desc')->select();
|
||||||
|
return View::fetch('tag/index', [
|
||||||
|
'tag' => null,
|
||||||
|
'tags' => $tags,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tag = TagModel::find($id);
|
||||||
|
if (! $tag) {
|
||||||
|
return $this->result->error('标签不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = Request::param('page', 1);
|
||||||
|
$pageSize = 10;
|
||||||
|
|
||||||
|
$articles = $tag->articles()
|
||||||
|
->with(['user', 'category'])
|
||||||
|
->where('status', 1)
|
||||||
|
->order('create_at', 'desc')
|
||||||
|
->paginate([
|
||||||
|
'list_rows' => $pageSize,
|
||||||
|
'page' => $page,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return View::fetch('tag/index', [
|
||||||
|
'tag' => $tag,
|
||||||
|
'articles' => $articles,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索页面
|
||||||
|
*/
|
||||||
|
public function search()
|
||||||
|
{
|
||||||
|
$keyword = Request::param('keyword', '');
|
||||||
|
if (empty($keyword)) {
|
||||||
|
return $this->result->error('请输入搜索关键词');
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = Request::param('page', 1);
|
||||||
|
$pageSize = 10;
|
||||||
|
|
||||||
|
$articles = \addon\blog\service\BlogSearchService::search($keyword, (int) $page, $pageSize);
|
||||||
|
|
||||||
|
return View::fetch('index/search', [
|
||||||
|
'keyword' => $keyword,
|
||||||
|
'articles' => $articles,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关于页面
|
||||||
|
*/
|
||||||
|
public function about()
|
||||||
|
{
|
||||||
|
return View::fetch('index/about');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 联系页面
|
||||||
|
*/
|
||||||
|
public function contact()
|
||||||
|
{
|
||||||
|
return View::fetch('index/contact');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附近文章(GEO/LBS)
|
||||||
|
*/
|
||||||
|
public function nearby()
|
||||||
|
{
|
||||||
|
$lat = (float) Request::param('lat', 0);
|
||||||
|
$lng = (float) Request::param('lng', 0);
|
||||||
|
$radius = (float) Request::param('radius', 10);
|
||||||
|
$list = \addon\blog\service\BlogSearchService::nearby($lat, $lng, $radius, 30);
|
||||||
|
$data = [];
|
||||||
|
foreach ($list as $a) {
|
||||||
|
$data[] = [
|
||||||
|
'id' => $a->id,
|
||||||
|
'title' => $a->title,
|
||||||
|
'distance' => isset($a->distance_km) ? round($a->distance_km, 2) : null,
|
||||||
|
'category' => $a->category ? $a->category->name : '',
|
||||||
|
'url' => url('blog/article/detail', ['id' => $a->id]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (Request::isAjax()) {
|
||||||
|
return json(['code' => 1, 'data' => $data]);
|
||||||
|
}
|
||||||
|
return View::fetch('index/nearby', [
|
||||||
|
'list' => $data,
|
||||||
|
'lat' => $lat,
|
||||||
|
'lng' => $lng,
|
||||||
|
'radius' => $radius,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller;
|
||||||
|
use ywxapp\controller\FrontendBase;
|
||||||
|
|
||||||
|
use addon\blog\model\BlogArticle as ArticleModel;
|
||||||
|
use addon\blog\model\BlogLike as LikeModel;
|
||||||
|
use addon\blog\model\BlogFavorite as FavoriteModel;
|
||||||
|
use think\facade\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interaction 类
|
||||||
|
*
|
||||||
|
* @author ywxapp <admin@ywxapp.cn>
|
||||||
|
*/
|
||||||
|
class Interaction extends FrontendBase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 公开访问(登录校验由方法内部通过 $this->auth 处理,会员统一以 uid 标识)
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $noNeedLogin = ['*'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点赞/取消点赞
|
||||||
|
*/
|
||||||
|
public function like()
|
||||||
|
{
|
||||||
|
$article_id = Request::param('article_id');
|
||||||
|
if (! $article_id) {
|
||||||
|
return json(['code' => 0, 'msg' => '参数错误']);
|
||||||
|
}
|
||||||
|
if (! $this->auth->isLogin) {
|
||||||
|
return json(['code' => 0, 'msg' => '请先登录']);
|
||||||
|
}
|
||||||
|
$uid = $this->auth->model->uid;
|
||||||
|
|
||||||
|
$article = ArticleModel::find($article_id);
|
||||||
|
if (! $article) {
|
||||||
|
return json(['code' => 0, 'msg' => '文章不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$like = LikeModel::where('uid', $uid)
|
||||||
|
->where('aid', $article_id)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if ($like) {
|
||||||
|
$like->delete();
|
||||||
|
$article->like_count = max(0, $article->like_count - 1);
|
||||||
|
$action = 'cancel';
|
||||||
|
} else {
|
||||||
|
$like = new LikeModel();
|
||||||
|
$like->uid = $uid;
|
||||||
|
$like->aid = $article_id;
|
||||||
|
$like->save();
|
||||||
|
$article->like_count += 1;
|
||||||
|
$action = 'add';
|
||||||
|
}
|
||||||
|
|
||||||
|
$article->save();
|
||||||
|
|
||||||
|
return json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => $action == 'add' ? '点赞成功' : '已取消点赞',
|
||||||
|
'count' => $article->like_count,
|
||||||
|
'action' => $action,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收藏/取消收藏
|
||||||
|
*/
|
||||||
|
public function favorite()
|
||||||
|
{
|
||||||
|
$article_id = Request::param('article_id');
|
||||||
|
if (! $article_id) {
|
||||||
|
return json(['code' => 0, 'msg' => '参数错误']);
|
||||||
|
}
|
||||||
|
if (! $this->auth->isLogin) {
|
||||||
|
return json(['code' => 0, 'msg' => '请先登录']);
|
||||||
|
}
|
||||||
|
$uid = $this->auth->model->uid;
|
||||||
|
|
||||||
|
$article = ArticleModel::find($article_id);
|
||||||
|
if (! $article) {
|
||||||
|
return json(['code' => 0, 'msg' => '文章不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$favorite = FavoriteModel::where('uid', $uid)
|
||||||
|
->where('aid', $article_id)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if ($favorite) {
|
||||||
|
$favorite->delete();
|
||||||
|
$article->favorite_count = max(0, $favorite->favorite_count - 1);
|
||||||
|
$action = 'cancel';
|
||||||
|
} else {
|
||||||
|
$favorite = new FavoriteModel();
|
||||||
|
$favorite->uid = $uid;
|
||||||
|
$favorite->aid = $article_id;
|
||||||
|
$favorite->save();
|
||||||
|
$article->favorite_count += 1;
|
||||||
|
$action = 'add';
|
||||||
|
}
|
||||||
|
|
||||||
|
$article->save();
|
||||||
|
|
||||||
|
return json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => $action == 'add' ? '收藏成功' : '已取消收藏',
|
||||||
|
'count' => $article->favorite_count,
|
||||||
|
'action' => $action,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分享计数 +1(无需登录)
|
||||||
|
*/
|
||||||
|
public function share()
|
||||||
|
{
|
||||||
|
$article_id = Request::param('article_id');
|
||||||
|
$article = ArticleModel::find($article_id);
|
||||||
|
if (! $article) {
|
||||||
|
return json(['code' => 0, 'msg' => '文章不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$article->share_count += 1;
|
||||||
|
$article->save();
|
||||||
|
|
||||||
|
return json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => '已记录分享',
|
||||||
|
'count' => $article->share_count,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户互动状态
|
||||||
|
*/
|
||||||
|
public function getUserInteractions($article_id)
|
||||||
|
{
|
||||||
|
$interactions = [
|
||||||
|
'liked' => false,
|
||||||
|
'favorited' => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->auth->isLogin) {
|
||||||
|
$uid = $this->auth->model->uid;
|
||||||
|
$interactions['liked'] = LikeModel::where('uid', $uid)
|
||||||
|
->where('aid', $article_id)
|
||||||
|
->count() > 0;
|
||||||
|
|
||||||
|
$interactions['favorited'] = FavoriteModel::where('uid', $uid)
|
||||||
|
->where('aid', $article_id)
|
||||||
|
->count() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json([
|
||||||
|
'code' => 1,
|
||||||
|
'data' => $interactions,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取热门互动文章
|
||||||
|
*/
|
||||||
|
public function getHotInteractions()
|
||||||
|
{
|
||||||
|
$type = Request::param('type', 'like');
|
||||||
|
$limit = Request::param('limit', 10);
|
||||||
|
|
||||||
|
$field = $type == 'like' ? 'like_count' : 'favorite_count';
|
||||||
|
|
||||||
|
$articles = ArticleModel::with(['user', 'category'])
|
||||||
|
->where('status', 1)
|
||||||
|
->order($field, 'desc')
|
||||||
|
->limit($limit)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
return json([
|
||||||
|
'code' => 1,
|
||||||
|
'data' => $articles,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller;
|
||||||
|
use ywxapp\controller\FrontendBase;
|
||||||
|
use addon\blog\model\BlogArticle as ArticleModel;
|
||||||
|
use think\facade\Config;
|
||||||
|
use think\facade\Response;
|
||||||
|
use think\facade\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rss 类
|
||||||
|
*
|
||||||
|
* @author ywxapp <admin@ywxapp.cn>
|
||||||
|
*/
|
||||||
|
class Rss extends FrontendBase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Summary of needLogin
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $noNeedLogin = ['*'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary of needRight
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 控制器初始化 _initialize
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function initialize()
|
||||||
|
{}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RSS订阅页面
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$page = Request::param('page', 1);
|
||||||
|
$pageSize = 20;
|
||||||
|
|
||||||
|
$articles = ArticleModel::with(['user', 'category'])
|
||||||
|
->where('status', 1)
|
||||||
|
->order('create_at', 'desc')
|
||||||
|
->page($page, $pageSize)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$siteName = Config::get('site.name', 'TP-Blog');
|
||||||
|
$siteUrl = Config::get('site.url', 'http://localhost');
|
||||||
|
$description = Config::get('site.description', 'TP-Blog是一个基于ThinkPHP的博客系统');
|
||||||
|
|
||||||
|
$xml = $this->generateRssXml($articles, $siteName, $siteUrl, $description);
|
||||||
|
|
||||||
|
return Response::create($xml, 'xml');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成RSS XML
|
||||||
|
*/
|
||||||
|
private function generateRssXml($articles, $siteName, $siteUrl, $description)
|
||||||
|
{
|
||||||
|
$xml = '<?xml version="1.0" encoding="UTF-8"?>';
|
||||||
|
$xml .= '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">';
|
||||||
|
$xml .= '<channel>';
|
||||||
|
$xml .= '<title>' . htmlspecialchars($siteName) . '</title>';
|
||||||
|
$xml .= '<link>' . htmlspecialchars($siteUrl) . '</link>';
|
||||||
|
$xml .= '<description>' . htmlspecialchars($description) . '</description>';
|
||||||
|
$xml .= '<language>zh-cn</language>';
|
||||||
|
$xml .= '<lastBuildDate>' . date('r') . '</lastBuildDate>';
|
||||||
|
$xml .= '<atom:link href="' . htmlspecialchars($siteUrl) . '/rss" rel="self" type="application/rss+xml" />';
|
||||||
|
|
||||||
|
foreach ($articles as $article) {
|
||||||
|
$xml .= '<item>';
|
||||||
|
$xml .= '<title>' . htmlspecialchars($article->title) . '</title>';
|
||||||
|
$xml .= '<link>' . htmlspecialchars($siteUrl . '/article/' . $article->id) . '</link>';
|
||||||
|
$xml .= '<description>' . htmlspecialchars($article->summary ?: strip_tags(mb_substr($article->content, 0, 200))) . '</description>';
|
||||||
|
$xml .= '<pubDate>' . date('r', $article->create_at) . '</pubDate>';
|
||||||
|
$xml .= '<author>' . htmlspecialchars($article->user->nickname) . '</author>';
|
||||||
|
$xml .= '<category>' . htmlspecialchars($article->category->name) . '</category>';
|
||||||
|
$xml .= '<guid>' . htmlspecialchars($siteUrl . '/article/' . $article->id) . '</guid>';
|
||||||
|
$xml .= '</item>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$xml .= '</channel>';
|
||||||
|
$xml .= '</rss>';
|
||||||
|
|
||||||
|
return $xml;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RSS阅读器页面
|
||||||
|
*/
|
||||||
|
public function reader()
|
||||||
|
{
|
||||||
|
$siteName = Config::get('site.name', 'TP-Blog');
|
||||||
|
$rssUrl = Config::get('site.url', 'http://localhost') . '/rss';
|
||||||
|
|
||||||
|
return View::fetch('index/rss/reader', [
|
||||||
|
'siteName' => $siteName,
|
||||||
|
'rssUrl' => $rssUrl
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller\backend;
|
||||||
|
|
||||||
|
use addon\blog\model\BlogArticle as ArticleModel;
|
||||||
|
use addon\blog\model\BlogCategory as CategoryModel;
|
||||||
|
use addon\blog\model\BlogTag as TagModel;
|
||||||
|
use think\facade\Request;
|
||||||
|
use think\facade\View;
|
||||||
|
use think\Validate;
|
||||||
|
use ywxapp\controller\BackendBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Article 类
|
||||||
|
*
|
||||||
|
* @author ywxapp <admin@ywxapp.cn>
|
||||||
|
*/
|
||||||
|
class Article extends BackendBase
|
||||||
|
{
|
||||||
|
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
|
||||||
|
protected function initialize()
|
||||||
|
{}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文章列表(ajax 请求返回 layui table 所需 JSON,普通请求渲染视图)
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$status = Request::param('status', 'all');
|
||||||
|
$audit = Request::param('audit', 'all');
|
||||||
|
$keyword = Request::param('keyword', '');
|
||||||
|
$page = Request::param('page', 1);
|
||||||
|
$limit = Request::param('limit', 15);
|
||||||
|
|
||||||
|
if (Request::isAjax()) {
|
||||||
|
$query = ArticleModel::with(['user', 'category', 'tags']);
|
||||||
|
|
||||||
|
if ($status !== 'all') {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
if ($audit !== 'all' && in_array((int) $audit, [0, 1, 2, 3], true)) {
|
||||||
|
$query->where('audit_status', (int) $audit);
|
||||||
|
}
|
||||||
|
if ($keyword) {
|
||||||
|
$query->where('title|content', 'like', "%{$keyword}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
$paginator = $query->order('create_at', 'desc')
|
||||||
|
->paginate(['list_rows' => $limit, 'page' => $page]);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($paginator->items() as $a) {
|
||||||
|
$rows[] = [
|
||||||
|
'id' => $a->id,
|
||||||
|
'title' => $a->title,
|
||||||
|
'is_top' => $a->is_top,
|
||||||
|
'author' => $a->user->nickname ?? '-',
|
||||||
|
'category' => $a->category->name ?? '-',
|
||||||
|
'tags' => implode(',', $a->tags->column('name')),
|
||||||
|
'status' => $a->status,
|
||||||
|
'audit_status' => $a->audit_status,
|
||||||
|
'view_count' => (int) $a->view_count,
|
||||||
|
'comment_count' => (int) $a->comment_count,
|
||||||
|
'like_count' => (int) $a->like_count,
|
||||||
|
'favorite_count' => (int) $a->favorite_count,
|
||||||
|
'share_count' => (int) $a->share_count,
|
||||||
|
'create_at' => $a->create_at,
|
||||||
|
'create_text' => (function ($t) {
|
||||||
|
if ($t instanceof \DateTime) return $t->format('Y-m-d H:i');
|
||||||
|
if (is_numeric($t)) return date('Y-m-d H:i', (int) $t);
|
||||||
|
$ts = strtotime((string) $t);
|
||||||
|
return $ts !== false ? date('Y-m-d H:i', $ts) : (string) $t;
|
||||||
|
})($a->create_at),
|
||||||
|
'edit_url' => (string) url('blog/backend.article/edit', ['id' => $a->id]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'count' => $paginator->total(),
|
||||||
|
'data' => $rows,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return View::fetch('article/index', [
|
||||||
|
'status' => $status,
|
||||||
|
'audit' => $audit,
|
||||||
|
'keyword' => $keyword,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑文章页面
|
||||||
|
*/
|
||||||
|
public function edit($id = null)
|
||||||
|
{
|
||||||
|
$article = ArticleModel::with(['tags'])->find($id);
|
||||||
|
if (! $article) {
|
||||||
|
return redirect('/blog/backend/article');
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories = CategoryModel::where('status', 1)->select();
|
||||||
|
$tags = TagModel::select();
|
||||||
|
$articleTags = $article->tags->column('id');
|
||||||
|
$tagNamesStr = implode(',', $article->tags->column('name'));
|
||||||
|
|
||||||
|
return View::fetch('article/edit', [
|
||||||
|
'article' => $article,
|
||||||
|
'categories' => $categories,
|
||||||
|
'tags' => $tags,
|
||||||
|
'articleTags' => $articleTags,
|
||||||
|
'tagNamesStr' => $tagNamesStr,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新文章
|
||||||
|
*/
|
||||||
|
public function update($id)
|
||||||
|
{
|
||||||
|
$article = ArticleModel::find($id);
|
||||||
|
if (! $article) {
|
||||||
|
return json(['code' => 0, 'msg' => '文章不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = Request::post();
|
||||||
|
$data['update_at'] = time();
|
||||||
|
$data['uid'] = $data['user_id'] ?? $article->uid;
|
||||||
|
$data['is_top'] = isset($data['is_top']) ? 1 : 0;
|
||||||
|
unset($data['user_id']);
|
||||||
|
|
||||||
|
$validate = new Validate([
|
||||||
|
'title|标题' => 'require|max:255',
|
||||||
|
'content|内容' => 'require',
|
||||||
|
'uid|作者' => 'require|number',
|
||||||
|
'cid|分类' => 'require|number',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $validate->check($data)) {
|
||||||
|
return json(['code' => 0, 'msg' => $validate->getError()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理标签
|
||||||
|
$tagIds = [];
|
||||||
|
if (! empty($data['tags'])) {
|
||||||
|
$tagNames = is_array($data['tags']) ? $data['tags'] : explode(',', $data['tags']);
|
||||||
|
foreach ($tagNames as $name) {
|
||||||
|
$name = trim($name);
|
||||||
|
if ($name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$tag = TagModel::where('name', $name)->find() ?: TagModel::create(['name' => $name]);
|
||||||
|
$tagIds[] = $tag->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$article->save($data);
|
||||||
|
$article->tags()->sync($tagIds);
|
||||||
|
\addon\blog\service\BlogSearchService::sync($article->id);
|
||||||
|
|
||||||
|
return json(['code' => 1, 'msg' => '文章更新成功', 'url' => '/blog/backend/article']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文章
|
||||||
|
*/
|
||||||
|
public function delete($id)
|
||||||
|
{
|
||||||
|
$article = ArticleModel::find($id);
|
||||||
|
if ($article) {
|
||||||
|
\addon\blog\service\BlogSearchService::remove($article->id);
|
||||||
|
$article->tags()->detach();
|
||||||
|
$article->delete();
|
||||||
|
return json(['code' => 1, 'msg' => '文章已删除']);
|
||||||
|
}
|
||||||
|
return json(['code' => 0, 'msg' => '文章不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量操作
|
||||||
|
*/
|
||||||
|
public function batch()
|
||||||
|
{
|
||||||
|
$action = Request::post('action');
|
||||||
|
$ids = Request::post('ids/a');
|
||||||
|
|
||||||
|
if (empty($ids)) {
|
||||||
|
return json(['code' => 0, 'msg' => '请选择文章']);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
case 'delete':
|
||||||
|
ArticleModel::destroy($ids);
|
||||||
|
return json(['code' => 1, 'msg' => '批量删除成功']);
|
||||||
|
case 'publish':
|
||||||
|
ArticleModel::whereIn('id', $ids)->update(['status' => 1]);
|
||||||
|
return json(['code' => 1, 'msg' => '批量发布成功']);
|
||||||
|
case 'draft':
|
||||||
|
ArticleModel::whereIn('id', $ids)->update(['status' => 0]);
|
||||||
|
return json(['code' => 1, 'msg' => '批量设为草稿成功']);
|
||||||
|
default:
|
||||||
|
return json(['code' => 0, 'msg' => '无效操作']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核通过(人工复核):置通过态并对外可见
|
||||||
|
*/
|
||||||
|
public function audit($id = 0)
|
||||||
|
{
|
||||||
|
$article = ArticleModel::find($id);
|
||||||
|
if (! $article) {
|
||||||
|
return json(['code' => 0, 'msg' => '文章不存在']);
|
||||||
|
}
|
||||||
|
$article->audit_status = 2;
|
||||||
|
$article->status = 1;
|
||||||
|
$article->save();
|
||||||
|
return json(['code' => 1, 'msg' => '审核通过']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核驳回:置驳回态并下架
|
||||||
|
*/
|
||||||
|
public function reject($id = 0)
|
||||||
|
{
|
||||||
|
$article = ArticleModel::find($id);
|
||||||
|
if (! $article) {
|
||||||
|
return json(['code' => 0, 'msg' => '文章不存在']);
|
||||||
|
}
|
||||||
|
$reason = trim((string) Request::post('reason', ''));
|
||||||
|
$article->audit_status = 3;
|
||||||
|
$article->status = 0;
|
||||||
|
$article->save();
|
||||||
|
return json(['code' => 1, 'msg' => '已驳回' . ($reason !== '' ? ':' . $reason : '')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller\backend;
|
||||||
|
|
||||||
|
use addon\blog\model\BlogCategory as CategoryModel;
|
||||||
|
use think\facade\Request;
|
||||||
|
use think\facade\View;
|
||||||
|
use think\Validate;
|
||||||
|
use ywxapp\controller\BackendBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category 类
|
||||||
|
*
|
||||||
|
* @author ywxapp <admin@ywxapp.cn>
|
||||||
|
*/
|
||||||
|
class Category extends BackendBase
|
||||||
|
{
|
||||||
|
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
|
||||||
|
protected function initialize()
|
||||||
|
{}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分类列表(后台管理全部分类:全局共享 + 各会员自建)
|
||||||
|
* 以 layui treeTable 所需的扁平结构返回(带 pid / is_parent),由前端 treeTable 自动成树。
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$categories = CategoryModel::withCount('articles')
|
||||||
|
->order('uid', 'asc')
|
||||||
|
->order('sort', 'asc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// 收集归属会员昵称
|
||||||
|
$uids = [];
|
||||||
|
foreach ($categories as $c) {
|
||||||
|
if (!empty($c['uid'])) {
|
||||||
|
$uids[] = $c['uid'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$owners = [];
|
||||||
|
if ($uids) {
|
||||||
|
$owners = \think\facade\Db::name('member')
|
||||||
|
->whereIn('uid', array_unique($uids))
|
||||||
|
->column('nickname', 'uid');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算哪些分类拥有子分类(用于 treeTable 的 is_parent 标记)
|
||||||
|
$parentIds = array_unique(array_filter(array_column($categories, 'pid')));
|
||||||
|
|
||||||
|
$treeData = [];
|
||||||
|
foreach ($categories as $c) {
|
||||||
|
$treeData[] = [
|
||||||
|
'id' => $c['id'],
|
||||||
|
'pid' => $c['pid'],
|
||||||
|
'title' => $c['name'], // treeTable 节点显示名称
|
||||||
|
'name' => $c['name'],
|
||||||
|
'description' => $c['description'] ?? '',
|
||||||
|
'sort' => $c['sort'],
|
||||||
|
'status' => $c['status'],
|
||||||
|
'is_parent' => in_array($c['id'], $parentIds, true) ? 1 : 0,
|
||||||
|
'owner' => empty($c['uid']) ? '全局共享' : ($owners[$c['uid']] ?? ('#' . $c['uid'])),
|
||||||
|
'articles_count' => $c['articles_count'] ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return View::fetch('category/index', [
|
||||||
|
'treeData' => json_encode($treeData, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加分类页面
|
||||||
|
*/
|
||||||
|
public function add()
|
||||||
|
{
|
||||||
|
$categories = CategoryModel::order('sort', 'asc')->select();
|
||||||
|
return View::fetch('category/add', ['categories' => $categories]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存分类
|
||||||
|
*/
|
||||||
|
public function save()
|
||||||
|
{
|
||||||
|
$data = Request::post();
|
||||||
|
$data['uid'] = 0; // 后台管理的分类为全局共享分类
|
||||||
|
$data['pid'] = (int) Request::post('pid', 0);
|
||||||
|
$data['create_at'] = time();
|
||||||
|
$data['update_at'] = time();
|
||||||
|
|
||||||
|
$validate = new Validate([
|
||||||
|
'name|分类名称' => 'require|unique:BlogCategory,name,0,id,uid,0',
|
||||||
|
'pid|上级分类' => 'number',
|
||||||
|
'sort|排序' => 'number|between:0,999',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $validate->check($data)) {
|
||||||
|
return json(['code' => 0, 'msg' => $validate->getError()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
CategoryModel::create($data);
|
||||||
|
return json(['code' => 1, 'msg' => '分类添加成功', 'url' => '/blog/backend/category']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑分类页面
|
||||||
|
*/
|
||||||
|
public function edit($id = null)
|
||||||
|
{
|
||||||
|
$category = CategoryModel::find($id);
|
||||||
|
$categories = CategoryModel::order('sort', 'asc')->select();
|
||||||
|
return View::fetch('category/edit', ['category' => $category, 'categories' => $categories]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新分类
|
||||||
|
*/
|
||||||
|
public function update($id)
|
||||||
|
{
|
||||||
|
$category = CategoryModel::find($id);
|
||||||
|
if (! $category) {
|
||||||
|
return json(['code' => 0, 'msg' => '分类不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) Request::post('pid', 0) === (int) $id) {
|
||||||
|
return json(['code' => 0, 'msg' => '上级分类不能选择自己']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = Request::post();
|
||||||
|
$data['pid'] = (int) Request::post('pid', 0);
|
||||||
|
$data['update_at'] = time();
|
||||||
|
|
||||||
|
$validate = new Validate([
|
||||||
|
'name|分类名称' => 'require|unique:BlogCategory,name,' . $id . ',id,uid,' . $category->uid,
|
||||||
|
'pid|上级分类' => 'number',
|
||||||
|
'sort|排序' => 'number|between:0,999',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $validate->check($data)) {
|
||||||
|
return json(['code' => 0, 'msg' => $validate->getError()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$category->save($data);
|
||||||
|
return json(['code' => 1, 'msg' => '分类更新成功', 'url' => '/blog/backend/category']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除分类
|
||||||
|
*/
|
||||||
|
public function delete($id)
|
||||||
|
{
|
||||||
|
$category = CategoryModel::find($id);
|
||||||
|
if ($category) {
|
||||||
|
if ($category->children()->count() > 0) {
|
||||||
|
return json(['code' => 0, 'msg' => '该分类下有子分类,请先删除子分类']);
|
||||||
|
}
|
||||||
|
if ($category->articles()->count() > 0) {
|
||||||
|
return json(['code' => 0, 'msg' => '该分类下有文章,无法删除']);
|
||||||
|
}
|
||||||
|
$category->delete();
|
||||||
|
return json(['code' => 1, 'msg' => '分类已删除']);
|
||||||
|
}
|
||||||
|
return json(['code' => 0, 'msg' => '分类不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除分类(前端 treeTable 勾选后调用)
|
||||||
|
*/
|
||||||
|
public function batchDelete()
|
||||||
|
{
|
||||||
|
$ids = Request::post('ids', '');
|
||||||
|
$idArr = array_filter(array_map('intval', explode(',', (string) $ids)));
|
||||||
|
if (empty($idArr)) {
|
||||||
|
return json(['code' => 0, 'msg' => '请选择要删除的分类']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleted = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
foreach ($idArr as $id) {
|
||||||
|
$category = CategoryModel::find($id);
|
||||||
|
if (! $category) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($category->children()->count() > 0) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($category->articles()->count() > 0) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$category->delete();
|
||||||
|
$deleted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($deleted === 0) {
|
||||||
|
return json(['code' => 0, 'msg' => '选中的分类均包含子分类或文章,无法删除']);
|
||||||
|
}
|
||||||
|
$msg = '成功删除 ' . $deleted . ' 个分类';
|
||||||
|
if ($skipped > 0) {
|
||||||
|
$msg .= ',' . $skipped . ' 个因含子分类/文章已跳过';
|
||||||
|
}
|
||||||
|
return json(['code' => 1, 'msg' => $msg]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller\backend;
|
||||||
|
|
||||||
|
use addon\blog\model\BlogArticle as ArticleModel;
|
||||||
|
use addon\blog\model\BlogComment as CommentModel;
|
||||||
|
use think\facade\Request;
|
||||||
|
use think\facade\View;
|
||||||
|
|
||||||
|
use ywxapp\controller\BackendBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comment 类
|
||||||
|
*
|
||||||
|
* @author ywxapp <admin@ywxapp.cn>
|
||||||
|
*/
|
||||||
|
class Comment extends BackendBase
|
||||||
|
{
|
||||||
|
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
|
||||||
|
protected function initialize()
|
||||||
|
{}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评论列表(ajax 请求返回 layui table 所需 JSON,普通请求渲染视图)
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$status = Request::param('status', 'all');
|
||||||
|
$keyword = Request::param('keyword', '');
|
||||||
|
$page = Request::param('page', 1);
|
||||||
|
$limit = Request::param('limit', 15);
|
||||||
|
|
||||||
|
if (Request::isAjax()) {
|
||||||
|
$query = CommentModel::with(['user', 'article']);
|
||||||
|
|
||||||
|
if ($status !== 'all') {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
if ($keyword) {
|
||||||
|
$query->where('content', 'like', "%{$keyword}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
$paginator = $query->order('create_at', 'desc')
|
||||||
|
->paginate(['list_rows' => $limit, 'page' => $page]);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($paginator->items() as $c) {
|
||||||
|
$createText = (function ($t) {
|
||||||
|
if ($t instanceof \DateTime) return $t->format('Y-m-d H:i');
|
||||||
|
if (is_numeric($t)) return date('Y-m-d H:i', (int) $t);
|
||||||
|
$ts = strtotime((string) $t);
|
||||||
|
return $ts !== false ? date('Y-m-d H:i', $ts) : (string) $t;
|
||||||
|
})($c->create_at);
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'id' => $c->id,
|
||||||
|
'nickname' => $c->user->nickname ?? '-',
|
||||||
|
'avatar' => $c->user->avatar ?? '',
|
||||||
|
'content' => $c->content,
|
||||||
|
'article_id' => $c->aid,
|
||||||
|
'article_title' => $c->article->title ?? '-',
|
||||||
|
'article_url' => $c->article ? (string) url('blog/backend.article/edit', ['id' => $c->aid]) : '',
|
||||||
|
'status' => $c->status,
|
||||||
|
'create_text' => $createText,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'count' => $paginator->total(),
|
||||||
|
'data' => $rows,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return View::fetch('comment/index', [
|
||||||
|
'status' => $status,
|
||||||
|
'keyword' => $keyword,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核通过
|
||||||
|
*/
|
||||||
|
public function approve($id)
|
||||||
|
{
|
||||||
|
$comment = CommentModel::find($id);
|
||||||
|
if ($comment) {
|
||||||
|
$comment->status = 1;
|
||||||
|
$comment->save();
|
||||||
|
|
||||||
|
ArticleModel::where('id', $comment->aid)->inc('comment_count')->update();
|
||||||
|
|
||||||
|
return json(['code' => 1, 'msg' => '评论已通过']);
|
||||||
|
}
|
||||||
|
return json(['code' => 0, 'msg' => '评论不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拒绝评论
|
||||||
|
*/
|
||||||
|
public function reject($id)
|
||||||
|
{
|
||||||
|
$comment = CommentModel::find($id);
|
||||||
|
if ($comment) {
|
||||||
|
$comment->status = 0;
|
||||||
|
$comment->save();
|
||||||
|
|
||||||
|
ArticleModel::where('id', $comment->aid)->dec('comment_count')->update();
|
||||||
|
|
||||||
|
return json(['code' => 1, 'msg' => '评论已拒绝']);
|
||||||
|
}
|
||||||
|
return json(['code' => 0, 'msg' => '评论不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除评论
|
||||||
|
*/
|
||||||
|
public function delete($id)
|
||||||
|
{
|
||||||
|
$comment = CommentModel::find($id);
|
||||||
|
if ($comment) {
|
||||||
|
$this->deleteChildren($id);
|
||||||
|
|
||||||
|
ArticleModel::where('id', $comment->aid)->dec('comment_count')->update();
|
||||||
|
|
||||||
|
return json(['code' => 1, 'msg' => '评论已删除']);
|
||||||
|
}
|
||||||
|
return json(['code' => 0, 'msg' => '评论不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归删除子评论
|
||||||
|
*/
|
||||||
|
private function deleteChildren($parentId)
|
||||||
|
{
|
||||||
|
$children = CommentModel::where('pid', $parentId)->select();
|
||||||
|
foreach ($children as $child) {
|
||||||
|
$this->deleteChildren($child->id);
|
||||||
|
$child->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量操作
|
||||||
|
*/
|
||||||
|
public function batch()
|
||||||
|
{
|
||||||
|
$action = Request::post('action');
|
||||||
|
$ids = Request::post('ids/a');
|
||||||
|
|
||||||
|
if (empty($ids)) {
|
||||||
|
return json(['code' => 0, 'msg' => '请选择评论']);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
case 'approve':
|
||||||
|
CommentModel::whereIn('id', $ids)->update(['status' => 1]);
|
||||||
|
return json(['code' => 1, 'msg' => '批量通过成功']);
|
||||||
|
case 'reject':
|
||||||
|
CommentModel::whereIn('id', $ids)->update(['status' => 0]);
|
||||||
|
return json(['code' => 1, 'msg' => '批量拒绝成功']);
|
||||||
|
case 'delete':
|
||||||
|
CommentModel::destroy($ids);
|
||||||
|
return json(['code' => 1, 'msg' => '批量删除成功']);
|
||||||
|
default:
|
||||||
|
return json(['code' => 0, 'msg' => '无效操作']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller\backend;
|
||||||
|
|
||||||
|
use addon\blog\model\BlogArticle as ArticleModel;
|
||||||
|
use ywxapp\model\MemberUser as UserModel;
|
||||||
|
use addon\blog\model\BlogComment as CommentModel;
|
||||||
|
use addon\blog\model\BlogVisit as VisitModel;
|
||||||
|
use addon\blog\model\BlogLike as LikeModel;
|
||||||
|
use think\facade\View;
|
||||||
|
use ywxapp\controller\BackendBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard 类
|
||||||
|
*
|
||||||
|
* @author ywxapp <admin@ywxapp.cn>
|
||||||
|
*/
|
||||||
|
class Dashboard extends BackendBase
|
||||||
|
{
|
||||||
|
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
|
||||||
|
protected function initialize()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 今日起始时间戳
|
||||||
|
*/
|
||||||
|
private function todayStart()
|
||||||
|
{
|
||||||
|
return strtotime(date('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘首页
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$today = $this->todayStart();
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'article_count' => ArticleModel::count(),
|
||||||
|
'user_count' => UserModel::count(),
|
||||||
|
'comment_count' => CommentModel::count(),
|
||||||
|
'today_article' => ArticleModel::where('create_at', '>=', $today)->count(),
|
||||||
|
'today_user' => UserModel::where('create_at', '>=', $today)->count(),
|
||||||
|
'today_comment' => CommentModel::where('create_at', '>=', $today)->count(),
|
||||||
|
'today_visit' => VisitModel::where('create_at', '>=', $today)->count(),
|
||||||
|
'total_visit' => VisitModel::count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$latestArticles = ArticleModel::with(['user', 'category'])
|
||||||
|
->order('create_at', 'desc')
|
||||||
|
->limit(5)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$latestComments = CommentModel::with(['user', 'article'])
|
||||||
|
->order('create_at', 'desc')
|
||||||
|
->limit(5)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$hotArticles = ArticleModel::with(['user', 'category'])
|
||||||
|
->order('view_count', 'desc')
|
||||||
|
->limit(5)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$visitTrend = [];
|
||||||
|
for ($i = 6; $i >= 0; $i--) {
|
||||||
|
$date = date('Y-m-d', strtotime("-{$i} days"));
|
||||||
|
$count = VisitModel::whereBetween('create_at', [strtotime($date), strtotime($date . ' +1 day') - 1])->count();
|
||||||
|
$visitTrend[] = ['date' => $date, 'count' => $count];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->view->fetch('dashboard/index', [
|
||||||
|
'stats' => $stats,
|
||||||
|
'latestArticles' => $latestArticles,
|
||||||
|
'latestComments' => $latestComments,
|
||||||
|
'hotArticles' => $hotArticles,
|
||||||
|
'visitTrend' => $visitTrend,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller\backend;
|
||||||
|
|
||||||
|
use think\facade\View;
|
||||||
|
use ywxapp\controller\BackendBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件后台入口页(addon/blog/controller/admin/Index.php)
|
||||||
|
*
|
||||||
|
* 继承 AddonBackend,并处于 route/app.php 的 admin 路由组中,
|
||||||
|
* 该组已挂 \addon\blog\middleware\AdminAuth 中间件,自动将 auth 绑定为管理员并强制登录。
|
||||||
|
*/
|
||||||
|
class Index extends BackendBase
|
||||||
|
{
|
||||||
|
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
|
||||||
|
protected function initialize()
|
||||||
|
{}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台首页
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$admin = $this->auth->info ?? null;
|
||||||
|
// 同时赋值 user,供 layout.html 头部的 {$user.nickname} 使用
|
||||||
|
$this->view->assign('member', $admin);
|
||||||
|
|
||||||
|
return View::fetch('index/index', [
|
||||||
|
'backend' => $admin,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller\backend;
|
||||||
|
use think\facade\Request;
|
||||||
|
use think\facade\View;
|
||||||
|
use think\facade\Config;
|
||||||
|
use think\facade\Cache;
|
||||||
|
use ywxapp\controller\BackendBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setting 类
|
||||||
|
*
|
||||||
|
* @author ywxapp <admin@ywxapp.cn>
|
||||||
|
*/
|
||||||
|
class Setting extends BackendBase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Summary of needLogin
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary of needRight
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 控制器初始化 _initialize
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function initialize()
|
||||||
|
{}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统设置首页
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$settings = [
|
||||||
|
'site' => Config::get('site'),
|
||||||
|
'seo' => Config::get('seo'),
|
||||||
|
'email' => Config::get('email'),
|
||||||
|
'upload' => Config::get('upload')
|
||||||
|
];
|
||||||
|
|
||||||
|
return View::fetch('setting/index', ['settings' => $settings]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基本设置
|
||||||
|
*/
|
||||||
|
public function site()
|
||||||
|
{
|
||||||
|
if (Request::isPost()) {
|
||||||
|
$data = Request::post();
|
||||||
|
$this->saveConfig('site', $data);
|
||||||
|
return json(['code' => 1, 'msg' => '基本设置已保存']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = Config::get('site');
|
||||||
|
return View::fetch('setting/site', ['settings' => $settings]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEO设置
|
||||||
|
*/
|
||||||
|
public function seo()
|
||||||
|
{
|
||||||
|
if (Request::isPost()) {
|
||||||
|
$data = Request::post();
|
||||||
|
$this->saveConfig('seo', $data);
|
||||||
|
return json(['code' => 1, 'msg' => 'SEO设置已保存']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = Config::get('seo');
|
||||||
|
return View::fetch('setting/seo', ['settings' => $settings]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮件设置
|
||||||
|
*/
|
||||||
|
public function email()
|
||||||
|
{
|
||||||
|
if (Request::isPost()) {
|
||||||
|
$data = Request::post();
|
||||||
|
$this->saveConfig('email', $data);
|
||||||
|
return json(['code' => 1, 'msg' => '邮件设置已保存']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = Config::get('email');
|
||||||
|
return View::fetch('setting/email', ['settings' => $settings]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存配置
|
||||||
|
*/
|
||||||
|
private function saveConfig($key, $data)
|
||||||
|
{
|
||||||
|
$configFile = app_path() . 'config/' . $key . '.php';
|
||||||
|
$content = "<?php\nreturn " . var_export($data, true) . ";\n";
|
||||||
|
file_put_contents($configFile, $content);
|
||||||
|
|
||||||
|
// 清除配置缓存
|
||||||
|
Config::set([], $key);
|
||||||
|
Cache::delete('config_' . $key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller\backend;
|
||||||
|
|
||||||
|
use addon\blog\model\BlogArticle as ArticleModel;
|
||||||
|
use addon\blog\model\BlogComment as CommentModel;
|
||||||
|
use ywxapp\model\MemberUser as UserModel;
|
||||||
|
use addon\blog\model\BlogVisit as VisitModel;
|
||||||
|
use think\facade\Db;
|
||||||
|
use think\facade\Request;
|
||||||
|
use think\facade\View;
|
||||||
|
use ywxapp\controller\BackendBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stat 类
|
||||||
|
*
|
||||||
|
* @author ywxapp <admin@ywxapp.cn>
|
||||||
|
*/
|
||||||
|
class Stat extends BackendBase
|
||||||
|
{
|
||||||
|
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
|
||||||
|
protected function initialize()
|
||||||
|
{}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 今日起始时间戳
|
||||||
|
*/
|
||||||
|
private function todayStart()
|
||||||
|
{
|
||||||
|
return strtotime(date('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计分析首页
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$range = request()->get('range', 'week');
|
||||||
|
$startTime = $this->getTimeRange($range);
|
||||||
|
$today = $this->todayStart();
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'total_visit' => VisitModel::count(),
|
||||||
|
'today_visit' => VisitModel::where('create_at', '>=', $today)->count(),
|
||||||
|
'total_article' => ArticleModel::count(),
|
||||||
|
'today_article' => ArticleModel::where('create_at', '>=', $today)->count(),
|
||||||
|
'total_user' => UserModel::count(),
|
||||||
|
'today_user' => UserModel::where('create_at', '>=', $today)->count(),
|
||||||
|
'total_comment' => CommentModel::count(),
|
||||||
|
'today_comment' => CommentModel::where('create_at', '>=', $today)->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$visitTrend = $this->getTrendData('visit', $startTime, $range);
|
||||||
|
$articleTrend = $this->getTrendData('article', $startTime, $range);
|
||||||
|
$userTrend = $this->getTrendData('member', $startTime, $range);
|
||||||
|
|
||||||
|
$hotPages = VisitModel::field('url, count(*) as count')
|
||||||
|
->where('create_at', '>=', $startTime)
|
||||||
|
->group('url')
|
||||||
|
->order('count', 'desc')
|
||||||
|
->limit(10)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$hotArticles = ArticleModel::with(['author'])
|
||||||
|
->where('create_at', '>=', $startTime)
|
||||||
|
->order('view_count', 'desc')
|
||||||
|
->limit(10)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
return View::fetch('stat/index', [
|
||||||
|
'stats' => $stats,
|
||||||
|
'visitTrend' => $visitTrend,
|
||||||
|
'articleTrend' => $articleTrend,
|
||||||
|
'userTrend' => $userTrend,
|
||||||
|
'hotPages' => $hotPages,
|
||||||
|
'hotArticles' => $hotArticles,
|
||||||
|
'range' => $range,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取时间范围
|
||||||
|
*/
|
||||||
|
private function getTimeRange($range)
|
||||||
|
{
|
||||||
|
switch ($range) {
|
||||||
|
case 'today':
|
||||||
|
return strtotime(date('Y-m-d'));
|
||||||
|
case 'week':
|
||||||
|
return strtotime('-7 days');
|
||||||
|
case 'month':
|
||||||
|
return strtotime('-30 days');
|
||||||
|
case 'year':
|
||||||
|
return strtotime('-1 year');
|
||||||
|
default:
|
||||||
|
return strtotime('-7 days');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取趋势数据
|
||||||
|
*/
|
||||||
|
private function getTrendData($type, $startTime, $range)
|
||||||
|
{
|
||||||
|
$dateFormat = $range == 'year' ? '%Y-%m' : '%Y-%m-%d';
|
||||||
|
|
||||||
|
$table = [
|
||||||
|
'visit' => 'blog_visit',
|
||||||
|
'article' => 'blog_article',
|
||||||
|
'member' => 'member',
|
||||||
|
][$type];
|
||||||
|
|
||||||
|
$dateField = 'create_at';
|
||||||
|
|
||||||
|
$rows = Db::name($table)
|
||||||
|
->field("FROM_UNIXTIME({$dateField}, '{$dateFormat}') as date, count(*) as count")
|
||||||
|
->where($dateField, '>=', $startTime)
|
||||||
|
->group("FROM_UNIXTIME({$dateField}, '{$dateFormat}')")
|
||||||
|
->order('date', 'asc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
return array_column($rows, 'count', 'date');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller\backend;
|
||||||
|
|
||||||
|
use ywxapp\controller\BackendBase;
|
||||||
|
use addon\blog\model\BlogTag as TagModel;
|
||||||
|
use think\facade\Request;
|
||||||
|
use think\facade\View;
|
||||||
|
use think\Validate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag 类
|
||||||
|
*
|
||||||
|
* @author ywxapp <admin@ywxapp.cn>
|
||||||
|
*/
|
||||||
|
class Tag extends BackendBase
|
||||||
|
{
|
||||||
|
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
|
||||||
|
protected function initialize()
|
||||||
|
{}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标签列表(ajax 请求返回 layui table 所需 JSON,普通请求渲染视图)
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$page = Request::param('page', 1);
|
||||||
|
$limit = Request::param('limit', 15);
|
||||||
|
|
||||||
|
if (Request::isAjax()) {
|
||||||
|
$paginator = TagModel::withCount('articles')
|
||||||
|
->order('create_at', 'desc')
|
||||||
|
->paginate(['list_rows' => $limit, 'page' => $page]);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($paginator->items() as $t) {
|
||||||
|
$rows[] = [
|
||||||
|
'id' => $t->id,
|
||||||
|
'name' => $t->name,
|
||||||
|
'description' => $t->description,
|
||||||
|
'articles_count' => $t->articles_count,
|
||||||
|
'create_at' => $t->create_at,
|
||||||
|
'create_text' => date('Y-m-d', $t->create_at),
|
||||||
|
'edit_url' => (string) url('blog/backend.tag/edit', ['id' => $t->id]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'count' => $paginator->total(),
|
||||||
|
'data' => $rows,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return View::fetch('tag/index');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加标签页面
|
||||||
|
*/
|
||||||
|
public function add()
|
||||||
|
{
|
||||||
|
return View::fetch('tag/add');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存标签
|
||||||
|
*/
|
||||||
|
public function save()
|
||||||
|
{
|
||||||
|
$data = Request::post();
|
||||||
|
$data['create_at'] = time();
|
||||||
|
$data['update_at'] = time();
|
||||||
|
|
||||||
|
$validate = new Validate([
|
||||||
|
'name|标签名称' => 'require|unique:BlogTag',
|
||||||
|
'description|描述' => 'max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$validate->check($data)) {
|
||||||
|
return json(['code' => 0, 'msg' => $validate->getError()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
TagModel::create($data);
|
||||||
|
return json(['code' => 1, 'msg' => '标签添加成功', 'url' => '/blog/backend/tag']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑标签页面
|
||||||
|
*/
|
||||||
|
public function edit($id = null)
|
||||||
|
{
|
||||||
|
$tag = TagModel::find($id);
|
||||||
|
return View::fetch('tag/edit', ['tag' => $tag]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新标签
|
||||||
|
*/
|
||||||
|
public function update($id)
|
||||||
|
{
|
||||||
|
$tag = TagModel::find($id);
|
||||||
|
if (!$tag) {
|
||||||
|
return json(['code' => 0, 'msg' => '标签不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = Request::post();
|
||||||
|
$data['update_at'] = time();
|
||||||
|
|
||||||
|
$validate = new Validate([
|
||||||
|
'name|标签名称' => 'require|unique:BlogTag,name,' . $id,
|
||||||
|
'description|描述' => 'max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$validate->check($data)) {
|
||||||
|
return json(['code' => 0, 'msg' => $validate->getError()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tag->save($data);
|
||||||
|
return json(['code' => 1, 'msg' => '标签更新成功', 'url' => '/blog/backend/tag']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除标签
|
||||||
|
*/
|
||||||
|
public function delete($id)
|
||||||
|
{
|
||||||
|
$tag = TagModel::find($id);
|
||||||
|
if ($tag) {
|
||||||
|
if ($tag->articles()->count() > 0) {
|
||||||
|
return json(['code' => 0, 'msg' => '该标签下有文章,无法删除']);
|
||||||
|
}
|
||||||
|
$tag->delete();
|
||||||
|
return json(['code' => 1, 'msg' => '标签已删除']);
|
||||||
|
}
|
||||||
|
return json(['code' => 0, 'msg' => '标签不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除标签
|
||||||
|
*/
|
||||||
|
public function batchDelete()
|
||||||
|
{
|
||||||
|
$ids = Request::post('ids/a');
|
||||||
|
if (empty($ids)) {
|
||||||
|
return json(['code' => 0, 'msg' => '请选择标签']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tags = TagModel::withCount('articles')->whereIn('id', $ids)->select();
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
if ($tag->articles_count > 0) {
|
||||||
|
return json(['code' => 0, 'msg' => "标签【{$tag->name}】下有文章,无法删除"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TagModel::destroy($ids);
|
||||||
|
return json(['code' => 1, 'msg' => '批量删除成功']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller\backend;
|
||||||
|
|
||||||
|
use ywxapp\model\MemberUser as UserModel;
|
||||||
|
use addon\blog\model\BlogArticle as ArticleModel;
|
||||||
|
use think\facade\Request;
|
||||||
|
use think\facade\View;
|
||||||
|
use ywxapp\controller\BackendBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member 类
|
||||||
|
*
|
||||||
|
* @author ywxapp <admin@ywxapp.cn>
|
||||||
|
*/
|
||||||
|
class Member extends BackendBase
|
||||||
|
{
|
||||||
|
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
|
||||||
|
protected function initialize()
|
||||||
|
{}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户列表(ajax 请求返回 layui table 所需 JSON,普通请求渲染视图)
|
||||||
|
* 这里管理的是全站用户(博客作者 / 会员),与博客文章通过 uid 关联。
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$status = Request::param('status', 'all');
|
||||||
|
$keyword = Request::param('keyword', '');
|
||||||
|
$page = Request::param('page', 1);
|
||||||
|
$limit = Request::param('limit', 15);
|
||||||
|
|
||||||
|
if (Request::isAjax()) {
|
||||||
|
$query = UserModel::where('delete_at', null);
|
||||||
|
|
||||||
|
if ($status !== 'all') {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
if ($keyword) {
|
||||||
|
$query->where('account|nickname|email|mobile', 'like', "%{$keyword}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
$paginator = $query->order('create_at', 'desc')
|
||||||
|
->paginate(['list_rows' => $limit, 'page' => $page]);
|
||||||
|
|
||||||
|
$items = $paginator->items();
|
||||||
|
$uids = array_column($items, 'uid');
|
||||||
|
$counts = [];
|
||||||
|
if ($uids) {
|
||||||
|
$counts = ArticleModel::whereIn('uid', $uids)
|
||||||
|
->group('uid')
|
||||||
|
->column('COUNT(*)', 'uid');
|
||||||
|
}
|
||||||
|
|
||||||
|
$statusMap = [1 => '正常', 0 => '禁用', -1 => '删除'];
|
||||||
|
$rows = [];
|
||||||
|
foreach ($items as $u) {
|
||||||
|
$rows[] = [
|
||||||
|
'uid' => $u->uid,
|
||||||
|
'account' => $u->account,
|
||||||
|
'nickname' => $u->nickname,
|
||||||
|
'email' => $u->email,
|
||||||
|
'mobile' => $u->mobile,
|
||||||
|
'status' => $u->status,
|
||||||
|
'status_text' => $statusMap[$u->status] ?? '未知',
|
||||||
|
'article_count' => $counts[$u->uid] ?? 0,
|
||||||
|
'create_at' => $u->create_at ? date('Y-m-d H:i', $u->create_at) : '-',
|
||||||
|
'update_at' => $u->update_at ? date('Y-m-d H:i', $u->update_at) : '从未登录',
|
||||||
|
'edit_url' => (string) url('blog/backend.user/edit', ['id' => $u->uid]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'count' => $paginator->total(),
|
||||||
|
'data' => $rows,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return View::fetch('user/index', [
|
||||||
|
'status' => $status,
|
||||||
|
'keyword' => $keyword,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑用户页面
|
||||||
|
*/
|
||||||
|
public function edit($id = null)
|
||||||
|
{
|
||||||
|
$user = UserModel::find($id);
|
||||||
|
if (! $user) {
|
||||||
|
return redirect('/blog/backend/user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return View::fetch('user/edit', ['member' => $user]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户
|
||||||
|
*/
|
||||||
|
public function update($id)
|
||||||
|
{
|
||||||
|
$user = UserModel::find($id);
|
||||||
|
if (! $user) {
|
||||||
|
return json(['code' => 0, 'msg' => '用户不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = Request::post();
|
||||||
|
$user->nickname = $data['nickname'] ?? $user->nickname;
|
||||||
|
$user->email = $data['email'] ?? $user->email;
|
||||||
|
$user->mobile = $data['mobile'] ?? $user->mobile;
|
||||||
|
$user->status = (int) ($data['status'] ?? $user->status);
|
||||||
|
$user->update_at = time();
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
return json(['code' => 1, 'msg' => '用户更新成功', 'url' => '/blog/backend/user']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户(软删除,Member 模型已启用 SoftDelete)
|
||||||
|
*/
|
||||||
|
public function delete($id)
|
||||||
|
{
|
||||||
|
$user = UserModel::find($id);
|
||||||
|
if ($user) {
|
||||||
|
$user->delete();
|
||||||
|
return json(['code' => 1, 'msg' => '用户已删除']);
|
||||||
|
}
|
||||||
|
return json(['code' => 0, 'msg' => '用户不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除用户
|
||||||
|
*/
|
||||||
|
public function batch()
|
||||||
|
{
|
||||||
|
$ids = Request::post('ids/a');
|
||||||
|
if (empty($ids)) {
|
||||||
|
return json(['code' => 0, 'msg' => '请选择用户']);
|
||||||
|
}
|
||||||
|
|
||||||
|
UserModel::destroy($ids);
|
||||||
|
return json(['code' => 1, 'msg' => '批量删除成功']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller\member;
|
||||||
|
|
||||||
|
use addon\blog\model\BlogArticle as ArticleModel;
|
||||||
|
use addon\blog\model\BlogCategory as CategoryModel;
|
||||||
|
use addon\blog\model\BlogTag as TagModel;
|
||||||
|
use think\facade\Request;
|
||||||
|
use think\facade\View;
|
||||||
|
use think\Validate;
|
||||||
|
use ywxapp\controller\MemberBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员中心文章管理(多用户模式:文章由会员在用户中心发布/管理)
|
||||||
|
* 继承 AddonMember,处于 route/app.php 的 member 路由组中,强制会员登录。
|
||||||
|
* 文章作者固定为当前登录会员(uid = $this->auth->info->id)。
|
||||||
|
*/
|
||||||
|
class Article extends MemberBase
|
||||||
|
{
|
||||||
|
protected $noNeedLogin = [];
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
// 当前登录会员 id(文章作者);会员主键为 uid,需从模型取 uid
|
||||||
|
|
||||||
|
protected function uid()
|
||||||
|
{
|
||||||
|
return $this->auth->model->uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的文章列表(ajax 请求返回 layui table 所需 JSON,普通请求渲染视图)
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$status = Request::param('status', 'all');
|
||||||
|
$keyword = Request::param('keyword', '');
|
||||||
|
$page = Request::param('page', 1);
|
||||||
|
$limit = Request::param('limit', 15);
|
||||||
|
|
||||||
|
if (Request::isAjax()) {
|
||||||
|
$query = ArticleModel::with(['category', 'tags'])->where('uid', $this->uid());
|
||||||
|
|
||||||
|
if ($status !== 'all') {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
if ($keyword) {
|
||||||
|
$query->where('title|content', 'like', "%{$keyword}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
$paginator = $query->order('create_at', 'desc')
|
||||||
|
->paginate(['list_rows' => $limit, 'page' => $page]);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($paginator->items() as $a) {
|
||||||
|
$rows[] = [
|
||||||
|
'id' => $a->id,
|
||||||
|
'title' => $a->title,
|
||||||
|
'is_top' => $a->is_top,
|
||||||
|
'category' => $a->category->name ?? '-',
|
||||||
|
'status' => $a->status,
|
||||||
|
'view_count' => (int) $a->view_count,
|
||||||
|
'comment_count' => (int) $a->comment_count,
|
||||||
|
'like_count' => (int) $a->like_count,
|
||||||
|
'favorite_count' => (int) $a->favorite_count,
|
||||||
|
'share_count' => (int) $a->share_count,
|
||||||
|
'create_at' => $a->create_at,
|
||||||
|
'create_text' => (function ($t) {
|
||||||
|
if ($t instanceof \DateTime) return $t->format('Y-m-d H:i');
|
||||||
|
if (is_numeric($t)) return date('Y-m-d H:i', (int) $t);
|
||||||
|
$ts = strtotime((string) $t);
|
||||||
|
return $ts !== false ? date('Y-m-d H:i', $ts) : (string) $t;
|
||||||
|
})($a->create_at),
|
||||||
|
'detail_url' => (string) url('index/article/detail', ['id' => $a->id]),
|
||||||
|
'edit_url' => (string) url('blog/member.article/edit', ['id' => $a->id]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'count' => $paginator->total(),
|
||||||
|
'data' => $rows,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return View::fetch('article/index', [
|
||||||
|
'status' => $status,
|
||||||
|
'keyword' => $keyword,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写文章页面
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$categories = CategoryModel::getAvailable($this->uid());
|
||||||
|
$tags = TagModel::select();
|
||||||
|
|
||||||
|
return View::fetch('article/create', [
|
||||||
|
'categories' => $categories,
|
||||||
|
'tags' => $tags,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存文章(作者为当前会员,不可指定)
|
||||||
|
*/
|
||||||
|
public function save()
|
||||||
|
{
|
||||||
|
$data = Request::post();
|
||||||
|
$data['uid'] = $this->uid(); // 多用户:作者固定为当前会员
|
||||||
|
$data['lat'] = (float) Request::post('lat', 0);
|
||||||
|
$data['lng'] = (float) Request::post('lng', 0);
|
||||||
|
unset($data['user_id'], $data['tags']);
|
||||||
|
|
||||||
|
$validate = new Validate([
|
||||||
|
'title|标题' => 'require|max:255',
|
||||||
|
'content|内容' => 'require',
|
||||||
|
'cid|分类' => 'require|number',
|
||||||
|
]);
|
||||||
|
if (! $validate->check($data)) {
|
||||||
|
return json(['code' => 0, 'msg' => $validate->getError()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagIds = $this->parseTags(Request::post('tags'));
|
||||||
|
|
||||||
|
// 内容审核:开启 need_audit 时进入审核流(机审 + 人工复核)
|
||||||
|
$needAudit = (int) config('blog.need_audit');
|
||||||
|
$auditStatus = 2; // 默认通过(2=通过)
|
||||||
|
$auditReason = '';
|
||||||
|
if ($needAudit) {
|
||||||
|
$ai = new \addon\wxchat\service\AiAuditService();
|
||||||
|
$res = $ai->audit(($data['title'] ?? '') . "\n" . ($data['content'] ?? ''));
|
||||||
|
if ($res['code'] === 0 && !empty($res['data']['blocked'])) {
|
||||||
|
$auditStatus = 3; // 机审命中违规:直接驳回
|
||||||
|
$auditReason = '机审不通过:' . ($res['data']['reason'] ?? '内容违规');
|
||||||
|
} elseif ($res['code'] === 0) {
|
||||||
|
$auditStatus = 1; // 机审通过:进入待审,等待人工复核
|
||||||
|
}
|
||||||
|
// AI 服务不可用:维持待审,转人工队列
|
||||||
|
}
|
||||||
|
$data['status'] = ($auditStatus === 2) ? 1 : 0; // 仅通过态对外可见
|
||||||
|
$data['audit_status'] = $auditStatus;
|
||||||
|
|
||||||
|
\addon\blog\library\BlogSchema::ensure();
|
||||||
|
$article = ArticleModel::create($data);
|
||||||
|
$article->tags()->sync($tagIds);
|
||||||
|
\addon\blog\service\BlogSearchService::sync($article->id);
|
||||||
|
|
||||||
|
if ($auditStatus === 3) {
|
||||||
|
return json(['code' => 0, 'msg' => $auditReason, 'url' => '/blog/member/article']);
|
||||||
|
}
|
||||||
|
if ($needAudit && $auditStatus === 1) {
|
||||||
|
return json(['code' => 1, 'msg' => '发布成功,等待审核', 'url' => '/blog/member/article']);
|
||||||
|
}
|
||||||
|
return json(['code' => 1, 'msg' => '文章发布成功', 'url' => '/blog/member/article']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑文章页面(仅能编辑自己的文章)
|
||||||
|
*/
|
||||||
|
public function edit($id = null)
|
||||||
|
{
|
||||||
|
$article = ArticleModel::with(['tags'])
|
||||||
|
->where('id', $id)
|
||||||
|
->where('uid', $this->uid())
|
||||||
|
->find();
|
||||||
|
if (! $article) {
|
||||||
|
return redirect('/blog/member/article');
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories = CategoryModel::getAvailable($this->uid());
|
||||||
|
$tags = TagModel::select();
|
||||||
|
$articleTagsStr = implode(',', $article->tags->column('name'));
|
||||||
|
|
||||||
|
return View::fetch('article/edit', [
|
||||||
|
'article' => $article,
|
||||||
|
'categories' => $categories,
|
||||||
|
'tags' => $tags,
|
||||||
|
'articleTagsStr' => $articleTagsStr,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新文章(仅能更新自己的文章,作者不可改)
|
||||||
|
*/
|
||||||
|
public function update($id)
|
||||||
|
{
|
||||||
|
$article = ArticleModel::where('id', $id)
|
||||||
|
->where('uid', $this->uid())
|
||||||
|
->find();
|
||||||
|
if (! $article) {
|
||||||
|
return json(['code' => 0, 'msg' => '文章不存在或无权限']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = Request::post();
|
||||||
|
$data['lat'] = (float) Request::post('lat', $article->lat ?? 0);
|
||||||
|
$data['lng'] = (float) Request::post('lng', $article->lng ?? 0);
|
||||||
|
unset($data['uid'], $data['user_id'], $data['tags'], $data['tag_names'], $data['id']);
|
||||||
|
|
||||||
|
$validate = new Validate([
|
||||||
|
'title|标题' => 'require|max:255',
|
||||||
|
'content|内容' => 'require',
|
||||||
|
'cid|分类' => 'require|number',
|
||||||
|
]);
|
||||||
|
if (! $validate->check($data)) {
|
||||||
|
return json(['code' => 0, 'msg' => $validate->getError()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagIds = $this->parseTags(Request::post('tags'));
|
||||||
|
$article->save($data);
|
||||||
|
$article->tags()->sync($tagIds);
|
||||||
|
\addon\blog\service\BlogSearchService::sync($article->id);
|
||||||
|
|
||||||
|
return json(['code' => 1, 'msg' => '文章更新成功', 'url' => '/blog/member/article']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文章(仅能删除自己的文章)
|
||||||
|
*/
|
||||||
|
public function delete($id)
|
||||||
|
{
|
||||||
|
$article = ArticleModel::where('id', $id)
|
||||||
|
->where('uid', $this->uid())
|
||||||
|
->find();
|
||||||
|
if ($article) {
|
||||||
|
\addon\blog\service\BlogSearchService::remove($article->id);
|
||||||
|
$article->tags()->detach();
|
||||||
|
$article->delete();
|
||||||
|
return json(['code' => 1, 'msg' => '文章已删除']);
|
||||||
|
}
|
||||||
|
return json(['code' => 0, 'msg' => '文章不存在或无权限']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量操作(仅限自己的文章)
|
||||||
|
*/
|
||||||
|
public function batch()
|
||||||
|
{
|
||||||
|
$action = Request::post('action');
|
||||||
|
$ids = Request::post('ids/a');
|
||||||
|
if (empty($ids)) {
|
||||||
|
return json(['code' => 0, 'msg' => '请选择文章']);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
case 'delete':
|
||||||
|
$list = ArticleModel::whereIn('id', $ids)->where('uid', $this->uid())->select();
|
||||||
|
foreach ($list as $a) {
|
||||||
|
$a->tags()->detach();
|
||||||
|
$a->delete();
|
||||||
|
}
|
||||||
|
return json(['code' => 1, 'msg' => '批量删除成功']);
|
||||||
|
case 'publish':
|
||||||
|
ArticleModel::whereIn('id', $ids)->where('uid', $this->uid())->update(['status' => 1]);
|
||||||
|
return json(['code' => 1, 'msg' => '批量发布成功']);
|
||||||
|
case 'draft':
|
||||||
|
ArticleModel::whereIn('id', $ids)->where('uid', $this->uid())->update(['status' => 0]);
|
||||||
|
return json(['code' => 1, 'msg' => '批量设为草稿成功']);
|
||||||
|
default:
|
||||||
|
return json(['code' => 0, 'msg' => '无效操作']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析标签:逗号分隔的名称 -> 标签 id 数组(不存在则自动创建)
|
||||||
|
*/
|
||||||
|
protected function parseTags($raw)
|
||||||
|
{
|
||||||
|
$tagIds = [];
|
||||||
|
if (empty($raw)) {
|
||||||
|
return $tagIds;
|
||||||
|
}
|
||||||
|
$names = is_array($raw) ? $raw : explode(',', $raw);
|
||||||
|
foreach ($names as $name) {
|
||||||
|
$name = trim($name);
|
||||||
|
if ($name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$tag = TagModel::where('name', $name)->find() ?: TagModel::create(['name' => $name]);
|
||||||
|
$tagIds[] = $tag->id;
|
||||||
|
}
|
||||||
|
return $tagIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller\member;
|
||||||
|
|
||||||
|
use addon\blog\model\BlogCategory as CategoryModel;
|
||||||
|
use think\facade\Request;
|
||||||
|
use think\facade\View;
|
||||||
|
use think\Validate;
|
||||||
|
use ywxapp\controller\MemberBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员中心「我的分类」管理(用户自建分类)
|
||||||
|
* 每个会员只能管理自己 uid 下的分类;全局共享分类(uid=0)由后台管理。
|
||||||
|
*/
|
||||||
|
class Category extends MemberBase
|
||||||
|
{
|
||||||
|
protected $noNeedLogin = [];
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
// 当前登录会员 id(会员主键为 uid)
|
||||||
|
|
||||||
|
protected function uid()
|
||||||
|
{
|
||||||
|
return $this->auth->model->uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的分类列表(仅当前会员自己的分类)
|
||||||
|
* 以 layui treeTable 所需的扁平结构返回(带 pid / is_parent),由前端 treeTable 自动成树。
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$categories = CategoryModel::where('uid', $this->uid())
|
||||||
|
->withCount('articles')
|
||||||
|
->order('sort', 'asc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// 计算哪些分类拥有子分类(用于 treeTable 的 is_parent 标记)
|
||||||
|
$parentIds = array_unique(array_filter(array_column($categories, 'pid')));
|
||||||
|
|
||||||
|
$treeData = [];
|
||||||
|
foreach ($categories as $c) {
|
||||||
|
$treeData[] = [
|
||||||
|
'id' => $c['id'],
|
||||||
|
'pid' => $c['pid'],
|
||||||
|
'title' => $c['name'], // treeTable 节点显示名称
|
||||||
|
'name' => $c['name'],
|
||||||
|
'description' => $c['description'] ?? '',
|
||||||
|
'sort' => $c['sort'],
|
||||||
|
'status' => $c['status'],
|
||||||
|
'is_parent' => in_array($c['id'], $parentIds, true) ? 1 : 0,
|
||||||
|
'articles_count' => $c['articles_count'] ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return View::fetch('category/index', [
|
||||||
|
'treeData' => json_encode($treeData, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加分类页面
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$categories = CategoryModel::where(function ($query) {
|
||||||
|
$query->where('uid', 0)->whereOr('uid', $this->uid());
|
||||||
|
})->order('sort', 'asc')->select();
|
||||||
|
return View::fetch('category/create', ['categories' => $categories]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存分类(归属当前会员)
|
||||||
|
*/
|
||||||
|
public function save()
|
||||||
|
{
|
||||||
|
$data = Request::post();
|
||||||
|
$data['uid'] = $this->uid();
|
||||||
|
$data['pid'] = (int) Request::post('pid', 0);
|
||||||
|
$data['status'] = 1;
|
||||||
|
$data['create_at'] = time();
|
||||||
|
$data['update_at'] = time();
|
||||||
|
|
||||||
|
$validate = new Validate([
|
||||||
|
'name|分类名称' => 'require|unique:BlogCategory,name,0,id,uid,' . $this->uid(),
|
||||||
|
'pid|上级分类' => 'number',
|
||||||
|
'sort|排序' => 'number|between:0,999',
|
||||||
|
]);
|
||||||
|
if (! $validate->check($data)) {
|
||||||
|
return json(['code' => 0, 'msg' => $validate->getError()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
CategoryModel::create($data);
|
||||||
|
return json(['code' => 1, 'msg' => '分类添加成功', 'url' => '/blog/member/category']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑分类页面(仅自己的分类)
|
||||||
|
*/
|
||||||
|
public function edit($id = null)
|
||||||
|
{
|
||||||
|
$category = CategoryModel::where('id', $id)
|
||||||
|
->where('uid', $this->uid())
|
||||||
|
->find();
|
||||||
|
if (! $category) {
|
||||||
|
return redirect('/blog/member/category');
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories = CategoryModel::where(function ($query) {
|
||||||
|
$query->where('uid', 0)->whereOr('uid', $this->uid());
|
||||||
|
})->order('sort', 'asc')->select();
|
||||||
|
return View::fetch('category/edit', ['category' => $category, 'categories' => $categories]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新分类(仅自己的分类)
|
||||||
|
*/
|
||||||
|
public function update($id)
|
||||||
|
{
|
||||||
|
$category = CategoryModel::where('id', $id)
|
||||||
|
->where('uid', $this->uid())
|
||||||
|
->find();
|
||||||
|
if (! $category) {
|
||||||
|
return json(['code' => 0, 'msg' => '分类不存在或无权限']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) Request::post('pid', 0) === (int) $id) {
|
||||||
|
return json(['code' => 0, 'msg' => '上级分类不能选择自己']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = Request::post();
|
||||||
|
$data['pid'] = (int) Request::post('pid', 0);
|
||||||
|
$data['update_at'] = time();
|
||||||
|
|
||||||
|
$validate = new Validate([
|
||||||
|
'name|分类名称' => 'require|unique:BlogCategory,name,' . $id . ',id,uid,' . $this->uid(),
|
||||||
|
'pid|上级分类' => 'number',
|
||||||
|
'sort|排序' => 'number|between:0,999',
|
||||||
|
]);
|
||||||
|
if (! $validate->check($data)) {
|
||||||
|
return json(['code' => 0, 'msg' => $validate->getError()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$category->save($data);
|
||||||
|
return json(['code' => 1, 'msg' => '分类更新成功', 'url' => '/blog/member/category']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除分类(仅自己的分类,有文章时禁止删除)
|
||||||
|
*/
|
||||||
|
public function delete($id)
|
||||||
|
{
|
||||||
|
$category = CategoryModel::where('id', $id)
|
||||||
|
->where('uid', $this->uid())
|
||||||
|
->find();
|
||||||
|
if (! $category) {
|
||||||
|
return json(['code' => 0, 'msg' => '分类不存在或无权限']);
|
||||||
|
}
|
||||||
|
if ($category->children()->count() > 0) {
|
||||||
|
return json(['code' => 0, 'msg' => '该分类下有子分类,请先删除子分类']);
|
||||||
|
}
|
||||||
|
if ($category->articles()->count() > 0) {
|
||||||
|
return json(['code' => 0, 'msg' => '该分类下有文章,无法删除']);
|
||||||
|
}
|
||||||
|
$category->delete();
|
||||||
|
return json(['code' => 1, 'msg' => '分类已删除']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量操作(仅限自己的分类)
|
||||||
|
*/
|
||||||
|
public function batch()
|
||||||
|
{
|
||||||
|
$action = Request::post('action');
|
||||||
|
$ids = Request::post('ids/a');
|
||||||
|
if (empty($ids)) {
|
||||||
|
return json(['code' => 0, 'msg' => '请选择分类']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'delete') {
|
||||||
|
$list = CategoryModel::whereIn('id', $ids)
|
||||||
|
->where('uid', $this->uid())
|
||||||
|
->select();
|
||||||
|
foreach ($list as $c) {
|
||||||
|
if ($c->articles()->count() > 0) {
|
||||||
|
continue; // 跳过有文章的分类
|
||||||
|
}
|
||||||
|
if (CategoryModel::where('pid', $c->id)->whereNotIn('id', $ids)->count() > 0) {
|
||||||
|
continue; // 跳过有外部子分类的分类
|
||||||
|
}
|
||||||
|
$c->delete();
|
||||||
|
}
|
||||||
|
return json(['code' => 1, 'msg' => '批量删除成功']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(['code' => 0, 'msg' => '无效操作']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog\controller\member;
|
||||||
|
|
||||||
|
use think\facade\View;
|
||||||
|
use ywxapp\controller\MemberBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员中心入口页(addon/blog/controller/member/Index.php)
|
||||||
|
*
|
||||||
|
* 继承 MemberBase,登录校验统一由 MemberBase::_initialize() 调用 Auth::verifyAuth 处理
|
||||||
|
* (本控制器设 $noNeedLogin = [] 表示强制会员登录,未登录跳 /user/login)。
|
||||||
|
* 容器 auth 默认即为 \ywxapp\library\Auth,故 $this->auth->info 即当前会员。
|
||||||
|
*/
|
||||||
|
class Index extends MemberBase
|
||||||
|
{
|
||||||
|
// 会员中心强制登录(由 MemberBase 的 verifyAuth 统一校验)
|
||||||
|
protected $noNeedLogin = [];
|
||||||
|
protected $noNeedVerify = ['*'];
|
||||||
|
|
||||||
|
|
||||||
|
protected function initialize()
|
||||||
|
{}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员中心首页
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$user = $this->auth->info ?? null;
|
||||||
|
|
||||||
|
return View::fetch('index/index', [
|
||||||
|
'member' => $user,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 个人资料
|
||||||
|
*/
|
||||||
|
public function profile()
|
||||||
|
{
|
||||||
|
$user = $this->auth->info ?? null;
|
||||||
|
|
||||||
|
return View::fetch('index/profile', [
|
||||||
|
'member' => $user,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => 'blog',
|
||||||
|
'title' => '博客应用',
|
||||||
|
'intro' => '',
|
||||||
|
'author' => '',
|
||||||
|
'website' => '',
|
||||||
|
'version' => '1.0.2',
|
||||||
|
'state' => 1,
|
||||||
|
'url' => '/blog',
|
||||||
|
'license' => '',
|
||||||
|
'licenseto' => 0,
|
||||||
|
'config' => [
|
||||||
|
],
|
||||||
|
'events' => [
|
||||||
|
],
|
||||||
|
'middleware' => [
|
||||||
|
],
|
||||||
|
'services' => [
|
||||||
|
],
|
||||||
|
'install_time' => 1783848064,
|
||||||
|
'update_time' => 1786365433,
|
||||||
|
];
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- addon/blog/install.sql —— blog 插件数据表
|
||||||
|
-- 由 docs/split_install_sql.py 从根 install.sql 抽取。
|
||||||
|
-- 框架约定:插件安装时由 ywxapp\service\AddonService 执行本文件
|
||||||
|
-- (仅允许 CREATE TABLE / INSERT,见 importsql 白名单)。
|
||||||
|
-- 表名须为 __PREFIX__<插件名>_*,与 __PREFIX__addon 等核心表命名一致。
|
||||||
|
-- ============================================================
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `__PREFIX__blog_article` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '标题',
|
||||||
|
`content` longtext NOT NULL COMMENT '内容',
|
||||||
|
`summary` varchar(500) DEFAULT NULL COMMENT '摘要',
|
||||||
|
`cover_image` varchar(255) DEFAULT NULL COMMENT '封面图',
|
||||||
|
`uid` int NOT NULL COMMENT '作者ID',
|
||||||
|
`cid` int DEFAULT NULL COMMENT '分类ID',
|
||||||
|
`status` tinyint(1) DEFAULT '1' COMMENT '状态:1发布 0草稿',
|
||||||
|
`audit_status` tinyint(1) DEFAULT '2' COMMENT '审核状态:0草稿 1待审 2通过 3驳回',
|
||||||
|
`lat` decimal(10,7) DEFAULT '0.0000000' COMMENT '纬度(可选,用于附近)',
|
||||||
|
`lng` decimal(10,7) DEFAULT '0.0000000' COMMENT '经度(可选,用于附近)',
|
||||||
|
`is_top` tinyint(1) DEFAULT '0' COMMENT '是否置顶',
|
||||||
|
`view_count` int DEFAULT '0' COMMENT '浏览次数',
|
||||||
|
`comment_count` int DEFAULT '0' COMMENT '评论数',
|
||||||
|
`like_count` int DEFAULT '0' COMMENT '点赞数',
|
||||||
|
`favorite_count` int DEFAULT '0' COMMENT '收藏数',
|
||||||
|
`create_at` int DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`update_at` int DEFAULT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_uid` (`uid`),
|
||||||
|
KEY `idx_cid` (`cid`),
|
||||||
|
KEY `idx_create_at` (`create_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文章表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `__PREFIX__blog_article_tag` (
|
||||||
|
`aid` int NOT NULL COMMENT '文章ID',
|
||||||
|
`tid` int NOT NULL COMMENT '标签ID',
|
||||||
|
PRIMARY KEY (`aid`,`tid`) USING BTREE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文章标签关联表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `__PREFIX__blog_category` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`uid` int unsigned NOT NULL DEFAULT '0' COMMENT '所属会员UID,0表示全局共享分类',
|
||||||
|
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '分类名称',
|
||||||
|
`description` varchar(255) DEFAULT NULL COMMENT '分类描述',
|
||||||
|
`sort` int DEFAULT '0' COMMENT '排序权重',
|
||||||
|
`create_at` int DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`update_at` int DEFAULT NULL COMMENT '更新时间',
|
||||||
|
`status` tinyint(1) DEFAULT '1',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_uid_name` (`uid`, `name`),
|
||||||
|
KEY `idx_uid` (`uid`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='分类表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `__PREFIX__blog_comment` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`uid` int NOT NULL COMMENT '用户ID',
|
||||||
|
`aid` int NOT NULL COMMENT '文章ID',
|
||||||
|
`pid` int NOT NULL DEFAULT '0' COMMENT '父评论ID',
|
||||||
|
`content` text NOT NULL COMMENT '评论内容',
|
||||||
|
`status` tinyint(1) DEFAULT '1' COMMENT '状态:1正常 0待审核',
|
||||||
|
`like_count` int DEFAULT '0' COMMENT '点赞数',
|
||||||
|
`ip` varchar(45) DEFAULT NULL COMMENT 'IP地址',
|
||||||
|
`user_agent` varchar(255) DEFAULT NULL COMMENT '用户代理',
|
||||||
|
`create_at` int DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`update_at` int DEFAULT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_create_at` (`create_at`),
|
||||||
|
KEY `idx_aid` (`aid`) USING BTREE,
|
||||||
|
KEY `idx_pid` (`pid`) USING BTREE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='评论表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `__PREFIX__blog_favorite` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`uid` int NOT NULL COMMENT '用户ID',
|
||||||
|
`aid` int NOT NULL COMMENT '文章ID',
|
||||||
|
`create_at` int DEFAULT NULL COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `user_article` (`uid`,`aid`) USING BTREE,
|
||||||
|
KEY `idx_aid` (`aid`) USING BTREE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='收藏表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `__PREFIX__blog_like` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`uid` int NOT NULL COMMENT '用户ID',
|
||||||
|
`aid` int NOT NULL COMMENT '文章ID',
|
||||||
|
`create_at` int DEFAULT NULL COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `user_article` (`uid`,`aid`) USING BTREE,
|
||||||
|
KEY `idx_aid` (`aid`) USING BTREE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='点赞表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `__PREFIX__blog_tag` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '标签名称',
|
||||||
|
`description` varchar(255) DEFAULT NULL COMMENT '标签描述',
|
||||||
|
`create_at` int DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`update_at` int DEFAULT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `name` (`name`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='标签表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `__PREFIX__blog_user` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`username` varchar(50) NOT NULL DEFAULT '' COMMENT '用户名',
|
||||||
|
`password` varchar(255) NOT NULL DEFAULT '' COMMENT '密码',
|
||||||
|
`email` varchar(100) NOT NULL DEFAULT '' COMMENT '邮箱',
|
||||||
|
`nickname` varchar(50) DEFAULT '' COMMENT '昵称',
|
||||||
|
`avatar` varchar(255) DEFAULT '' COMMENT '头像',
|
||||||
|
`bio` varchar(255) DEFAULT '' COMMENT '个人简介',
|
||||||
|
`role` enum('admin','editor','user') DEFAULT 'user' COMMENT '角色',
|
||||||
|
`status` tinyint(1) DEFAULT '1' COMMENT '状态:1正常 0禁用',
|
||||||
|
`create_at` int DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`update_at` int DEFAULT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `username` (`username`),
|
||||||
|
UNIQUE KEY `email` (`email`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `__PREFIX__blog_visit` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`ip` varchar(45) DEFAULT NULL COMMENT 'IP地址',
|
||||||
|
`url` varchar(255) DEFAULT NULL COMMENT '访问URL',
|
||||||
|
`referer` varchar(255) DEFAULT '' COMMENT '来源URL',
|
||||||
|
`user_agent` varchar(255) DEFAULT '' COMMENT '用户代理',
|
||||||
|
`create_at` int DEFAULT NULL COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_url` (`url`),
|
||||||
|
KEY `idx_create_at` (`create_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='访客记录表';
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
namespace addon\blog\library;
|
||||||
|
|
||||||
|
use ywxapp\model\BaseModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 博客(blog)插件专属表结构自愈。
|
||||||
|
*
|
||||||
|
* 归属原则:blog_* 表属于 blog 插件私有表,其列兜底逻辑内聚在本插件,
|
||||||
|
* 不污染核心框架 ywxapp/library/BaseModel 自愈引擎(核心只保留通用引擎)。
|
||||||
|
*
|
||||||
|
* 状态语义:0草稿 1待审 2通过 3驳回(发布接口按 need_audit 配置走机审/人工审)。
|
||||||
|
*/
|
||||||
|
class BlogSchema
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 补齐 blog_article 扩展列(审核状态 / 经纬度),幂等。
|
||||||
|
* 建表由插件 install.sql 负责,此处仅做运行时兜底(老库 / 全新安装后结构差异)。
|
||||||
|
*/
|
||||||
|
public static function ensure(): void
|
||||||
|
{
|
||||||
|
$prefix = BaseModel::currentPrefix();
|
||||||
|
$p = $prefix;
|
||||||
|
BaseModel::ensureColumn("{$p}blog_article", 'audit_status', "tinyint NOT NULL DEFAULT 2 COMMENT '审核状态:0草稿 1待审 2通过 3驳回'");
|
||||||
|
BaseModel::ensureColumn("{$p}blog_article", 'lat', "decimal(10,7) NOT NULL DEFAULT '0.0000000' COMMENT '纬度(可选)'");
|
||||||
|
BaseModel::ensureColumn("{$p}blog_article", 'lng', "decimal(10,7) NOT NULL DEFAULT '0.0000000' COMMENT '经度(可选)'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"backend": [
|
||||||
|
{
|
||||||
|
"name": "blog",
|
||||||
|
"title": "博客管理",
|
||||||
|
"icon": "fa fa-pencil-square-o",
|
||||||
|
"type": 1,
|
||||||
|
"sort": 50,
|
||||||
|
"status": 1,
|
||||||
|
"child": [
|
||||||
|
{ "name": "blog/dashboard", "title": "仪表盘", "icon": "fa fa-dashboard", "type": 2, "sort": 1, "route": "/blog/backend/dashboard" },
|
||||||
|
{ "name": "blog/article", "title": "文章管理", "icon": "fa fa-file-text", "type": 2, "sort": 2, "route": "/blog/backend/article",
|
||||||
|
"child": [
|
||||||
|
{ "name": "blog/article/pending", "title": "待审文章", "icon": "fa fa-hourglass-half", "type": 3, "sort": 1, "route": "/blog/backend/article?audit=1" }
|
||||||
|
] },
|
||||||
|
{ "name": "blog/category", "title": "分类管理", "icon": "fa fa-list-ul", "type": 2, "sort": 3, "route": "/blog/backend/category" },
|
||||||
|
{ "name": "blog/tag", "title": "标签管理", "icon": "fa fa-tags", "type": 2, "sort": 4, "route": "/blog/backend/tag" },
|
||||||
|
{ "name": "blog/comment", "title": "评论管理", "icon": "fa fa-comments", "type": 2, "sort": 5, "route": "/blog/backend/comment" },
|
||||||
|
{ "name": "blog/user", "title": "用户管理", "icon": "fa fa-users", "type": 2, "sort": 6, "route": "/blog/backend/user" },
|
||||||
|
{ "name": "blog/stat", "title": "统计分析", "icon": "fa fa-bar-chart", "type": 2, "sort": 7, "route": "/blog/backend/stat" },
|
||||||
|
{ "name": "blog/setting", "title": "系统设置", "icon": "fa fa-cog", "type": 2, "sort": 8, "route": "/blog/backend/setting" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"member": [
|
||||||
|
{
|
||||||
|
"name": "blog/member",
|
||||||
|
"title": "博客会员中心",
|
||||||
|
"icon": "fa fa-user-circle",
|
||||||
|
"type": 1,
|
||||||
|
"sort": 50,
|
||||||
|
"status": 1,
|
||||||
|
"child": [
|
||||||
|
{ "name": "blog/member/index", "title": "概览", "icon": "fa fa-home", "type": 2, "sort": 1, "route": "/blog/member" },
|
||||||
|
{ "name": "blog/member/article", "title": "我的文章", "icon": "fa fa-file-text", "type": 2, "sort": 2, "route": "/blog/member/article" },
|
||||||
|
{ "name": "blog/member/article/create", "title": "写文章", "icon": "fa fa-pencil", "type": 2, "sort": 3, "route": "/blog/member/article/create" },
|
||||||
|
{ "name": "blog/member/category", "title": "我的分类", "icon": "fa fa-list-ul", "type": 2, "sort": 4, "route": "/blog/member/category" },
|
||||||
|
{ "name": "blog/member/profile", "title": "个人资料", "icon": "fa fa-id-card", "type": 2, "sort": 5, "route": "/blog/member/profile" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"frontend": [
|
||||||
|
{
|
||||||
|
"name": "blog/frontend",
|
||||||
|
"title": "博客前台",
|
||||||
|
"icon": "fa fa-pencil",
|
||||||
|
"type": 1,
|
||||||
|
"sort": 50,
|
||||||
|
"status": 1,
|
||||||
|
"child": [
|
||||||
|
{ "name": "blog/frontend/index", "title": "首页", "icon": "fa fa-home", "type": 2, "sort": 1, "route": "/blog" },
|
||||||
|
{ "name": "blog/frontend/category", "title": "分类", "icon": "fa fa-list", "type": 2, "sort": 2, "route": "/blog/category" },
|
||||||
|
{ "name": "blog/frontend/tag", "title": "标签", "icon": "fa fa-tags", "type": 2, "sort": 3, "route": "/blog/tag" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | Author: ywxapp<admin@ywxapp.cn>
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
declare (strict_types = 1);
|
||||||
|
|
||||||
|
namespace addon\blog\middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use think\facade\Session;
|
||||||
|
use think\Request;
|
||||||
|
use ywxapp\library\Result;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 博客后台管理员鉴权中间件
|
||||||
|
*
|
||||||
|
* 插件应用(blog)在 MultiApp 中走 setAddon(),框架默认仅在 backend 应用
|
||||||
|
* 把容器中的 auth 绑定为 \ywxapp\library\AdminAuth。本中间件在 blog 后台路由
|
||||||
|
* 分组生效,将 auth 重新绑定为管理员鉴权,使后台控制器继承的 BaseController
|
||||||
|
* 能通过 app('auth') 完成登录态校验(与 backend 应用同源、共享 Session 登录态)。
|
||||||
|
*
|
||||||
|
* 未登录处理:
|
||||||
|
* - AJAX / JSON 请求:返回 401 JSON(交由前端处理)
|
||||||
|
* - 整页访问(blog 后台以 iframe 嵌入 backend):跳转后台登录页,
|
||||||
|
* 若处于 iframe 内则自动 top.location 跳出
|
||||||
|
*/
|
||||||
|
class AdminAuth
|
||||||
|
{
|
||||||
|
public function handle($request, Closure $next)
|
||||||
|
{
|
||||||
|
// 绑定后台管理员鉴权实例(与 app/backend 应用一致)
|
||||||
|
app()->bind('auth', \ywxapp\library\AdminAuth::class);
|
||||||
|
|
||||||
|
$auth = app('auth');
|
||||||
|
if (! $auth->isLogin) {
|
||||||
|
// AJAX / JSON 请求:保持 401,交由前端逻辑处理
|
||||||
|
if ($request->isAjax() || $request->isJson()) {
|
||||||
|
return Result::instance()->setStatusCode(401)->error(lang('Please login first'));
|
||||||
|
}
|
||||||
|
// 整页加载:跳转到后台登录页(iframe 内跳出到顶层)
|
||||||
|
// 登录路径跟随 app_map 中 backend 对应的别名(默认 admin),避免硬编码失效;
|
||||||
|
// 用 url() 助手生成完整绝对 URL,避免子目录部署 / 跨域时解析错误
|
||||||
|
$appMap = config('app.app_map', []);
|
||||||
|
$alias = array_search('backend', $appMap, true);
|
||||||
|
$loginPath = '/' . ($alias === false ? 'backend' : $alias) . '/login';
|
||||||
|
$url = (string) url($loginPath, [], false, true);
|
||||||
|
$html = sprintf(
|
||||||
|
'<!DOCTYPE html><html><head><meta charset="utf-8">'
|
||||||
|
. '<title>Redirect</title>'
|
||||||
|
. '<script>var u=%s;'
|
||||||
|
. 'if(window.self!==window.top){window.top.location.href=u;}'
|
||||||
|
. 'else{window.location.href=u;}</script>'
|
||||||
|
. '</head><body></body></html>',
|
||||||
|
json_encode($url)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response($html)->contentType('text/html; charset=utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user