chore: 重写初始提交(清空历史,整理后全量提交)
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\forum;
|
||||
|
||||
use ywxapp\AddonBase;
|
||||
|
||||
class Addon extends addon
|
||||
{
|
||||
/**
|
||||
* 安装
|
||||
*/
|
||||
public function install()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载:清理菜单与权限节点
|
||||
*/
|
||||
public function uninstall()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用
|
||||
*/
|
||||
public function enabled()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用
|
||||
*/
|
||||
public function disabled()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp 轻社区论坛插件配置
|
||||
return [
|
||||
// 站点标题
|
||||
'site_title' => '轻社区',
|
||||
// 每页帖子数
|
||||
'page_size' => 20,
|
||||
// 是否允许游客发帖(false 需登录)
|
||||
'guest_post' => false,
|
||||
// 发帖是否需要审核
|
||||
'need_audit' => 0,
|
||||
// 上传目录(相对于 public/)
|
||||
'upload_dir' => 'static/forum/uploads',
|
||||
|
||||
/* ============ 编辑器(UEditor)配置 ============ */
|
||||
// 编辑器上传根目录(相对于 public/),按插件隔离
|
||||
'editor_upload_dir' => 'uploads/forum',
|
||||
// 图片允许类型(逗号分隔,不含点)
|
||||
'editor_image_ext' => 'jpg,jpeg,png,gif,bmp,webp',
|
||||
// 图片大小上限(MB)
|
||||
'editor_image_size' => 10,
|
||||
// 附件允许类型(逗号分隔,不含点)
|
||||
'editor_file_ext' => 'zip,rar,7z,pdf,doc,docx,xls,xlsx,ppt,pptx,txt,md',
|
||||
// 附件大小上限(MB)
|
||||
'editor_file_size' => 50,
|
||||
// 视频允许类型(逗号分隔,不含点)
|
||||
'editor_video_ext' => 'mp4,webm,ogg,mov',
|
||||
// 视频大小上限(MB)
|
||||
'editor_video_size' => 100,
|
||||
// 远程图片抓取(粘贴 Word 外链图落地):1 开启 0 关闭
|
||||
'editor_catch_image' => 1,
|
||||
// 编辑器初始高度(px)
|
||||
'editor_height' => 360,
|
||||
|
||||
/* ============ 全文检索(ES 可选增强,默认 LIKE) ============ */
|
||||
// 搜索引擎:like=原生模糊检索(默认);es=ElasticSearch
|
||||
'search_engine' => 'like',
|
||||
// ES 节点地址
|
||||
'es_host' => 'http://127.0.0.1:9200',
|
||||
// ES 索引名
|
||||
'es_index' => 'ywxapp_forum',
|
||||
];
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\forum\controller;
|
||||
|
||||
use ywxapp\controller\FrontendBase;
|
||||
|
||||
/**
|
||||
* 论坛前台控制器基类
|
||||
* 继承 AddonFrontend(插件专用前台基类,提供 fetch 与会员 auth 注入)
|
||||
* 注意:框架 BaseController 未暴露 assign(),这里补充 assign() 代理,与 docs 保持一致。
|
||||
*/
|
||||
class ForumFrontend extends FrontendBase
|
||||
{
|
||||
protected $noNeedLogin = ['*'];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
/**
|
||||
* 初始化:调用父类,并保证布局所需的 user 变量始终存在(游客为空数组)
|
||||
*/
|
||||
public function _initialize()
|
||||
{
|
||||
parent::_initialize();
|
||||
$this->assign('member', $this->member() ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* assign 代理,返回 $this 便于链式
|
||||
*/
|
||||
protected function assign($name, $value = null)
|
||||
{
|
||||
$this->view->assign($name, $value);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录会员(未登录返回 null)
|
||||
* @return \ywxapp\model\Member|null
|
||||
*/
|
||||
protected function member()
|
||||
{
|
||||
if ($this->auth && $this->auth->isLogin) {
|
||||
return $this->auth->info;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已登录
|
||||
*/
|
||||
protected function requireLogin(): bool
|
||||
{
|
||||
return $this->auth && $this->auth->isLogin;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\forum\controller;
|
||||
|
||||
use addon\forum\model\ForumBoard;
|
||||
use addon\forum\model\ForumTopic;
|
||||
use addon\forum\model\ForumReply;
|
||||
use addon\forum\model\ForumMessage;
|
||||
use ywxapp\model\BaseModel;
|
||||
use think\facade\Db;
|
||||
|
||||
class Index extends ForumFrontend
|
||||
{
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
/**
|
||||
* 社区首页:版块 + 帖子流
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$boards = ForumBoard::enabledList();
|
||||
$topics = ForumTopic::listFront(0, '', 'new', 1, 20);
|
||||
$hot = ForumTopic::listFront(0, '', 'hot', 1, 10);
|
||||
$this->attachAuthors($topics);
|
||||
$this->attachAuthors($hot);
|
||||
|
||||
// 当前会员未读消息
|
||||
$unread = 0;
|
||||
if ($member = $this->member()) {
|
||||
$unread = ForumMessage::unreadCount((int) $member['uid']);
|
||||
}
|
||||
|
||||
$this->assign([
|
||||
'boards' => $boards,
|
||||
'topics' => $topics,
|
||||
'hot' => $hot,
|
||||
'unread' => $unread,
|
||||
'typeMap'=> ForumTopic::$typeMap,
|
||||
]);
|
||||
return $this->fetch('index/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 版块帖子列表
|
||||
*/
|
||||
public function board($slug = '')
|
||||
{
|
||||
$board = ForumBoard::findBySlug($slug);
|
||||
if (!$board) {
|
||||
$this->assign('msg', '版块不存在');
|
||||
return $this->fetch('index/notfound');
|
||||
}
|
||||
$type = $this->request->param('type', '');
|
||||
$order = $this->request->param('order', 'new');
|
||||
$page = (int) $this->request->param('page', 1);
|
||||
$topics = ForumTopic::listFront((int) $board->id, $type, $order, $page, 20);
|
||||
$this->attachAuthors($topics);
|
||||
|
||||
$this->assign([
|
||||
'board' => $board,
|
||||
'topics' => $topics,
|
||||
'type' => $type,
|
||||
'order' => $order,
|
||||
'typeMap'=> ForumTopic::$typeMap,
|
||||
]);
|
||||
return $this->fetch('index/board');
|
||||
}
|
||||
|
||||
/**
|
||||
* 帖子详情 + 回复列表
|
||||
*/
|
||||
public function topic($id = 0)
|
||||
{
|
||||
$topic = ForumTopic::findById((int) $id);
|
||||
if (!$topic || (int) $topic->status !== 1) {
|
||||
$this->assign('msg', '帖子不存在、待审核或已下架');
|
||||
return $this->fetch('index/notfound');
|
||||
}
|
||||
ForumTopic::addViews((int) $topic->id);
|
||||
|
||||
$replies = ForumReply::listByTopic((int) $topic->id);
|
||||
$board = ForumBoard::find((int) $topic->board_id);
|
||||
|
||||
// 作者信息(复用会员表),带默认值避免模板内联查库
|
||||
$author = Db::name('member')->where('id', $topic->user_id)
|
||||
->field('id,nickname,avatar,intro')->find();
|
||||
if (!$author) {
|
||||
$author = ['id' => $topic->user_id, 'nickname' => '匿名', 'avatar' => '', 'intro' => ''];
|
||||
}
|
||||
|
||||
// 每条回复附带用户信息
|
||||
$uids = array_column($replies->toArray(), 'user_id');
|
||||
$users = [];
|
||||
if (!empty($uids)) {
|
||||
$users = Db::name('member')->where('id', 'in', array_unique($uids))
|
||||
->column('nickname,avatar', 'id');
|
||||
}
|
||||
$replyList = [];
|
||||
foreach ($replies as $rp) {
|
||||
$u = $users[$rp['user_id']] ?? ['nickname' => '匿名', 'avatar' => ''];
|
||||
$replyList[] = [
|
||||
'id' => $rp['id'],
|
||||
'user_id' => $rp['user_id'],
|
||||
'floor' => $rp['floor'],
|
||||
'content' => $rp['content'],
|
||||
'create_at' => $rp['create_at'],
|
||||
'nickname' => $u['nickname'] ?: '匿名',
|
||||
'avatar' => $u['avatar'] ?: '',
|
||||
];
|
||||
}
|
||||
|
||||
$this->assign([
|
||||
'topic' => $topic,
|
||||
'author' => $author,
|
||||
'replyList' => $replyList,
|
||||
'board' => $board,
|
||||
'related' => \addon\forum\service\ForumSearchService::related((int) $topic->id, 6),
|
||||
'typeName' => ForumTopic::$typeMap[$topic->type] ?? '',
|
||||
]);
|
||||
return $this->fetch('index/topic');
|
||||
}
|
||||
|
||||
/**
|
||||
* 发帖页
|
||||
*/
|
||||
public function post()
|
||||
{
|
||||
if (!$this->requireLogin()) {
|
||||
return redirect('/user/login');
|
||||
}
|
||||
$boards = ForumBoard::enabledList();
|
||||
$this->assign('boards', $boards);
|
||||
$this->assign('typeMap', ForumTopic::$typeMap);
|
||||
$cfg = \ywxapp\service\AddonService::config('forum');
|
||||
$this->assign('editorHeight', (int) ($cfg['editor_height'] ?? 360));
|
||||
return $this->fetch('index/post');
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交发帖
|
||||
*/
|
||||
public function doPost()
|
||||
{
|
||||
if (!$this->requireLogin()) {
|
||||
return $this->error('请先登录');
|
||||
}
|
||||
$member = $this->member();
|
||||
$data = $this->request->post();
|
||||
|
||||
$title = trim((string) ($data['title'] ?? ''));
|
||||
$content = trim((string) ($data['content'] ?? ''));
|
||||
$boardId = (int) ($data['board_id'] ?? 0);
|
||||
$type = (int) ($data['type'] ?? 0);
|
||||
$lat = (float) ($data['lat'] ?? 0);
|
||||
$lng = (float) ($data['lng'] ?? 0);
|
||||
|
||||
if ($title === '' || $content === '' || $boardId < 1) {
|
||||
return $this->error('请填写完整信息');
|
||||
}
|
||||
if (!isset(ForumTopic::$typeMap[$type])) {
|
||||
$type = ForumTopic::TYPE_ASK;
|
||||
}
|
||||
|
||||
$needAudit = (int) config('forum.need_audit');
|
||||
$status = $needAudit ? 0 : 1; // 0待审 1正常
|
||||
$auditReason = '';
|
||||
|
||||
// 机审(云审):开启 need_audit 且无 AI 审核服务时,按策略直接通过/转人工
|
||||
if ($needAudit) {
|
||||
$ai = new \addon\wxchat\service\AiAuditService();
|
||||
$res = $ai->audit($title . "\n" . $content);
|
||||
if ($res['code'] === 0) {
|
||||
if (!empty($res['data']['blocked'])) {
|
||||
// 机审命中违规:直接驳回,不进入人工队列
|
||||
$status = -1;
|
||||
$auditReason = '机审不通过:' . ($res['data']['reason'] ?? '内容违规');
|
||||
}
|
||||
// 机审通过:保持待审(status=0),等待人工复核
|
||||
}
|
||||
// AI 服务不可用(code!=0 且非 blocked):维持待审,转人工队列
|
||||
}
|
||||
|
||||
\addon\forum\library\ForumSchema::ensure();
|
||||
$topicId = ForumTopic::insertGetId([
|
||||
'board_id' => $boardId,
|
||||
'user_id' => $member['id'],
|
||||
'title' => $title,
|
||||
'content' => $content,
|
||||
'type' => $type,
|
||||
'status' => $status,
|
||||
'lat' => $lat,
|
||||
'lng' => $lng,
|
||||
'audit_reason' => $auditReason,
|
||||
'create_at' => time(),
|
||||
'update_at' => time(),
|
||||
]);
|
||||
|
||||
ForumBoard::incr($boardId, 'topic_count', 1);
|
||||
\addon\forum\service\ForumSearchService::sync($topicId);
|
||||
|
||||
if ($status === -1) {
|
||||
return $this->success(['id' => $topicId], $auditReason);
|
||||
}
|
||||
return $this->success(['id' => $topicId], $needAudit ? '发布成功,等待审核' : '发布成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交回复
|
||||
*/
|
||||
public function doReply()
|
||||
{
|
||||
if (!$this->requireLogin()) {
|
||||
return $this->error('请先登录');
|
||||
}
|
||||
$member = $this->member();
|
||||
$data = $this->request->post();
|
||||
$topicId = (int) ($data['topic_id'] ?? 0);
|
||||
$content = trim((string) ($data['content'] ?? ''));
|
||||
|
||||
if ($topicId < 1 || $content === '') {
|
||||
return $this->error('回复内容不能为空');
|
||||
}
|
||||
$topic = ForumTopic::findById($topicId);
|
||||
if (!$topic) {
|
||||
return $this->error('帖子不存在');
|
||||
}
|
||||
|
||||
$floor = ForumReply::countByTopic($topicId) + 1;
|
||||
$replyId = ForumReply::insertGetId([
|
||||
'topic_id' => $topicId,
|
||||
'user_id' => $member['id'],
|
||||
'content' => $content,
|
||||
'floor' => $floor,
|
||||
'status' => 1,
|
||||
'create_at' => time(),
|
||||
'update_at' => time(),
|
||||
]);
|
||||
|
||||
// 更新帖子回复计数 + 最后回复
|
||||
ForumTopic::where('id', $topicId)->inc('reply_count', 1)->update([
|
||||
'last_reply_id' => $replyId,
|
||||
'last_reply_uid' => $member['id'],
|
||||
'last_reply_time' => time(),
|
||||
]);
|
||||
ForumBoard::incr((int) $topic->board_id, 'reply_count', 1);
|
||||
|
||||
// 给帖子作者发消息(非自己)
|
||||
if ($topic->user_id != $member['id']) {
|
||||
ForumMessage::insert([
|
||||
'user_id' => $topic->user_id,
|
||||
'from_uid' => $member['id'],
|
||||
'type' => 'reply',
|
||||
'topic_id' => $topicId,
|
||||
'reply_id' => $replyId,
|
||||
'content' => mb_substr(strip_tags($content), 0, 120),
|
||||
'is_read' => 0,
|
||||
'create_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->success(['floor' => $floor], '回复成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索
|
||||
*/
|
||||
public function search()
|
||||
{
|
||||
$kw = trim((string) $this->request->param('q', ''));
|
||||
$list = [];
|
||||
if ($kw !== '') {
|
||||
$list = \addon\forum\service\ForumSearchService::search($kw, (int) $this->request->param('page', 1), 20);
|
||||
}
|
||||
$this->assign('kw', $kw);
|
||||
$this->assign('list', $list);
|
||||
return $this->fetch('index/search');
|
||||
}
|
||||
|
||||
/**
|
||||
* 附近帖子(GEO/LBS):由前端定位传入 lat/lng,展示半径内帖子
|
||||
*/
|
||||
public function nearby()
|
||||
{
|
||||
$lat = (float) $this->request->param('lat', 0);
|
||||
$lng = (float) $this->request->param('lng', 0);
|
||||
$radius = (float) $this->request->param('radius', 10);
|
||||
$list = \addon\forum\service\ForumSearchService::nearby($lat, $lng, $radius, 30);
|
||||
$this->assign('list', $list);
|
||||
$this->assign('lat', $lat);
|
||||
$this->assign('lng', $lng);
|
||||
$this->assign('radius', $radius);
|
||||
return $this->fetch('index/nearby');
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量给帖子列表(Paginator)注入 author 字段,避免模板读取不存在的关联属性
|
||||
* @param \think\Paginator $paginator
|
||||
*/
|
||||
private function attachAuthors($collection)
|
||||
{
|
||||
if (!is_iterable($collection)) {
|
||||
return;
|
||||
}
|
||||
$uids = [];
|
||||
foreach ($collection as $t) {
|
||||
$uids[] = $t['user_id'] ?? ($t->user_id ?? 0);
|
||||
}
|
||||
$uids = array_filter(array_unique($uids));
|
||||
$users = [];
|
||||
if (!empty($uids)) {
|
||||
$users = Db::name('member')->where('id', 'in', $uids)
|
||||
->column('nickname,avatar', 'id');
|
||||
}
|
||||
foreach ($collection as $t) {
|
||||
$uid = $t['user_id'] ?? ($t->user_id ?? 0);
|
||||
$u = $users[$uid] ?? ['nickname' => '匿名', 'avatar' => ''];
|
||||
$t->author = [
|
||||
'nickname' => $u['nickname'] ?: '匿名',
|
||||
'avatar' => $u['avatar'] ?: '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户主页(展示其论坛数据)
|
||||
*/
|
||||
public function user($id = 0)
|
||||
{
|
||||
$user = Db::name('member')->where('id', (int) $id)->field('id,nickname,avatar,intro')->find();
|
||||
if (!$user) {
|
||||
$this->assign('msg', '用户不存在');
|
||||
return $this->fetch('index/notfound');
|
||||
}
|
||||
$topics = ForumTopic::where('user_id', (int) $id)
|
||||
->where('status', 1)
|
||||
->order('id desc')
|
||||
->limit(20)->select();
|
||||
$this->assign('member', $user);
|
||||
$this->assign('topics', $topics);
|
||||
return $this->fetch('index/user');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\forum\controller\backend;
|
||||
|
||||
use addon\forum\model\ForumBoard;
|
||||
use ywxapp\controller\BackendBase;
|
||||
|
||||
class Board extends BackendBase
|
||||
{
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
public function index()
|
||||
{
|
||||
$list = ForumBoard::order('sort asc,id asc')->select();
|
||||
$this->assign('list', $list);
|
||||
return $this->fetch('board/index');
|
||||
}
|
||||
|
||||
public function add()
|
||||
{
|
||||
return $this->fetch('board/add');
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$data['create_at'] = time();
|
||||
$data['update_at'] = time();
|
||||
ForumBoard::insert($data);
|
||||
return $this->success([], '保存成功');
|
||||
}
|
||||
|
||||
public function edit($id = 0)
|
||||
{
|
||||
$row = ForumBoard::find($id);
|
||||
$this->assign('row', $row);
|
||||
return $this->fetch('board/edit');
|
||||
}
|
||||
|
||||
public function update($id = 0)
|
||||
{
|
||||
$row = ForumBoard::find($id);
|
||||
if (!$row) {
|
||||
return $this->error('记录不存在');
|
||||
}
|
||||
$row->save($this->request->post());
|
||||
return $this->success([], '更新成功');
|
||||
}
|
||||
|
||||
public function delete($id = 0)
|
||||
{
|
||||
ForumBoard::where('id', $id)->delete();
|
||||
return $this->success([], '删除成功');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?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\forum\controller\backend;
|
||||
|
||||
use think\facade\Config;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\service\AddonService;
|
||||
|
||||
/**
|
||||
* 编辑器(UEditor)配置
|
||||
*
|
||||
* 在后台可视化管理发帖富文本编辑器的参数:
|
||||
* 上传目录、图片/附件/视频的允许类型与大小上限、远程图片抓取开关、初始高度。
|
||||
* 数据持久化到框架统一的插件配置表(wxapp_addon_config),运行时由
|
||||
* AddonService::config('forum') 读取并与 config.php 默认值合并。
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Editor extends BackendBase
|
||||
{
|
||||
/**
|
||||
* 可被后台保存的配置字段 => 默认值
|
||||
*/
|
||||
protected array $fields = [
|
||||
'editor_upload_dir' => 'uploads/forum',
|
||||
'editor_image_ext' => 'jpg,jpeg,png,gif,bmp,webp',
|
||||
'editor_image_size' => 10,
|
||||
'editor_file_ext' => 'zip,rar,7z,pdf,doc,docx,xls,xlsx,ppt,pptx,txt,md',
|
||||
'editor_file_size' => 50,
|
||||
'editor_video_ext' => 'mp4,webm,ogg,mov',
|
||||
'editor_video_size' => 100,
|
||||
'editor_catch_image' => 1,
|
||||
'editor_height' => 360,
|
||||
];
|
||||
|
||||
/**
|
||||
* 读取当前有效配置(config.php 默认 + 后台保存覆盖)
|
||||
*/
|
||||
protected function currentConfig(): array
|
||||
{
|
||||
$defaults = (array) Config::get('forum', []);
|
||||
$saved = AddonService::config('forum');
|
||||
// 用 $fields 的默认值兜底,确保所有后台字段都有值(模板无需 default 过滤器)
|
||||
return array_merge($this->fields, $defaults, $saved);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置页
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$cfg = $this->currentConfig();
|
||||
$this->assign('cfg', $cfg);
|
||||
return $this->fetch('editor/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
$post = (array) $this->request->post();
|
||||
|
||||
$values = [];
|
||||
foreach ($this->fields as $key => $default) {
|
||||
if (!isset($post[$key])) {
|
||||
continue;
|
||||
}
|
||||
$raw = $post[$key];
|
||||
|
||||
if (is_bool($default)) {
|
||||
$values[$key] = $raw ? 1 : 0;
|
||||
} elseif (is_int($default)) {
|
||||
$values[$key] = (int) $raw;
|
||||
} else {
|
||||
// 字符串 / 逗号分隔扩展名列表:去空格、过滤空项、小写
|
||||
$str = trim((string) $raw);
|
||||
if (str_contains($str, ',')) {
|
||||
$parts = array_filter(array_map(
|
||||
fn($s) => strtolower(trim($s, " .\t\n\r\0\x0B")),
|
||||
explode(',', $str)
|
||||
), fn($s) => $s !== '');
|
||||
$str = implode(',', $parts);
|
||||
}
|
||||
$values[$key] = $str;
|
||||
}
|
||||
}
|
||||
|
||||
// 上传目录安全校验:仅允许相对路径,禁止目录穿越
|
||||
if (isset($values['editor_upload_dir'])) {
|
||||
$dir = preg_replace('#\.+/#', '', str_replace('\\', '/', $values['editor_upload_dir']));
|
||||
$values['editor_upload_dir'] = trim($dir, '/') ?: $this->fields['editor_upload_dir'];
|
||||
}
|
||||
|
||||
AddonService::config('forum', $values);
|
||||
|
||||
return $this->result->success([], '编辑器配置已保存');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\forum\controller\backend;
|
||||
|
||||
use addon\forum\model\ForumReply;
|
||||
use ywxapp\controller\BackendBase;
|
||||
|
||||
class Reply extends BackendBase
|
||||
{
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
public function index()
|
||||
{
|
||||
// layui.table 动态加载
|
||||
if ($this->request->isAjax()) {
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limit = $this->request->param('limit/d', 20);
|
||||
|
||||
$map = [];
|
||||
// 主题 ID 过滤
|
||||
if ($topicId = $this->request->param('topic_id/d')) {
|
||||
$map[] = ['r.topic_id', '=', $topicId];
|
||||
}
|
||||
// 关键词搜索(内容模糊匹配)
|
||||
if ($keyword = $this->request->param('keyword/s')) {
|
||||
$map[] = ['r.content', 'like', '%' . $keyword . '%'];
|
||||
}
|
||||
|
||||
$rows = ForumReply::alias('r')
|
||||
->join('forum_topic t', 't.id = r.topic_id', 'left')
|
||||
->join('user u', 'u.uid = r.user_id', 'left')
|
||||
->where($map)
|
||||
->field([
|
||||
'r.*',
|
||||
't.title as topic_title',
|
||||
'u.nickname as user_name',
|
||||
])
|
||||
->order('r.create_at', 'desc')
|
||||
->paginate([
|
||||
'list_rows' => $limit,
|
||||
'page' => $page,
|
||||
]);
|
||||
|
||||
$list = $rows->items();
|
||||
$total = $rows->total();
|
||||
foreach ($list as &$item) {
|
||||
$item['content_preview'] = mb_substr(strip_tags($item['content'] ?? ''), 0, 40);
|
||||
}
|
||||
unset($item);
|
||||
|
||||
return $this->result->setCount($total)->success($list);
|
||||
}
|
||||
|
||||
$this->assign('title', '回复管理');
|
||||
return $this->fetch('reply/index');
|
||||
}
|
||||
|
||||
public function delete($id = 0)
|
||||
{
|
||||
$id = $id ?: $this->request->param('id/d');
|
||||
$row = ForumReply::find($id);
|
||||
if ($row) {
|
||||
$row->status = -1;
|
||||
$row->save();
|
||||
}
|
||||
return $this->success([], '删除成功');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\forum\controller\backend;
|
||||
|
||||
use addon\forum\model\ForumTag;
|
||||
use ywxapp\controller\BackendBase;
|
||||
|
||||
class Tag extends BackendBase
|
||||
{
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
public function index()
|
||||
{
|
||||
$list = ForumTag::order('id desc')->select();
|
||||
$this->assign('list', $list);
|
||||
return $this->fetch('tag/index');
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$name = trim((string) $this->request->post('name', ''));
|
||||
if ($name === '') {
|
||||
return $this->error('名称不能为空');
|
||||
}
|
||||
ForumTag::create(['name' => $name, 'create_at' => time()]);
|
||||
return $this->success([], '保存成功');
|
||||
}
|
||||
|
||||
public function delete($id = 0)
|
||||
{
|
||||
ForumTag::where('id', $id)->delete();
|
||||
return $this->success([], '删除成功');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\forum\controller\backend;
|
||||
|
||||
use addon\forum\model\ForumTopic;
|
||||
use addon\forum\model\ForumBoard;
|
||||
use ywxapp\controller\BackendBase;
|
||||
|
||||
class Topic extends BackendBase
|
||||
{
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
public function index()
|
||||
{
|
||||
$status = $this->request->param('status', '');
|
||||
$q = ForumTopic::with(['board']);
|
||||
if ($status !== '' && in_array((int) $status, [-1, 0, 1], true)) {
|
||||
$q->where('status', (int) $status);
|
||||
}
|
||||
$list = $q->order('id desc')->paginate(20);
|
||||
$this->assign('list', $list);
|
||||
$this->assign('typeMap', ForumTopic::$typeMap);
|
||||
$this->assign('filterStatus', $status);
|
||||
return $this->fetch('topic/index');
|
||||
}
|
||||
|
||||
public function edit($id = 0)
|
||||
{
|
||||
$row = ForumTopic::find($id);
|
||||
$boards = ForumBoard::enabledList();
|
||||
$this->assign('row', $row);
|
||||
$this->assign('boards', $boards);
|
||||
$this->assign('typeMap', ForumTopic::$typeMap);
|
||||
return $this->fetch('topic/edit');
|
||||
}
|
||||
|
||||
public function update($id = 0)
|
||||
{
|
||||
$row = ForumTopic::find($id);
|
||||
if (!$row) {
|
||||
return $this->error('记录不存在');
|
||||
}
|
||||
$row->save($this->request->post());
|
||||
\addon\forum\service\ForumSearchService::sync($id);
|
||||
return $this->success([], '更新成功');
|
||||
}
|
||||
|
||||
public function delete($id = 0)
|
||||
{
|
||||
$row = ForumTopic::find($id);
|
||||
if ($row) {
|
||||
$row->status = -1;
|
||||
$row->save();
|
||||
\addon\forum\service\ForumSearchService::remove($id);
|
||||
}
|
||||
return $this->success([], '删除成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 置顶/加精/审核状态切换
|
||||
*/
|
||||
public function state($id = 0)
|
||||
{
|
||||
$field = $this->request->post('field', '');
|
||||
$value = (int) $this->request->post('value', 0);
|
||||
if (!in_array($field, ['is_top', 'is_essence', 'status'])) {
|
||||
return $this->error('非法字段');
|
||||
}
|
||||
ForumTopic::where('id', $id)->update([$field => $value]);
|
||||
return $this->success([], '操作成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过(人工复核):置为正常可见
|
||||
*/
|
||||
public function audit($id = 0)
|
||||
{
|
||||
$row = ForumTopic::find($id);
|
||||
if (!$row) {
|
||||
return $this->error('帖子不存在');
|
||||
}
|
||||
$row->status = 1;
|
||||
$row->audit_reason = '人工审核通过';
|
||||
$row->save();
|
||||
return $this->success([], '审核通过');
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核驳回:下架并填写理由
|
||||
*/
|
||||
public function reject($id = 0)
|
||||
{
|
||||
$row = ForumTopic::find($id);
|
||||
if (!$row) {
|
||||
return $this->error('帖子不存在');
|
||||
}
|
||||
$reason = trim((string) $this->request->post('reason', ''));
|
||||
$row->status = -1;
|
||||
$row->audit_reason = $reason === '' ? '人工审核驳回' : $reason;
|
||||
$row->save();
|
||||
return $this->success([], '已驳回');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
<?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\forum\controller\backend;
|
||||
|
||||
use think\facade\Config;
|
||||
use think\Response;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\service\AddonService;
|
||||
|
||||
/**
|
||||
* UEditor 服务端统一入口(论坛发帖富文本)
|
||||
*
|
||||
* 按 action 参数分发:
|
||||
* config 返回编辑器配置
|
||||
* uploadimage 图片上传
|
||||
* uploadfile 附件上传
|
||||
* uploadvideo 视频上传
|
||||
* listimage 图片管理器列表
|
||||
* listfile 附件管理器列表
|
||||
* catchimage 远程图片抓取(粘贴 Word 时把外链图落地)
|
||||
*
|
||||
* 全部参数(上传目录、允许类型、大小上限、抓取开关)均由后台「编辑器配置」页
|
||||
* 持久化到插件配置表,运行期与 config.php 默认值合并后生效。
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Ueditor extends BackendBase
|
||||
{
|
||||
/**
|
||||
* 合并后的有效配置
|
||||
*/
|
||||
protected array $cfg = [];
|
||||
|
||||
/**
|
||||
* 从 config.php 默认值 + 后台保存值(插件配置表)合并出当前配置
|
||||
*/
|
||||
protected function cfg(): array
|
||||
{
|
||||
if ($this->cfg) {
|
||||
return $this->cfg;
|
||||
}
|
||||
$defaults = (array) Config::get('forum', []);
|
||||
$saved = AddonService::config('forum');
|
||||
$this->cfg = array_merge($defaults, $saved);
|
||||
return $this->cfg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取逗号分隔扩展名列表为小写数组
|
||||
*/
|
||||
protected function extArray(string $key, array $fallback): array
|
||||
{
|
||||
$raw = $this->cfg()[$key] ?? '';
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
return $fallback;
|
||||
}
|
||||
return array_values(array_filter(array_map(
|
||||
fn($e) => strtolower(trim($e, " .\t\n\r\0\x0B")),
|
||||
explode(',', $raw)
|
||||
), fn($e) => $e !== ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取图片/附件/视频的允许扩展名
|
||||
*/
|
||||
protected function imageExt(): array
|
||||
{
|
||||
return $this->extArray('editor_image_ext', ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']);
|
||||
}
|
||||
|
||||
protected function fileExt(): array
|
||||
{
|
||||
return $this->extArray('editor_file_ext', ['zip', 'rar', '7z', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md']);
|
||||
}
|
||||
|
||||
protected function videoExt(): array
|
||||
{
|
||||
return $this->extArray('editor_video_ext', ['mp4', 'webm', 'ogg', 'mov']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取大小上限(MB -> 字节)
|
||||
*/
|
||||
protected function sizeBytes(string $key, int $mbFallback): int
|
||||
{
|
||||
$v = (int) ($this->cfg()[$key] ?? $mbFallback);
|
||||
return max(1, $v) * 1048576;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一入口
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$action = (string) $this->request->param('action', '');
|
||||
|
||||
switch ($action) {
|
||||
case 'config':
|
||||
return $this->jsonp($this->editorConfig());
|
||||
case 'uploadimage':
|
||||
return $this->jsonp($this->upload('upfile', $this->imageExt(), 'image'));
|
||||
case 'uploadfile':
|
||||
return $this->jsonp($this->upload('upfile', $this->fileExt(), 'file'));
|
||||
case 'uploadvideo':
|
||||
return $this->jsonp($this->upload('upfile', $this->videoExt(), 'video'));
|
||||
case 'uploadscrawl':
|
||||
return $this->jsonp($this->uploadScrawl());
|
||||
case 'listimage':
|
||||
return $this->jsonp($this->listFiles('image', $this->imageExt()));
|
||||
case 'listfile':
|
||||
return $this->jsonp($this->listFiles('file', $this->fileExt()));
|
||||
case 'catchimage':
|
||||
return $this->jsonp($this->catchImage());
|
||||
default:
|
||||
return $this->jsonp(['state' => '请求地址出错']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出 JSON,兼容 UEditor 的 jsonp 回调
|
||||
*/
|
||||
protected function jsonp(array $data): Response
|
||||
{
|
||||
$callback = (string) $this->request->param('callback', '');
|
||||
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($callback !== '' && preg_match('/^[A-Za-z_][A-Za-z0-9_\.]*$/', $callback)) {
|
||||
return Response::create($callback . '(' . $json . ')', 'html')
|
||||
->contentType('application/javascript');
|
||||
}
|
||||
|
||||
return Response::create($json, 'html')->contentType('application/json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器服务端配置
|
||||
*/
|
||||
protected function editorConfig(): array
|
||||
{
|
||||
$prefix = '/' . trim($this->uploadDir(), '/') . '/';
|
||||
$imageExt = $this->imageExt();
|
||||
$fileExt = $this->fileExt();
|
||||
$videoExt = $this->videoExt();
|
||||
$imageSize = $this->sizeBytes('editor_image_size', 10);
|
||||
$fileSize = $this->sizeBytes('editor_file_size', 50);
|
||||
$videoSize = $this->sizeBytes('editor_video_size', 100);
|
||||
|
||||
return [
|
||||
'imageActionName' => 'uploadimage',
|
||||
'imageFieldName' => 'upfile',
|
||||
'imageMaxSize' => $imageSize,
|
||||
'imageAllowFiles' => array_map(fn($e) => '.' . $e, $imageExt),
|
||||
'imageCompressEnable' => true,
|
||||
'imageCompressBorder' => 1600,
|
||||
'imageInsertAlign' => 'none',
|
||||
'imageUrlPrefix' => '',
|
||||
'imagePathFormat' => $prefix . 'image/{yyyy}{mm}{dd}/{time}{rand:6}',
|
||||
|
||||
'scrawlActionName' => 'uploadscrawl',
|
||||
'scrawlFieldName' => 'upfile',
|
||||
'scrawlPathFormat' => $prefix . 'image/{yyyy}{mm}{dd}/{time}{rand:6}',
|
||||
'scrawlMaxSize' => $imageSize,
|
||||
'scrawlUrlPrefix' => '',
|
||||
'scrawlInsertAlign' => 'none',
|
||||
|
||||
'catcherLocalDomain' => ['127.0.0.1', 'localhost'],
|
||||
'catcherActionName' => 'catchimage',
|
||||
'catcherFieldName' => 'source',
|
||||
'catcherPathFormat' => $prefix . 'image/{yyyy}{mm}{dd}/{time}{rand:6}',
|
||||
'catcherUrlPrefix' => '',
|
||||
'catcherMaxSize' => $imageSize,
|
||||
'catcherAllowFiles' => array_map(fn($e) => '.' . $e, $imageExt),
|
||||
|
||||
'videoActionName' => 'uploadvideo',
|
||||
'videoFieldName' => 'upfile',
|
||||
'videoPathFormat' => $prefix . 'video/{yyyy}{mm}{dd}/{time}{rand:6}',
|
||||
'videoUrlPrefix' => '',
|
||||
'videoMaxSize' => $videoSize,
|
||||
'videoAllowFiles' => array_map(fn($e) => '.' . $e, $videoExt),
|
||||
|
||||
'fileActionName' => 'uploadfile',
|
||||
'fileFieldName' => 'upfile',
|
||||
'filePathFormat' => $prefix . 'file/{yyyy}{mm}{dd}/{time}{rand:6}',
|
||||
'fileUrlPrefix' => '',
|
||||
'fileMaxSize' => $fileSize,
|
||||
'fileAllowFiles' => array_map(fn($e) => '.' . $e, $fileExt),
|
||||
|
||||
'imageManagerActionName' => 'listimage',
|
||||
'imageManagerListPath' => $prefix . 'image/',
|
||||
'imageManagerListSize' => 20,
|
||||
'imageManagerUrlPrefix' => '',
|
||||
'imageManagerInsertAlign' => 'none',
|
||||
'imageManagerAllowFiles' => array_map(fn($e) => '.' . $e, $imageExt),
|
||||
|
||||
'fileManagerActionName' => 'listfile',
|
||||
'fileManagerListPath' => $prefix . 'file/',
|
||||
'fileManagerUrlPrefix' => '',
|
||||
'fileManagerListSize' => 20,
|
||||
'fileManagerAllowFiles' => array_map(fn($e) => '.' . $e, $fileExt),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用上传处理
|
||||
*/
|
||||
protected function upload(string $field, array $allowExt, string $group): array
|
||||
{
|
||||
$file = $this->request->file($field);
|
||||
if (!$file) {
|
||||
return ['state' => '未找到上传文件'];
|
||||
}
|
||||
|
||||
$ext = strtolower($file->getOriginalExtension());
|
||||
if (!in_array($ext, $allowExt, true)) {
|
||||
return ['state' => '不允许的文件类型:' . $ext];
|
||||
}
|
||||
|
||||
$limit = $group === 'video'
|
||||
? $this->sizeBytes('editor_video_size', 100)
|
||||
: ($group === 'file' ? $this->sizeBytes('editor_file_size', 50) : $this->sizeBytes('editor_image_size', 10));
|
||||
if ($file->getSize() > $limit) {
|
||||
return ['state' => '文件大小超出限制'];
|
||||
}
|
||||
|
||||
if ($group === 'image') {
|
||||
$info = @getimagesize($file->getRealPath());
|
||||
if ($info === false) {
|
||||
return ['state' => '文件不是有效的图片'];
|
||||
}
|
||||
}
|
||||
|
||||
$relativeDir = trim($this->uploadDir(), '/') . '/' . $group . '/' . date('Ymd');
|
||||
$targetDir = public_path() . str_replace('/', DIRECTORY_SEPARATOR, $relativeDir);
|
||||
|
||||
if (!is_dir($targetDir) && !@mkdir($targetDir, 0755, true) && !is_dir($targetDir)) {
|
||||
return ['state' => '上传目录创建失败,请检查权限'];
|
||||
}
|
||||
|
||||
$original = $file->getOriginalName();
|
||||
$saveName = date('His') . substr(md5(uniqid('', true)), 0, 10) . '.' . $ext;
|
||||
|
||||
try {
|
||||
$file->move($targetDir, $saveName);
|
||||
} catch (\Throwable $e) {
|
||||
return ['state' => '文件保存失败:' . $e->getMessage()];
|
||||
}
|
||||
|
||||
$url = '/' . $relativeDir . '/' . $saveName;
|
||||
|
||||
return [
|
||||
'state' => 'SUCCESS',
|
||||
'url' => $url,
|
||||
'title' => $original,
|
||||
'original' => $original,
|
||||
'type' => '.' . $ext,
|
||||
'size' => (string) filesize($targetDir . DIRECTORY_SEPARATOR . $saveName),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 涂鸦上传:接收 base64 数据
|
||||
*/
|
||||
protected function uploadScrawl(): array
|
||||
{
|
||||
$base64 = (string) $this->request->post('upfile', '');
|
||||
if ($base64 === '') {
|
||||
return ['state' => '未接收到涂鸦数据'];
|
||||
}
|
||||
|
||||
$binary = base64_decode($base64, true);
|
||||
if ($binary === false || strlen($binary) > $this->sizeBytes('editor_image_size', 10)) {
|
||||
return ['state' => '涂鸦数据无效或过大'];
|
||||
}
|
||||
|
||||
$relativeDir = trim($this->uploadDir(), '/') . '/image/' . date('Ymd');
|
||||
$targetDir = public_path() . str_replace('/', DIRECTORY_SEPARATOR, $relativeDir);
|
||||
|
||||
if (!is_dir($targetDir) && !@mkdir($targetDir, 0755, true) && !is_dir($targetDir)) {
|
||||
return ['state' => '上传目录创建失败,请检查权限'];
|
||||
}
|
||||
|
||||
$saveName = date('His') . substr(md5(uniqid('', true)), 0, 10) . '.png';
|
||||
if (@file_put_contents($targetDir . DIRECTORY_SEPARATOR . $saveName, $binary) === false) {
|
||||
return ['state' => '涂鸦保存失败'];
|
||||
}
|
||||
|
||||
return [
|
||||
'state' => 'SUCCESS',
|
||||
'url' => '/' . $relativeDir . '/' . $saveName,
|
||||
'title' => $saveName,
|
||||
'original' => $saveName,
|
||||
'type' => '.png',
|
||||
'size' => (string) strlen($binary),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程图片抓取
|
||||
*/
|
||||
protected function catchImage(): array
|
||||
{
|
||||
if ((int) ($this->cfg()['editor_catch_image'] ?? 1) !== 1) {
|
||||
return ['state' => '远程图片抓取已关闭', 'list' => []];
|
||||
}
|
||||
|
||||
$field = 'source';
|
||||
$sources = $this->request->param($field, []);
|
||||
if (!is_array($sources)) {
|
||||
$sources = [$sources];
|
||||
}
|
||||
|
||||
if (empty($sources)) {
|
||||
return ['state' => '未接收到图片地址'];
|
||||
}
|
||||
|
||||
$imageExt = $this->imageExt();
|
||||
$relativeDir = trim($this->uploadDir(), '/') . '/image/' . date('Ymd');
|
||||
$targetDir = public_path() . str_replace('/', DIRECTORY_SEPARATOR, $relativeDir);
|
||||
|
||||
if (!is_dir($targetDir) && !@mkdir($targetDir, 0755, true) && !is_dir($targetDir)) {
|
||||
return ['state' => '上传目录创建失败,请检查权限'];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($sources as $remote) {
|
||||
$remote = (string) $remote;
|
||||
$item = ['state' => '抓取失败', 'source' => $remote, 'url' => ''];
|
||||
|
||||
if (!$this->isSafeRemoteUrl($remote)) {
|
||||
$item['state'] = '非法的图片地址';
|
||||
$list[] = $item;
|
||||
continue;
|
||||
}
|
||||
|
||||
$binary = $this->fetchRemote($remote);
|
||||
if ($binary === null) {
|
||||
$list[] = $item;
|
||||
continue;
|
||||
}
|
||||
|
||||
$info = @getimagesizefromstring($binary);
|
||||
if ($info === false) {
|
||||
$item['state'] = '远程文件不是有效图片';
|
||||
$list[] = $item;
|
||||
continue;
|
||||
}
|
||||
|
||||
$ext = image_type_to_extension($info[2], false);
|
||||
if (!in_array(strtolower((string) $ext), $imageExt, true)) {
|
||||
$item['state'] = '不允许的图片类型';
|
||||
$list[] = $item;
|
||||
continue;
|
||||
}
|
||||
|
||||
$saveName = date('His') . substr(md5(uniqid('', true)), 0, 10) . '.' . $ext;
|
||||
if (@file_put_contents($targetDir . DIRECTORY_SEPARATOR . $saveName, $binary) === false) {
|
||||
$item['state'] = '图片保存失败';
|
||||
$list[] = $item;
|
||||
continue;
|
||||
}
|
||||
|
||||
$list[] = [
|
||||
'state' => 'SUCCESS',
|
||||
'url' => '/' . $relativeDir . '/' . $saveName,
|
||||
'size' => (string) strlen($binary),
|
||||
'title' => $saveName,
|
||||
'original' => basename(parse_url($remote, PHP_URL_PATH) ?: $saveName),
|
||||
'source' => $remote,
|
||||
];
|
||||
}
|
||||
|
||||
return ['state' => 'SUCCESS', 'list' => $list];
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片 / 附件管理器列表
|
||||
*/
|
||||
protected function listFiles(string $group, array $allowExt): array
|
||||
{
|
||||
$start = (int) $this->request->param('start', 0);
|
||||
$size = (int) $this->request->param('size', 20);
|
||||
$size = $size > 0 && $size <= 100 ? $size : 20;
|
||||
|
||||
$relativeRoot = trim($this->uploadDir(), '/') . '/' . $group;
|
||||
$rootDir = public_path() . str_replace('/', DIRECTORY_SEPARATOR, $relativeRoot);
|
||||
|
||||
if (!is_dir($rootDir)) {
|
||||
return ['state' => 'SUCCESS', 'list' => [], 'start' => $start, 'total' => 0];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
$iter = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($rootDir, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iter as $fileInfo) {
|
||||
if (!$fileInfo->isFile()) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array(strtolower($fileInfo->getExtension()), $allowExt, true)) {
|
||||
continue;
|
||||
}
|
||||
$relative = str_replace('\\', '/', substr($fileInfo->getPathname(), strlen(public_path())));
|
||||
$files[] = [
|
||||
'url' => '/' . ltrim($relative, '/'),
|
||||
'mtime' => $fileInfo->getMTime(),
|
||||
];
|
||||
}
|
||||
|
||||
usort($files, fn($a, $b) => $b['mtime'] <=> $a['mtime']);
|
||||
|
||||
$total = count($files);
|
||||
$page = array_slice($files, $start, $size);
|
||||
|
||||
return ['state' => 'SUCCESS', 'list' => $page, 'start' => $start, 'total' => $total];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验远程地址是否安全,阻断 SSRF
|
||||
*/
|
||||
protected function isSafeRemoteUrl(string $url): bool
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
if (!$parts || empty($parts['scheme']) || empty($parts['host'])) {
|
||||
return false;
|
||||
}
|
||||
if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$host = $parts['host'];
|
||||
$ip = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host);
|
||||
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$public = filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
);
|
||||
|
||||
return $public !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载远程内容
|
||||
*/
|
||||
protected function fetchRemote(string $url): ?string
|
||||
{
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_USERAGENT => 'YwxApp-Forum/1.0',
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($body === false || $code !== 200 || strlen((string) $body) > $this->sizeBytes('editor_image_size', 10)) {
|
||||
return null;
|
||||
}
|
||||
return (string) $body;
|
||||
}
|
||||
|
||||
$ctx = stream_context_create(['http' => ['timeout' => 15, 'follow_location' => 0]]);
|
||||
$body = @file_get_contents($url, false, $ctx, 0, $this->sizeBytes('editor_image_size', 10) + 1);
|
||||
|
||||
if ($body === false || strlen($body) > $this->sizeBytes('editor_image_size', 10)) {
|
||||
return null;
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取上传目录配置(后台可配,安全校验防穿越)
|
||||
*/
|
||||
protected function uploadDir(): string
|
||||
{
|
||||
$dir = (string) ($this->cfg()['editor_upload_dir'] ?? 'uploads/forum');
|
||||
$dir = str_replace('\\', '/', $dir);
|
||||
$dir = preg_replace('#\.+/#', '', $dir) ?? 'uploads/forum';
|
||||
return trim($dir, '/') ?: 'uploads/forum';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace addon\forum\controller\member;
|
||||
|
||||
use ywxapp\controller\MemberBase;
|
||||
use addon\forum\model\ForumTopic;
|
||||
use addon\forum\model\ForumBoard;
|
||||
use think\facade\Db;
|
||||
use think\facade\Validate;
|
||||
|
||||
/**
|
||||
* 会员中心 - 论坛(发帖 / 我的帖子)
|
||||
*/
|
||||
class Forum extends MemberBase
|
||||
{
|
||||
/**
|
||||
* 当前会员主键(论坛 user_id 使用 auth->info 的 id)
|
||||
*/
|
||||
protected function uid(): int
|
||||
{
|
||||
$info = $this->auth->info ?? [];
|
||||
return (int) ($info['id'] ?? 0);
|
||||
}
|
||||
|
||||
/** 发帖页 */
|
||||
public function post()
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
return $this->doPost();
|
||||
}
|
||||
$boards = ForumBoard::enabledList();
|
||||
$cfg = \ywxapp\service\AddonService::config('forum');
|
||||
$this->view->assign('boards', $boards);
|
||||
$this->view->assign('editorHeight', (int) ($cfg['editor_height'] ?? 360));
|
||||
return $this->view->fetch('forum/post');
|
||||
}
|
||||
|
||||
/** 提交发帖 */
|
||||
protected function doPost()
|
||||
{
|
||||
$uid = $this->uid();
|
||||
if ($uid < 1) {
|
||||
return $this->result->error('请先登录');
|
||||
}
|
||||
$data = $this->request->post();
|
||||
|
||||
$validate = Validate::rule([
|
||||
'board_id' => 'require|integer',
|
||||
'title' => 'require|length:2,120',
|
||||
'content' => 'require|length:5,65535',
|
||||
]);
|
||||
if (!$validate->check($data)) {
|
||||
return $this->result->error($validate->getError());
|
||||
}
|
||||
|
||||
$board = ForumBoard::find($data['board_id']);
|
||||
if (!$board || $board->status != 1) {
|
||||
return $this->result->error('版块不存在或已禁用');
|
||||
}
|
||||
|
||||
$topicId = ForumTopic::insertGetId([
|
||||
'user_id' => $uid,
|
||||
'board_id' => (int) $data['board_id'],
|
||||
'board_slug' => $board->slug ?? '',
|
||||
'title' => trim($data['title']),
|
||||
'content' => $data['content'],
|
||||
'type' => (int) ($data['type'] ?? 99),
|
||||
'reply_count' => 0,
|
||||
'view_count' => 0,
|
||||
'digest' => 0,
|
||||
'top' => 0,
|
||||
'status' => 1,
|
||||
'create_at' => date('Y-m-d H:i:s'),
|
||||
'update_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
// 版块帖子数 +1
|
||||
ForumBoard::incr((int) $data['board_id'], 'topic_count', 1);
|
||||
|
||||
if ($topicId) {
|
||||
return $this->result->success(['id' => $topicId], '发布成功');
|
||||
}
|
||||
return $this->result->error('发布失败,请重试');
|
||||
}
|
||||
|
||||
/** 我的帖子管理 */
|
||||
public function mytopics()
|
||||
{
|
||||
$uid = $this->uid();
|
||||
if ($uid < 1) {
|
||||
return $this->result->error('请先登录');
|
||||
}
|
||||
$page = (int) $this->request->get('page', 1);
|
||||
$limit = 15;
|
||||
|
||||
$query = ForumTopic::where('user_id', $uid)->where('status', '>=', 0);
|
||||
$total = (clone $query)->count();
|
||||
$topics = $query->order('id desc')->page($page, $limit)->select();
|
||||
|
||||
$boardIds = $topics->column('board_id');
|
||||
$boards = ForumBoard::whereIn('id', $boardIds ?: [0])->column('title', 'id');
|
||||
$list = [];
|
||||
foreach ($topics as $t) {
|
||||
$list[] = [
|
||||
'id' => $t->id,
|
||||
'title' => $t->title,
|
||||
'board_title' => $boards[$t->board_id] ?? '未知版块',
|
||||
'reply_count' => $t->reply_count,
|
||||
'view_count' => $t->view_count,
|
||||
'status' => $t->status,
|
||||
'create_at' => date('Y-m-d H:i', strtotime($t->create_at)),
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->request->isAjax()) {
|
||||
return $this->result->success([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
], 'ok');
|
||||
}
|
||||
|
||||
$this->view->assign('list', $list);
|
||||
$this->view->assign('total', $total);
|
||||
$this->view->assign('page', $page);
|
||||
return $this->view->fetch('forum/mytopics');
|
||||
}
|
||||
|
||||
/** 删除我的帖子 */
|
||||
public function del()
|
||||
{
|
||||
if (!$this->request->isPost()) {
|
||||
return $this->result->error('请求方式错误');
|
||||
}
|
||||
$uid = $this->uid();
|
||||
if ($uid < 1) {
|
||||
return $this->result->error('请先登录');
|
||||
}
|
||||
$id = (int) $this->request->post('id', 0);
|
||||
if ($id < 1) {
|
||||
return $this->result->error('参数错误');
|
||||
}
|
||||
$topic = ForumTopic::where('id', $id)->where('user_id', $uid)->find();
|
||||
if (!$topic) {
|
||||
return $this->result->error('帖子不存在');
|
||||
}
|
||||
$topic->status = -1;
|
||||
$topic->save();
|
||||
if ($topic->board_id > 0) {
|
||||
ForumBoard::where('id', $topic->board_id)->where('topic_count', '>', 0)
|
||||
->dec('topic_count', 1)->update();
|
||||
}
|
||||
return $this->result->success([], '已删除');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'forum',
|
||||
'title' => '轻社区论坛',
|
||||
'intro' => '基于 Fly-3.0 风格打造的轻量社区论坛:版块、发帖、回复、消息。',
|
||||
'author' => 'ywxapp',
|
||||
'version' => '1.0.2',
|
||||
'state' => 1,
|
||||
'type' => 1,
|
||||
'install_time' => 1785604242,
|
||||
'update_time' => 1786365817,
|
||||
];
|
||||
@@ -0,0 +1,142 @@
|
||||
-- +----------------------------------------------------------------------
|
||||
-- | YwxApp 轻社区论坛插件(forum)数据表
|
||||
-- | 基于 Fly-3.0 社区模板结构分析设计
|
||||
-- | 表前缀:__PREFIX__forum_*
|
||||
-- +----------------------------------------------------------------------
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- ----------------------------
|
||||
-- 版块 / 专栏(对应 Fly 的专栏分类)
|
||||
-- ----------------------------
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__forum_board` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`parent_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父版块ID,0为顶级',
|
||||
`name` VARCHAR(60) NOT NULL DEFAULT '' COMMENT '版块名称',
|
||||
`slug` VARCHAR(60) NOT NULL DEFAULT '' COMMENT '英文标识,用于URL',
|
||||
`icon` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '图标',
|
||||
`description` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '版块简介',
|
||||
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序',
|
||||
`topic_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '帖子数',
|
||||
`reply_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '回复数',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用0禁用',
|
||||
`create_at` INT NOT NULL DEFAULT 0,
|
||||
`update_at` INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `parent_id` (`parent_id`),
|
||||
KEY `slug` (`slug`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='论坛版块';
|
||||
|
||||
-- ----------------------------
|
||||
-- 帖子(对应 Fly 的 jie)
|
||||
-- ----------------------------
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__forum_topic` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`board_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '版块ID',
|
||||
`user_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '作者会员ID',
|
||||
`title` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '标题',
|
||||
`content` MEDIUMTEXT NOT NULL COMMENT '正文(HTML)',
|
||||
`type` SMALLINT NOT NULL DEFAULT 0 COMMENT '类型:0提问 99分享 100讨论 101建议 168公告 169动态',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0待审 -1删除',
|
||||
`is_top` TINYINT NOT NULL DEFAULT 0 COMMENT '置顶',
|
||||
`is_essence` TINYINT NOT NULL DEFAULT 0 COMMENT '加精',
|
||||
`audit_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '审核结论/驳回理由',
|
||||
`lat` DECIMAL(10,7) NOT NULL DEFAULT '0.0000000' COMMENT '纬度(可选,用于附近)',
|
||||
`lng` DECIMAL(10,7) NOT NULL DEFAULT '0.0000000' COMMENT '经度(可选,用于附近)',
|
||||
`views` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '浏览数',
|
||||
`reply_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '回复数',
|
||||
`last_reply_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后回复ID',
|
||||
`last_reply_uid` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后回复用户ID',
|
||||
`last_reply_time` INT NOT NULL DEFAULT 0 COMMENT '最后回复时间',
|
||||
`create_at` INT NOT NULL DEFAULT 0,
|
||||
`update_at` INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `board_id` (`board_id`),
|
||||
KEY `user_id` (`user_id`),
|
||||
KEY `type` (`type`),
|
||||
KEY `status` (`status`),
|
||||
KEY `is_top` (`is_top`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='论坛帖子';
|
||||
|
||||
-- ----------------------------
|
||||
-- 回复(对应 Fly 的帖子详情里的回复列表)
|
||||
-- ----------------------------
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__forum_reply` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`topic_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属帖子',
|
||||
`user_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '回复用户ID',
|
||||
`content` MEDIUMTEXT NOT NULL COMMENT '回复内容(HTML)',
|
||||
`floor` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '楼层',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 -1删除',
|
||||
`create_at` INT NOT NULL DEFAULT 0,
|
||||
`update_at` INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `topic_id` (`topic_id`),
|
||||
KEY `user_id` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='论坛回复';
|
||||
|
||||
-- ----------------------------
|
||||
-- 标签
|
||||
-- ----------------------------
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__forum_tag` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(40) NOT NULL DEFAULT '',
|
||||
`topic_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`create_at` INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `name` (`name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='论坛标签';
|
||||
|
||||
-- ----------------------------
|
||||
-- 帖子-标签关联
|
||||
-- ----------------------------
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__forum_topic_tag` (
|
||||
`topic_id` INT UNSIGNED NOT NULL,
|
||||
`tag_id` INT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (`topic_id`, `tag_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='帖子标签关联';
|
||||
|
||||
-- ----------------------------
|
||||
-- 消息(@提醒 / 回复通知 / 系统)
|
||||
-- ----------------------------
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__forum_message` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '接收人',
|
||||
`from_uid` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '发送人,0为系统',
|
||||
`type` VARCHAR(20) NOT NULL DEFAULT 'reply' COMMENT 'reply/at/system',
|
||||
`topic_id` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`reply_id` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`content` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`is_read` TINYINT NOT NULL DEFAULT 0 COMMENT '0未读1已读',
|
||||
`create_at` INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `user_id` (`user_id`),
|
||||
KEY `is_read` (`is_read`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='论坛消息';
|
||||
|
||||
-- ----------------------------
|
||||
-- 种子数据:默认版块(与 Fly 专栏一致)
|
||||
-- ----------------------------
|
||||
INSERT INTO `__PREFIX__forum_board` (`id`, `parent_id`, `name`, `slug`, `icon`, `description`, `sort`, `status`, `create_at`, `update_at`)
|
||||
SELECT 1, 0, '提问', 'ask', '', '遇到难题?在这里提问', 1, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `__PREFIX__forum_board` WHERE `id` = 1);
|
||||
|
||||
INSERT INTO `__PREFIX__forum_board` (`id`, `parent_id`, `name`, `slug`, `icon`, `description`, `sort`, `status`, `create_at`, `update_at`)
|
||||
SELECT 2, 0, '分享', 'share', '', '好东西大家一起分享', 2, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `__PREFIX__forum_board` WHERE `id` = 2);
|
||||
|
||||
INSERT INTO `__PREFIX__forum_board` (`id`, `parent_id`, `name`, `slug`, `icon`, `description`, `sort`, `status`, `create_at`, `update_at`)
|
||||
SELECT 3, 0, '讨论', 'discuss', '', '随意讨论技术话题', 3, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `__PREFIX__forum_board` WHERE `id` = 3);
|
||||
|
||||
INSERT INTO `__PREFIX__forum_board` (`id`, `parent_id`, `name`, `slug`, `icon`, `description`, `sort`, `status`, `create_at`, `update_at`)
|
||||
SELECT 4, 0, '建议', 'suggest', '', '给社区提建议', 4, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `__PREFIX__forum_board` WHERE `id` = 4);
|
||||
|
||||
INSERT INTO `__PREFIX__forum_board` (`id`, `parent_id`, `name`, `slug`, `icon`, `description`, `sort`, `status`, `create_at`, `update_at`)
|
||||
SELECT 5, 0, '公告', 'notice', '', '社区公告', 5, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `__PREFIX__forum_board` WHERE `id` = 5);
|
||||
|
||||
INSERT INTO `__PREFIX__forum_board` (`id`, `parent_id`, `name`, `slug`, `icon`, `description`, `sort`, `status`, `create_at`, `update_at`)
|
||||
SELECT 6, 0, '动态', 'dynamic', '', '社区新鲜事', 6, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `__PREFIX__forum_board` WHERE `id` = 6);
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
namespace addon\forum\library;
|
||||
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 论坛(forum)插件专属表结构自愈。
|
||||
*
|
||||
* 归属原则:forum_* 表属于 forum 插件私有表,其列兜底逻辑内聚在本插件,
|
||||
* 不污染核心框架 ywxapp/library/BaseModel 自愈引擎(核心只保留通用引擎)。
|
||||
*/
|
||||
class ForumSchema
|
||||
{
|
||||
/**
|
||||
* 补齐 forum_topic 扩展列(审核结论 / 经纬度),幂等。
|
||||
* 建表由插件 install.sql 负责,此处仅做运行时兜底(老库 / 全新安装后结构差异)。
|
||||
*/
|
||||
public static function ensure(): void
|
||||
{
|
||||
$prefix = BaseModel::currentPrefix();
|
||||
$p = $prefix;
|
||||
BaseModel::ensureColumn("{$p}forum_topic", 'audit_reason', "varchar(255) NOT NULL DEFAULT '' COMMENT '审核结论/驳回理由'");
|
||||
BaseModel::ensureColumn("{$p}forum_topic", 'lat', "decimal(10,7) NOT NULL DEFAULT '0.0000000' COMMENT '纬度(可选)'");
|
||||
BaseModel::ensureColumn("{$p}forum_topic", 'lng', "decimal(10,7) NOT NULL DEFAULT '0.0000000' COMMENT '经度(可选)'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"backend": [
|
||||
{ "name": "board", "title": "版块管理", "url": "forum/backend/board", "icon": "fa fa-list", "weigh": 1 },
|
||||
{ "name": "topic", "title": "帖子管理", "url": "forum/backend/topic", "icon": "fa fa-file-text", "weigh": 2,
|
||||
"child": [
|
||||
{ "name": "topic_pending", "title": "待审帖子", "url": "forum/backend/topic?status=0", "icon": "fa fa-hourglass-half", "weigh": 1 }
|
||||
] },
|
||||
{ "name": "reply", "title": "回复管理", "url": "forum/backend/reply", "icon": "fa fa-comments", "weigh": 3 },
|
||||
{ "name": "tag", "title": "标签管理", "url": "forum/backend/tag", "icon": "fa fa-tags", "weigh": 4 },
|
||||
{ "name": "editor", "title": "编辑器配置", "url": "forum/backend/editor", "icon": "fa fa-edit", "weigh": 5 }
|
||||
],
|
||||
"frontend": [
|
||||
{ "name": "index", "title": "社区首页", "url": "forum/index/index", "weigh": 1 },
|
||||
{ "name": "search", "title": "搜索", "url": "forum/index/search", "weigh": 2 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\forum\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
class ForumBoard extends Model
|
||||
{
|
||||
protected $name = 'forum_board';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = 'create_at';
|
||||
protected $updateTime = 'update_at';
|
||||
|
||||
/**
|
||||
* 启用的版块列表
|
||||
*/
|
||||
public static function enabledList()
|
||||
{
|
||||
return self::where('status', 1)->order('sort asc,id asc')->select();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 slug 解析版块
|
||||
*/
|
||||
public static function findBySlug(string $slug)
|
||||
{
|
||||
return self::where('slug', $slug)->where('status', 1)->find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加帖子/回复计数
|
||||
*/
|
||||
public static function incr(int $boardId, string $field, int $step = 1)
|
||||
{
|
||||
if ($boardId < 1) {
|
||||
return;
|
||||
}
|
||||
self::where('id', $boardId)->inc($field, $step)->update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\forum\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
class ForumMessage extends Model
|
||||
{
|
||||
protected $name = 'forum_message';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = 'create_at';
|
||||
protected $updateTime = 'update_at';
|
||||
|
||||
public static function unreadCount(int $userId): int
|
||||
{
|
||||
return self::where('user_id', $userId)->where('is_read', 0)->count();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\forum\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
class ForumReply extends Model
|
||||
{
|
||||
protected $name = 'forum_reply';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = 'create_at';
|
||||
protected $updateTime = 'update_at';
|
||||
|
||||
public static function listByTopic(int $topicId)
|
||||
{
|
||||
return self::where('topic_id', $topicId)
|
||||
->where('status', 1)
|
||||
->order('floor asc, id asc')
|
||||
->select();
|
||||
}
|
||||
|
||||
public static function countByTopic(int $topicId): int
|
||||
{
|
||||
return self::where('topic_id', $topicId)->where('status', 1)->count();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\forum\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
class ForumTag extends Model
|
||||
{
|
||||
protected $name = 'forum_tag';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = 'create_at';
|
||||
protected $updateTime = 'update_at';
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\forum\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
class ForumTopic extends Model
|
||||
{
|
||||
protected $name = 'forum_topic';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = 'create_at';
|
||||
protected $updateTime = 'update_at';
|
||||
|
||||
// 帖子类型(对应 Fly 专栏 class 值)
|
||||
const TYPE_ASK = 0; // 提问
|
||||
const TYPE_SHARE = 99; // 分享
|
||||
const TYPE_DISCUSS = 100; // 讨论
|
||||
const TYPE_SUGGEST = 101; // 建议
|
||||
const TYPE_NOTICE = 168; // 公告
|
||||
const TYPE_DYNAMIC = 169; // 动态
|
||||
|
||||
public static $typeMap = [
|
||||
self::TYPE_ASK => '提问',
|
||||
self::TYPE_SHARE => '分享',
|
||||
self::TYPE_DISCUSS => '讨论',
|
||||
self::TYPE_SUGGEST => '建议',
|
||||
self::TYPE_NOTICE => '公告',
|
||||
self::TYPE_DYNAMIC => '动态',
|
||||
];
|
||||
|
||||
/**
|
||||
* 前台列表查询(返回普通集合,便于模板直接 volist)
|
||||
* @param int $boardId
|
||||
* @param string $type 空=全部
|
||||
* @param string $order hot|new
|
||||
* @param int $size 返回条数
|
||||
*/
|
||||
public static function listFront(int $boardId = 0, string $type = '', string $order = 'new', int $page = 1, int $size = 20)
|
||||
{
|
||||
$q = self::where('status', 1);
|
||||
if ($boardId > 0) {
|
||||
$q->where('board_id', $boardId);
|
||||
}
|
||||
if ($type !== '' && isset(self::$typeMap[(int) $type])) {
|
||||
$q->where('type', (int) $type);
|
||||
}
|
||||
if ($order === 'hot') {
|
||||
$q->order('reply_count desc, views desc');
|
||||
} else {
|
||||
$q->order('is_top desc, id desc');
|
||||
}
|
||||
return $q->limit($size)->select();
|
||||
}
|
||||
|
||||
public static function findById(int $id)
|
||||
{
|
||||
return self::where('id', $id)->where('status', '>=', 0)->find();
|
||||
}
|
||||
|
||||
public static function addViews(int $id)
|
||||
{
|
||||
self::where('id', $id)->inc('views', 1)->update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | 轻社区论坛插件路由
|
||||
// | 注意:本文件由 AddonService::loadAddonRoutes() 包裹在 Route::group('forum') 内,
|
||||
// | 因此这里一律写相对路径,不要再加 forum/ 前缀。
|
||||
use think\facade\Route;
|
||||
|
||||
// ==================== 后台管理 ====================
|
||||
Route::group('backend', function () {
|
||||
// 版块管理
|
||||
Route::get('board', 'backend.Board/index');
|
||||
Route::get('board/add', 'backend.Board/add');
|
||||
Route::post('board/save', 'backend.Board/save');
|
||||
Route::get('board/edit/:id', 'backend.Board/edit');
|
||||
Route::post('board/update/:id', 'backend.Board/update');
|
||||
Route::post('board/delete/:id', 'backend.Board/delete');
|
||||
|
||||
// 帖子管理
|
||||
Route::get('topic', 'backend.Topic/index');
|
||||
Route::post('topic/delete/:id', 'backend.Topic/delete');
|
||||
Route::post('topic/state/:id', 'backend.Topic/state');
|
||||
Route::get('topic/edit/:id', 'backend.Topic/edit');
|
||||
Route::post('topic/update/:id', 'backend.Topic/update');
|
||||
Route::post('topic/audit/:id', 'backend.Topic/audit');
|
||||
Route::post('topic/reject/:id', 'backend.Topic/reject');
|
||||
|
||||
// 回复管理
|
||||
Route::get('reply', 'backend.Reply/index');
|
||||
Route::post('reply/delete/:id', 'backend.Reply/delete');
|
||||
|
||||
// 标签管理
|
||||
Route::get('tag', 'backend.Tag/index');
|
||||
Route::post('tag/save', 'backend.Tag/save');
|
||||
Route::post('tag/delete/:id', 'backend.Tag/delete');
|
||||
|
||||
// UEditor 富文本上传服务(发帖页使用,需登录)
|
||||
Route::get('ueditor', 'backend.Ueditor/index');
|
||||
Route::post('ueditor', 'backend.Ueditor/index');
|
||||
|
||||
// 编辑器配置(后台可视化配置 UEditor 参数)
|
||||
Route::get('editor', 'backend.Editor/index');
|
||||
Route::post('editor/save', 'backend.Editor/save');
|
||||
});
|
||||
|
||||
// ==================== 前台社区 ====================
|
||||
Route::group('', function () {
|
||||
// 首页 / 版块列表 / 帖子流
|
||||
Route::get('index', 'Index/index');
|
||||
Route::get('board/:slug', 'Index/board');
|
||||
Route::get('topic/:id', 'Index/topic');
|
||||
// 发帖 / 回复(需登录,由控制器 noNeedLogin 控制)
|
||||
Route::get('post', 'Index/post');
|
||||
Route::post('post', 'Index/doPost');
|
||||
Route::post('reply', 'Index/doReply');
|
||||
// 搜索
|
||||
Route::get('search', 'Index/search');
|
||||
// 附近帖子
|
||||
Route::get('nearby', 'Index/nearby');
|
||||
// 用户主页(复用 app/member 会员体系,这里仅展示其论坛数据)
|
||||
Route::get('u/:id', 'Index/user');
|
||||
});
|
||||
|
||||
// ==================== 会员中心 ====================
|
||||
// 该组路由最终落在 /forum/member/*,由 AddonMember 体系接管。
|
||||
// 登录校验已统一由控制器层 verifyAuth 处理(FrontendBase/MemberBase 的 _initialize),
|
||||
// 无需在此挂 MemberAuth 中间件。
|
||||
Route::group('member', function () {
|
||||
// 发帖
|
||||
Route::get('forum/post', 'member.Forum/post');
|
||||
Route::post('forum/post', 'member.Forum/post');
|
||||
// 我的帖子
|
||||
Route::get('forum/mytopics', 'member.Forum/mytopics');
|
||||
// 删除帖子
|
||||
Route::post('forum/del', 'member.Forum/del');
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
/**
|
||||
* ForumSearchService —— 论坛帖子检索(ES 增强,默认 LIKE 降级)
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\forum\service;
|
||||
|
||||
use addon\forum\model\ForumTopic;
|
||||
use think\facade\Db;
|
||||
use ywxapp\library\Search\EsSearch;
|
||||
|
||||
class ForumSearchService
|
||||
{
|
||||
protected static function conf(): array
|
||||
{
|
||||
return [
|
||||
'engine' => config('forum.search_engine', 'like'),
|
||||
'host' => config('forum.es_host', 'http://127.0.0.1:9200'),
|
||||
'index' => config('forum.es_index', 'ywxapp_forum'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 前台搜索:仅返回 status=1 的帖子
|
||||
* @return \think\Paginator
|
||||
*/
|
||||
public static function search(string $kw, int $page = 1, int $size = 20)
|
||||
{
|
||||
$kw = trim($kw);
|
||||
if ($kw === '') {
|
||||
return ForumTopic::where('status', 1)->order('id desc')->paginate($size, false, ['page' => $page]);
|
||||
}
|
||||
|
||||
$cfg = self::conf();
|
||||
if ($cfg['engine'] === 'es') {
|
||||
try {
|
||||
EsSearch::ensureIndex($cfg['host'], $cfg['index'], ['title', 'content']);
|
||||
$ids = EsSearch::search($cfg['host'], $cfg['index'], $kw, ['title', 'content'], 200);
|
||||
if (!empty($ids)) {
|
||||
// 保持相关度顺序 + 回表取完整行
|
||||
$list = ForumTopic::with(['board'])
|
||||
->where('status', 1)
|
||||
->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();
|
||||
// 包装成分页结构(ES 结果集直接返回,分页信息从 ids 推算)
|
||||
$total = count($ids);
|
||||
$coll = $list;
|
||||
$paginator = new \think\Paginator($coll, $size, $page);
|
||||
return $paginator;
|
||||
}
|
||||
} catch (\RuntimeException $e) {
|
||||
// ES 不可用:降级 LIKE
|
||||
}
|
||||
}
|
||||
|
||||
// 默认 LIKE 分支
|
||||
return ForumTopic::with(['board'])
|
||||
->where('status', 1)
|
||||
->where('title', 'like', '%' . addslashes($kw) . '%')
|
||||
->order('id desc')
|
||||
->paginate($size, false, ['page' => $page]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步单条到 ES(发帖/编辑后调用)
|
||||
*/
|
||||
public static function sync(int $topicId): void
|
||||
{
|
||||
$cfg = self::conf();
|
||||
if ($cfg['engine'] !== 'es') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$t = ForumTopic::find($topicId);
|
||||
if (!$t) {
|
||||
return;
|
||||
}
|
||||
EsSearch::ensureIndex($cfg['host'], $cfg['index'], ['title', 'content']);
|
||||
if ((int) $t->status !== 1) {
|
||||
EsSearch::deleteDoc($cfg['host'], $cfg['index'], $topicId);
|
||||
return;
|
||||
}
|
||||
EsSearch::indexDoc($cfg['host'], $cfg['index'], $topicId, [
|
||||
'title' => $t->title,
|
||||
'content' => $t->content,
|
||||
]);
|
||||
} catch (\RuntimeException $e) {
|
||||
// 静默:索引失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除索引文档
|
||||
*/
|
||||
public static function remove(int $topicId): void
|
||||
{
|
||||
$cfg = self::conf();
|
||||
if ($cfg['engine'] !== 'es') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
EsSearch::deleteDoc($cfg['host'], $cfg['index'], $topicId);
|
||||
} catch (\RuntimeException $e) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 相关帖子推荐:优先 ES more_like_this,未启用 ES 时降级为「同版块 + 标题关键词」相似度
|
||||
* @return \addon\forum\model\ForumTopic[]
|
||||
*/
|
||||
public static function related(int $topicId, int $limit = 6): array
|
||||
{
|
||||
$topic = ForumTopic::find($topicId);
|
||||
if (!$topic) {
|
||||
return [];
|
||||
}
|
||||
$cfg = self::conf();
|
||||
if ($cfg['engine'] === 'es') {
|
||||
try {
|
||||
EsSearch::ensureIndex($cfg['host'], $cfg['index'], ['title', 'content']);
|
||||
$ids = EsSearch::moreLikeThis(
|
||||
$cfg['host'], $cfg['index'],
|
||||
$topic->title . "\n" . $topic->content,
|
||||
['title', 'content'], $topicId, $limit
|
||||
);
|
||||
if (!empty($ids)) {
|
||||
return ForumTopic::with(['board'])
|
||||
->where('status', 1)
|
||||
->whereIn('id', $ids)
|
||||
->select()
|
||||
->all();
|
||||
}
|
||||
} catch (\RuntimeException $e) {
|
||||
// 降级
|
||||
}
|
||||
}
|
||||
|
||||
// 降级:同版块 + 标题关键词重叠,按发布时间取最近
|
||||
$kw = trim($topic->title);
|
||||
return ForumTopic::with(['board'])
|
||||
->where('status', 1)
|
||||
->where('id', '<>', $topicId)
|
||||
->where('board_id', $topic->board_id)
|
||||
->where('title', 'like', '%' . addslashes(mb_substr($kw, 0, 10)) . '%')
|
||||
->order('id desc')
|
||||
->limit($limit)
|
||||
->select()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 附近帖子(GEO/LBS):Haversine 球面距离过滤,零外部依赖
|
||||
* @param float $lat 纬度
|
||||
* @param float $lng 经度
|
||||
* @param float $radiusKm 半径(千米),默认 10
|
||||
* @param int $limit 返回数量
|
||||
* @return \addon\forum\model\ForumTopic[]
|
||||
*/
|
||||
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;
|
||||
// 地球半径 km
|
||||
$R = 6371;
|
||||
return ForumTopic::with(['board'])
|
||||
->where('status', 1)
|
||||
->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">添加版块</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" id="form">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">名称</label>
|
||||
<div class="layui-input-block"><input name="name" class="layui-input" lay-verify="required"></div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">标识</label>
|
||||
<div class="layui-input-block"><input name="slug" class="layui-input" lay-verify="required"></div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">图标</label>
|
||||
<div class="layui-input-block"><input name="icon" class="layui-input"></div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">简介</label>
|
||||
<div class="layui-input-block"><input name="description" class="layui-input"></div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-block"><input name="sort" value="0" 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="启用" checked>
|
||||
<input type="radio" name="status" value="0" title="禁用">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<button class="layui-btn" lay-submit lay-filter="save">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
layui.use(['form','jquery'], function(){
|
||||
var form = layui.form, $ = layui.jquery;
|
||||
form.on('submit(save)', function(data){
|
||||
$.post('/forum/backend/board/save', data.field, function(res){
|
||||
if (res.code === 0) { location.href = '/forum/backend/board'; }
|
||||
else { layer.msg(res.message); }
|
||||
}, 'json');
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">编辑版块</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" id="form">
|
||||
<input type="hidden" name="id" value="{$row.id}">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">名称</label>
|
||||
<div class="layui-input-block"><input name="name" value="{$row.name}" class="layui-input" lay-verify="required"></div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">标识</label>
|
||||
<div class="layui-input-block"><input name="slug" value="{$row.slug}" class="layui-input" lay-verify="required"></div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">图标</label>
|
||||
<div class="layui-input-block"><input name="icon" value="{$row.icon}" class="layui-input"></div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">简介</label>
|
||||
<div class="layui-input-block"><input name="description" value="{$row.description}" class="layui-input"></div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-block"><input name="sort" value="{$row.sort}" 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 $row.status==1}checked{/if}>
|
||||
<input type="radio" name="status" value="0" title="禁用" {if $row.status==0}checked{/if}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<button class="layui-btn" lay-submit lay-filter="save">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
layui.use(['form','jquery'], function(){
|
||||
var form = layui.form, $ = layui.jquery;
|
||||
form.on('submit(save)', function(data){
|
||||
$.post('/forum/backend/board/update/{$row.id}', data.field, function(res){
|
||||
if (res.code === 0) { location.href = '/forum/backend/board'; }
|
||||
else { layer.msg(res.message); }
|
||||
}, 'json');
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
版块管理
|
||||
<a href="/forum/backend/board/add" class="layui-btn layui-btn-sm layui-btn-normal" style="float:right;">添加版块</a>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-table">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>名称</th><th>标识</th><th>帖子数</th><th>排序</th><th>状态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{volist name="list" id="b"}
|
||||
<tr>
|
||||
<td>{$b.id}</td>
|
||||
<td>{$b.name}</td>
|
||||
<td>{$b.slug}</td>
|
||||
<td>{$b.topic_count}</td>
|
||||
<td>{$b.sort}</td>
|
||||
<td>{if $b.status==1}启用{else}禁用{/if}</td>
|
||||
<td>
|
||||
<a href="/forum/backend/board/edit/{$b.id}" class="layui-btn layui-btn-xs">编辑</a>
|
||||
<button class="layui-btn layui-btn-xs layui-btn-danger" data-del="{$b.id}">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/volist}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
layui.use(['jquery'], function(){
|
||||
var $ = layui.jquery;
|
||||
$('[data-del]').on('click', function(){
|
||||
var id = $(this).data('del');
|
||||
layer.confirm('确认删除?', function(){
|
||||
$.post('/forum/backend/board/delete/'+id, {}, function(res){
|
||||
if (res.code === 0) { location.reload(); } else { layer.msg(res.message); }
|
||||
}, 'json');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,102 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">编辑器配置(UEditor)</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" lay-filter="editorForm">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">上传目录</label>
|
||||
<div class="layui-input-inline" style="width:360px;">
|
||||
<input type="text" name="editor_upload_dir" value="{$cfg.editor_upload_dir}" class="layui-input" placeholder="相对 public/ 的目录">
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux">图片/附件/视频均存放于此目录下(按类型分子目录)</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">图片类型</label>
|
||||
<div class="layui-input-inline" style="width:360px;">
|
||||
<input type="text" name="editor_image_ext" value="{$cfg.editor_image_ext}" class="layui-input" placeholder="逗号分隔,不含点">
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux">允许上传的图片扩展名</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">图片大小(MB)</label>
|
||||
<div class="layui-input-inline" style="width:160px;">
|
||||
<input type="number" name="editor_image_size" value="{$cfg.editor_image_size}" class="layui-input" min="1" step="1">
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux">单张图片最大体积</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">附件类型</label>
|
||||
<div class="layui-input-inline" style="width:360px;">
|
||||
<input type="text" name="editor_file_ext" value="{$cfg.editor_file_ext}" class="layui-input" placeholder="逗号分隔,不含点">
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux">允许上传的附件扩展名</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">附件大小(MB)</label>
|
||||
<div class="layui-input-inline" style="width:160px;">
|
||||
<input type="number" name="editor_file_size" value="{$cfg.editor_file_size}" class="layui-input" min="1" step="1">
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux">单个附件最大体积</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">视频类型</label>
|
||||
<div class="layui-input-inline" style="width:360px;">
|
||||
<input type="text" name="editor_video_ext" value="{$cfg.editor_video_ext}" class="layui-input" placeholder="逗号分隔,不含点">
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux">允许上传的视频扩展名</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">视频大小(MB)</label>
|
||||
<div class="layui-input-inline" style="width:160px;">
|
||||
<input type="number" name="editor_video_size" value="{$cfg.editor_video_size}" class="layui-input" min="1" step="1">
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux">单个视频最大体积</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">远程图片抓取</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="checkbox" name="editor_catch_image" value="1" {if $cfg.editor_catch_image == 1}checked{/if} lay-skin="switch" lay-text="开启|关闭">
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux">粘贴 Word / 网页时是否把外链图落地到本地</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">初始高度(px)</label>
|
||||
<div class="layui-input-inline" style="width:160px;">
|
||||
<input type="number" name="editor_height" value="{$cfg.editor_height}" class="layui-input" min="100" step="20">
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux">发帖页编辑器默认高度</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button class="layui-btn" lay-submit lay-filter="saveEditor">保存配置</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
layui.use(['form', 'jquery', 'layer'], function(){
|
||||
var form = layui.form, $ = layui.jquery, layer = layui.layer;
|
||||
form.on('submit(saveEditor)', function(data){
|
||||
// 开关未勾选时浏览器不提交该字段,这里补齐
|
||||
if (!data.field.editor_catch_image) { data.field.editor_catch_image = 0; }
|
||||
$.post('/forum/backend/editor/save', data.field, function(res){
|
||||
if (res.code === 0) {
|
||||
layer.msg('保存成功', {icon:1});
|
||||
setTimeout(function(){ location.reload(); }, 800);
|
||||
} else {
|
||||
layer.msg(res.message || '保存失败');
|
||||
}
|
||||
}, 'json');
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,85 @@
|
||||
<!--
|
||||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||
* @Date: 2026-08-02 01:10:36
|
||||
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||
* @LastEditTime: 2026-08-03 23:40:59
|
||||
* @Description:
|
||||
* @FilePath: \ywxapp_dev\addon\forum\view\backend\reply\index.html
|
||||
* @CustomString: Copyright (c) 2026 YwxApp
|
||||
-->
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">回复管理</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" lay-filter="searchForm">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<input type="text" name="keyword" placeholder="回复内容关键词" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<button class="layui-btn" lay-submit lay-filter="searchBtn">搜索</button>
|
||||
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/html" id="dataBar">
|
||||
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="delete">删除</a>
|
||||
</script>
|
||||
|
||||
<script>
|
||||
layui.use(['table', 'jquery', 'layer', 'form'], function () {
|
||||
var table = layui.table, $ = layui.jquery, layer = layui.layer, form = layui.form;
|
||||
|
||||
var dataTable = table.render({
|
||||
elem: '#dataTable',
|
||||
url: 'index',
|
||||
page: true,
|
||||
limit: 20,
|
||||
cols: [[
|
||||
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
|
||||
{ field: 'topic_id', title: '帖子', width: 90, templet: function (d) {
|
||||
return '<a href="/forum/topic/' + d.topic_id + '" target="_blank">#' + d.topic_id + '</a>';
|
||||
} },
|
||||
{ field: 'user_name', title: '用户', width: 120, templet: function (d) {
|
||||
return d.user_name || ('UID:' + d.user_id);
|
||||
} },
|
||||
{ field: 'floor', title: '楼层', width: 80 },
|
||||
{ field: 'content_preview', title: '内容', minWidth: 200 },
|
||||
{ field: 'create_at', title: '时间', width: 170, templet: function (d) {
|
||||
return d.create_at ? layui.util.toDateString(d.create_at * 1000, 'yyyy-MM-dd HH:mm') : '';
|
||||
} },
|
||||
{ title: '操作', width: 80, align: 'center', toolbar: '#dataBar', fixed: 'right' }
|
||||
]]
|
||||
});
|
||||
|
||||
// 搜索
|
||||
form.on('submit(searchBtn)', function (data) {
|
||||
dataTable.reload({
|
||||
where: data.field,
|
||||
page: { curr: 1 }
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
// 工具条事件
|
||||
table.on('tool(dataTable)', function (obj) {
|
||||
var data = obj.data;
|
||||
if (obj.event === 'delete') {
|
||||
layer.confirm('确认删除?', function (index) {
|
||||
layer.close(index);
|
||||
$.post('delete', { id: data.id }, function (res) {
|
||||
if (res.code === 0) {
|
||||
layer.msg('删除成功', { icon: 1 });
|
||||
dataTable.reload();
|
||||
} else {
|
||||
layer.msg(res.message || '删除失败');
|
||||
}
|
||||
}, 'json');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
标签管理
|
||||
<button class="layui-btn layui-btn-sm layui-btn-normal" id="addTag" style="float:right;">添加标签</button>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-table">
|
||||
<thead><tr><th>ID</th><th>名称</th><th>使用数</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{volist name="list" id="t"}
|
||||
<tr>
|
||||
<td>{$t.id}</td>
|
||||
<td>{$t.name}</td>
|
||||
<td>{$t.topic_count}</td>
|
||||
<td><button class="layui-btn layui-btn-xs layui-btn-danger" data-del="{$t.id}">删除</button></td>
|
||||
</tr>
|
||||
{/volist}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
layui.use(['jquery','layer'], function(){
|
||||
var $ = layui.jquery, layer = layui.layer;
|
||||
$('#addTag').on('click', function(){
|
||||
layer.prompt({title:'输入标签名称'}, function(val, index){
|
||||
$.post('/forum/backend/tag/save', {name:val}, function(res){
|
||||
if (res.code === 0) { location.reload(); } else { layer.msg(res.message); }
|
||||
}, 'json');
|
||||
});
|
||||
});
|
||||
$('[data-del]').on('click', function(){
|
||||
var id = $(this).data('del');
|
||||
layer.confirm('确认删除?', function(){
|
||||
$.post('/forum/backend/tag/delete/'+id, {}, function(res){
|
||||
if (res.code === 0) { location.reload(); } else { layer.msg(res.message); }
|
||||
}, 'json');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,72 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">编辑帖子</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" id="form">
|
||||
<input type="hidden" name="id" value="{$row.id}">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">版块</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="board_id">
|
||||
{volist name="boards" id="b"}
|
||||
<option value="{$b.id}" {if $row.board_id==$b.id}selected{/if}>{$b.name}</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">类型</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="type">
|
||||
{foreach $typeMap as $k=>$v}
|
||||
<option value="{$k}" {if $row.type==$k}selected{/if}>{$v}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">标题</label>
|
||||
<div class="layui-input-block"><input name="title" value="{$row.title}" class="layui-input" lay-verify="required"></div>
|
||||
</div>
|
||||
<div class="layui-form-item layui-form-text">
|
||||
<label class="layui-form-label">内容</label>
|
||||
<div class="layui-input-block"><textarea name="content" class="layui-textarea" style="min-height:200px;">{$row.content}</textarea></div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">置顶</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="radio" name="is_top" value="1" title="是" {if $row.is_top==1}checked{/if}>
|
||||
<input type="radio" name="is_top" value="0" title="否" {if $row.is_top==0}checked{/if}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">加精</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="radio" name="is_essence" value="1" title="是" {if $row.is_essence==1}checked{/if}>
|
||||
<input type="radio" name="is_essence" value="0" title="否" {if $row.is_essence==0}checked{/if}>
|
||||
</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 $row.status==1}checked{/if}>
|
||||
<input type="radio" name="status" value="0" title="待审" {if $row.status==0}checked{/if}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<button class="layui-btn" lay-submit lay-filter="save">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
layui.use(['form','jquery'], function(){
|
||||
var form = layui.form, $ = layui.jquery;
|
||||
form.on('submit(save)', function(data){
|
||||
$.post('/forum/backend/topic/update/{$row.id}', data.field, function(res){
|
||||
if (res.code === 0) { location.href = '/forum/backend/topic'; }
|
||||
else { layer.msg(res.message); }
|
||||
}, 'json');
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">帖子管理(共 {$list.total} 条)</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-table">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>标题</th><th>版块</th><th>类型</th><th>作者</th><th>回复</th><th>状态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{volist name="list.list" id="t"}
|
||||
<tr>
|
||||
<td>{$t.id}</td>
|
||||
<td><a href="/forum/topic/{$t.id}" target="_blank">{$t.title}</a></td>
|
||||
<td>{$t.board.name|default='-'}</td>
|
||||
<td>{$typeMap[$t.type]|default='-'}</td>
|
||||
<td>{$t.user_id}</td>
|
||||
<td>{$t.reply_count}</td>
|
||||
<td>
|
||||
{if $t.is_top==1}<span class="layui-badge">顶</span>{/if}
|
||||
{if $t.is_essence==1}<span class="layui-badge layui-bg-green">精</span>{/if}
|
||||
{if $t.status==1}正常{elseif $t.status==0}待审{else}已删{/if}
|
||||
</td>
|
||||
<td>
|
||||
<a href="/forum/backend/topic/edit/{$t.id}" class="layui-btn layui-btn-xs">编辑</a>
|
||||
{if $t.status==0}
|
||||
<button class="layui-btn layui-btn-xs layui-btn-normal" data-audit="{$t.id}">通过</button>
|
||||
<button class="layui-btn layui-btn-xs layui-btn-warm" data-reject="{$t.id}">驳回</button>
|
||||
{/if}
|
||||
<button class="layui-btn layui-btn-xs layui-btn-danger" data-del="{$t.id}">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/volist}
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="pager"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
layui.use(['jquery','laypage'], function(){
|
||||
var $ = layui.jquery, laypage = layui.laypage;
|
||||
laypage.render({ elem:'pager', count:{$list.total}, limit:20,
|
||||
curr:{$list.currentPage}, jump:function(obj,first){
|
||||
if(!first){ location.href='?page='+obj.curr; }
|
||||
}});
|
||||
$('[data-del]').on('click', function(){
|
||||
var id = $(this).data('del');
|
||||
layer.confirm('确认删除?', function(){
|
||||
$.post('/forum/backend/topic/audit/'+id+'?act=del', {}, function(res){}, 'json');
|
||||
$.post('/forum/backend/topic/delete/'+id, {}, function(res){
|
||||
if (res.code === 0) { location.reload(); } else { layer.msg(res.message); }
|
||||
}, 'json');
|
||||
});
|
||||
});
|
||||
$('[data-audit]').on('click', function(){
|
||||
var id = $(this).data('audit');
|
||||
$.post('/forum/backend/topic/audit/'+id, {}, function(res){
|
||||
if (res.code === 0) { location.reload(); } else { layer.msg(res.message); }
|
||||
}, 'json');
|
||||
});
|
||||
$('[data-reject]').on('click', function(){
|
||||
var id = $(this).data('reject');
|
||||
layer.prompt({title:'驳回理由', formType:2}, function(val, index){
|
||||
$.post('/forum/backend/topic/reject/'+id, {reason:val}, function(res){
|
||||
layer.close(index);
|
||||
if (res.code === 0) { location.reload(); } else { layer.msg(res.message); }
|
||||
}, 'json');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
{extend name="layout" /}
|
||||
{block name="title"}{$board.name}{/block}
|
||||
{block name="body"}
|
||||
<div class="layui-container fly-marginTop">
|
||||
<div class="fly-panel">
|
||||
<div class="fly-panel-title">
|
||||
<span>{$board.name}</span>
|
||||
<span class="fly-board-desc">{$board.description}</span>
|
||||
<span class="fly-board-actions">
|
||||
<a href="/forum/board/{$board.slug}?order=new" {if $order=='new'}class="layui-this"{/if}>最新</a>
|
||||
<a href="/forum/board/{$board.slug}?order=hot" {if $order=='hot'}class="layui-this"{/if}>热门</a>
|
||||
</span>
|
||||
</div>
|
||||
<ul class="fly-list">
|
||||
{volist name="topics" id="t"}
|
||||
<li class="fly-list-item">
|
||||
<h2><a class="fly-link" href="/forum/topic/{$t.id}">{$t.title}</a></h2>
|
||||
<div class="fly-list-info">
|
||||
<span class="fly-list-author">{$t.author.nickname|default='匿名'}</span>
|
||||
<span>{:date('Y-m-d', $t.create_at)}</span>
|
||||
<span class="fly-list-hint">
|
||||
<i class="iconfont icon-pinglun1"></i> {$t.reply_count}
|
||||
<i class="iconfont icon-kiss"></i> {$t.views}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
{/volist}
|
||||
</ul>
|
||||
<div class="fly-none" style="display:{if empty($topics.list)}block{else}none{/if}">本版块暂无帖子</div>
|
||||
</div>
|
||||
</div>
|
||||
{/block}
|
||||
@@ -0,0 +1,67 @@
|
||||
{extend name="layout" /}
|
||||
{block name="title"}社区首页{/block}
|
||||
{block name="body"}
|
||||
|
||||
<div class="layui-row layui-col-space15">
|
||||
<!-- 左侧:版块 + 帖子流 -->
|
||||
<div class="layui-col-md8">
|
||||
<div class="fly-panel">
|
||||
<div class="fly-panel-title fly-cols">
|
||||
<a class="fly-col-title">最新帖子</a>
|
||||
<a class="fly-col-extra" href="/forum?order=hot">热门</a>
|
||||
</div>
|
||||
<ul class="fly-list">
|
||||
{volist name="topics" id="t"}
|
||||
<li class="fly-list-item">
|
||||
<a href="/forum/u/{$t.user_id}" class="fly-avatar">
|
||||
<img src="{$t.author.avatar|default='/static/forum/images/avatar.png'}" alt="">
|
||||
</a>
|
||||
<h2>
|
||||
<a class="fly-link" href="/forum/topic/{$t.id}">{$t.title}</a>
|
||||
{if $t.is_top == 1}<span class="layui-badge">置顶</span>{/if}
|
||||
{if $t.is_essence == 1}<span class="layui-badge layui-bg-green">精</span>{/if}
|
||||
</h2>
|
||||
<div class="fly-list-info">
|
||||
<a href="/forum/u/{$t.user_id}" class="fly-list-author">{$t.author.nickname|default='匿名'}</a>
|
||||
<span>{:date('Y-m-d', $t.create_at)}</span>
|
||||
<span class="fly-list-hint">
|
||||
<i class="iconfont icon-pinglun1" title="回复"></i> {$t.reply_count}
|
||||
<i class="iconfont icon-kiss" title="浏览"></i> {$t.views}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
{/volist}
|
||||
</ul>
|
||||
<div class="fly-none" style="display:{if empty($topics.list)}block{else}none{/if}">暂无帖子</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:版块导航 + 热议 -->
|
||||
<div class="layui-col-md4">
|
||||
<div class="fly-panel">
|
||||
<h3 class="fly-panel-title">版块</h3>
|
||||
<div class="fly-board">
|
||||
{volist name="boards" id="b"}
|
||||
<a class="fly-board-item" href="/forum/board/{$b.slug}">
|
||||
<i class="layui-icon">{$b.icon|default=''}</i> {$b.name}
|
||||
<em>{$b.topic_count}</em>
|
||||
</a>
|
||||
{/volist}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fly-panel fly-rank">
|
||||
<h3 class="fly-panel-title">热议</h3>
|
||||
<ul class="fly-rank-list">
|
||||
{volist name="hot" id="h" length="10"}
|
||||
<li><a href="/forum/topic/{$h.id}">{$h.title}</a></li>
|
||||
{/volist}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="fly-panel" style="text-align:center;">
|
||||
<a href="/forum/post" class="layui-btn layui-btn-danger" style="width:100%;">发表新帖</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/block}
|
||||
@@ -0,0 +1,38 @@
|
||||
{extend name="index/layout" /}
|
||||
{block name="title"}附近帖子{/block}
|
||||
{block name="body"}
|
||||
<div class="fly-panel">
|
||||
<div class="fly-panel-title">附近帖子(半径 {$radius} km)</div>
|
||||
<div id="geo-tip" style="padding:10px;color:#999;">
|
||||
正在获取您的位置…
|
||||
</div>
|
||||
<ul class="fly-list" id="nearby-list" style="display:none;">
|
||||
{volist name="list" id="t"}
|
||||
<li>
|
||||
<a class="fly-list-title" href="/forum/topic/{$t.id}">{$t.title}</a>
|
||||
<span class="fly-list-info">
|
||||
{if isset($t.distance_km)}约 {$t.distance_km|round=2} km{/if}
|
||||
{$t.board.name|default=''}
|
||||
</span>
|
||||
</li>
|
||||
{/volist}
|
||||
</ul>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var tip = document.getElementById('geo-tip');
|
||||
var list = document.getElementById('nearby-list');
|
||||
function load(lat, lng){
|
||||
tip.style.display='none'; list.style.display='block';
|
||||
}
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(function(p){
|
||||
var lat = p.coords.latitude, lng = p.coords.longitude;
|
||||
window.location.href = '/forum/nearby?lat='+lat+'&lng='+lng+'&radius=10';
|
||||
}, function(){ tip.textContent='获取位置失败,可在 URL 手动传 ?lat=&lng='; });
|
||||
} else {
|
||||
tip.textContent='浏览器不支持定位,请在 URL 手动传 ?lat=&lng=';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{/block}
|
||||
@@ -0,0 +1,11 @@
|
||||
{extend name="layout" /}
|
||||
{block name="title"}未找到{/block}
|
||||
{block name="body"}
|
||||
<div class="layui-container fly-marginTop">
|
||||
<div class="fly-panel" style="padding:40px;text-align:center;">
|
||||
<i class="layui-icon" style="font-size:60px;color:#FF5722;"></i>
|
||||
<p style="margin-top:20px;font-size:16px;">{$msg|default='页面不存在'}</p>
|
||||
<p style="margin-top:10px;"><a href="/forum" class="layui-btn layui-btn-primary">返回社区首页</a></p>
|
||||
</div>
|
||||
</div>
|
||||
{/block}
|
||||
@@ -0,0 +1,84 @@
|
||||
{extend name="layout" /}
|
||||
{block name="title"}发表新帖{/block}
|
||||
{block name="body"}
|
||||
<div class="layui-container fly-marginTop">
|
||||
<div class="fly-panel" pad20>
|
||||
<div class="layui-form layui-form-pane">
|
||||
<form class="layui-form" id="postForm">
|
||||
<div class="layui-row layui-col-space15 layui-form-item">
|
||||
<div class="layui-col-md3">
|
||||
<label class="layui-form-label">所在版块</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="board_id" lay-verify="required">
|
||||
<option value=""></option>
|
||||
{volist name="boards" id="b"}
|
||||
<option value="{$b.id}">{$b.name}</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<label class="layui-form-label">类型</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="type" lay-verify="required">
|
||||
{foreach $typeMap as $k=>$v}
|
||||
<option value="{$k}">{$v}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item layui-form-text">
|
||||
<label class="layui-form-label">内容</label>
|
||||
<div class="layui-input-block">
|
||||
<script id="editor" name="content" type="text/plain" style="height:360px;"></script>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<button class="layui-btn" lay-submit lay-filter="postSubmit">立即发布</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/block}
|
||||
{block name="js"}
|
||||
<script src="/assets/library/ueditor/ueditor.config.js"></script>
|
||||
<script src="/assets/library/ueditor/ueditor.all.js"></script>
|
||||
<script>
|
||||
layui.use(['form', 'jquery'], function(){
|
||||
var form = layui.form, $ = layui.jquery;
|
||||
|
||||
// 初始化 UEditor 富文本编辑器
|
||||
var ue = UE.getEditor('editor', {
|
||||
serverUrl: '/forum/backend/ueditor',
|
||||
initialFrameHeight: {$editorHeight|default=360},
|
||||
zIndex: 9999
|
||||
});
|
||||
|
||||
form.on('submit(postSubmit)', function(data){
|
||||
// 同步编辑器内容到表单字段
|
||||
data.field.content = UE.getEditor('editor').getContent();
|
||||
if (!data.field.content || data.field.content.replace(/<[^>]*>/g, '').trim() === '') {
|
||||
layer.msg('请填写帖子内容');
|
||||
return false;
|
||||
}
|
||||
$.post('/forum/post', data.field, function(res){
|
||||
if (res.code === 0) {
|
||||
var id = res.data && res.data.id;
|
||||
location.href = '/forum/topic/' + id;
|
||||
} else {
|
||||
layer.msg(res.message || '发布失败');
|
||||
}
|
||||
}, 'json');
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{/block}
|
||||
@@ -0,0 +1,19 @@
|
||||
{extend name="layout" /}
|
||||
{block name="title"}搜索{/block}
|
||||
{block name="body"}
|
||||
<div class="layui-container fly-marginTop">
|
||||
<form class="fly-search-form" action="/forum/search" method="get">
|
||||
<input type="text" name="q" value="{$kw}" placeholder="搜索帖子标题" class="layui-input">
|
||||
<button class="layui-btn">搜索</button>
|
||||
</form>
|
||||
<div class="fly-panel">
|
||||
{volist name="list" id="t"}
|
||||
<div class="fly-search-item">
|
||||
<a href="/forum/topic/{$t.id}">{$t.title}</a>
|
||||
<span>{:date('Y-m-d', $t.create_at)}</span>
|
||||
</div>
|
||||
{/volist}
|
||||
<div class="fly-none" style="display:{if empty($list.list)}block{else}none{/if}">{if $kw==''}请输入关键词{else}未找到相关帖子{/if}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/block}
|
||||
@@ -0,0 +1,101 @@
|
||||
{extend name="layout" /}
|
||||
{block name="title"}{$topic.title}{/block}
|
||||
{block name="body"}
|
||||
|
||||
<div class="layui-row layui-col-space15">
|
||||
<div class="layui-col-md9">
|
||||
<div class="fly-panel" pad20>
|
||||
<div class="fly-detail">
|
||||
<h1 class="fly-detail-title">
|
||||
{if $topic.is_top == 1}<span class="layui-badge">置顶</span>{/if}
|
||||
{$topic.title}
|
||||
</h1>
|
||||
<div class="fly-detail-info">
|
||||
<span class="layui-badge layui-bg-green">{$typeName}</span>
|
||||
<a href="/forum/board/{$board.slug}">{$board.name}</a>
|
||||
<span>作者:<a href="/forum/u/{$topic.user_id}">{$author.nickname|default='匿名'}</a></span>
|
||||
<span>{:date('Y-m-d H:i', $topic.create_at)}</span>
|
||||
<span class="fly-detail-hint">{$topic.views} 阅 / {$topic.reply_count} 答</span>
|
||||
</div>
|
||||
<div class="fly-detail-body">
|
||||
{$topic.content|raw}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="layui-bg-gray">
|
||||
|
||||
<div class="fly-detail-reply">
|
||||
<div class="fly-detail-reply-title">
|
||||
回复 ({$topic.reply_count})
|
||||
</div>
|
||||
<ul class="fly-reply-list">
|
||||
{volist name="replyList" id="r"}
|
||||
<li class="fly-reply-item">
|
||||
<a href="/forum/u/{$r.user_id}" class="fly-reply-avatar">
|
||||
<img src="{if $r.avatar}{$r.avatar}{else}/static/forum/images/avatar.png{/if}">
|
||||
</a>
|
||||
<div class="fly-reply-main">
|
||||
<div class="fly-reply-head">
|
||||
<a href="/forum/u/{$r.user_id}" class="fly-reply-name">{$r.nickname}</a>
|
||||
<span class="fly-reply-floor">{$r.floor}楼</span>
|
||||
<span class="fly-reply-time">{:date('Y-m-d H:i', $r.create_at)}</span>
|
||||
</div>
|
||||
<div class="fly-reply-body">
|
||||
{$r.content|raw}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/volist}
|
||||
</ul>
|
||||
<div class="fly-none" style="display:{if empty($replies)}block{else}none{/if}">暂无回复,快来抢沙发</div>
|
||||
</div>
|
||||
|
||||
<!-- 回复表单 -->
|
||||
{if $user}
|
||||
<form class="fly-reply-form layui-form" id="replyForm">
|
||||
<input type="hidden" name="topic_id" value="{$topic.id}">
|
||||
<div class="layui-form-item layui-form-text">
|
||||
<textarea name="content" required lay-verify="required" placeholder="回复内容" class="layui-textarea"></textarea>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<button class="layui-btn" lay-submit lay-filter="replySubmit">提交回复</button>
|
||||
</div>
|
||||
</form>
|
||||
{else}
|
||||
<div class="fly-reply-tip"><a href="/user/login">登录</a> 后参与回复</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-col-md3">
|
||||
<div class="fly-panel">
|
||||
<h3 class="fly-panel-title">{$board.name}</h3>
|
||||
<p>{$board.description}</p>
|
||||
</div>
|
||||
{if !empty($related)}
|
||||
<div class="fly-panel" style="margin-top:15px;">
|
||||
<h3 class="fly-panel-title">相关帖子</h3>
|
||||
<ul class="fly-related-list">
|
||||
{volist name="related" id="rel"}
|
||||
<li><a href="/forum/topic/{$rel.id}">{$rel.title}</a></li>
|
||||
{/volist}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/block}
|
||||
{block name="js"}
|
||||
<script>
|
||||
layui.use(['form', 'jquery'], function(){
|
||||
var form = layui.form, $ = layui.jquery;
|
||||
form.on('submit(replySubmit)', function(data){
|
||||
$.post('/forum/reply', data.field, function(res){
|
||||
if (res.code === 0) { location.reload(); }
|
||||
else { layer.msg(res.message || '失败'); }
|
||||
}, 'json');
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{/block}
|
||||
@@ -0,0 +1,26 @@
|
||||
{extend name="layout" /}
|
||||
{block name="title"}{$user.nickname} 的主页{/block}
|
||||
{block name="body"}
|
||||
<div class="layui-container fly-marginTop">
|
||||
<div class="fly-home fly-panel">
|
||||
<img src="{$user.avatar|default='/static/forum/images/avatar.png'}" alt="{$user.nickname}">
|
||||
<h1>{$user.nickname}</h1>
|
||||
<p class="fly-home-sign">{$user.intro|default='这家伙很懒,什么都没留下'}</p>
|
||||
</div>
|
||||
<div class="fly-panel">
|
||||
<h3 class="fly-panel-title">TA 的帖子</h3>
|
||||
<ul class="fly-list">
|
||||
{volist name="topics" id="t"}
|
||||
<li class="fly-list-item">
|
||||
<h2><a class="fly-link" href="/forum/topic/{$t.id}">{$t.title}</a></h2>
|
||||
<div class="fly-list-info">
|
||||
<span>{:date('Y-m-d', $t.create_at)}</span>
|
||||
<span class="fly-list-hint">{$t.reply_count} 答 / {$t.views} 阅</span>
|
||||
</div>
|
||||
</li>
|
||||
{/volist}
|
||||
</ul>
|
||||
<div class="fly-none" style="display:{if empty($topics)}block{else}none{/if}">TA 还没有发帖</div>
|
||||
</div>
|
||||
</div>
|
||||
{/block}
|
||||
@@ -0,0 +1,64 @@
|
||||
<!--
|
||||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||
* @Date: 2026-08-02 01:09:44
|
||||
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||
* @LastEditTime: 2026-08-02 08:56:23
|
||||
* @Description:
|
||||
* @FilePath: \ywxapp_dev\addon\forum\view\frontend\layout.html
|
||||
* @CustomString: Copyright (c) 2026 YwxApp
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{block name="title"}{$site_title|default='轻社区'}{/block}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<link rel="stylesheet" href="/static/forum/css/global.css">
|
||||
<link rel="stylesheet" href="/static/forum/layui/css/layui.css">
|
||||
{block name="css"}{/block}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="fly-header layui-bg-black">
|
||||
<div class="layui-container">
|
||||
<a class="fly-logo" href="/forum">
|
||||
<img src="/static/forum/images/logo.png" alt="logo">
|
||||
</a>
|
||||
<ul class="layui-nav fly-nav layui-hide-xs">
|
||||
<li class="layui-nav-item layui-this"><a href="/forum"><i class="iconfont icon-jiaoliu"></i>社区</a></li>
|
||||
<li class="layui-nav-item"><a href="/forum/index/search"><i class="iconfont icon-ui"></i>搜索</a></li>
|
||||
</ul>
|
||||
<ul class="layui-nav fly-nav-user">
|
||||
{if $user}
|
||||
<li class="layui-nav-item">
|
||||
<a class="fly-nav-avatar" href="javascript:;">
|
||||
<cite class="layui-hide-xs">{$user.nickname}</cite>
|
||||
{if isset($unread) && $unread > 0}<i class="layui-badge fly-badge-vip">{$unread}</i>{/if}
|
||||
<img src="{$user.avatar|default='/static/forum/images/avatar.png'}">
|
||||
</a>
|
||||
<dl class="layui-nav-child">
|
||||
<dd><a href="/forum/u/{$user.uid}"><i class="layui-icon"></i>我的主页</a></dd>
|
||||
<dd><a href="/user/set"><i class="layui-icon"></i>基本设置</a></dd>
|
||||
<dd><a href="/user/logout" style="text-align:center;">退出</a></dd>
|
||||
</dl>
|
||||
</li>
|
||||
{else}
|
||||
<li class="layui-nav-item"><a href="/user/login">登入</a></li>
|
||||
<li class="layui-nav-item"><a href="/user/reg">注册</a></li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-container">
|
||||
{block name="body"}{/block}
|
||||
</div>
|
||||
|
||||
<div class="fly-footer">
|
||||
<p>轻社区论坛 · Powered by YwxApp</p>
|
||||
</div>
|
||||
|
||||
<script src="/static/forum/layui/layui.js"></script>
|
||||
{block name="js"}{/block}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,84 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
我的帖子
|
||||
<a class="layui-btn layui-btn-sm layui-btn-normal" style="float:right;margin-top:8px;" href="{:addon_url('forum/member.forum/post')}">+ 我要发帖</a>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>标题</th>
|
||||
<th>版块</th>
|
||||
<th>回复</th>
|
||||
<th>浏览</th>
|
||||
<th>状态</th>
|
||||
<th>发布时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="topicList">
|
||||
{volist name="list" id="item"}
|
||||
<tr data-id="{$item.id}">
|
||||
<td>{$item.id}</td>
|
||||
<td><a href="/forum/topic/{$item.id}" target="_blank" style="color:#1e9fff;">{$item.title}</a></td>
|
||||
<td>{$item.board_title}</td>
|
||||
<td>{$item.reply_count}</td>
|
||||
<td>{$item.view_count}</td>
|
||||
<td>
|
||||
{if $item.status == 1}<span class="layui-badge layui-bg-green">正常</span>
|
||||
{elseif $item.status == 0}<span class="layui-badge layui-bg-orange">待审</span>
|
||||
{else}<span class="layui-badge">已删</span>{/if}
|
||||
</td>
|
||||
<td>{$item.create_at}</td>
|
||||
<td>
|
||||
<a class="layui-btn layui-btn-xs" href="/forum/post?edit={$item.id}">编辑</a>
|
||||
<button class="layui-btn layui-btn-xs layui-btn-danger btn-del">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/volist}
|
||||
{if empty($list)}
|
||||
<tr><td colspan="8" style="text-align:center;color:#999;">暂无帖子,<a href="{:addon_url('forum/member.forum/post')}" style="color:#1e9fff;">去发帖</a></td></tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="pages"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var _layui = (top.window && top.window.layui) ? top.window.layui : layui;
|
||||
_layui.use(['layer', '$', 'laypage'], function () {
|
||||
var layer = _layui.layer, $ = _layui.$, laypage = _layui.laypage;
|
||||
|
||||
{if $total > 0}
|
||||
laypage.render({
|
||||
elem: 'pages',
|
||||
count: {$total},
|
||||
limit: 15,
|
||||
curr: {$page},
|
||||
jump: function (obj, first) {
|
||||
if (!first) {
|
||||
location.href = '{:addon_url("forum/member.forum/mytopics")}?page=' + obj.curr;
|
||||
}
|
||||
}
|
||||
});
|
||||
{/if}
|
||||
|
||||
$('#topicList').on('click', '.btn-del', function () {
|
||||
var $tr = $(this).closest('tr');
|
||||
var id = $tr.data('id');
|
||||
layer.confirm('确定删除该帖子吗?', function (index) {
|
||||
$.post('{:addon_url("forum/member.forum/del")}', { id: id }, function (res) {
|
||||
if (res && res.code === 0) {
|
||||
layer.msg(res.message || '已删除', { icon: 1 });
|
||||
$tr.remove();
|
||||
} else {
|
||||
layer.msg((res && res.message) || '删除失败');
|
||||
}
|
||||
}, 'json').fail(function () { layer.msg('请求失败'); });
|
||||
layer.close(index);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,77 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">发布帖子</div>
|
||||
<div class="layui-card-body" pad15>
|
||||
<form class="layui-form" lay-filter="postForm">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">所属版块</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="board_id" lay-verify="required">
|
||||
<option value="">请选择版块</option>
|
||||
{volist name="boards" id="b"}
|
||||
<option value="{$b.id}">{$b.title}</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">帖子类型</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="type">
|
||||
<option value="99">分享</option>
|
||||
<option value="0">提问</option>
|
||||
<option value="100">讨论</option>
|
||||
<option value="101">建议</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">标题</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="title" 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">
|
||||
<script id="editor" name="content" type="text/plain" style="height:{$editorHeight|default=360}px;"></script>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button class="layui-btn" lay-submit lay-filter="submitPost">立即发布</button>
|
||||
<a class="layui-btn layui-btn-primary" href="{:addon_url('forum/member.forum/mytopics')}">我的帖子</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var _layui = (top.window && top.window.layui) ? top.window.layui : layui;
|
||||
_layui.use(['form', 'layer', 'jquery'], function () {
|
||||
var form = _layui.form, layer = _layui.layer, $ = _layui.jquery;
|
||||
var editor = UE.getEditor('editor', {
|
||||
serverUrl: '/forum/backend/ueditor',
|
||||
initialFrameHeight: {$editorHeight|default=360},
|
||||
zIndex: 9999
|
||||
});
|
||||
|
||||
form.on('submit(submitPost)', function (data) {
|
||||
data.field.content = UE.getEditor('editor').getContent();
|
||||
if (!data.field.content || data.field.content.length < 5) {
|
||||
layer.msg('内容太少啦');
|
||||
return false;
|
||||
}
|
||||
$.post('{:addon_url("forum/member.forum/post")}', data.field, function (res) {
|
||||
if (res && res.code === 0) {
|
||||
layer.msg(res.message || '发布成功', { icon: 1 }, function () {
|
||||
location.href = '{:addon_url("forum/member.forum/mytopics")}';
|
||||
});
|
||||
} else {
|
||||
layer.msg((res && res.message) || '发布失败');
|
||||
}
|
||||
}, 'json').fail(function () { layer.msg('请求失败'); });
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>{$title | default='论坛 - 会员中心'}</title>
|
||||
<meta name="renderer" content="webkit" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
<link href="/assets/layui/css/layui.css" rel="stylesheet" />
|
||||
<link href="/static/member/css/common.css" rel="stylesheet" />
|
||||
<script src="/assets/library/ueditor/ueditor.config.js"></script>
|
||||
<script src="/assets/library/ueditor/ueditor.all.js"></script>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
.child-page {
|
||||
flex: 1;
|
||||
margin: 14px;
|
||||
}
|
||||
|
||||
.in-iframe .child-page {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
<script type="text/javascript">
|
||||
var layui = layui || window.top.layui;
|
||||
window.UEDITOR_HOME_URL = '/assets/library/ueditor/';
|
||||
if (window !== top) {
|
||||
document.documentElement.className += ' in-iframe';
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="child-page">
|
||||
{__CONTENT__}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user