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
+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);
}
}
+229
View File
@@ -0,0 +1,229 @@
<?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\model;
use think\facade\Cache;
use ywxapp\model\BaseModel;
use ywxapp\model\MemberUser;
class BlogArticle extends BaseModel
{
// 设置数据表名
protected $name = 'blog_article';
// 设置主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = 'int';
protected $createTime = 'create_at';
protected $updateTime = 'update_at';
// 定义允许写入的字段
protected $allowField = [
'title', 'content', 'summary', 'cover_image',
'uid', 'cid', 'status', 'audit_status', 'is_top',
'view_count', 'comment_count', 'like_count', 'favorite_count', 'share_count',
];
// 设置字段类型
protected $type = [
'id' => 'int',
'uid' => 'int',
'cid' => 'int',
'status' => 'int',
'audit_status' => 'int',
'is_top' => 'int',
'view_count' => 'int',
'comment_count' => 'int',
'like_count' => 'int',
'favorite_count' => 'int',
'share_count' => 'int',
'create_at' => 'int',
'update_at' => 'int',
];
// 内容修改器(过滤危险标签)
public function setContentAttr($value)
{
return htmlspecialchars_decode($value);
}
// 摘要写入修改器:提交时若留空,自动从正文截取并持久化
public function setSummaryAttr($value, $data)
{
$value = trim((string) $value);
if ($value !== '') {
return $value;
}
$content = $data['content'] ?? '';
$content = is_string($content) ? strip_tags($content) : '';
$content = trim($content);
return $content !== '' ? mb_substr($content, 0, 150) : '';
}
// 获取文章摘要(自动生成,兼容遗留未存摘要的文章)
public function getSummaryAttr($value, $data)
{
if ($value !== '' && $value !== null) {
return $value;
}
$content = is_string($data['content'] ?? '') ? (string) $data['content'] : '';
$content = html_entity_decode(strip_tags($content), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$content = trim(preg_replace('/\s+/', ' ', $content));
// 正文为 JSON 或无可读文本时返回空,由模板显示「暂无描述」,避免首页展示 JSON 片段
if ($content === '' || $this->isJsonContent($content)) {
return '';
}
return mb_substr($content, 0, 150);
}
// 判断文本是否像 JSON(自动摘要时跳过,避免卡片展示 JSON 片段)
protected function isJsonContent($text)
{
$text = ltrim($text);
if ($text === '' || ($text[0] !== '{' && $text[0] !== '[')) {
return false;
}
json_decode($text);
return json_last_error() === JSON_ERROR_NONE;
}
// 格式化创建时间(兼容 int / 数字字符串 / 日期字符串 / DateTime
public function getCreateTextAttr($value, $data)
{
$t = $data['create_at'] ?? $value;
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;
}
// 定义关联 - 作者(博客作者即主系统会员,统一使用 wxapp_user 表,主键 uid
public function author()
{
return $this->belongsTo(MemberUser::class, 'uid', 'uid');
}
// 兼容别名:部分视图/调用使用 user 关系
public function user()
{
return $this->belongsTo(MemberUser::class, 'uid', 'uid');
}
// 定义关联 - 分类
public function category()
{
return $this->belongsTo(BlogCategory::class, 'cid');
}
// 定义关联 - 标签(关联表 wxapp_blog_article_tag,列 aid/tid
public function tags()
{
return $this->belongsToMany(BlogTag::class, 'blog_article_tag', 'tid', 'aid');
}
// 定义关联 - 评论
public function comments()
{
return $this->hasMany(BlogComment::class, 'aid');
}
// 定义关联 - 点赞
public function likes()
{
return $this->hasMany(BlogLike::class, 'aid');
}
// 定义关联 - 收藏
public function favorites()
{
return $this->hasMany(BlogFavorite::class, 'aid');
}
// 增加浏览次数
public function incrementViewCount()
{
$this->view_count++;
$this->save();
// 清除相关缓存
Cache::delete('article_' . $this->id);
}
// 检查文章是否置顶
public function isTop()
{
return $this->is_top === 1;
}
// 检查文章是否发布
public function isPublished()
{
return $this->status === 1;
}
// 获取相关文章(同分类)
public function getRelatedArticles($limit = 5)
{
return self::where('cid', $this->cid)
->where('id', '<>', $this->id)
->where('status', 1)
->order('view_count', 'desc')
->limit($limit)
->select();
}
// 获取上一篇/下一篇文章
public function getPrevNextArticles()
{
$prev = self::where('id', '<', $this->id)
->where('status', 1)
->order('id', 'desc')
->find();
$next = self::where('id', '>', $this->id)
->where('status', 1)
->order('id', 'asc')
->find();
return [
'prev' => $prev,
'next' => $next,
];
}
}
+118
View File
@@ -0,0 +1,118 @@
<?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\model;
use ywxapp\model\BaseModel;
class BlogCategory extends BaseModel
{
// 设置数据表名
protected $name = 'blog_category';
// 设置主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = 'int';
protected $createTime = 'create_at';
protected $updateTime = 'update_at';
// 定义允许写入的字段
protected $allowField = [
'uid', 'pid', 'name', 'description', 'sort', 'status'
];
// 设置字段类型
protected $type = [
'uid' => 'int',
'pid' => 'int',
'sort' => 'int',
'status' => 'int',
'create_at' => 'int',
'update_at' => 'int'
];
// 定义关联 - 文章
public function articles()
{
return $this->hasMany(BlogArticle::class, 'cid');
}
// 定义关联 - 父级分类
public function parent()
{
return $this->belongsTo(self::class, 'pid');
}
// 定义关联 - 子分类
public function children()
{
return $this->hasMany(self::class, 'pid');
}
// 获取文章数量
public function getArticleCount()
{
return $this->articles()->where('status', 1)->count();
}
// 检查分类是否可用
public function isActive()
{
return $this->status === 1;
}
/**
* 获取某会员「可用」的分类:自己创建的(uid=$uid) + 全局共享的(uid=0)
* 用于会员中心写文章时的分类下拉。
*/
public static function getAvailable(int $uid)
{
return self::where('status', 1)
->where(function ($query) use ($uid) {
$query->where('uid', 0)->whereOr('uid', $uid);
})
->order('sort', 'asc')
->select();
}
// 获取分类树形结构
public static function getCategoryTree()
{
$categories = self::order('sort', 'asc')->select()->toArray();
return self::buildTree($categories);
}
// 递归构建树形结构
private static function buildTree($items, $parentId = 0)
{
$tree = [];
foreach ($items as $item) {
if (($item['pid'] ?? 0) == $parentId) {
$children = self::buildTree($items, $item['id']);
if ($children) {
$item['children'] = $children;
}
$tree[] = $item;
}
}
return $tree;
}
}
+147
View File
@@ -0,0 +1,147 @@
<?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\model;
use think\facade\Db;
use ywxapp\model\BaseModel;
use ywxapp\model\MemberUser;
class BlogComment extends BaseModel
{
// 设置数据表名
protected $name = 'blog_comment';
// 设置主键
protected $pk = 'id';
// 自动写入时间戳(int 型 Unix 时间戳,匹配 create_at/update_at int 列)
protected $autoWriteTimestamp = 'int';
protected $createTime = 'create_at';
protected $updateTime = 'update_at';
// 定义允许写入的字段
protected $allowField = [
'content', 'uid', 'aid', 'pid',
'status', 'like_count', 'ip', 'user_agent',
];
// 设置字段类型
protected $type = [
'id' => 'int',
'uid' => 'int',
'aid' => 'int',
'pid' => 'int',
'status' => 'int',
'like_count' => 'int',
'create_at' => 'int',
'update_at' => 'int',
];
// 内容修改器(过滤敏感词)
public function setContentAttr($value)
{
$sensitiveWords = ['垃圾', '广告', '违法', '政治'];
return str_replace($sensitiveWords, '***', $value);
}
// 定义关联 - 用户(博客用户即主系统会员,统一使用 wxapp_user 表,主键 uid
public function user()
{
return $this->belongsTo(MemberUser::class, 'uid', 'uid');
}
// 定义关联 - 文章
public function article()
{
return $this->belongsTo(BlogArticle::class, 'aid');
}
// 定义关联 - 子评论
public function children()
{
return $this->hasMany(BlogComment::class, 'pid');
}
// 定义关联 - 父评论
public function parent()
{
return $this->belongsTo(BlogComment::class, 'pid');
}
// 检查评论是否通过审核
public function isApproved()
{
return $this->status === 1;
}
// 格式化创建时间(兼容 int / 数字字符串 / 日期字符串 / DateTime
public function getCreateTextAttr($value, $data)
{
$t = $data['create_at'] ?? $value;
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;
}
// 获取评论树
public static function getCommentTree($articleId, $parentId = 0, $depth = 0)
{
$comments = self::with('member')
->where('aid', $articleId)
->where('pid', $parentId)
->where('status', 1)
->order('create_at', 'asc')
->select();
$tree = [];
foreach ($comments as $comment) {
$comment->depth = $depth;
$comment->children = self::getCommentTree($articleId, $comment->id, $depth + 1);
$tree[] = $comment;
}
return $tree;
}
// 删除评论及其子评论
public function deleteWithChildren()
{
// 删除子评论
$children = $this->children()->select();
foreach ($children as $child) {
$child->deleteWithChildren();
}
// 删除当前评论
$this->delete();
// 更新文章评论数
Db::name('blog_article')
->where('id', $this->aid)
->dec('comment_count')
->update();
}
}
+99
View File
@@ -0,0 +1,99 @@
<?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\model;
use ywxapp\model\BaseModel;
use ywxapp\model\MemberUser;
class BlogFavorite extends BaseModel
{
// 设置数据表名
protected $name = 'blog_favorite';
// 设置主键
protected $pk = 'id';
// 自动写入时间戳(int 型 Unix 时间戳,匹配 create_at int 列)
protected $autoWriteTimestamp = 'int';
protected $createTime = 'create_at';
protected $updateTime = false;
// 定义允许写入的字段
protected $allowField = [
'uid', 'aid'
];
// 设置字段类型
protected $type = [
'id' => 'int',
'uid' => 'int',
'aid' => 'int',
'create_at' => 'int'
];
// 定义关联 - 用户(博客用户即主系统会员,统一使用 wxapp_user,主键 uid
public function user()
{
return $this->belongsTo(MemberUser::class, 'uid', 'uid');
}
// 定义关联 - 文章
public function article()
{
return $this->belongsTo(BlogArticle::class, 'aid');
}
// 检查用户是否已收藏
public static function hasFavorited($userId, $articleId)
{
return self::where('uid', $userId)
->where('aid', $articleId)
->count() > 0;
}
// 切换收藏状态
public static function toggle($userId, $articleId)
{
$favorite = self::where('uid', $userId)
->where('aid', $articleId)
->find();
if ($favorite) {
$favorite->delete();
return false; // 取消收藏
} else {
$favorite = new self();
$favorite->uid = $userId;
$favorite->aid = $articleId;
$favorite->save();
return true; // 添加收藏
}
}
// 获取用户收藏列表
public static function getUserFavorites($userId, $page = 1, $pageSize = 10)
{
return self::with(['article' =>
function($query) {
$query->with('author,category')->where('status', 1);
}])
->where('uid', $userId)
->order('create_at', 'desc')
->page($page, $pageSize)
->select();
}
}
+85
View File
@@ -0,0 +1,85 @@
<?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\model;
use ywxapp\model\BaseModel;
use ywxapp\model\MemberUser;
class BlogLike extends BaseModel
{
// 设置数据表名
protected $name = 'blog_like';
// 设置主键
protected $pk = 'id';
// 自动写入时间戳(int 型 Unix 时间戳,匹配 create_at int 列)
protected $autoWriteTimestamp = 'int';
protected $createTime = 'create_at';
protected $updateTime = false;
// 定义允许写入的字段
protected $allowField = [
'uid', 'aid'
];
// 设置字段类型
protected $type = [
'id' => 'int',
'uid' => 'int',
'aid' => 'int',
'create_at' => 'int'
];
// 定义关联 - 用户(博客用户即主系统会员,统一使用 wxapp_user,主键 uid
public function user()
{
return $this->belongsTo(MemberUser::class, 'uid', 'uid');
}
// 定义关联 - 文章
public function article()
{
return $this->belongsTo(BlogArticle::class, 'aid');
}
// 检查用户是否已点赞
public static function hasLiked($userId, $articleId)
{
return self::where('uid', $userId)
->where('aid', $articleId)
->count() > 0;
}
// 切换点赞状态
public static function toggle($userId, $articleId)
{
$like = self::where('uid', $userId)
->where('aid', $articleId)
->find();
if ($like) {
$like->delete();
return false; // 取消点赞
} else {
$like = new self();
$like->uid = $userId;
$like->aid = $articleId;
$like->save();
return true; // 添加点赞
}
}
}
+78
View File
@@ -0,0 +1,78 @@
<?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\model;
use ywxapp\model\BaseModel;
class BlogTag extends BaseModel
{
// 设置数据表名
protected $name = 'blog_tag';
// 设置主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = 'int';
protected $createTime = 'create_at';
protected $updateTime = 'update_at';
// 定义允许写入的字段
protected $allowField = [
'name', 'description'
];
// 设置字段类型
protected $type = [
'id' => 'int',
'create_at' => 'int',
'update_at' => 'int'
];
// 定义关联 - 文章
public function articles()
{
return $this->belongsToMany(BlogArticle::class, 'blog_article_tag', 'aid', 'tid');
}
// 获取文章数量
public function getArticleCount()
{
return $this->articles()->where('status', 1)->count();
}
// 获取热门标签
public static function getHotTags($limit = 20)
{
return self::withCount('articles')
->having('articles_count > 0')
->order('articles_count', 'desc')
->limit($limit)
->select();
}
// 根据名称查找或创建标签
public static function findOrCreate($name)
{
$tag = self::where('name', $name)->find();
if (!$tag) {
$tag = new self();
$tag->name = $name;
$tag->save();
}
return $tag;
}
}
+123
View File
@@ -0,0 +1,123 @@
<?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\model;
use ywxapp\model\BaseModel;
class BlogVisit extends BaseModel
{
// 设置数据表名
protected $name = 'blog_visit';
// 设置主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = 'int';
protected $createTime = 'create_at';
protected $updateTime = false;
// 定义允许写入的字段
protected $allowField = [
'ip', 'url', 'referer', 'user_agent'
];
// 设置字段类型
protected $type = [
'create_at' => 'int'
];
// 获取客户端IP地址
public static function getClientIp()
{
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
$ip = trim($ips[0]);
}
return $ip;
}
// 记录访问
public static function record($url, $referer = '', $userAgent = '')
{
$visit = new self();
$visit->ip = self::getClientIp();
$visit->url = $url;
$visit->referer = $referer ?: request()->server('HTTP_REFERER', '');
$visit->user_agent = $userAgent ?: request()->server('HTTP_USER_AGENT', '');
$visit->save();
}
// 获取访问统计
public static function getStats($days = 7)
{
$startTime = time() - ($days * 86400);
// 总访问量
$total = self::where('create_at', '>=', $startTime)->count();
// 独立访客
$uniqueVisitors = self::where('create_at', '>=', $startTime)
->distinct(true)
->field('ip')
->count();
// 热门页面
$hotPages = self::where('create_at', '>=', $startTime)
->field('url, COUNT(*) as count')
->group('url')
->order('count', 'desc')
->limit(10)
->select();
// 访问趋势
$trend = [];
for ($i = 0; $i < $days; $i++) {
$date = date('Y-m-d', time() - ($i * 86400));
$count = self::whereDay('create_at', $date)->count();
$trend[$date] = $count;
}
return [
'total' => $total,
'unique_visitors' => $uniqueVisitors,
'hot_pages' => $hotPages,
'trend' => $trend
];
}
// 获取真实IP(考虑代理)
public static function getRealIp()
{
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (isset($_SERVER['HTTP_CLIENT_IP']) && filter_var($_SERVER['HTTP_CLIENT_IP'], FILTER_VALIDATE_IP)) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
foreach ($ips as $candidate) {
$candidate = trim($candidate);
if (filter_var($candidate, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
$ip = $candidate;
break;
}
}
}
return $ip;
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
use think\facade\Route;
// 前台文章路由
// 变量/具体路由写在通用 article(index) 之前,避免静态短规则前缀匹配吞掉详情页
Route::get('article/:id', 'Article/detail');
Route::get('article/:id/comments', 'Article/getComments');
Route::post('article/like/:id', 'Article/like');
Route::post('article/favorite/:id', 'Article/favorite');
Route::get('article', 'Article/index');
// 作者主页(按作者筛选已发布文章)
Route::get('user/:id', 'Article/author');
// 评论路由
Route::post('comment/add', 'Comment/add');
Route::post('comment/delete/:id', 'Comment/delete');
Route::post('comment/like/:id', 'Comment/like');
// 互动路由
Route::post('interaction/like', 'Interaction/like');
Route::post('interaction/favorite', 'Interaction/favorite');
Route::post('interaction/share', 'Interaction/share');
Route::get('interaction/status/:article_id', 'Interaction/getUserInteractions');
Route::get('interaction/hot', 'Interaction/getHotInteractions');
// 首页路由(注:本文件被 loadAddonRoutes() 包在 Route::group('blog') 内,
// 因此这里用空规则 '' 会被加成 /blog,而非带前导 / 的绝对规则(那样会变成根 /))
Route::get('', 'Index/index');
Route::get('category/index', 'Index/category');
Route::get('category', 'Index/category');
Route::get('category/:id', 'Index/category');
Route::get('tag/index', 'Index/tag');
Route::get('tag', 'Index/tag');
Route::get('tag/:id', 'Index/tag');
Route::get('search', 'Index/search');
Route::get('nearby', 'Index/nearby');
Route::get('about', 'Index/about');
Route::get('contact', 'Index/contact');
// RSS路由
Route::get('rss', 'Rss/index');
Route::get('rss/reader', 'Rss/reader');
// 其他路由...
// 后台路由
// 说明:TP6 合并正则会把短规则(如 article/category/setting 的 index)优先匹配,
// 吞掉更长的子规则(article/edit/:id、category/add、setting/site 等),
// 因此每条具体路由都写在对应的 index 路由之前。
Route::group('backend', function () {
// 插件后台入口
Route::get('/', 'backend.Index/index');
Route::get('index', 'backend.Index/index');
// 仪表盘
Route::get('dashboard', 'backend.Dashboard/index');
// 文章管理(多用户:后台仅管理/审核,不发布新文章)
Route::get('article/edit/:id', 'backend.Article/edit');
Route::post('article/update/:id', 'backend.Article/update');
Route::get('article/delete/:id', 'backend.Article/delete');
Route::post('article/batch', 'backend.Article/batch');
Route::post('article/audit/:id', 'backend.Article/audit');
Route::post('article/reject/:id', 'backend.Article/reject');
Route::get('article', 'backend.Article/index');
// 分类管理
Route::get('category/add', 'backend.Category/add');
Route::post('category/save', 'backend.Category/save');
Route::get('category/edit/:id', 'backend.Category/edit');
Route::post('category/update/:id', 'backend.Category/update');
Route::get('category/delete/:id', 'backend.Category/delete');
Route::get('category', 'backend.Category/index');
// 标签管理
Route::get('tag/add', 'backend.Tag/add');
Route::post('tag/save', 'backend.Tag/save');
Route::get('tag/edit/:id', 'backend.Tag/edit');
Route::post('tag/update/:id', 'backend.Tag/update');
Route::get('tag/delete/:id', 'backend.Tag/delete');
Route::post('tag/batchDelete', 'backend.Tag/batchDelete');
Route::get('tag', 'backend.Tag/index');
// 评论管理
Route::post('comment/approve/:id', 'backend.Comment/approve');
Route::post('comment/reject/:id', 'backend.Comment/reject');
Route::get('comment/delete/:id', 'backend.Comment/delete');
Route::post('comment/batch', 'backend.Comment/batch');
Route::get('comment', 'backend.Comment/index');
// 用户管理(管理全站用户,与博客文章通过 uid 关联)
Route::get('user/edit/:id', 'backend.User/edit');
Route::post('user/update/:id', 'backend.User/update');
Route::get('user/delete/:id', 'backend.User/delete');
Route::post('user/batch', 'backend.User/batch');
Route::get('member', 'backend.User/index');
// AdminAuth
Route::get('stat', 'backend.Stat/index');
// 系统设置
Route::get('setting/site', 'backend.Setting/site');
Route::post('setting/site', 'backend.Setting/site');
Route::get('setting/seo', 'backend.Setting/seo');
Route::post('setting/seo', 'backend.Setting/seo');
Route::get('setting/email', 'backend.Setting/email');
Route::post('setting/email', 'backend.Setting/email');
Route::get('setting', 'backend.Setting/index');
});
// ============================================================
// 会员中心路由(已启用)
// 控制器放 addon/blog/controller/member/ 下,继承 \ywxapp\controller\AddonMember
// 未登录访问会自动跳转 /member/login
// ============================================================
Route::group('member', function () {
Route::get('/', 'member.Index/index');
Route::get('profile', 'member.Index/profile');
// 会员中心文章管理(多用户:文章在此发布/管理)
// 注意:具体路由必须写在通用 index 路由之前,否则 TP6 合并正则会把
// article/create、article/edit/:id 等长规则误匹配成 article(index)。
Route::get('article/create', 'member.Article/create');
Route::get('article/edit/:id', 'member.Article/edit');
Route::post('article/save', 'member.Article/save');
Route::post('article/update/:id', 'member.Article/update');
Route::get('article/delete/:id', 'member.Article/delete');
Route::post('article/batch', 'member.Article/batch');
Route::get('article', 'member.Article/index');
// 会员中心分类管理(用户自建分类)
Route::get('category/create', 'member.Category/create');
Route::get('category/edit/:id', 'member.Category/edit');
Route::post('category/save', 'member.Category/save');
Route::post('category/update/:id', 'member.Category/update');
Route::get('category/delete/:id', 'member.Category/delete');
Route::post('category/batch', 'member.Category/batch');
Route::get('category', 'member.Category/index');
});
dump(app()->route);
+239
View File
@@ -0,0 +1,239 @@
<?php
/**
* BlogSearchService —— 博客文章检索(ES 增强,默认 LIKE 降级)
*/
declare(strict_types=1);
namespace addon\blog\service;
use addon\blog\model\BlogArticle;
use think\facade\Db;
use ywxapp\library\Search\EsSearch;
class BlogSearchService
{
protected static function conf(): array
{
return [
'engine' => config('blog.search_engine', 'like'),
'host' => config('blog.es_host', 'http://127.0.0.1:9200'),
'index' => config('blog.es_index', 'ywxapp_blog'),
];
}
/**
* 前台搜索:status=1 + audit_status=2 通过的文章
* @return \think\Paginator
*/
public static function search(string $kw, int $page = 1, int $size = 12)
{
$kw = trim($kw);
if ($kw === '') {
return BlogArticle::with(['user', 'category'])
->where('status', 1)->where('audit_status', 2)
->order('create_at', 'desc')->paginate($size, false, ['page' => $page]);
}
$cfg = self::conf();
if ($cfg['engine'] === 'es') {
try {
EsSearch::ensureIndex($cfg['host'], $cfg['index'], ['title', 'content', 'summary']);
$ids = EsSearch::search($cfg['host'], $cfg['index'], $kw, ['title', 'content', 'summary'], 200);
if (!empty($ids)) {
$list = BlogArticle::with(['user', 'category'])
->where('status', 1)
->where('audit_status', 2)
->whereIn('id', $ids)
->select()
->all();
$order = array_flip($ids);
$list = collect($list)->sortBy(function ($m) use ($order) {
return $order[$m->id] ?? PHP_INT_MAX;
})->values();
return new \think\Paginator($list, $size, $page);
}
} catch (\RuntimeException $e) {
// 降级 LIKE
}
}
return BlogArticle::with(['user', 'category'])
->where('title|content|summary', 'like', '%' . $kw . '%')
->where('status', 1)
->where('audit_status', 2)
->order('create_at', 'desc')
->paginate($size, false, ['page' => $page]);
}
/**
* 后台/会员中心文章搜索(按 uid 可选限定)
*/
public static function searchAdmin(string $kw, int $page = 1, int $size = 15, int $uid = 0)
{
$kw = trim($kw);
$query = BlogArticle::with(['user', 'category']);
if ($uid > 0) {
$query->where('uid', $uid);
}
if ($kw === '') {
return $query->order('create_at', 'desc')->paginate($size, false, ['page' => $page]);
}
$cfg = self::conf();
if ($cfg['engine'] === 'es') {
try {
EsSearch::ensureIndex($cfg['host'], $cfg['index'], ['title', 'content', 'summary']);
$ids = EsSearch::search($cfg['host'], $cfg['index'], $kw, ['title', 'content', 'summary'], 200);
if (!empty($ids)) {
$q = BlogArticle::with(['user', 'category'])->whereIn('id', $ids);
if ($uid > 0) {
$q->where('uid', $uid);
}
$list = $q->select()->all();
$order = array_flip($ids);
$list = collect($list)->sortBy(fn($m) => $order[$m->id] ?? PHP_INT_MAX)->values();
return new \think\Paginator($list, $size, $page);
}
} catch (\RuntimeException $e) {
}
}
if ($uid > 0) {
$query->where('uid', $uid);
}
return $query->where('title|content', 'like', '%' . $kw . '%')
->order('create_at', 'desc')
->paginate($size, false, ['page' => $page]);
}
/**
* 同步单条到 ES
*/
public static function sync(int $articleId): void
{
$cfg = self::conf();
if ($cfg['engine'] !== 'es') {
return;
}
try {
$a = BlogArticle::find($articleId);
if (!$a) {
return;
}
EsSearch::ensureIndex($cfg['host'], $cfg['index'], ['title', 'content', 'summary']);
if ((int) $a->status !== 1 || (int) $a->audit_status !== 2) {
EsSearch::deleteDoc($cfg['host'], $cfg['index'], $articleId);
return;
}
EsSearch::indexDoc($cfg['host'], $cfg['index'], $articleId, [
'title' => $a->title,
'content' => $a->content,
'summary' => $a->summary,
]);
} catch (\RuntimeException $e) {
}
}
public static function remove(int $articleId): void
{
$cfg = self::conf();
if ($cfg['engine'] !== 'es') {
return;
}
try {
EsSearch::deleteDoc($cfg['host'], $cfg['index'], $articleId);
} catch (\RuntimeException $e) {
}
}
/**
* 相关文章推荐:优先 ES more_like_this,未启用 ES 时降级为「同分类 + 标签重叠」
* @return \addon\blog\model\BlogArticle[]
*/
public static function related(int $articleId, int $limit = 6): array
{
$article = BlogArticle::find($articleId);
if (!$article) {
return [];
}
$cfg = self::conf();
if ($cfg['engine'] === 'es') {
try {
EsSearch::ensureIndex($cfg['host'], $cfg['index'], ['title', 'content', 'summary']);
$ids = EsSearch::moreLikeThis(
$cfg['host'], $cfg['index'],
$article->title . "\n" . $article->summary . "\n" . $article->content,
['title', 'content', 'summary'], $articleId, $limit
);
if (!empty($ids)) {
return BlogArticle::with(['user', 'category'])
->where('status', 1)
->where('audit_status', 2)
->whereIn('id', $ids)
->select()
->all();
}
} catch (\RuntimeException $e) {
// 降级
}
}
// 降级:同分类最新文章(或标签重叠)
$tagIds = $article->tags()->column('id');
if (!empty($tagIds)) {
$rel = BlogArticle::with(['user', 'category'])
->alias('a')
->join('blog_article_tag t', 't.article_id = a.id')
->where('a.id', '<>', $articleId)
->where('a.status', 1)
->where('a.audit_status', 2)
->whereIn('t.tag_id', $tagIds)
->order('a.create_at', 'desc')
->limit($limit)
->select()
->all();
if ($rel->count() >= $limit) {
return $rel->all();
}
}
return BlogArticle::with(['user', 'category'])
->where('id', '<>', $articleId)
->where('status', 1)
->where('audit_status', 2)
->where('cid', $article->cid)
->order('create_at', 'desc')
->limit($limit)
->select()
->all();
}
/**
* 附近文章(GEO/LBS):Haversine 球面距离过滤,零外部依赖
* @param float $lat 纬度
* @param float $lng 经度
* @param float $radiusKm 半径(千米),默认 10
* @param int $limit 返回数量
* @return \addon\blog\model\BlogArticle[]
*/
public static function nearby(float $lat, float $lng, float $radiusKm = 10.0, int $limit = 20): array
{
if ($lat == 0.0 && $lng == 0.0) {
return [];
}
$lat = (float) $lat;
$lng = (float) $lng;
$R = 6371;
return BlogArticle::with(['user', 'category'])
->where('status', 1)
->where('audit_status', 2)
->where('lat', '<>', 0)
->where('lng', '<>', 0)
->fieldRaw("*, ({$R} * acos(cos(radians({$lat})) * cos(radians(lat)) * cos(radians(lng) - radians({$lng})) + sin(radians({$lat})) * sin(radians(lat)))) AS distance_km")
->having('distance_km <= ' . floatval($radiusKm))
->order('distance_km', 'asc')
->limit($limit)
->select()
->all();
}
}
+145
View File
@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>公众号式文章编辑器</title>
<!-- 引入 Layui 样式 -->
<link rel="stylesheet" href="/assets/layui/css/layui.css" />
<link rel="stylesheet" href="/assets/ywxapp/css/ywxapp.css" />
<!-- 引入 UEditorPlus 核心 -->
<script type="text/javascript" src=" /assets/library/ueditor/ueditor.config.js"></script>
<script type="text/javascript" src="/assets/library/ueditor/ueditor.all.js"></script>
<style>
</style>
</head>
<body class=" layui-container">
<!-- 核心编辑区 -->
<form class="layui-form action="">
<!-- 标题输入 -->
<div class="layui-form-item">
<div class="layui-input-wrap">
<input type="text" name="title" autocomplete="off" placeholder="请在这里输入标题(最多64字)" lay-verify="required" class="layui-input" maxlength="64">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-wrap">
<input type="text" name="author" autocomplete="off" placeholder="请输入作者(最多8字)" maxlength="8" lay-verify="required" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-wrap">
<script id="editor" type="text/plain" style="height: 450px;"></script>
</div>
</div>
<!-- 功能按钮:保存/预览/发表 -->
<div class="function-buttons">
<button class="layui-btn layui-btn-normal" id="saveDraft">保存为草稿</button>
<button class="layui-btn layui-btn-primary" id="preview">预览</button>
<button class="layui-btn layui-btn-danger" id="publish">发表</button>
</div>
</form>
<!-- 引入 Layui 脚本 -->
<script src="/assets/layui/layui.js"></script>
<script src="/assets/ywxapp/ywxapp.js" module="backend"></script>
<script>
// 初始化 Layui
layui.use(["jquery", "form"], function () {
var $ = layui.jquery;
var form = layui.form;
// 1. 初始化 UEditorPlus(富文本编辑器)
var ue = UE.getEditor("editor", {
toolbars: [
[
"undo", // 撤销
"redo", "|", // 重做
"removeformat", // 清除格式
"formatmatch", // 格式刷
"|",
"fontsize", // 字号
"bold", // 加粗
"italic", // 斜体
"underline", // 下划线
"strikethrough",// 删除线
"forecolor", // 字体颜色
"backcolor", // 背景色
"|",
"justifyleft", // 居左对齐
"justifycenter", // 居中对齐
"justifyright",
"justifyjustify", // 两端对齐
"indent", // 首行缩进
"|",
"inserttable", // 插入表格
"blockquote", // 引用
"horizontal",
"insertcode", // 插入代码
"emotion", // 表情
"|",
"ai", // AI智能
"|",
"print", // 打印
"preview", // 预览
"searchreplace", // 查询替换
]
],
autoHeightEnabled: true,
initialFrameWidth: "100%",
initialFrameHeight: 450,
});
// 2. 功能按钮事件绑定
// 保存草稿
$("#saveDraft").on("click", function () {
var title = $(".editor-title input").val().trim();
var author = $(".editor-author input").val().trim();
var content = ue.getContent(); // 获取编辑器内容
// 这里对接后端 API 保存草稿(示例仅打印)
console.log("保存草稿:", { title, author, content });
layui.layer.msg("草稿已保存!", { icon: 1 });
});
// 预览
$("#preview").on("click", function () {
// 实际需打开新窗口渲染内容,这里模拟
var previewContent = `标题:${$(".editor-title input").val()}\n作者:${$(".editor-author input").val()}\n内容:${ue.getContent()}`;
layui.layer.open({
type: 1,
title: "预览",
content: `<p>${previewContent}</p>`,
area: ["500px", "400px"],
});
});
// 发表
$("#publish").on("click", function () {
var title = $(".editor-title input").val().trim();
var author = $(".editor-author input").val().trim();
var content = ue.getContent();
// 对接后端 API 发表文章(示例仅打印)
console.log("发表文章:", { title, author, content });
layui.layer.msg("文章已发表!", { icon: 1 });
});
// 3. 表单开关事件(原创/赞赏/留言等)
form.on("switch()", function (data) {
var label = data.othis.parents("div").prev("label").text();
console.log(`${label} 状态:`, data.elem.checked);
});
});
</script>
</body>
</html>
+129
View File
@@ -0,0 +1,129 @@
<div class="layui-container">
<div class="admin-title">编辑文章</div>
<div class="layui-card">
<div class="layui-card-body">
<form class="layui-form" id="articleForm" action="{:url('blog/backend.article/update', ['id' => $article.id])}" method="post">
<div class="layui-form-item">
<label class="layui-form-label">文章标题</label>
<div class="layui-input-block">
<input type="text" name="title" value="{$article.title}" required lay-verify="required" placeholder="请输入文章标题" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">文章分类</label>
<div class="layui-input-block">
<select name="cid" lay-verify="required">
<option value="">请选择分类</option>
{volist name="categories" id="category"}
<option value="{$category.id}" {if $article.cid==$category.id}selected{/if}>{$category.name}</option>
{/volist}
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">文章标签</label>
<div class="layui-input-block">
<input type="text" name="tags" value="{$tagNamesStr}" placeholder="多个标签用逗号分隔" autocomplete="off" class="layui-input">
<div class="layui-form-mid layui-word-aux">例如:PHP,ThinkPHP,编程</div>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">文章摘要</label>
<div class="layui-input-block">
<textarea name="summary" class="layui-textarea" placeholder="选填,留空将自动截取正文前 150 字" style="max-width:600px;">{$article.summary}</textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">封面图</label>
<div class="layui-input-block">
<button type="button" class="layui-btn layui-btn-normal" id="uploadCover">
<i class="layui-icon"></i>上传图片
</button>
<input type="hidden" name="cover_image" id="coverImage" value="{$article.cover_image}">
<div class="layui-word-aux">建议尺寸:800x450</div>
<div id="coverPreview" style="margin-top:10px;">
{if $article.cover_image}
<img src="{$article.cover_image}" style="max-width:200px;max-height:150px;border:1px solid #eee;">
{/if}
</div>
</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="发布" {if $article.status==1}checked{/if}>
<input type="radio" name="status" value="0" title="草稿" {if $article.status==0}checked{/if}>
<input type="checkbox" name="is_top" value="1" title="置顶" {if $article.is_top==1}checked{/if}>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">文章内容</label>
<div class="layui-input-block">
<textarea id="editor" name="content" style="display:none;">{$article.content}</textarea>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="articleSubmit">更新文章</button>
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</form>
</div>
</div>
</div>
<script src="/assets/lib/wangEditor/wangEditor.min.js"></script>
<script>
layui.use(['form', 'layer', 'upload'], function(){
var form = layui.form;
var layer = layui.layer;
var upload = layui.upload;
var $ = layui.$;
// 初始化富文本编辑器
var editor = new window.wangEditor('#editor');
editor.config.uploadImgServer = '{:url("upload/image")}';
editor.config.uploadFileName = 'image';
editor.config.zIndex = 1000;
editor.create();
// 封面图上传
upload.render({
elem: '#uploadCover',
url: '{:url("upload/image")}',
done: function(res){
if(res.code === 1){
$('#coverImage').val(res.data.url);
$('#coverPreview').html('<img src="'+res.data.url+'" style="max-width:200px;max-height:150px;border:1px solid #eee;">');
} else {
layer.msg(res.msg, {icon: 2});
}
}
});
// 监听提交
form.on('submit(articleSubmit)', function(data){
data.field.content = editor.txt.html();
$.post(data.form.action, data.field, function(res){
if(res.code === 1){
layer.msg(res.msg, {icon: 1}, function(){
location.href = res.url;
});
} else {
layer.msg(res.msg, {icon: 2});
}
}, 'json');
return false;
});
});
</script>
+154
View File
@@ -0,0 +1,154 @@
<div class="layui-fluid">
<div class="admin-title">文章管理</div>
<div class="layui-card">
<div class="layui-card-body">
<form class="layui-form layui-inline" lay-filter="searchForm">
<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">
<select name="audit">
<option value="all">全部审核</option>
<option value="1">待审</option>
<option value="2">通过</option>
<option value="3">驳回</option>
</select>
</div>
<div class="layui-inline">
<input type="text" name="keyword" placeholder="搜索标题/内容" class="layui-input" id="keywordInput">
</div>
<div class="layui-inline">
<button class="layui-btn layui-btn-primary" lay-submit lay-filter="search">搜索</button>
</div>
<button type="button" class="layui-btn layui-btn-danger" id="batchDeleteBtn">批量删除</button>
</form>
<table class="layui-hide" id="articleTable" lay-filter="articleTable"></table>
</div>
</div>
</div>
<script type="text/html" id="rowTpl">
<a class="layui-btn layui-btn-xs" href="{{ d.edit_url }}">编辑</a>
{{# if (d.audit_status == 1) { }}
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="audit">通过</a>
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="reject">驳回</a>
{{# } }}
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="delete">删除</a>
</script>
<script>
layui.use(['table', 'layer', 'form'], function () {
var table = layui.table;
var layer = layui.layer;
var form = layui.form;
var $ = layui.$;
var status = '{$status|default="all"}';
var audit = '{$audit|default="all"}';
var keyword = '{$keyword|default=""}';
$('select[name="status"]').val(status);
$('select[name="audit"]').val(audit);
$('#keywordInput').val(keyword);
var renderTable = function () {
table.render({
elem: '#articleTable',
url: '{:url("blog/backend.article/index")}',
where: { status: status, audit: audit, keyword: keyword },
page: true,
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'title', title: '标题', minWidth: 220, templet: function (d) {
var html = '<a href="' + d.edit_url + '">' + d.title + '</a>';
if (d.is_top == 1) { html += ' <span class="layui-badge layui-bg-red">置顶</span>'; }
return html;
} },
{ field: 'author', title: '作者', width: 120 },
{ field: 'category', title: '分类', width: 120 },
{ field: 'tags', title: '标签', width: 150, templet: function (d) { return d.tags || '-'; } },
{ 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: 'audit_status', title: '审核', width: 90, templet: function (d) {
var map = { 0: '<span class="layui-badge">草稿</span>', 1: '<span class="layui-badge layui-bg-orange">待审</span>', 2: '<span class="layui-badge layui-bg-green">通过</span>', 3: '<span class="layui-badge layui-bg-red">驳回</span>' };
return map[d.audit_status] || '-';
} },
{ field: 'view_count', title: '阅读', width: 80, sort: true, align: 'center' },
{ field: 'comment_count', title: '评论', width: 80, sort: true, align: 'center' },
{ field: 'like_count', title: '点赞', width: 80, sort: true, align: 'center' },
{ field: 'favorite_count', title: '收藏', width: 80, sort: true, align: 'center' },
{ field: 'share_count', title: '分享', width: 80, sort: true, align: 'center' },
{ field: 'create_text', title: '发布时间', width: 160 },
{ title: '操作', width: 130, align: 'center', toolbar: '#rowTpl', fixed: 'right' }
]]
});
};
renderTable();
// 搜索
form.on('submit(search)', function () {
var statusVal = $('select[name="status"]').val();
var auditVal = $('select[name="audit"]').val();
var kw = $('#keywordInput').val();
table.reload('articleTable', { where: { status: statusVal, audit: auditVal, keyword: kw }, page: { curr: 1 } });
return false;
});
// 批量删除
$('#batchDeleteBtn').click(function () {
var checkData = table.checkStatus('articleTable').data;
if (! checkData.length) {
return layer.msg('请选择要删除的文章');
}
var ids = checkData.map(function (i) { return i.id; });
layer.confirm('确定要删除选中的 ' + ids.length + ' 篇文章吗?', function (index) {
$.post('{:url("blog/backend.article/batch")}', { action: 'delete', ids: ids }, function (res) {
if (res.code === 1) {
layer.msg(res.msg, { icon: 1 }, function () { renderTable(); });
} else {
layer.msg(res.msg, { icon: 2 });
}
});
layer.close(index);
});
});
// 行操作
table.on('tool(articleTable)', function (obj) {
if (obj.event === 'audit') {
layer.confirm('确认审核通过该文章?', function (index) {
$.post('{:url("blog/backend.article/audit")}', { id: obj.data.id }, function (res) {
layer.msg(res.msg, { icon: res.code === 1 ? 1 : 2 }, function () { renderTable(); });
});
layer.close(index);
});
} else if (obj.event === 'reject') {
layer.prompt({ title: '驳回理由', formType: 2 }, function (val, index) {
$.post('{:url("blog/backend.article/reject")}', { id: obj.data.id, reason: val }, function (res) {
layer.close(index);
layer.msg(res.msg, { icon: res.code === 1 ? 1 : 2 }, function () { renderTable(); });
});
});
} else if (obj.event === 'delete') {
layer.confirm('确定要删除这篇文章吗?', function (index) {
$.post('{:url("blog/backend.article/delete")}', { id: obj.data.id }, function (res) {
if (res.code === 1) {
layer.msg(res.msg, { icon: 1 }, function () { renderTable(); });
} else {
layer.msg(res.msg, { icon: 2 });
}
});
layer.close(index);
});
}
});
});
</script>
+59
View File
@@ -0,0 +1,59 @@
<div class="layui-container">
<div class="admin-title">添加分类</div>
<div class="layui-card">
<div class="layui-card-body">
<form class="layui-form" action="{:url('blog/backend.category/save')}" method="post">
<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">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">上级分类</label>
<div class="layui-input-block">
<select name="pid">
<option value="0">顶级分类</option>
{volist name="categories" id="c"}
<option value="{$c.id}">{$c.name}</option>
{/volist}
</select>
</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"></textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">排序</label>
<div class="layui-input-block">
<input type="number" name="sort" value="0" placeholder="请输入排序值" autocomplete="off" class="layui-input">
<div class="layui-form-mid layui-word-aux">数值越小越靠前</div>
</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="正常" checked>
<input type="radio" name="status" value="0" title="禁用">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="categorySubmit">添加分类</button>
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</form>
</div>
</div>
</div>
@@ -0,0 +1,59 @@
<div class="layui-container">
<div class="admin-title">编辑分类</div>
<div class="layui-card">
<div class="layui-card-body">
<form class="layui-form" action="{:url('blog/backend.category/update', ['id' => $category.id])}" method="post">
<div class="layui-form-item">
<label class="layui-form-label">分类名称</label>
<div class="layui-input-block">
<input type="text" name="name" value="{$category.name}" required lay-verify="required" placeholder="请输入分类名称" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">上级分类</label>
<div class="layui-input-block">
<select name="pid">
<option value="0" {if $category.pid==0}selected{/if}>顶级分类</option>
{volist name="categories" id="c"}
<option value="{$c.id}" {if $category.pid==$c.id}selected{/if}>{$c.name}</option>
{/volist}
</select>
</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">{$category.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" value="{$category.sort}" placeholder="请输入排序值" autocomplete="off" class="layui-input">
<div class="layui-form-mid layui-word-aux">数值越小越靠前</div>
</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="正常" {if $category.status==1}checked{/if}>
<input type="radio" name="status" value="0" title="禁用" {if $category.status==0}checked{/if}>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="categorySubmit">更新分类</button>
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</form>
</div>
</div>
</div>
+228
View File
@@ -0,0 +1,228 @@
<div class="layui-card">
<div class="layui-card-header">
<button class="layui-btn layui-btn-sm" id="addRoot">添加根分类</button>
<button class="layui-btn layui-btn-sm layui-btn-danger" id="batchDelete">批量删除</button>
<button class="layui-btn layui-btn-sm" id="expandAll">展开全部</button>
<button class="layui-btn layui-btn-sm" id="foldAll">折叠全部</button>
</div>
<div class="layui-card-body">
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
</div>
</div>
<!-- 行操作模板 -->
<script type="text/html" id="dataBarTpl">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="addChild">添加子分类</a>
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="delete">删除</a>
</script>
<!-- 新增/编辑 表单模板(弹窗内渲染) -->
<script type="text/html" id="dataFormTpl">
<div style="padding:15px;">
<form class="layui-form" lay-filter="dataForm">
<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" value="{{ d.name || '' }}" required lay-verify="required" placeholder="请输入分类名称" autocomplete="off" class="layui-input">
</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">
<select name="pid" id="pidSelect"></select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">排序</label>
<div class="layui-input-block">
<input type="number" name="sort" value="{{ d.sort || 0 }}" placeholder="数值越小越靠前" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<input type="checkbox" name="status" value="1" {{ d.status == 1 ? 'checked' : '' }} lay-skin="switch" lay-text="正常|禁用">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="dataFormSubmit" id="dataFormSubmit">保存</button>
<button type="button" class="layui-btn layui-btn-primary" id="cancelBtn">取消</button>
</div>
</div>
</form>
</div>
</script>
<script>
layui.use(['treeTable', 'layer', 'form', 'laytpl', 'http'], function () {
var treeTable = layui.treeTable;
var layer = layui.layer;
var $ = layui.$;
var form = layui.form;
var laytpl = layui.laytpl;
var http = layui.http;
var treeData = {$treeData|raw};
var pmap = {};
treeData.forEach(function (c) { pmap[c.id] = c; });
// 构建上级分类下拉(编辑时排除自身及其子孙节点,避免形成环)
function buildPidOptions(selfId) {
var html = '<option value="0">顶级分类</option>';
treeData.forEach(function (c) {
if (selfId && c.id === selfId) return;
if (selfId) {
var cur = pmap[c.id];
while (cur && cur.pid) {
if (cur.pid == selfId) return;
cur = pmap[cur.pid];
}
}
html += '<option value="' + c.id + '">' + c.title + '</option>';
});
return html;
}
var renderTable = function () {
treeTable.render({
elem: '#dataTable',
data: treeData,
tree: {
customName: {
isParent: "is_parent",
name: "title",
id: "id",
pid: "pid"
},
data: { isSimpleData: true, rootPid: 0 },
view: { showIcon: false }
},
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 80 },
{ field: 'title', title: '分类名称', minWidth: 160 },
{ field: 'owner', title: '归属', width: 150 },
{ field: 'description', title: '描述', minWidth: 160 },
{ field: 'articles_count', title: '文章数', width: 90 },
{ field: 'sort', title: '排序', width: 80, sort: true },
{
field: 'status', title: '状态', width: 90, templet: function (d) {
return d.status == 1
? '<span class="layui-badge layui-bg-blue">正常</span>'
: '<span class="layui-badge layui-bg-gray">禁用</span>';
}
},
{ title: '操作', width: 210, align: 'center', toolbar: '#dataBarTpl' }
]],
page: false,
skin: 'line'
});
};
renderTable();
// 展开 / 折叠全部
$('#expandAll').click(function () { treeTable.expandAll('#dataTable'); });
$('#foldAll').click(function () { treeTable.foldAll('#dataTable'); });
// 批量删除
$('#batchDelete').click(function () {
var checkData = treeTable.checkStatus('dataTable').data;
if (!checkData.length) {
return layer.msg('请选择要删除的分类');
}
var ids = checkData.map(function (item) { return item.id; });
layer.confirm('确定删除选中的 ' + ids.length + ' 个分类吗?', function (index) {
$.post('{:url("blog/backend.category/batchDelete")}', { ids: ids.join(',') }, function (res) {
if (res.code === 1) {
layer.msg(res.msg, { icon: 1 }, function () { location.reload(); });
} else {
layer.msg(res.msg, { icon: 2 });
}
});
layer.close(index);
});
});
// 新增 / 编辑 弹窗
function openForm(data) {
data = data || {};
var formHtml = laytpl($('#dataFormTpl').html()).render(data);
layer.open({
title: data.id ? '编辑分类' : (data.pid ? '添加子分类' : '添加分类'),
content: formHtml,
anim: 'slideLeft',
offset: 'r',
area: ['480px', '98%'],
shade: 0.1,
shadeClose: true,
btn: ['保存', '取消'],
success: function (layero, index) {
$('#pidSelect').html(buildPidOptions(data.id));
if (data.pid) { $('#pidSelect').val(data.pid); }
form.render('select');
},
yes: function (index, layero) {
layero.contents().find('#dataFormSubmit').trigger('click');
},
btn2: function (index) { layer.close(index); }
});
}
// 表单提交:仅绑定一次(layui form submit 为事件委托,弹窗内同 lay-filter 表单均可触发)
form.on('submit(dataFormSubmit)', function (elem) {
var field = elem.field;
var isEdit = !!field.id;
var url = isEdit
? '{:url("blog/backend.category/update")}'
: '{:url("blog/backend.category/save")}';
var postData = $.extend({}, field);
postData.status = field.status ? 1 : 0;
if (isEdit) { postData.id = field.id; }
http.post(url, postData).then(function (res) {
if (res.code === 1) {
layer.closeAll('dialog');
layer.msg(res.msg, { icon: 1 });
setTimeout(function () { location.reload(); }, 500);
} else {
layer.msg(res.msg || '操作失败', { icon: 2 });
}
}).catch(function () { layer.msg('请求失败,请重试', { icon: 2 }); });
return false;
});
// 行操作
treeTable.on('tool(dataTable)', function (obj) {
var data = obj.data;
if (obj.event === 'edit') {
openForm(data);
} else if (obj.event === 'addChild') {
openForm({ pid: data.id, sort: 0, status: 1 });
} else if (obj.event === 'delete') {
layer.confirm('确定要删除该分类吗?', function (index) {
$.post('{:url("blog/backend.category/delete")}', { id: data.id }, function (res) {
if (res.code === 1) {
layer.msg(res.msg, { icon: 1 }, function () { location.reload(); });
} else {
layer.msg(res.msg, { icon: 2 });
}
});
layer.close(index);
});
}
});
// 添加根分类
$('#addRoot').click(function () {
openForm({ pid: 0, sort: 0, status: 1 });
});
});
</script>
+143
View File
@@ -0,0 +1,143 @@
<div class="layui-fluid">
<div class="admin-title">评论管理</div>
<div class="layui-card">
<div class="layui-card-body">
<form class="layui-form layui-inline" lay-filter="searchForm">
<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" id="keywordInput">
</div>
<div class="layui-inline">
<button class="layui-btn layui-btn-primary" lay-submit lay-filter="search">搜索</button>
</div>
<button type="button" class="layui-btn layui-btn-normal" id="batchApproveBtn">批量通过</button>
<button type="button" class="layui-btn layui-btn-warm" id="batchRejectBtn">批量拒绝</button>
<button type="button" class="layui-btn layui-btn-danger" id="batchDeleteBtn">批量删除</button>
</form>
<table class="layui-hide" id="commentTable" lay-filter="commentTable"></table>
</div>
</div>
</div>
<script type="text/html" id="rowTpl">
{{# if (d.status == 0) { }}
<a class="layui-btn layui-btn-xs" lay-event="approve">通过</a>
{{# } }}
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="delete">删除</a>
</script>
<script>
layui.use(['table', 'layer', 'form'], function () {
var table = layui.table;
var layer = layui.layer;
var form = layui.form;
var $ = layui.$;
var status = '{$status|default="all"}';
var keyword = '{$keyword|default=""}';
$('select[name="status"]').val(status);
$('#keywordInput').val(keyword);
var renderTable = function () {
table.render({
elem: '#commentTable',
url: '{:url("blog/backend.comment/index")}',
where: { status: status, keyword: keyword },
page: true,
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'nickname', title: '用户', width: 140, templet: function (d) {
var av = d.avatar ? d.avatar : '/assets/images/avatar.png';
return '<img src="' + av + '" class="layui-nav-img" style="width:30px;height:30px;border-radius:50%;">' + (d.nickname || '-');
} },
{ field: 'content', title: '评论内容', minWidth: 220 },
{ field: 'article_title', title: '文章', minWidth: 160, templet: function (d) {
if (d.article_url) {
return '<a href="' + d.article_url + '">' + (d.article_title || '-') + '</a>';
}
return d.article_title || '-';
} },
{ field: 'create_text', title: '时间', width: 160 },
{ field: 'status', title: '状态', width: 90, templet: function (d) {
return d.status == 1
? '<span class="status-badge status-approved">已通过</span>'
: '<span class="status-badge status-pending">待审核</span>';
} },
{ title: '操作', width: 110, align: 'center', toolbar: '#rowTpl', fixed: 'right' }
]]
});
};
renderTable();
// 搜索
form.on('submit(search)', function () {
var statusVal = $('select[name="status"]').val();
var kw = $('#keywordInput').val();
table.reload('commentTable', { where: { status: statusVal, keyword: kw }, page: { curr: 1 } });
return false;
});
// 批量通过
$('#batchApproveBtn').click(function () {
batchOp('approve', '确定要批量通过选中的评论吗?');
});
// 批量拒绝
$('#batchRejectBtn').click(function () {
batchOp('reject', '确定要批量拒绝选中的评论吗?');
});
// 批量删除
$('#batchDeleteBtn').click(function () {
batchOp('delete', '确定要删除选中的评论吗?');
});
function batchOp(action, tip) {
var checkData = table.checkStatus('commentTable').data;
if (! checkData.length) {
return layer.msg('请选择评论');
}
var ids = checkData.map(function (i) { return i.id; });
layer.confirm(tip, function (index) {
$.post('{:url("blog/backend.comment/batch")}', { action: action, ids: ids }, function (res) {
if (res.code === 1) {
layer.msg(res.msg, { icon: 1 }, function () { renderTable(); });
} else {
layer.msg(res.msg, { icon: 2 });
}
});
layer.close(index);
});
}
// 行操作
table.on('tool(commentTable)', function (obj) {
if (obj.event === 'approve') {
$.post('{:url("blog/backend.comment/approve")}', { id: obj.data.id }, function (res) {
if (res.code === 1) {
layer.msg(res.msg, { icon: 1 }, function () { renderTable(); });
} else {
layer.msg(res.msg, { icon: 2 });
}
});
} else if (obj.event === 'delete') {
layer.confirm('确定要删除这条评论吗?', function (index) {
$.post('{:url("blog/backend.comment/delete")}', { id: obj.data.id }, function (res) {
if (res.code === 1) {
layer.msg(res.msg, { icon: 1 }, function () { renderTable(); });
} else {
layer.msg(res.msg, { icon: 2 });
}
});
layer.close(index);
});
}
});
});
</script>
@@ -0,0 +1,153 @@
<div class="layui-container">
<div class="admin-title">控制面板</div>
<!-- 数据概览 -->
<div class="layui-row layui-col-space15">
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">文章总数</div>
<div class="layui-card-body">
<h1 style="color: #1E9FFF; font-size: 28px; margin: 0;">{$stats.article_count}</h1>
<p style="margin: 5px 0 0 0; color: #666;">今日新增: {$stats.today_article}</p>
</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">用户总数</div>
<div class="layui-card-body">
<h1 style="color: #5FB878; font-size: 28px; margin: 0;">{$stats.user_count}</h1>
<p style="margin: 5px 0 0 0; color: #666;">今日新增: {$stats.today_user}</p>
</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">评论总数</div>
<div class="layui-card-body">
<h1 style="color: #FFB800; font-size: 28px; margin: 0;">{$stats.comment_count}</h1>
<p style="margin: 5px 0 0 0; color: #666;">今日新增: {$stats.today_comment}</p>
</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">今日访问</div>
<div class="layui-card-body">
<h1 style="color: #FF5722; font-size: 28px; margin: 0;">{$stats.today_visit}</h1>
<p style="margin: 5px 0 0 0; color: #666;">总访问量: {$stats.total_visit}</p>
</div>
</div>
</div>
</div>
<!-- 最新文章 -->
<div class="layui-row layui-col-space15" style="margin-top: 20px;">
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">最新文章</div>
<div class="layui-card-body">
<table class="layui-table">
<colgroup>
<col width="60%">
<col width="20%">
<col width="20%">
</colgroup>
<thead>
<tr>
<th>标题</th>
<th>作者</th>
<th>发布时间</th>
</tr>
</thead>
<tbody>
{volist name="latestArticles" id="article"}
<tr>
<td><a href="{:url('blog/backend.article/edit', ['id' => $article.id])}" style="color: #333; text-decoration: none;">{$article.title}</a></td>
<td>{$article.author.nickname}</td>
<td>{:date('Y-m-d', $article.create_at)}</td>
</tr>
{/volist}
</tbody>
</table>
</div>
</div>
</div>
<!-- 最新评论 -->
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">最新评论</div>
<div class="layui-card-body">
<table class="layui-table">
<colgroup>
<col width="60%">
<col width="20%">
<col width="20%">
</colgroup>
<thead>
<tr>
<th>内容</th>
<th>用户</th>
<th>文章</th>
</tr>
</thead>
<tbody>
{volist name="latestComments" id="comment"}
<tr>
<td>{$comment.content|mb_substr=0,20}</td>
<td>{$comment.user.nickname}</td>
<td><a href="{:url('blog/backend.article/edit', ['id' => $comment.article_id])}" style="color: #333; text-decoration: none;">{$comment.article.title|mb_substr=0,20}</a></td>
</tr>
{/volist}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 热门文章 -->
<div class="layui-row layui-col-space15" style="margin-top: 20px;">
<div class="layui-col-md12">
<div class="layui-card">
<div class="layui-card-header">热门文章</div>
<div class="layui-card-body">
<table class="layui-table">
<colgroup>
<col width="5%">
<col width="45%">
<col width="15%">
<col width="15%">
<col width="10%">
<col width="10%">
</colgroup>
<thead>
<tr>
<th>排名</th>
<th>标题</th>
<th>作者</th>
<th>分类</th>
<th>阅读量</th>
<th>点赞数</th>
</tr>
</thead>
<tbody>
{volist name="hotArticles" id="article" key="k"}
<tr>
<td>{$k}</td>
<td><a href="{:url('index/article/detail', ['id' => $article.id])}" target="_blank" style="color: #333; text-decoration: none;">{$article.title}</a></td>
<td>{$article.author.nickname}</td>
<td>{$article.category.name}</td>
<td>{$article.view_count}</td>
<td>{$article.like_count}</td>
</tr>
{/volist}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
+27
View File
@@ -0,0 +1,27 @@
<div class="layui-container">
<div class="admin-title">博客管理首页</div>
<div class="layui-card">
<div class="layui-card-header">欢迎使用</div>
<div class="layui-card-body">
<p>当前管理员:<strong>{$admin.nickname|default='管理员'}</strong>
(账号:{$admin.account|default='-'}</p>
<p>这是插件 <code>blog</code> 的后台入口页,对应文件:
<code>addon/blog/controller/admin/Index.php</code></p>
<a href="{:url('blog/backend.dashboard/index')}" class="layui-btn">前往仪表盘</a>
</div>
</div>
<div class="layui-card">
<div class="layui-card-header">本页链路说明</div>
<div class="layui-card-body">
<ol>
<li>路由:<code>addon/blog/route/app.php</code> 的 admin 分组映射 <code>/blog/backend/</code><code>backend.Index/index</code></li>
<li>鉴权:admin 分组挂 <code>AdminAuth</code> 中间件,将容器 auth 绑定为 <code>\ywxapp\library\Admin</code> 并强制登录</li>
<li>基类:<code>Index extends \ywxapp\controller\AddonBackend</code>,视图目录指向 <code>view/backend/</code></li>
<li>模板:经全局 layout<code>view/layout.html</code>)渲染为完整 HTML,并加载 layui 的 css/js</li>
</ol>
</div>
</div>
</div>
@@ -0,0 +1,99 @@
<div class="layui-container">
<div class="admin-title">邮件设置</div>
<!-- 设置表单 -->
<div class="layui-card">
<div class="layui-card-body">
<form class="layui-form" action="{:url('blog/backend.setting/email')}" method="post">
<div class="layui-form-item">
<label class="layui-form-label">SMTP服务器</label>
<div class="layui-input-block">
<input type="text" name="smtp_host" value="{$settings.email.smtp_host}" placeholder="请输入SMTP服务器地址" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">SMTP端口</label>
<div class="layui-input-block">
<input type="number" name="smtp_port" value="{$settings.email.smtp_port}" placeholder="请输入SMTP端口" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">邮箱账号</label>
<div class="layui-input-block">
<input type="email" name="smtp_user" value="{$settings.email.smtp_user}" placeholder="请输入邮箱账号" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">邮箱密码</label>
<div class="layui-input-block">
<input type="password" name="smtp_pass" value="{$settings.email.smtp_pass}" placeholder="请输入邮箱密码" autocomplete="off" 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="from_name" value="{$settings.email.from_name}" placeholder="请输入发件人名称" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="emailSubmit">保存设置</button>
<button type="button" class="layui-btn layui-btn-primary" id="testEmailBtn">测试发送</button>
</div>
</div>
</form>
</div>
</div>
</div>
<script>
layui.use(['form', 'layer'], function(){
var form = layui.form;
var layer = layui.layer;
var $ = layui.$;
// 表单提交
form.on('submit(emailSubmit)', function(data){
$.post(data.form.action, data.field, function(res){
if(res.code === 1){
layer.msg(res.msg, {icon: 1});
} else {
layer.msg(res.msg, {icon: 2});
}
});
return false;
});
// 测试发送邮件
$('#testEmailBtn').click(function(){
var formData = {
smtp_host: $('input[name="smtp_host"]').val(),
smtp_port: $('input[name="smtp_port"]').val(),
smtp_user: $('input[name="smtp_user"]').val(),
smtp_pass: $('input[name="smtp_pass"]').val(),
from_name: $('input[name="from_name"]').val()
};
if (!formData.smtp_host || !formData.smtp_port || !formData.smtp_user || !formData.smtp_pass) {
layer.msg('请填写完整的SMTP配置', {icon: 2});
return;
}
layer.load(2);
$.post('{:url("blog/backend.setting/testEmail")}', formData, function(res){
layer.closeAll('loading');
if(res.code === 1){
layer.msg(res.msg, {icon: 1});
} else {
layer.msg(res.msg, {icon: 2});
}
});
});
});
</script>
+186
View File
@@ -0,0 +1,186 @@
<div class="layui-container">
<div class="admin-title">系统设置</div>
<div class="layui-card">
<div class="layui-card-body">
<div class="layui-tab layui-tab-brief" lay-filter="settingTab">
<ul class="layui-tab-title">
<li class="layui-this">基本设置</li>
<li>SEO设置</li>
<li>邮件设置</li>
</ul>
<div class="layui-tab-content" style="padding-top:20px;">
<!-- 基本设置 -->
<div class="layui-tab-item layui-show">
<form class="layui-form" action="{:url('blog/backend.setting/site')}" method="post">
<div class="layui-form-item">
<label class="layui-form-label">站点名称</label>
<div class="layui-input-block">
<input type="text" name="site_name" value="{$settings.site.site_name|default=''}" required lay-verify="required" placeholder="请输入站点名称" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">站点描述</label>
<div class="layui-input-block">
<textarea name="site_description" placeholder="请输入站点描述" class="layui-textarea">{$settings.site.site_description|default=''}</textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">站点关键词</label>
<div class="layui-input-block">
<input type="text" name="site_keywords" value="{$settings.site.site_keywords|default=''}" placeholder="请输入站点关键词,多个用逗号分隔" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">站长邮箱</label>
<div class="layui-input-block">
<input type="email" name="site_email" value="{$settings.site.site_email|default=''}" placeholder="请输入站长邮箱" autocomplete="off" 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="site_icp" value="{$settings.site.site_icp|default=''}" placeholder="请输入备案号" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="siteSubmit">保存设置</button>
</div>
</div>
</form>
</div>
<!-- SEO设置 -->
<div class="layui-tab-item">
<form class="layui-form" action="{:url('blog/backend.setting/seo')}" method="post">
<div class="layui-form-item">
<label class="layui-form-label">网站标题</label>
<div class="layui-input-block">
<input type="text" name="seo_title" value="{$settings.seo.seo_title|default=''}" placeholder="请输入网站标题" autocomplete="off" 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="seo_keywords" value="{$settings.seo.seo_keywords|default=''}" placeholder="请输入网站关键词,多个用逗号分隔" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">网站描述</label>
<div class="layui-input-block">
<textarea name="seo_description" placeholder="请输入网站描述" class="layui-textarea">{$settings.seo.seo_description|default=''}</textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">百度推送Token</label>
<div class="layui-input-block">
<input type="text" name="baidu_token" value="{$settings.seo.baidu_token|default=''}" placeholder="请输入百度推送Token" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="seoSubmit">保存设置</button>
</div>
</div>
</form>
</div>
<!-- 邮件设置 -->
<div class="layui-tab-item">
<form class="layui-form" action="{:url('blog/backend.setting/email')}" method="post">
<div class="layui-form-item">
<label class="layui-form-label">SMTP服务器</label>
<div class="layui-input-block">
<input type="text" name="smtp_host" value="{$settings.email.smtp_host|default=''}" placeholder="请输入SMTP服务器地址" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">SMTP端口</label>
<div class="layui-input-block">
<input type="number" name="smtp_port" value="{$settings.email.smtp_port|default=''}" placeholder="请输入SMTP端口" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">邮箱账号</label>
<div class="layui-input-block">
<input type="email" name="smtp_user" value="{$settings.email.smtp_user|default=''}" placeholder="请输入邮箱账号" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">邮箱密码</label>
<div class="layui-input-block">
<input type="password" name="smtp_pass" value="{$settings.email.smtp_pass|default=''}" placeholder="请输入邮箱密码" autocomplete="off" 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="from_name" value="{$settings.email.from_name|default=''}" placeholder="请输入发件人名称" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="emailSubmit">保存设置</button>
<button type="button" class="layui-btn layui-btn-primary" id="testEmailBtn">测试发送</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
layui.use(['form', 'element', 'layer'], function(){
var form = layui.form;
var layer = layui.layer;
var $ = layui.$;
// 通用表单提交
function bindSubmit(filter){
form.on('submit(' + filter + ')', function(data){
$.post(data.form.action, data.field, function(res){
if(res.code === 1){
layer.msg(res.msg, {icon: 1});
} else {
layer.msg(res.msg, {icon: 2});
}
});
return false;
});
}
bindSubmit('siteSubmit');
bindSubmit('seoSubmit');
bindSubmit('emailSubmit');
// 测试发送邮件
$('#testEmailBtn').click(function(){
var formData = {
smtp_host: $('input[name="smtp_host"]').val(),
smtp_port: $('input[name="smtp_port"]').val(),
smtp_user: $('input[name="smtp_user"]').val(),
smtp_pass: $('input[name="smtp_pass"]').val(),
from_name: $('input[name="from_name"]').val()
};
if (!formData.smtp_host || !formData.smtp_port || !formData.smtp_user || !formData.smtp_pass) {
layer.msg('请填写完整的SMTP配置', {icon: 2});
return;
}
layer.load(2);
$.post('{:url("blog/backend.setting/testEmail")}', formData, function(res){
layer.closeAll('loading');
if(res.code === 1){
layer.msg(res.msg, {icon: 1});
} else {
layer.msg(res.msg, {icon: 2});
}
});
});
});
</script>
+63
View File
@@ -0,0 +1,63 @@
<div class="layui-container">
<div class="admin-title">SEO设置</div>
<!-- 设置表单 -->
<div class="layui-card">
<div class="layui-card-body">
<form class="layui-form" action="{:url('blog/backend.setting/seo')}" method="post">
<div class="layui-form-item">
<label class="layui-form-label">网站标题</label>
<div class="layui-input-block">
<input type="text" name="seo_title" value="{$settings.seo.seo_title}" placeholder="请输入网站标题" autocomplete="off" 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="seo_keywords" value="{$settings.seo.seo_keywords}" placeholder="请输入网站关键词,多个用逗号分隔" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">网站描述</label>
<div class="layui-input-block">
<textarea name="seo_description" placeholder="请输入网站描述" class="layui-textarea">{$settings.seo.seo_description}</textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">百度推送Token</label>
<div class="layui-input-block">
<input type="text" name="baidu_token" value="{$settings.seo.baidu_token}" placeholder="请输入百度推送Token" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="seoSubmit">保存设置</button>
</div>
</div>
</form>
</div>
</div>
</div>
<script>
layui.use(['form'], function(){
var form = layui.form;
// 表单提交
form.on('submit(seoSubmit)', function(data){
$.post(data.form.action, data.field, function(res){
if(res.code === 1){
layer.msg(res.msg, {icon: 1});
} else {
layer.msg(res.msg, {icon: 2});
}
});
return false;
});
});
</script>
+91
View File
@@ -0,0 +1,91 @@
<div class="layui-container">
<div class="admin-title">基本设置</div>
<!-- 设置表单 -->
<div class="layui-card">
<div class="layui-card-body">
<form class="layui-form" action="{:url('blog/backend.setting/site')}" method="post">
<div class="layui-form-item">
<label class="layui-form-label">站点名称</label>
<div class="layui-input-block">
<input type="text" name="site_name" value="{$settings.site.site_name}" required lay-verify="required" placeholder="请输入站点名称" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">站点描述</label>
<div class="layui-input-block">
<textarea name="site_description" placeholder="请输入站点描述" class="layui-textarea">{$settings.site.site_description}</textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">站点关键词</label>
<div class="layui-input-block">
<input type="text" name="site_keywords" value="{$settings.site.site_keywords}" placeholder="请输入站点关键词,多个用逗号分隔" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">站长邮箱</label>
<div class="layui-input-block">
<input type="email" name="site_email" value="{$settings.site.site_email}" placeholder="请输入站长邮箱" autocomplete="off" 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="site_icp" value="{$settings.site.site_icp}" placeholder="请输入备案号" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">主题色</label>
<div class="layui-input-block" style="display:flex;align-items:center;gap:12px;">
<input type="color" name="theme_color" value="{$settings.site.theme_color|default='#2d6cdf'}" style="width:46px;height:38px;border:1px solid #eee;border-radius:6px;padding:2px;cursor:pointer;">
<input type="text" name="theme_color_text" id="themeColorText" value="{$settings.site.theme_color|default='#2d6cdf'}" placeholder="#2d6cdf" class="layui-input" style="max-width:160px;">
<span class="layui-form-mid" style="color:#999;">前台主题色(顶部导航/按钮/底部Tab等)</span>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="siteSubmit">保存设置</button>
</div>
</div>
</form>
</div>
</div>
</div>
<script>
layui.use(['form'], function(){
var form = layui.form, $ = layui.$;
// 颜色选择器 <-> 文本框 双向同步
$('input[name="theme_color"]').on('input', function(){
$('#themeColorText').val(this.value);
});
$('#themeColorText').on('input', function(){
var v = $.trim(this.value);
if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v)) {
$('input[name="theme_color"]').val(v);
}
});
// 表单提交(统一使用文本框的值为最终主题色)
form.on('submit(siteSubmit)', function(data){
data.field.theme_color = $('#themeColorText').val() || data.field.theme_color;
$.post(data.form.action, data.field, function(res){
if(res.code === 1){
layer.msg(res.msg, {icon: 1});
} else {
layer.msg(res.msg, {icon: 2});
}
});
return false;
});
});
</script>
+120
View File
@@ -0,0 +1,120 @@
<div class="layui-container">
<div class="admin-title">统计分析</div>
<!-- 时间范围选择 -->
<div class="layui-row">
<div class="layui-col-md12">
<div class="layui-card">
<div class="layui-card-body">
<form class="layui-form" id="statForm">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">时间范围</label>
<div class="layui-input-inline">
<select name="range" lay-filter="range">
<option value="week" {if $range=='week'}selected{/if}>近7天</option>
<option value="month" {if $range=='month'}selected{/if}>近30天</option>
<option value="year" {if $range=='year'}selected{/if}>近1年</option>
</select>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- 统计卡片 -->
<div class="layui-row layui-col-space15" style="margin-top: 20px;">
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">总访问量</div>
<div class="layui-card-body">
<h1 style="color: #1E9FFF; font-size: 28px; margin: 0;">{$stats.total_visit}</h1>
</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">今日访问</div>
<div class="layui-card-body">
<h1 style="color: #5FB878; font-size: 28px; margin: 0;">{$stats.today_visit}</h1>
</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">总用户数</div>
<div class="layui-card-body">
<h1 style="color: #FFB800; font-size: 28px; margin: 0;">{$stats.total_user}</h1>
</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">总文章数</div>
<div class="layui-card-body">
<h1 style="color: #FF5722; font-size: 28px; margin: 0;">{$stats.total_article}</h1>
</div>
</div>
</div>
</div>
<!-- 热门文章 -->
<div class="layui-row layui-col-space15" style="margin-top: 20px;">
<div class="layui-col-md12">
<div class="layui-card">
<div class="layui-card-header">热门文章</div>
<div class="layui-card-body">
<table class="layui-table">
<colgroup>
<col width="5%">
<col width="45%">
<col width="15%">
<col width="15%">
<col width="10%">
<col width="10%">
</colgroup>
<thead>
<tr>
<th>排名</th>
<th>标题</th>
<th>作者</th>
<th>分类</th>
<th>阅读量</th>
<th>点赞数</th>
</tr>
</thead>
<tbody>
{volist name="hotArticles" id="article" key="k"}
<tr>
<td>{$k}</td>
<td><a href="{:url('index/article/detail', ['id' => $article.id])}" target="_blank">{$article.title}</a></td>
<td>{$article.author.nickname}</td>
<td>{$article.category.name}</td>
<td>{$article.view_count}</td>
<td>{$article.like_count}</td>
</tr>
{/volist}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
layui.use(['form'], function(){
var form = layui.form;
var $ = layui.$;
// 时间范围筛选
form.on('select(range)', function(data){
location.href = '{:url("blog/backend.stat/index")}?range=' + data.value;
});
});
</script>
+31
View File
@@ -0,0 +1,31 @@
<div class="layui-container">
<div class="admin-title">添加标签</div>
<div class="layui-card">
<div class="layui-card-body">
<form class="layui-form" action="{:url('blog/backend.tag/save')}" method="post">
<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">
</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"></textarea>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="tagSubmit">添加标签</button>
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</form>
</div>
</div>
</div>
+31
View File
@@ -0,0 +1,31 @@
<div class="layui-container">
<div class="admin-title">编辑标签</div>
<div class="layui-card">
<div class="layui-card-body">
<form class="layui-form" action="{:url('blog/backend.tag/update', ['id' => $tag.id])}" method="post">
<div class="layui-form-item">
<label class="layui-form-label">标签名称</label>
<div class="layui-input-block">
<input type="text" name="name" value="{$tag.name}" required lay-verify="required" placeholder="请输入标签名称" autocomplete="off" class="layui-input">
</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">{$tag.description}</textarea>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="tagSubmit">更新标签</button>
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</form>
</div>
</div>
</div>
+164
View File
@@ -0,0 +1,164 @@
<div class="layui-fluid">
<div class="admin-title">标签管理</div>
<div class="layui-card">
<div class="layui-card-body">
<div class="table-toolbar" style="margin-bottom:15px;">
<button type="button" class="layui-btn layui-btn-normal" id="addTagBtn">添加标签</button>
<button type="button" class="layui-btn layui-btn-danger" id="batchDeleteBtn">批量删除</button>
</div>
<table class="layui-hide" id="tagTable" lay-filter="tagTable"></table>
</div>
</div>
</div>
<!-- 行操作 -->
<script type="text/html" id="rowTpl">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="delete">删除</a>
</script>
<!-- 新增/编辑 表单模板(弹窗内渲染) -->
<script type="text/html" id="tagFormTpl">
<div style="padding:15px;">
<form class="layui-form" lay-filter="tagForm">
<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" value="{{ d.name || '' }}" required lay-verify="required" placeholder="请输入标签名称" autocomplete="off" class="layui-input">
</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">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="tagFormSubmit" id="tagFormSubmit">保存</button>
<button type="button" class="layui-btn layui-btn-primary" id="cancelBtn">取消</button>
</div>
</div>
</form>
</div>
</script>
<script>
layui.use(['table', 'layer', 'form', 'laytpl', 'http'], function () {
var table = layui.table;
var layer = layui.layer;
var $ = layui.$;
var form = layui.form;
var laytpl = layui.laytpl;
var http = layui.http;
var renderTable = function () {
table.render({
elem: '#tagTable',
url: '{:url("blog/backend.tag/index")}',
page: true,
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 80, sort: true },
{ field: 'name', title: '标签名称', minWidth: 160 },
{ field: 'description', title: '描述', minWidth: 160, templet: function (d) { return d.description || '-'; } },
{ field: 'articles_count', title: '文章数', width: 100 },
{ field: 'create_text', title: '创建时间', width: 130 },
{ title: '操作', width: 130, align: 'center', toolbar: '#rowTpl', fixed: 'right' }
]]
});
};
renderTable();
// 新增 / 编辑 弹窗
function openForm(data) {
data = data || {};
var formHtml = laytpl($('#tagFormTpl').html()).render(data);
layer.open({
title: data.id ? '编辑标签' : '添加标签',
content: formHtml,
anim: 'slideLeft',
offset: 'r',
area: ['480px', '98%'],
shade: 0.1,
shadeClose: true,
btn: ['保存', '取消'],
success: function () {
form.render();
},
yes: function (index, layero) {
layero.contents().find('#tagFormSubmit').trigger('click');
},
btn2: function (index) { layer.close(index); }
});
}
// 表单提交:仅绑定一次
form.on('submit(tagFormSubmit)', function (elem) {
var field = elem.field;
var isEdit = !!field.id;
var url = isEdit
? '{:url("blog/backend.tag/update")}'
: '{:url("blog/backend.tag/save")}';
var postData = $.extend({}, field);
if (isEdit) { postData.id = field.id; }
http.post(url, postData).then(function (res) {
if (res.code === 1) {
layer.closeAll('dialog');
layer.msg(res.msg, { icon: 1 });
setTimeout(function () { renderTable(); }, 500);
} else {
layer.msg(res.msg || '操作失败', { icon: 2 });
}
},
error: function () { layer.msg('请求失败,请重试', { icon: 2 }); }
});
return false;
});
// 添加标签
$('#addTagBtn').click(function () {
openForm({});
});
// 批量删除
$('#batchDeleteBtn').click(function () {
var checkData = table.checkStatus('tagTable').data;
if (! checkData.length) {
return layer.msg('请选择要删除的标签');
}
var ids = checkData.map(function (i) { return i.id; });
layer.confirm('确定要删除选中的 ' + ids.length + ' 个标签吗?', function (index) {
$.post('{:url("blog/backend.tag/batchDelete")}', { ids: ids }, function (res) {
if (res.code === 1) {
layer.msg(res.msg, { icon: 1 }, function () { renderTable(); });
} else {
layer.msg(res.msg, { icon: 2 });
}
});
layer.close(index);
});
});
// 行操作
table.on('tool(tagTable)', function (obj) {
var data = obj.data;
if (obj.event === 'edit') {
openForm(data);
} else if (obj.event === 'delete') {
layer.confirm('确定要删除这个标签吗?', function (index) {
$.post('{:url("blog/backend.tag/delete")}', { id: data.id }, function (res) {
if (res.code === 1) {
layer.msg(res.msg, { icon: 1 }, function () { renderTable(); });
} else {
layer.msg(res.msg, { icon: 2 });
}
});
layer.close(index);
});
}
});
});
</script>
+74
View File
@@ -0,0 +1,74 @@
<div class="layui-fluid">
<div class="admin-title">编辑用户</div>
<div class="layui-card">
<div class="layui-card-body">
<form class="layui-form" lay-filter="userForm" style="max-width: 600px; margin-top: 20px;">
<input type="hidden" name="id" value="{$user.uid}">
<div class="layui-form-item">
<label class="layui-form-label">账号</label>
<div class="layui-input-block">
<input type="text" class="layui-input" value="{$user.account}" disabled>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">昵称</label>
<div class="layui-input-block">
<input type="text" name="nickname" value="{$user.nickname}" placeholder="请输入昵称" autocomplete="off" 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="email" value="{$user.email}" placeholder="请输入邮箱" autocomplete="off" 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="mobile" value="{$user.mobile}" placeholder="请输入手机号" autocomplete="off" class="layui-input">
</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="正常" {if $user.status == 1}checked{/if}>
<input type="radio" name="status" value="0" title="禁用" {if $user.status == 0}checked{/if}>
<input type="radio" name="status" value="-1" title="删除" {if $user.status == -1}checked{/if}>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="userFormSubmit">保存</button>
<a class="layui-btn layui-btn-primary" href="{:url('blog/backend.user/index')}">返回</a>
</div>
</div>
</form>
</div>
</div>
</div>
<script>
layui.use(['form', 'layer', 'http'], function () {
var form = layui.form;
var layer = layui.layer;
var $ = layui.$;
var http = layui.http;
form.on('submit(userFormSubmit)', function (elem) {
var field = elem.field;
http.post('{:url("blog/backend.user/update")}', field).then(function (res) {
if (res.code === 1) {
layer.msg(res.msg, { icon: 1 }, function () {
location.href = '{:url("blog/backend.user/index")}';
});
} else {
layer.msg(res.msg || '操作失败', { icon: 2 });
}
},
error: function () { layer.msg('请求失败,请重试', { icon: 2 }); }
});
return false;
});
});
</script>
+115
View File
@@ -0,0 +1,115 @@
<div class="layui-fluid">
<div class="admin-title">用户管理</div>
<div class="layui-card">
<div class="layui-card-body">
<form class="layui-form layui-inline" lay-filter="searchForm">
<div class="layui-inline">
<select name="status">
<option value="all">全部状态</option>
<option value="1">正常</option>
<option value="0">禁用</option>
<option value="-1">删除</option>
</select>
</div>
<div class="layui-inline">
<input type="text" name="keyword" placeholder="搜索账号/昵称/邮箱/手机号" class="layui-input" id="keywordInput">
</div>
<div class="layui-inline">
<button class="layui-btn layui-btn-primary" lay-submit lay-filter="search">搜索</button>
</div>
<button type="button" class="layui-btn layui-btn-danger" id="batchDeleteBtn">批量删除</button>
</form>
<table class="layui-hide" id="userTable" lay-filter="userTable"></table>
</div>
</div>
</div>
<script type="text/html" id="rowTpl">
<a class="layui-btn layui-btn-xs" href="{{ d.edit_url }}">编辑</a>
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="delete">删除</a>
</script>
<script>
layui.use(['table', 'layer', 'form'], function () {
var table = layui.table;
var layer = layui.layer;
var form = layui.form;
var $ = layui.$;
var status = '{$status|default="all"}';
var keyword = '{$keyword|default=""}';
$('select[name="status"]').val(status);
$('#keywordInput').val(keyword);
var renderTable = function () {
table.render({
elem: '#userTable',
url: '{:url("blog/backend.user/index")}',
where: { status: status, keyword: keyword },
page: true,
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'uid', title: 'UID', width: 80, sort: true },
{ field: 'account', title: '账号', width: 140 },
{ field: 'nickname', title: '昵称', width: 140 },
{ field: 'email', title: '邮箱', minWidth: 160 },
{ field: 'mobile', title: '手机号', width: 130 },
{ field: 'article_count', title: '文章数', width: 90, sort: true, align: 'center' },
{ field: 'status_text', title: '状态', width: 90, templet: function (d) {
var map = { '正常': 'layui-bg-green', '禁用': 'layui-bg-gray', '删除': 'layui-bg-red' };
var cls = map[d.status_text] || 'layui-bg-gray';
return '<span class="layui-badge ' + cls + '">' + d.status_text + '</span>';
} },
{ field: 'create_at', title: '注册时间', width: 160 },
{ field: 'update_at', title: '最后登录', width: 160 },
{ title: '操作', width: 130, align: 'center', toolbar: '#rowTpl', fixed: 'right' }
]]
});
};
renderTable();
// 搜索
form.on('submit(search)', function () {
var statusVal = $('select[name="status"]').val();
var kw = $('#keywordInput').val();
table.reload('userTable', { where: { status: statusVal, keyword: kw }, page: { curr: 1 } });
return false;
});
// 批量删除
$('#batchDeleteBtn').click(function () {
var checkData = table.checkStatus('userTable').data;
if (! checkData.length) {
return layer.msg('请选择要删除的用户');
}
var ids = checkData.map(function (i) { return i.uid; });
layer.confirm('确定要删除选中的 ' + ids.length + ' 个用户吗?', function (index) {
$.post('{:url("blog/backend.user/batch")}', { ids: ids }, function (res) {
if (res.code === 1) {
layer.msg(res.msg, { icon: 1 }, function () { renderTable(); });
} else {
layer.msg(res.msg, { icon: 2 });
}
});
layer.close(index);
});
});
// 行操作
table.on('tool(userTable)', function (obj) {
if (obj.event === 'delete') {
layer.confirm('确定要删除该用户吗?', function (index) {
$.post('{:url("blog/backend.user/delete")}', { id: obj.data.uid }, function (res) {
if (res.code === 1) {
layer.msg(res.msg, { icon: 1 }, function () { renderTable(); });
} else {
layer.msg(res.msg, { icon: 2 });
}
});
layer.close(index);
});
}
});
});
</script>
@@ -0,0 +1,52 @@
{extend name="layout" /}
{block name="title"}{$author.nickname} 的个人博客 - {$siteConf.site_name|default='博客'}{/block}
{block name="content"}
<div class="fb-layout">
<main class="fb-content">
<!-- 个人资料卡 -->
<div class="fb-card" style="display:flex;align-items:center;gap:18px;margin-bottom:18px;">
<img src="{$avatar}" alt="{$author.nickname}" style="width:72px;height:72px;border-radius:50%;object-fit:cover;border:2px solid var(--theme);">
<div>
<h2 style="margin:0 0 6px;font-size:20px;color:var(--text);">{$author.nickname}</h2>
<p style="margin:0 0 8px;color:var(--text-2);font-size:13px;">{$author.bio|default='这个人很懒,什么都没留下~'}</p>
<p style="margin:0;font-size:13px;color:var(--text-3);">
<span style="margin-right:16px;">文章 <b style="color:var(--theme);">{$stats.articles}</b></span>
<span style="margin-right:16px;">阅读 <b style="color:var(--theme);">{$stats.views}</b></span>
<span style="margin-right:16px;">获赞 <b style="color:var(--theme);">{$stats.likes}</b></span>
<span>加入于 {$author_join}</span>
</p>
</div>
</div>
<div class="fb-section-title"><h2>他的文章({$articles->total()} 篇)</h2></div>
{if $articles->isEmpty()}
<div class="fb-empty"><p>该用户暂无已发布文章。</p></div>
{else}
<div class="fb-post-list">
{volist name="articles" id="article"}
<article class="fb-post">
{if $article.cover_image}
<a class="thumb" href="{:url('blog/article/detail', ['id' => $article.id])}">
<img src="{$article.cover_image}" alt="{$article.title}">
</a>
{/if}
<div class="body">
<span class="cat">{$article.category.name|default='默认'}</span>
<h3><a href="{:url('blog/article/detail', ['id' => $article.id])}">{$article.title}</a></h3>
<p class="summary">{$article.summary|default='(暂无描述)'}</p>
<div class="meta">
<span class="sp">{$article.create_text}</span>
</div>
</div>
</article>
{/volist}
</div>
<div class="fb-pager">{$articles|raw}</div>
{/if}
</main>
{include file="index/_sidebar" /}
</div>
{/block}
@@ -0,0 +1,159 @@
{extend name="layout" /}
{block name="title"}{$article.title} - {$siteConf.site_name|default='博客'}{/block}
{block name="content"}
<div class="fb-layout">
<main class="fb-content">
<!-- 文章正文 -->
<article class="fb-article">
<div class="crumb">博文 &gt; <a href="{:url('blog/category', ['id' => $article.cid])}">{$article.category.name|default='默认'}</a></div>
<h1>{$article.title}</h1>
<div class="post-meta">
<a class="author" href="{:url('blog/article/author', ['id' => $article.uid])}">
<img src="{$article.user.avatar|default='/static/blog/img/avatar.jpg'}" alt="">
{$article.user.nickname|default='佚名'}
</a>
<span><i class="layui-icon layui-icon-read"></i> {$article.view_count|default=0}</span>
<span>{$article.create_text}</span>
</div>
{if $article.cover_image}
<div class="cover"><img src="{$article.cover_image}" alt="{$article.title}"></div>
{/if}
<div class="content">{$article.content|raw}</div>
<div class="action-bar">
<button type="button" class="fb-btn like-btn" data-id="{$article.id}"><i class="layui-icon layui-icon-praise"></i> 点赞 (<span id="likeCount">{$article.like_count}</span>)</button>
<button type="button" class="fb-btn ghost fav-btn" data-id="{$article.id}"><i class="layui-icon layui-icon-star"></i> 收藏 (<span id="favCount">{$article.favorite_count}</span>)</button>
<button type="button" class="fb-btn ghost share-btn"><i class="layui-icon layui-icon-share"></i> 分享</button>
</div>
</article>
<!-- 评论区 -->
<section class="fb-comments">
<p class="ct-title">全部留言 <em id="count">{$article.comment_count}</em></p>
{if $is_login}
<div class="fb-cmt-form">
<textarea id="LAY-msg-content" placeholder="既然来了,就说几句吧" style="resize:none;"></textarea>
<div style="margin-top:10px;display:flex;align-items:center;gap:10px;">
<button class="fb-btn" type="button" id="item-btn">发表留言</button>
<span class="reply-tip" id="replyTip" style="display:none;color:var(--theme);"></span>
<button class="fb-btn ghost" type="button" id="cancelReply" style="display:none;">取消回复</button>
</div>
</div>
{else /}
<div style="padding:10px 0;color:var(--text-3);"><a href="{:url('auth/login')}" style="color:var(--theme);">登录</a> 后发表留言</div>
{/if}
<ul class="fb-cmt-list" id="LAY-msg-box" style="list-style:none;margin:0;padding:0;">
{volist name="comments" id="comment"}
<li class="fb-cmt-item" data-id="{$comment.id}">
<img src="{$comment.user.avatar|default='/static/blog/img/portrait.jpg'}" alt="">
<div class="ci-body">
<p style="margin:0;">
<span class="ci-name">{$comment.user.nickname|default='佚名'}</span>
<span class="ci-time">{$comment.create_text}</span>
{if $is_login}<span class="ci-reply" data-id="{$comment.id}" data-name="{$comment.user.nickname}">回复</span>{/if}
</p>
<p class="ci-text">{$comment.content}</p>
<ul class="fb-cmt-children">
{volist name="comment.children" id="child"}
<li data-id="{$child.id}">
<img src="{$child.user.avatar|default='/static/blog/img/portrait.jpg'}" alt="">
<div class="ci-body">
<p style="margin:0;">
<span class="ci-name">{$child.user.nickname|default='佚名'}</span>
<span class="ci-time">{$child.create_text}</span>
{if $is_login}<span class="ci-reply" data-id="{$comment.id}" data-name="{$comment.user.nickname}">回复</span>{/if}
</p>
<p class="ci-text">{$child.content}</p>
</div>
</li>
{/volist}
</ul>
</div>
</li>
{/volist}
</ul>
<div class="fb-pager">{$comments|raw}</div>
</section>
<!-- 相关文章推荐 -->
{if !empty($relatedArticles)}
<section class="fb-related">
<p class="ct-title">相关推荐</p>
<ul class="fb-related-list">
{volist name="relatedArticles" id="ra"}
<li><a href="{:url('blog/article/detail', ['id' => $ra.id])}">{$ra.title}</a><span>{$ra.category.name|default=''}</span></li>
{/volist}
</ul>
</section>
{/if}
</main>
{include file="index/_sidebar" /}
</div>
{/block}
{block name="script"}
<script>
layui.use(['layer', 'jquery'], function(){
var layer = layui.layer, $ = layui.$;
$('.like-btn').click(function(){
var id = $(this).data('id');
$.post('{:url("interaction/like")}', { article_id: id }, function(res){
if(res.code===1){ $('#likeCount').text(res.count); layer.msg(res.msg,{icon:1}); } else { layer.msg(res.msg,{icon:2}); }
});
});
$('.fav-btn').click(function(){
var id = $(this).data('id');
$.post('{:url("interaction/favorite")}', { article_id: id }, function(res){
if(res.code===1){ $('#favCount').text(res.count); layer.msg(res.msg,{icon:1}); } else { layer.msg(res.msg,{icon:2}); }
});
});
$('.share-btn').click(function(){
layer.open({ title:'分享文章', content:'<div style="padding:10px;"><input type="text" value="'+window.location.href+'" class="layui-input" readonly></div>', area:['500px','200px'] });
$.post('{:url("interaction/share")}', { article_id: {$article.id} });
});
// ===== 留言 / 回复 =====
var replyTo = 0;
$(document).on('click', '.ci-reply', function(){
if(!$('#LAY-msg-content').length){ return; }
replyTo = $(this).data('id');
var name = $(this).data('name');
$('#replyTip').text('回复 @' + name).show();
$('#cancelReply').show();
$('#LAY-msg-content').attr('placeholder', '回复 @' + name);
$('#LAY-msg-content').focus();
$('html,body').animate({ scrollTop: $('.fb-cmt-form').offset().top - 20 }, 300);
});
$('#cancelReply').on('click', function(){
replyTo = 0;
$('#replyTip').hide().text('');
$(this).hide();
$('#LAY-msg-content').attr('placeholder', '既然来了,就说几句吧').val('');
});
$('#item-btn').on('click', function(){
var content = $('#LAY-msg-content').val();
if(content.replace(/\s/g,'')==''){ layer.msg('请先输入留言内容'); return; }
$.post('{:url("comment/add")}', { article_id: {$article.id}, parent_id: replyTo, content: content }, function(res){
if(res.code===1){
var d = res.data;
var avatar = d.user.avatar || '/static/blog/img/portrait.jpg';
if(replyTo > 0){
var html = '<li data-id="'+d.id+'"><img src="'+avatar+'"><div class="ci-body"><p style="margin:0;"><span class="ci-name">'+d.user.nickname+'</span><span class="ci-time">'+d.create_text+'</span> <span class="ci-reply" data-id="'+replyTo+'" data-name="'+d.user.nickname+'">回复</span></p><p class="ci-text">'+d.content+'</p></div></li>';
$('.fb-cmt-item[data-id="'+replyTo+'"] > .ci-body > .fb-cmt-children').prepend(html);
} else {
var html = '<li class="fb-cmt-item" data-id="'+d.id+'"><img src="'+avatar+'"><div class="ci-body"><p style="margin:0;"><span class="ci-name">'+d.user.nickname+'</span><span class="ci-time">'+d.create_text+'</span> <span class="ci-reply" data-id="'+d.id+'" data-name="'+d.user.nickname+'">回复</span></p><p class="ci-text">'+d.content+'</p><ul class="fb-cmt-children"></ul></div></li>';
$('#LAY-msg-box').prepend(html);
}
$('#LAY-msg-content').val('');
$('#count').text((parseInt($('#count').text())||0) + 1);
replyTo = 0; $('#replyTip').hide(); $('#cancelReply').hide();
layer.msg(res.msg,{icon:1});
} else { layer.msg(res.msg,{icon:2}); }
});
});
});
</script>
{/block}
@@ -0,0 +1,47 @@
{extend name="layout" /}
{block name="title"}文章列表 - {$siteConf.site_name|default='博客'}{/block}
{block name="content"}
<div class="fb-layout">
<main class="fb-content">
<div class="fb-section-title"><h2>全部文章</h2></div>
{if $articles->isEmpty()}
<div class="fb-empty"><p>站点还没有发布文章,敬请期待。</p></div>
{else}
<div class="fb-post-list">
{volist name="articles" id="article"}
<article class="fb-post">
{if $article.cover_image}
<a class="thumb" href="{:url('blog/article/detail', ['id' => $article.id])}">
<img src="{$article.cover_image}" alt="{$article.title}">
</a>
{/if}
<div class="body">
<span class="cat">{$article.category.name|default='默认'}</span>
<h3><a href="{:url('blog/article/detail', ['id' => $article.id])}">{$article.title}</a></h3>
<p class="summary">{$article.summary|default='(暂无描述)'}</p>
<div class="article-stats" style="margin:2px 0 10px;color:#999;font-size:13px;">
<span style="margin-right:12px;"><i class="layui-icon layui-icon-read"></i> {$article.view_count|default=0} 阅读</span>
<span style="margin-right:12px;"><i class="layui-icon layui-icon-praise"></i> {$article.like_count|default=0} 点赞</span>
<span style="margin-right:12px;"><i class="layui-icon layui-icon-star"></i> {$article.favorite_count|default=0} 收藏</span>
<span style="margin-right:12px;"><i class="layui-icon layui-icon-reply"></i> {$article.comment_count|default=0} 评论</span>
<span><i class="layui-icon layui-icon-share"></i> {$article.share_count|default=0} 分享</span>
</div>
<div class="meta">
<a class="author" href="{:url('blog/article/author', ['id' => $article.uid])}">
<img src="{$article.user.avatar|default='/static/blog/img/avatar.jpg'}" alt="">
{$article.user.nickname|default='佚名'}
</a>
<span class="sp">{$article.create_text}</span>
</div>
</div>
</article>
{/volist}
</div>
<div class="fb-pager">{$articles|raw}</div>
{/if}
</main>
{include file="index/_sidebar" /}
</div>
{/block}
@@ -0,0 +1,52 @@
{extend name="layout" /}
{assign name="nav_active" value="category"/}
{block name="title"}{$category.name|default='全部分类'} - {$siteConf.site_name|default='博客'}{/block}
{block name="content"}
<div class="fb-layout">
<main class="fb-content">
{if $category}
<div class="fb-section-title"><h2>{$category.name}</h2></div>
{if $articles->isEmpty()}
<div class="fb-empty"><p>该分类下暂无文章。</p></div>
{else}
<div class="fb-post-list">
{volist name="articles" id="article"}
<article class="fb-post">
{if $article.cover_image}
<a class="thumb" href="{:url('blog/article/detail', ['id' => $article.id])}">
<img src="{$article.cover_image}" alt="{$article.title}">
</a>
{/if}
<div class="body">
<span class="cat">{$article.category.name|default='默认'}</span>
<h3><a href="{:url('blog/article/detail', ['id' => $article.id])}">{$article.title}</a></h3>
<p class="summary">{$article.summary|default='(暂无描述)'}</p>
<div class="meta">
<a class="author" href="{:url('blog/article/author', ['id' => $article.uid])}">
<img src="{$article.user.avatar|default='/static/blog/img/avatar.jpg'}" alt="">
{$article.user.nickname|default='佚名'}
</a>
<span class="sp">{$article.create_text}</span>
</div>
</div>
</article>
{/volist}
</div>
<div class="fb-pager">{$articles|raw}</div>
{/if}
{else}
<div class="fb-section-title"><h2>全部分类</h2></div>
<div class="fb-card">
<ul class="fb-catlist">
{volist name="categories" id="c"}
<li><a href="{:url('blog/category', ['id' => $c.id])}">{$c.name}<span class="cnt">{$c.card_count|default=0}</span></a></li>
{/volist}
</ul>
</div>
{/if}
</main>
{include file="index/_sidebar" /}
</div>
{/block}
@@ -0,0 +1,48 @@
<!-- 右侧栏:热门文章 / 分类 / 标签云 / 最新评论(前台共用 fb-aside 风格) -->
<aside class="fb-aside">
<div class="fb-widget">
<h4>热门文章</h4>
<ul class="fb-hot">
{volist name="hotArticles" id="a"}
<li>
<span class="rank">{$i}</span>
<a href="{:url('blog/article/detail', ['id' => $a.id])}">{$a.title}</a>
</li>
{/volist}
</ul>
</div>
<div class="fb-widget">
<h4>文章分类</h4>
<ul class="fb-catlist">
{volist name="categories" id="c"}
<li><a href="{:url('blog/category/index', ['id' => $c.id])}">{$c.name}<span class="cnt">{$c.card_count|default=0}</span></a></li>
{/volist}
</ul>
</div>
<div class="fb-widget">
<h4>标签云</h4>
<div class="fb-tagcloud">
{volist name="tags" id="t"}
<a href="{:url('blog/tag/index', ['id' => $t.id])}">{$t.name}</a>
{/volist}
</div>
</div>
{notempty name="latestComments"}
{if !$latestComments->isEmpty()}
<div class="fb-widget">
<h4>最新评论</h4>
<ul class="fb-cmt">
{volist name="latestComments" id="cmt"}
<li>
<span class="who">{$cmt.user.nickname|default='匿名'}</span>
<span class="txt">{$cmt.content}</span>
</li>
{/volist}
</ul>
</div>
{/if}
{/notempty}
</aside>
+22
View File
@@ -0,0 +1,22 @@
{extend name="layout" /}
{assign name="nav_active" value="about"/}
{block name="title"}关于我们 - {$siteConf.site_name|default='博客'}{/block}
{block name="content"}
<div class="fb-layout">
<main class="fb-content">
<div class="fb-page">
<h3>关于我们</h3>
<img src="/static/blog/img/portrait.jpg" alt="" style="width:96px;height:96px;border-radius:50%;margin:0 0 16px;border:3px solid var(--theme);">
<p class="lead">TP-Blog 是一个基于 ThinkPHP 8 开发的现代化博客系统,致力于提供简洁、高效、安全的博客解决方案。</p>
<h3>技术栈</h3>
<div class="layui-progress layui-progress-big" lay-showpercent="true" style="margin-bottom:10px;"><em>PHP</em><div class="layui-progress-bar" lay-percent="80%"></div></div>
<div class="layui-progress layui-progress-big" lay-showpercent="true" style="margin-bottom:10px;"><em>MySQL</em><div class="layui-progress-bar layui-bg-blue" lay-percent="70%"></div></div>
<div class="layui-progress layui-progress-big" lay-showpercent="true"><em>Layui</em><div class="layui-progress-bar layui-bg-orange" lay-percent="90%"></div></div>
<h3 style="margin-top:18px;">联系我们</h3>
<p>邮箱:admin@example.com<br/>GitHubhttps://github.com/yourname/tp-blog</p>
</div>
</main>
{include file="index/_sidebar" /}
</div>
{/block}
@@ -0,0 +1,47 @@
{extend name="layout" /}
{block name="title"}联系我们 - {$siteConf.site_name|default='博客'}{/block}
{block name="content"}
<div class="fb-layout">
<main class="fb-content">
<div class="fb-page">
<h3>联系我们</h3>
<p>邮箱:admin@example.com</p>
<p>工作时间:周一至周五 9:00-18:00</p>
<form class="layui-form" onsubmit="return false;">
<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="请输入您的姓名" 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="email" required lay-verify="required|email" placeholder="请输入您的邮箱" class="layui-input"></div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">留言</label>
<div class="layui-input-block"><textarea name="message" required lay-verify="required" placeholder="请输入您的留言" class="layui-textarea"></textarea></div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="fb-btn" lay-submit lay-filter="contactSubmit">提交留言</button>
<button type="reset" class="fb-btn ghost">重置</button>
</div>
</div>
</form>
</div>
</main>
{include file="index/_sidebar" /}
</div>
{/block}
{block name="script"}
<script>
layui.use(['form', 'layer'], function(){
var form = layui.form, layer = layui.layer;
form.on('submit(contactSubmit)', function(){
layer.msg('感谢您的留言,我们会尽快与您联系!');
return false;
});
});
</script>
{/block}
+124
View File
@@ -0,0 +1,124 @@
{extend name="layout" /}
{block name="title"}{$siteConf.site_name|default='博客'} - 首页{/block}
{block name="content"}
<!-- Hero 推荐 -->
{notempty name="recommendedArticles"}
{if !$recommendedArticles->isEmpty()}
<div class="fb-hero">
<div class="fb-hero-grid">
{volist name="recommendedArticles" id="rec" offset="0" length="1"}
<a class="fb-hero-main" href="{:url('blog/article/detail', ['id' => $rec.id])}">
<img src="{$rec.cover_image|default='/static/blog/img/pic.jpg'}" alt="{$rec.title}">
<div class="fb-hero-cap">
<span class="tag">{$rec.category.name|default='推荐'}</span>
<h2>{$rec.title}</h2>
</div>
</a>
{/volist}
<div class="fb-hero-sub">
{volist name="recommendedArticles" id="rec" offset="1" length="2"}
<a class="fb-hero-sub-item" href="{:url('blog/article/detail', ['id' => $rec.id])}">
<img src="{$rec.cover_image|default='/static/blog/img/code.jpg'}" alt="{$rec.title}">
<div class="fb-hero-cap">
<h3>{$rec.title}</h3>
</div>
</a>
{/volist}
</div>
</div>
</div>
{/if}
{/notempty}
<div class="fb-layout">
<!-- 左:文章流 -->
<main class="fb-content">
<div class="fb-section-title"><h2>最新文章</h2></div>
{if $articles->isEmpty()}
<div class="fb-empty"><p>站点还没有发布文章,敬请期待。</p></div>
{else}
<div class="fb-post-list">
{volist name="articles" id="article"}
<article class="fb-post">
<a class="thumb" href="{:url('blog/article/detail', ['id' => $article.id])}">
<img src="{$article.cover_image|default='/static/blog/img/book.jpg'}" alt="{$article.title}">
</a>
<div class="body">
<span class="cat">{$article.category.name|default='默认'}</span>
<h3><a href="{:url('blog/article/detail', ['id' => $article.id])}">{$article.title}</a></h3>
<p class="summary">{$article.summary|default='(暂无描述)'}</p>
<div class="meta">
<a class="author" href="{:url('blog/article/author', ['id' => $article.uid])}">
<img src="{$article.user.avatar|default='/static/blog/img/avatar.jpg'}" alt="">
{$article.user.nickname|default='佚名'}
</a>
<span><i class="layui-icon layui-icon-read"></i> {$article.view_count|default=0}</span>
<span><i class="layui-icon layui-icon-praise"></i> {$article.like_count|default=0}</span>
<span class="sp">{$article.create_text}</span>
</div>
</div>
</article>
{/volist}
</div>
{/if}
</main>
<!-- 右:侧栏 -->
<aside class="fb-aside">
<div class="fb-widget">
<div class="fb-stat">
<div><div class="num">{$total_articles|default=0}</div><div class="lbl">文章</div></div>
<div><div class="num">{$total_categories|default=0}</div><div class="lbl">分类</div></div>
<div><div class="num">{$total_views|default=0}</div><div class="lbl">阅读</div></div>
</div>
</div>
<div class="fb-widget">
<h4>热门文章</h4>
<ul class="fb-hot">
{volist name="hotArticles" id="a"}
<li>
<span class="rank">{$i}</span>
<a href="{:url('blog/article/detail', ['id' => $a.id])}">{$a.title}</a>
</li>
{/volist}
</ul>
</div>
<div class="fb-widget">
<h4>文章分类</h4>
<ul class="fb-catlist">
{volist name="categories" id="c"}
<li><a href="{:url('blog/category', ['id' => $c.id])}">{$c.name}<span class="cnt">{$c.card_count|default=0}</span></a></li>
{/volist}
</ul>
</div>
<div class="fb-widget">
<h4>标签云</h4>
<div class="fb-tagcloud">
{volist name="tags" id="t"}
<a href="{:url('blog/tag', ['id' => $t.id])}">{$t.name}</a>
{/volist}
</div>
</div>
{notempty name="latestComments"}
{if !$latestComments->isEmpty()}
<div class="fb-widget">
<h4>最新评论</h4>
<ul class="fb-cmt">
{volist name="latestComments" id="cmt"}
<li>
<span class="who">{$cmt.user.nickname|default='匿名'}</span>
<span class="txt">{$cmt.content}</span>
</li>
{/volist}
</ul>
</div>
{/if}
{/notempty}
</aside>
</div>
{/block}
@@ -0,0 +1,35 @@
{extend name="layout" /}
{block name="title"}附近文章{/block}
{block name="content"}
<div class="main article">
<div class="layui-tab layui-tab-brief">
<ul class="layui-tab-title"><li class="layui-this">附近文章(半径 {$radius} km</li></ul>
</div>
<div id="geo-tip" class="item"><div class="item-content"><h3>正在获取您的位置…</h3></div></div>
{if empty($list)}
<div class="item"><div class="item-content"><h3>附近暂无文章</h3><p>可在 URL 手动传 ?lat=&lng= 查看</p></div></div>
{else}
{volist name="list" id="item"}
<div class="item">
<div class="item-content">
<h3><a href="{$item.url}">{$item.title}</a></h3>
<p class="meta"><span>{$item.category}</span>{if $item.distance}<span>约 {$item.distance} km</span>{/if}</p>
</div>
</div>
{/volist}
{/if}
</div>
<script>
(function(){
var tip = document.getElementById('geo-tip');
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(p){
if (tip) tip.style.display='none';
window.location.href = '/blog/nearby?lat='+p.coords.latitude+'&lng='+p.coords.longitude+'&radius=10';
}, function(){ if (tip) tip.querySelector('h3').textContent='获取位置失败,可在 URL 手动传 ?lat=&lng='; });
} else if (tip) {
tip.querySelector('h3').textContent='浏览器不支持定位,请在 URL 手动传 ?lat=&lng=';
}
})();
</script>
{/block}
@@ -0,0 +1,33 @@
{extend name="layout" /}
{block name="title"}搜索:{$keyword} - 静谧博客{/block}
{block name="content"}
<div class="main article">
<div class="layui-tab layui-tab-brief">
<ul class="layui-tab-title"><li class="layui-this">搜索:{$keyword}</li></ul>
</div>
{if $articles->isEmpty()}
<div class="item"><div class="item-content"><h3>没有找到相关文章</h3><p>尝试更换关键词搜索</p></div></div>
{else}
{volist name="articles" id="article"}
<div class="item">
{if $article.cover_image}<img src="{$article.cover_image}" alt="{$article.title}">{/if}
<div class="item-content">
<h3><a href="{:url('blog/article/detail', ['id' => $article.id])}">{$article.title}</a></h3>
<span class="Subtitle">{$article.category.name|default='默认'}</span><br/>
<p>{$article.summary}</p>
<a href="{:url('blog/article/detail', ['id' => $article.id])}"><button class="layui-btn">阅读全文</button></a>
</div>
<div class="date-box">
<div class="date">
<h4>{:date('d', strtotime($article.create_text))}</h4>
<span>{:date('Y/m', strtotime($article.create_text))}</span>
</div>
</div>
</div>
{/volist}
<div id="demo1">{$articles|raw}</div>
{/if}
</div>
{include file="index/_sidebar" /}
{/block}
+179
View File
@@ -0,0 +1,179 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{block name="title"}{$siteConf.site_name|default='博客'}{/block}</title>
<meta name="description" content="{$siteConf.site_description|default=''}" />
<link rel="stylesheet" href="/assets/layui/css/layui.css">
<link rel="stylesheet" href="/static/blog/css/theme.css">
<style>
/* 主题色由后台「基本设置」配置,注入 CSS 变量驱动整站配色 */
:root { --theme: {$theme_color|default='#2d6cdf'}; }
</style>
{block name="style"}{/block}
</head>
<body class="has-tab">
<!-- 顶部导航 -->
<div class="fb-nav">
<div class="inner">
<a href="{:url('blog/index/index')}" class="logo">{$siteConf.site_name|default='博客'}</a>
<nav class="menu">
<a href="{:url('blog/index/index')}" {if $nav_active=='index'}class="active"{/if}>首页</a>
<a href="{:url('category/index')}" {if $nav_active=='category'}class="active"{/if}>分类</a>
<a href="{:url('blog/tag/index')}" {if $nav_active=='tag'}class="active"{/if}>标签</a>
<a href="{:url('blog/index/about')}" {if $nav_active=='about'}class="active"{/if}>关于</a>
</nav>
<form class="search" action="{:url('blog/search/index')}" method="get">
<input type="text" name="keyword" placeholder="搜索文章..." autocomplete="off">
<button type="submit"><i class="layui-icon layui-icon-search"></i></button>
</form>
<div class="user">
{if $is_login}
<a href="{:url('blog/article/author', ['id' => $current_uid])}">我的博客</a>
<a href="{:url('/user/index/index')}">中心</a>
{else}
<a href="{:url('auth/login')}">登录</a>
{/if}
</div>
<button class="hamburger" id="fbMenuBtn"><i class="layui-icon layui-icon-more-vertical"></i></button>
</div>
<div class="drawer" id="fbDrawer">
<a href="{:url('index/index')}">首页</a>
<a href="{:url('category')}">分类</a>
<a href="{:url('/tag')}">标签</a>
<a href="{:url('/index/about')}">关于</a>
<div class="user">
{if $is_login}
<a href="{:url('/article/author', ['id' => $current_uid])}">我的博客</a>
<a href="{:url('user/index')}">个人中心</a>
{else}
<a href="{:url('auth/login')}">登录</a>
{/if}
</div>
</div>
</div>
<!-- 主体内容 -->
<div class="fb-main">
<div class="fb-wrap">
{block name="content"}{/block}
</div>
</div>
<!-- 移动端底部 Tab 导航(精简:只保留高频入口,分类/标签移入顶部汉堡菜单) -->
<div class="fb-tabbar">
<a href="{:url('/blog/index/index')}" {if $nav_active=='index'}class="active"{/if}><i class="layui-icon layui-icon-home"></i>首页</a>
{if $is_login}
<a href="{:url('/blog/article/author', ['id' => $current_uid])}"><i class="layui-icon layui-icon-username"></i>我的</a>
{else}
<a href="{:url('auth/login')}"><i class="layui-icon layui-icon-username"></i>我的</a>
{/if}
</div>
<!-- 页脚 -->
<footer class="fb-footer">
<p>© {:date('Y')} {$siteConf.site_name|default='博客'}. All Rights Reserved.</p>
</footer>
<script src="/assets/layui/layui.js"></script>
<script>
layui.use(['element', 'layer', 'form'], function(){
var element = layui.element, layer = layui.layer, form = layui.form;
window.layer = layer; window.layui = layui;
var drawer = document.getElementById('fbDrawer');
document.getElementById('fbMenuBtn').addEventListener('click', function(e){
e.stopPropagation();
drawer.classList.toggle('open');
});
document.addEventListener('click', function(e){
if (!drawer.contains(e.target) && e.target.id !== 'fbMenuBtn' && !document.getElementById('fbMenuBtn').contains(e.target)) {
drawer.classList.remove('open');
}
});
});
</script>
<!-- 全局登录弹窗(前台通用) -->
<div id="globalLoginBox" style="display:none; padding: 20px 24px;">
<form class="layui-form" id="globalLoginForm" lay-filter="globalLoginForm">
<div class="layui-form-item">
<label class="layui-form-label">用户名</label>
<div class="layui-input-block">
<input type="text" name="username" lay-verify="required" placeholder="请输入用户名" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">密码</label>
<div class="layui-input-block">
<input type="password" name="password" lay-verify="required" placeholder="请输入密码" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">验证码</label>
<div class="layui-input-block">
<div class="layui-row layui-col-space10">
<div class="layui-col-xs7">
<input type="text" name="captcha" lay-verify="required" placeholder="图形验证码" autocomplete="off" class="layui-input">
</div>
<div class="layui-col-xs5">
<img id="globalLoginCaptcha" src="{:url('user/ajax/verify')}" alt="点击刷新验证码" title="点击刷新验证码" style="height:38px;cursor:pointer;border-radius:2px;">
</div>
</div>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="globalLoginSubmit">登 录</button>
<button type="reset" class="layui-btn layui-btn-primary">重 置</button>
</div>
</div>
</form>
</div>
<script>
layui.use(['layer', 'form', 'jquery'], function(){
var layer = layui.layer, form = layui.form, $ = layui.$;
var globalLoginIndex = null;
function refreshGlobalCaptcha(){
$('#globalLoginCaptcha').attr('src', '{:url(\'member/ajax/verify\')}?t=' + Date.now());
}
$('#globalLoginCaptcha').on('click', refreshGlobalCaptcha);
window.openLogin = function(cb){
globalLoginIndex = layer.open({
type: 1, title: '用户登录', area: ['480px', 'auto'],
content: $('#globalLoginBox'),
success: function(){
refreshGlobalCaptcha();
$('#globalLoginForm')[0].reset();
if (typeof cb === 'function') { window.__loginCb = cb; }
}
});
};
form.on('submit(globalLoginSubmit)', function(data){
$.post('{:url(\'member/login/save\')}', data.field, function(res){
if (res.code === 0) {
layer.msg(res.message || '登录成功', {icon: 1});
setTimeout(function(){
if (globalLoginIndex) { layer.close(globalLoginIndex); }
if (typeof window.__loginCb === 'function') {
var cb = window.__loginCb; window.__loginCb = null; cb();
} else { location.reload(); }
}, 600);
} else {
layer.msg(res.message || '登录失败', {icon: 2});
refreshGlobalCaptcha();
}
}, 'json');
return false;
});
$(document).on('click', 'a[href*="auth/login"], a[href*="member/login"]', function(e){
if ($(this).attr('target') === '_blank') { return; }
e.preventDefault();
window.openLogin();
});
});
</script>
{block name="script"}{/block}
</body>
</html>
@@ -0,0 +1,36 @@
{extend name="layout" /}
{block name="title"}搜索:{$keyword} - {$siteConf.site_name|default='博客'}{/block}
{block name="content"}
<div class="fb-layout">
<main class="fb-content">
<div class="fb-section-title"><h2>搜索:{$keyword}</h2></div>
{if $articles->isEmpty()}
<div class="fb-empty"><p>没有找到相关文章,尝试更换关键词搜索。</p></div>
{else}
<div class="fb-post-list">
{volist name="articles" id="article"}
<article class="fb-post">
{if $article.cover_image}
<a class="thumb" href="{:url('blog/article/detail', ['id' => $article.id])}">
<img src="{$article.cover_image}" alt="{$article.title}">
</a>
{/if}
<div class="body">
<span class="cat">{$article.category.name|default='默认'}</span>
<h3><a href="{:url('blog/article/detail', ['id' => $article.id])}">{$article.title}</a></h3>
<p class="summary">{$article.summary|default='(暂无描述)'}</p>
<div class="meta">
<span class="sp">{$article.create_text}</span>
</div>
</div>
</article>
{/volist}
</div>
<div class="fb-pager">{$articles|raw}</div>
{/if}
</main>
{include file="index/_sidebar" /}
</div>
{/block}
+52
View File
@@ -0,0 +1,52 @@
{extend name="layout" /}
{assign name="nav_active" value="tag"/}
{block name="title"}{$tag.name|default='全部标签'} - {$siteConf.site_name|default='博客'}{/block}
{block name="content"}
<div class="fb-layout">
<main class="fb-content">
{if $tag}
<div class="fb-section-title"><h2>标签:{$tag.name}</h2></div>
{if $articles->isEmpty()}
<div class="fb-empty"><p>该标签下暂无文章。</p></div>
{else}
<div class="fb-post-list">
{volist name="articles" id="article"}
<article class="fb-post">
{if $article.cover_image}
<a class="thumb" href="{:url('blog/article/detail', ['id' => $article.id])}">
<img src="{$article.cover_image}" alt="{$article.title}">
</a>
{/if}
<div class="body">
<span class="cat">{$article.category.name|default='默认'}</span>
<h3><a href="{:url('blog/article/detail', ['id' => $article.id])}">{$article.title}</a></h3>
<p class="summary">{$article.summary|default='(暂无描述)'}</p>
<div class="meta">
<a class="author" href="{:url('blog/article/author', ['id' => $article.uid])}">
<img src="{$article.user.avatar|default='/static/blog/img/avatar.jpg'}" alt="">
{$article.user.nickname|default='佚名'}
</a>
<span class="sp">{$article.create_text}</span>
</div>
</div>
</article>
{/volist}
</div>
<div class="fb-pager">{$articles|raw}</div>
{/if}
{else}
<div class="fb-section-title"><h2>全部标签</h2></div>
<div class="fb-card">
<div class="fb-tagcloud">
{volist name="tags" id="t"}
<a href="{:url('blog/tag', ['id' => $t.id])}">{$t.name}</a>
{/volist}
</div>
</div>
{/if}
</main>
{include file="index/_sidebar" /}
</div>
{/block}
+105
View File
@@ -0,0 +1,105 @@
{layout name="layout"}
{block name="title"}写文章 - TP-Blog 会员中心{/block}
{block name="content"}
<div class="layui-card">
<div class="layui-card-header">写文章</div>
<div class="layui-card-body">
<form class="layui-form" action="" id="articleForm">
<div class="layui-form-item">
<label class="layui-form-label">标题</label>
<div class="layui-input-block">
<input type="text" name="title" required lay-verify="required" placeholder="请输入文章标题" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">分类</label>
<div class="layui-input-block">
<select name="cid" lay-verify="required" style="max-width:320px;">
<option value="">请选择分类</option>
{volist name="categories" id="c"}
<option value="{$c.id}">{$c.name}</option>
{/volist}
</select>
<a href="{:url('blog/member.category/index')}" target="_blank" class="layui-btn layui-btn-primary layui-btn-sm" style="margin-left:10px;">管理分类</a>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">标签</label>
<div class="layui-input-block">
<input type="text" name="tags" placeholder="多个标签用逗号分隔,如:PHP,ThinkPHP" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">摘要</label>
<div class="layui-input-block">
<textarea name="summary" placeholder="选填,留空将自动截取正文" class="layui-textarea"></textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">封面图</label>
<div class="layui-input-block">
<input type="text" name="cover_image" placeholder="选填,封面图 URL" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">正文</label>
<div class="layui-input-block">
<script id="editor" type="text/plain" style="height:400px;"></script>
</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="立即发布" checked>
<input type="radio" name="status" value="0" title="存为草稿">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="articleSubmit">提交</button>
<a href="{:url('blog/member.article/index')}" class="layui-btn layui-btn-primary">返回</a>
</div>
</div>
</form>
</div>
</div>
<script type="text/javascript" src="/assets/library/ueditor/ueditor.config.js"></script>
<script type="text/javascript" src="/assets/library/ueditor/ueditor.all.js"></script>
<script>
var ue = UE.getEditor('editor', {
toolbars: [[
'undo', 'redo', '|',
'removeformat', 'formatmatch', '|',
'fontsize', 'bold', 'italic', 'underline', 'strikethrough', 'forecolor', 'backcolor', '|',
'justifyleft', 'justifycenter', 'justifyright', 'justifyjustify', 'indent', '|',
'inserttable', 'blockquote', 'horizontal', 'insertcode', 'emotion', '|',
'searchreplace'
]],
autoHeightEnabled: true,
initialFrameWidth: '100%',
initialFrameHeight: 400
});
layui.use(['form', 'layer'], function(){
var form = layui.form, layer = layui.layer, $ = layui.$;
form.on('submit(articleSubmit)', function(data){
var content = UE.getEditor('editor').getContent();
if (! content.trim()) {
layer.msg('请输入文章内容', {icon:2});
return false;
}
data.field.content = content;
$.post('{:url("blog/member.article/save")}', data.field, function(res){
if(res.code === 1){
layer.msg(res.msg, {icon:1}, function(){ location.href = res.url; });
} else {
layer.msg(res.msg, {icon:2});
}
});
return false;
});
});
</script>
{/block}
+106
View File
@@ -0,0 +1,106 @@
{layout name="layout"}
{block name="title"}编辑文章 - TP-Blog 会员中心{/block}
{block name="content"}
<div class="layui-card">
<div class="layui-card-header">编辑文章</div>
<div class="layui-card-body">
<form class="layui-form" action="" id="articleForm">
<input type="hidden" name="id" value="{$article.id}">
<div class="layui-form-item">
<label class="layui-form-label">标题</label>
<div class="layui-input-block">
<input type="text" name="title" value="{$article.title}" required lay-verify="required" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">分类</label>
<div class="layui-input-block">
<select name="cid" lay-verify="required" style="max-width:320px;">
<option value="">请选择分类</option>
{volist name="categories" id="c"}
<option value="{$c.id}" {if $c.id==$article.cid}selected{/if}>{$c.name}</option>
{/volist}
</select>
<a href="{:url('blog/member.category/index')}" target="_blank" class="layui-btn layui-btn-primary layui-btn-sm" style="margin-left:10px;">管理分类</a>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">标签</label>
<div class="layui-input-block">
<input type="text" name="tags" value="{$articleTagsStr}" placeholder="多个标签用逗号分隔" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">摘要</label>
<div class="layui-input-block">
<textarea name="summary" placeholder="选填,留空将自动截取正文" class="layui-textarea">{$article.summary}</textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">封面图</label>
<div class="layui-input-block">
<input type="text" name="cover_image" value="{$article.cover_image}" placeholder="选填,封面图 URL" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">正文</label>
<div class="layui-input-block">
<script id="editor" type="text/plain" style="height:400px;">{$article.content}</script>
</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="已发布" {if $article.status==1}checked{/if}>
<input type="radio" name="status" value="0" title="草稿" {if $article.status==0}checked{/if}>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="articleSubmit">保存</button>
<a href="{:url('blog/member.article/index')}" class="layui-btn layui-btn-primary">返回</a>
</div>
</div>
</form>
</div>
</div>
<script type="text/javascript" src="/assets/library/ueditor/ueditor.config.js"></script>
<script type="text/javascript" src="/assets/library/ueditor/ueditor.all.js"></script>
<script>
var ue = UE.getEditor('editor', {
toolbars: [[
'undo', 'redo', '|',
'removeformat', 'formatmatch', '|',
'fontsize', 'bold', 'italic', 'underline', 'strikethrough', 'forecolor', 'backcolor', '|',
'justifyleft', 'justifycenter', 'justifyright', 'justifyjustify', 'indent', '|',
'inserttable', 'blockquote', 'horizontal', 'insertcode', 'emotion', '|',
'searchreplace'
]],
autoHeightEnabled: true,
initialFrameWidth: '100%',
initialFrameHeight: 400
});
layui.use(['form', 'layer'], function(){
var form = layui.form, layer = layui.layer, $ = layui.$;
form.on('submit(articleSubmit)', function(data){
var content = UE.getEditor('editor').getContent();
if (! content.trim()) {
layer.msg('请输入文章内容', {icon:2});
return false;
}
data.field.content = content;
$.post('{:url("blog/member.article/update", ["id" => $article.id])}', data.field, function(res){
if(res.code === 1){
layer.msg(res.msg, {icon:1}, function(){ location.href = res.url; });
} else {
layer.msg(res.msg, {icon:2});
}
});
return false;
});
});
</script>
{/block}
+123
View File
@@ -0,0 +1,123 @@
{layout name="layout"}
{block name="content"}
<div class="layui-card">
<div class="layui-card-header">我的文章</div>
<div class="layui-card-body">
<form class="layui-form layui-inline" lay-filter="searchForm">
<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" id="keywordInput">
</div>
<div class="layui-inline">
<button class="layui-btn layui-btn-primary" lay-submit lay-filter="search">搜索</button>
</div>
<a href="{:url('blog/member.article/create')}" class="layui-btn layui-btn-normal">写文章</a>
<button type="button" class="layui-btn layui-btn-danger" id="batchDeleteBtn">批量删除</button>
</form>
<table class="layui-hide" id="articleTable" lay-filter="articleTable"></table>
</div>
</div>
<script type="text/html" id="rowTpl">
<a class="layui-btn layui-btn-xs" href="{{ d.edit_url }}">编辑</a>
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="delete">删除</a>
</script>
{/block}
{block name="js"}
<script>
layui.use(['table', 'layer', 'form'], function () {
var table = layui.table;
var layer = layui.layer;
var form = layui.form;
var $ = layui.$;
var status = '{$status|default="all"}';
var keyword = '{$keyword|default=""}';
$('select[name="status"]').val(status);
$('#keywordInput').val(keyword);
var renderTable = function () {
table.render({
elem: '#articleTable',
url: '{:url("blog/member.article/index")}',
where: { status: status, keyword: keyword },
page: true,
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'title', title: '标题', minWidth: 220, templet: function (d) {
var html = '<a href="' + d.detail_url + '" target="_blank">' + d.title + '</a>';
if (d.is_top == 1) { html += ' <span class="layui-badge layui-bg-red">置顶</span>'; }
return html;
} },
{ field: 'category', title: '分类', width: 120 },
{ 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: 'view_count', title: '阅读', width: 80, sort: true, align: 'center' },
{ field: 'comment_count', title: '评论', width: 80, sort: true, align: 'center' },
{ field: 'like_count', title: '点赞', width: 80, sort: true, align: 'center' },
{ field: 'favorite_count', title: '收藏', width: 80, sort: true, align: 'center' },
{ field: 'share_count', title: '分享', width: 80, sort: true, align: 'center' },
{ field: 'create_text', title: '发布时间', width: 160 },
{ title: '操作', width: 130, align: 'center', toolbar: '#rowTpl', fixed: 'right' }
]]
});
};
renderTable();
// 搜索
form.on('submit(search)', function () {
var statusVal = $('select[name="status"]').val();
var kw = $('#keywordInput').val();
table.reload('articleTable', { where: { status: statusVal, keyword: kw }, page: { curr: 1 } });
return false;
});
// 批量删除
$('#batchDeleteBtn').click(function () {
var checkData = table.checkStatus('articleTable').data;
if (! checkData.length) {
return layer.msg('请选择要删除的文章');
}
var ids = checkData.map(function (i) { return i.id; });
layer.confirm('确定要删除选中的 ' + ids.length + ' 篇文章吗?', function (index) {
$.post('{:url("blog/member.article/batch")}', { action: 'delete', ids: ids }, function (res) {
if (res.code === 1) {
layer.msg(res.msg, { icon: 1 }, function () { renderTable(); });
} else {
layer.msg(res.msg, { icon: 2 });
}
});
layer.close(index);
});
});
// 行操作
table.on('tool(articleTable)', function (obj) {
if (obj.event === 'delete') {
layer.confirm('确定要删除这篇文章吗?', function (index) {
$.post('{:url("blog/member.article/delete")}', { id: obj.data.id }, function (res) {
if (res.code === 1) {
layer.msg(res.msg, { icon: 1 }, function () { renderTable(); });
} else {
layer.msg(res.msg, { icon: 2 });
}
});
layer.close(index);
});
}
});
});
</script>
{/block}
@@ -0,0 +1,43 @@
{layout name="layout" /}
<div class="layui-card">
<div class="layui-card-header">添加分类</div>
<div class="layui-card-body">
<form class="layui-form" action="{:url('blog/member.category/save')}" method="post">
<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">
</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"></textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">上级分类</label>
<div class="layui-input-block">
<select name="pid">
<option value="0">顶级分类</option>
{volist name="categories" id="c"}
<option value="{$c.id}">{$c.name}</option>
{/volist}
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">排序</label>
<div class="layui-input-block">
<input type="number" name="sort" value="0" placeholder="数值越小越靠前" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="categorySubmit">添加分类</button>
<a href="{:url('blog/member.category/index')}" class="layui-btn layui-btn-primary">返回</a>
</div>
</div>
</form>
</div>
</div>
+43
View File
@@ -0,0 +1,43 @@
{layout name="layout" /}
<div class="layui-card">
<div class="layui-card-header">编辑分类</div>
<div class="layui-card-body">
<form class="layui-form" action="{:url('blog/member.category/update', ['id' => $category.id])}" method="post">
<div class="layui-form-item">
<label class="layui-form-label">分类名称</label>
<div class="layui-input-block">
<input type="text" name="name" value="{$category.name}" required lay-verify="required" placeholder="请输入分类名称" autocomplete="off" class="layui-input">
</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">{$category.description}</textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">上级分类</label>
<div class="layui-input-block">
<select name="pid">
<option value="0" {if $category.pid==0}selected{/if}>顶级分类</option>
{volist name="categories" id="c"}
<option value="{$c.id}" {if $category.pid==$c.id}selected{/if}>{$c.name}</option>
{/volist}
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">排序</label>
<div class="layui-input-block">
<input type="number" name="sort" value="{$category.sort}" placeholder="数值越小越靠前" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="categorySubmit">更新分类</button>
<a href="{:url('blog/member.category/index')}" class="layui-btn layui-btn-primary">返回</a>
</div>
</div>
</form>
</div>
</div>
+223
View File
@@ -0,0 +1,223 @@
{layout name="layout" /}
<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>
<!-- 顶部工具栏 -->
<script type="text/html" id="toolbarTpl">
<div class="layui-btn-container">
<button class="layui-btn layui-btn-sm layui-btn-normal" lay-event="createRoot">添加分类</button>
<button class="layui-btn layui-btn-sm layui-btn-danger" lay-event="batchDelete">批量删除</button>
<button class="layui-btn layui-btn-sm" lay-event="expandAll">展开全部</button>
<button class="layui-btn layui-btn-sm" lay-event="foldAll">折叠全部</button>
</div>
</script>
<!-- 行操作 -->
<script type="text/html" id="rowTpl">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="addChild">添加子分类</a>
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="delete">删除</a>
</script>
<!-- 新增/编辑 表单模板(弹窗内渲染) -->
<script type="text/html" id="dataFormTpl">
<div style="padding:15px;">
<form class="layui-form" lay-filter="dataForm">
<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" value="{{ d.name || '' }}" required lay-verify="required" placeholder="请输入分类名称" autocomplete="off" class="layui-input">
</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">
<select name="pid" id="pidSelect"></select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">排序</label>
<div class="layui-input-block">
<input type="number" name="sort" value="{{ d.sort || 0 }}" placeholder="数值越小越靠前" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="dataFormSubmit" id="dataFormSubmit">保存</button>
<button type="button" class="layui-btn layui-btn-primary" id="cancelBtn">取消</button>
</div>
</div>
</form>
</div>
</script>
<script>
layui.use(['treeTable', 'layer', 'form', 'laytpl', 'http'], function () {
var treeTable = layui.treeTable;
var layer = layui.layer;
var $ = layui.$;
var form = layui.form;
var laytpl = layui.laytpl;
var http = layui.http;
// 后端注入的扁平树数据
var treeData = {$treeData|raw};
var pmap = {};
treeData.forEach(function (c) { pmap[c.id] = c; });
// 构建上级分类下拉(编辑时排除自身及其子孙节点,避免形成环)
function buildPidOptions(selfId) {
var html = '<option value="0">顶级分类</option>';
treeData.forEach(function (c) {
if (selfId && c.id === selfId) return;
if (selfId) {
var cur = pmap[c.id];
while (cur && cur.pid) {
if (cur.pid == selfId) return;
cur = pmap[cur.pid];
}
}
html += '<option value="' + c.id + '">' + c.title + '</option>';
});
return html;
}
// 渲染树形表格
function renderTable() {
treeTable.render({
elem: '#dataTable',
data: treeData,
tree: {
customName: { isParent: 'is_parent', name: 'title', id: 'id', pid: 'pid' },
data: { isSimpleData: true, rootPid: 0 },
view: { showIcon: false }
},
toolbar: '#toolbarTpl',
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
{ field: 'title', title: '分类名称', minWidth: 160, fixed: 'left' },
{ field: 'description', title: '描述', minWidth: 160, templet: function (d) { return d.description || '-'; } },
{ field: 'articles_count', title: '文章数', width: 90 },
{ field: 'sort', title: '排序', width: 80, sort: true },
{ title: '操作', width: 230, align: 'center', toolbar: '#rowTpl' }
]],
page: false,
skin: 'line'
});
}
renderTable();
// 新增 / 编辑 弹窗
function openForm(data) {
data = data || {};
var formHtml = laytpl($('#dataFormTpl').html()).render(data);
layer.open({
title: data.id ? '编辑分类' : (data.pid ? '添加子分类' : '添加分类'),
content: formHtml,
anim: 'slideLeft',
offset: 'r',
area: ['480px', '98%'],
shade: 0.1,
shadeClose: true,
btn: ['保存', '取消'],
success: function (layero, index) {
$('#pidSelect').html(buildPidOptions(data.id));
if (data.pid) { $('#pidSelect').val(data.pid); }
form.render('select');
},
yes: function (index, layero) {
layero.contents().find('#dataFormSubmit').trigger('click');
},
btn2: function (index) { layer.close(index); }
});
}
// 表单提交:仅绑定一次(layui form 的 submit 为事件委托,弹窗内同 lay-filter 表单均可触发,不会叠加)
form.on('submit(dataFormSubmit)', function (elem) {
var field = elem.field;
var isEdit = !!field.id;
var url = isEdit
? '{:url("blog/member.category/update")}'
: '{:url("blog/member.category/save")}';
var postData = $.extend({}, field);
if (isEdit) { postData.id = field.id; }
http.post(url, postData).then(function (res) {
if (res.code === 1) {
layer.closeAll('dialog');
layer.msg(res.msg, { icon: 1 });
setTimeout(function () { location.reload(); }, 600);
} else {
layer.msg(res.msg || '操作失败', { icon: 2 });
}
},
error: function () { layer.msg('请求失败,请重试', { icon: 2 }); }
});
return false;
});
// 顶部工具条
treeTable.on('toolbar(dataTable)', function (obj) {
switch (obj.event) {
case 'createRoot':
openForm({ pid: 0, sort: 0 });
break;
case 'batchDelete':
var checkData = treeTable.checkStatus('dataTable').data;
if (!checkData.length) {
return layer.msg('请选择要删除的分类');
}
var ids = checkData.map(function (item) { return item.id; });
layer.confirm('确定删除选中的 ' + ids.length + ' 个分类吗?', function (index) {
$.post('{:url("blog/member.category/batch")}', { action: 'delete', ids: ids }, function (res) {
if (res.code === 1) {
layer.msg(res.msg, { icon: 1 }, function () { location.reload(); });
} else {
layer.msg(res.msg, { icon: 2 });
}
});
layer.close(index);
});
break;
case 'expandAll':
treeTable.expandAll('#dataTable');
break;
case 'foldAll':
treeTable.foldAll('#dataTable');
break;
}
});
// 行操作
treeTable.on('tool(dataTable)', function (obj) {
var data = obj.data;
if (obj.event === 'edit') {
openForm(data);
} else if (obj.event === 'addChild') {
openForm({ pid: data.id, sort: 0 });
} else if (obj.event === 'delete') {
layer.confirm('确定要删除该分类吗?', function (index) {
$.post('{:url("blog/member.category/delete")}', { id: data.id }, function (res) {
if (res.code === 1) {
layer.msg(res.msg, { icon: 1 }, function () { location.reload(); });
} else {
layer.msg(res.msg, { icon: 2 });
}
});
layer.close(index);
});
}
});
});
</script>
+23
View File
@@ -0,0 +1,23 @@
{layout name="layout" /}
<div class="layui-card">
<div class="layui-card-header">会员中心概览</div>
<div class="layui-card-body">
<p>当前会员:<strong>{$user.nickname|default='会员'}</strong>
(账号:{$user.account|default='-'}UID{$user.uid|default='0'}</p>
<p>这是插件 <code>blog</code> 的会员中心入口页,对应文件:
<code>addon/blog/controller/member/Index.php</code></p>
<a href="{:url('blog/member.index/profile')}" class="layui-btn layui-btn-normal">查看个人资料</a>
</div>
</div>
<div class="layui-card">
<div class="layui-card-header">本页链路说明</div>
<div class="layui-card-body">
<ol>
<li>路由:<code>addon/blog/route/app.php</code> 的 member 分组映射 <code>/blog/member/</code><code>member.Index/index</code></li>
<li>鉴权:member 分组挂 <code>MemberAuth</code> 中间件,容器 auth 为 <code>\ywxapp\library\User</code> 并强制登录</li>
<li>基类:<code>Index extends \ywxapp\controller\AddonMember</code>,视图目录指向 <code>view/member/</code></li>
<li>模板:本页通过 <code>{layout name="layout" /}</code> 复用会员中心外壳 <code>view/layout.html</code></li>
</ol>
</div>
</div>
+20
View File
@@ -0,0 +1,20 @@
{layout name="layout" /}
<div class="layui-card">
<div class="layui-card-header">个人资料</div>
<div class="layui-card-body">
<table class="layui-table">
<colgroup>
<col width="30%">
<col width="70%">
</colgroup>
<tbody>
<tr><td>会员ID</td><td>{$user.uid|default='0'}</td></tr>
<tr><td>账号</td><td>{$user.account|default='-'}</td></tr>
<tr><td>昵称</td><td>{$user.nickname|default='-'}</td></tr>
<tr><td>角色</td><td>{$user.roles|default='-'}</td></tr>
<tr><td>积分</td><td>{$user.score|default='0'}</td></tr>
</tbody>
</table>
<a href="{:url('blog/member.index/index')}" class="layui-btn">返回概览</a>
</div>
</div>
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{$title|default='会员中心 - TP-Blog'}</title>
<link rel="stylesheet" href="/assets/layui/css/layui.css">
<script src="/assets/layui/layui.js"></script>
<style>
body { background-color: #f2f3f5; font-family: "Microsoft YaHei", sans-serif; }
.member-nav { background: #23262e; color: #fff; padding: 0 15px; }
.member-nav a { color: #ccc; margin-left: 18px; line-height: 50px; text-decoration: none; font-size: 14px; }
.member-nav a:hover { color: #fff; }
.member-nav .member-nav-brand { font-size: 16px; font-weight: bold; margin-right: 10px; }
.member-nav .member-nav-right { float: right; margin-right: 0; }
</style>
</head>
<body>
<div style="margin-top:20px;margin-bottom:20px;">
{__CONTENT__}
</div>
</body>
</html>