chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+63
View File
@@ -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()
+9
View File
@@ -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
View File
@@ -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.
+1530
View File
File diff suppressed because it is too large Load Diff
+119
View File
@@ -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.sqlCREATE TABLE IF NOT EXISTS 幂等建表)。
* 单一事实源 = install.sql,避免复制 DDL 导致漂移。
*/
private function ensureTablesFromInstallSql(): void
{
$sqlFile = __DIR__ . DIRECTORY_SEPARATOR . 'install.sql';
if (!is_file($sqlFile)) {
return;
}
$content = (string) file_get_contents($sqlFile);
$content = preg_replace('/--.*|\/\*[\s\S]*?\*\//', '', $content);
$stmts = array_filter(
array_map('trim', explode(';', $content)),
function ($s) {
return strlen($s) > 5 && preg_match('/^CREATE\s+TABLE/i', $s);
}
);
foreach ($stmts as $sql) {
if (preg_match('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([\w]+)`?/i', $sql, $m)) {
BaseModel::ensureTable($m[1], $sql);
}
}
}
/**
* 升级钩子(在线升级 / 后台手动升级均会触发)。
*
* 在线升级(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;
}
}
+97
View File
@@ -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。',
],
];
+39
View File
@@ -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')。
// |
// | 约定:布尔项统一用 booltrue/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 语法+高危函数扫描
];
+388
View File
@@ -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 个工作日内完成审核,"
. '通过后令牌会发送至本邮箱,请注意查收。';
}
}
+93
View File
@@ -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
}
}
}
+163
View File
@@ -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;
}
}
+235
View File
@@ -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) & ts5 分钟有效期)。
*/
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());
}
}
}
+19
View File
@@ -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,
];
+19
View File
@@ -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,
];
+215
View File
@@ -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='主框架下载日志表';
+247
View File
@@ -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为永久'");
}
}
+49
View File
@@ -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"
}
]
}
+33
View File
@@ -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,
];
}
}
+57
View File
@@ -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);
}
}
+34
View File
@@ -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',
];
}
}
+51
View File
@@ -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');
}
}
+42
View File
@@ -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');
}
}
+42
View File
@@ -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');
}
}
+129
View File
@@ -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/indexAddonService 走 /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\-]+']);
+20
View File
@@ -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
{
}
+701
View File
@@ -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 的 orderMapdownload_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> &nbsp;|&nbsp; ' +
'平台抽成:<b>' + summaryData.total_commission + '</b> &nbsp;|&nbsp; ' +
'开发者收益:<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>
+54
View File
@@ -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;
}
}
+98
View File
@@ -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' => '',
]);
}
}
+33
View File
@@ -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');
}
}
+47
View File
@@ -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();
}
}
+32
View File
@@ -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' => '评论不存在']);
}
}
+101
View File
@@ -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' => '标签不存在']);
}
}
+14
View File
@@ -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,
];
+91
View File
@@ -0,0 +1,91 @@
-- ============================================================
-- addon/articles/install.sql —— 文章CMS插件数据表
-- 框架约定:插件安装时由 ywxapp\service\AddonService 执行本文件
-- (仅允许 CREATE TABLE / INSERT,见 importsql 白名单)。
-- 表名须为 __PREFIX__articles_*,与插件名一致。
-- 时间字段统一:create_at / update_at / delete_atint 时间戳,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 '作者IDmember_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 '用户IDmember_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;
+28
View File
@@ -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();
}
}
+37
View File
@@ -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"
}
]
}
]
}
+141
View File
@@ -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);
}
}
}
+93
View File
@@ -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;
}
}
+64
View File
@@ -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');
}
}
+38
View File
@@ -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');
}
}
+65
View File
@@ -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>
+40
View File
@@ -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&nbsp;</span>&nbsp;&copy;<span>文章CMS&nbsp;&nbsp;</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&nbsp;</span>&nbsp;&copy;<span>文章CMS&nbsp;&nbsp;</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&nbsp;</span>&nbsp;&copy;<span>文章CMS&nbsp;&nbsp;</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&nbsp;</span>&nbsp;&copy;<span>文章CMS&nbsp;&nbsp;</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>
+145
View File
@@ -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&nbsp;</span>&nbsp;&copy;<span>文章CMS&nbsp;&nbsp;</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>
+53
View File
@@ -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_rulemodule='member',会员中心)
* frontend → wxapp_user_rulemodule='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;
}
}
+325
View File
@@ -0,0 +1,325 @@
# ThinkPHP 8 博客应用 - 说明文档与应用目录
## 一、说明文档
### 1. 项目概述
**项目名称**TP-Blog(基于ThinkPHP 8的现代化博客系统)
**项目定位**:轻量级、可扩展的博客平台,支持用户管理、文章发布、分类标签、评论互动、后台管理等核心功能,适合个人博客或小型社区使用。
**技术栈**
- 后端:ThinkPHP 8PHP 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支持),可在对应目录下新增模块。
+11
View File
@@ -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);
+20
View File
@@ -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',
];
+301
View File
@@ -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,
]);
}
}
+71
View File
@@ -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'),
]);
}
}
+180
View File
@@ -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
]);
}
}
+263
View File
@@ -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,
]);
}
}
+193
View File
@@ -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,
]);
}
}
+114
View File
@@ -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
]);
}
}
+248
View File
@@ -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 : '')]);
}
}
+217
View File
@@ -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]);
}
}
+181
View File
@@ -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,
]);
}
}
+44
View File
@@ -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,
]);
}
}
+117
View File
@@ -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);
}
}
+136
View File
@@ -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');
}
}
+171
View File
@@ -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' => '批量删除成功']);
}
}
+158
View File
@@ -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' => '批量删除成功']);
}
}
+294
View File
@@ -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;
}
}
+207
View File
@@ -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' => '无效操作']);
}
}
+56
View File
@@ -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,
]);
}
}
+24
View File
@@ -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,
];
+132
View File
@@ -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 '所属会员UID0表示全局共享分类',
`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;
+28
View File
@@ -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 '经度(可选)'");
}
}
+57
View File
@@ -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" }
]
}
]
}
+66
View File
@@ -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