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

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
<?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\haonav\controller\backend;
use think\facade\Db;
use addon\haonav\model\Ad as AdModel;
/**
* 广告位后台管理
*/
class Ad extends HaonavBackend
{
protected $noNeedVerify = ['*'];
protected function initialize()
{
$this->model = new AdModel();
}
public function index()
{
if ($this->request->isAjax()) {
$slot = $this->request->param('slot', '');
$status = $this->request->param('status', '');
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 20);
$data = $this->model
->when($slot, fn($q, $s) => $q->where('slot', $s))
->when($status !== '', fn($q, $s) => $q->where('status', $s))
->order('slot')->order('sort', 'desc')->order('id', 'desc')
->paginate(['page' => $page, 'list_rows' => $limit]);
$this->result->setCount($data->total());
$this->result->success($data->items());
}
return $this->view->fetch('ad/index');
}
/**
* 表单下拉数据(广告位列表)
*/
public function create()
{
if ($this->request->isAjax()) {
$slots = [];
foreach (AdModel::slots() as $k => $v) {
$slots[] = ['id' => $k, 'title' => $v];
}
// 直接返回数组,前端 res.data 即数组(勿再包 data
$this->result->success($slots);
}
}
public function save()
{
if (! $this->request->isPost()) {
return $this->result->error('请求方式错误');
}
$post = $this->request->post();
$this->normalizeDates($post);
Db::startTrans();
try {
$this->model->save($post);
AdModel::clearCache();
Db::commit();
$this->result->success($this->model, '保存成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('保存失败: ' . $e->getMessage());
}
}
public function edit($id = 0)
{
$id = $this->request->param('id');
$model = $this->model->find($id);
if (! $model) {
return $this->result->error('数据不存在');
}
if ($this->request->isAjax()) {
$slots = [];
foreach (AdModel::slots() as $k => $v) {
$slots[] = ['id' => $k, 'title' => $v];
}
$info = $model->toArray();
$info['start_at_text'] = $model->start_at ? date('Y-m-d H:i:s', (int)$model->start_at) : '';
$info['end_at_text'] = $model->end_at ? date('Y-m-d H:i:s', (int)$model->end_at) : '';
$this->result->success(['info' => $info, 'slots' => $slots]);
}
}
public function update()
{
if (! ($this->request->isAjax() && $this->request->isPut())) {
return $this->result->error('请求方式错误');
}
$id = $this->request->param('id');
$post = $this->request->param();
$this->normalizeDates($post);
Db::startTrans();
try {
$model = $this->model->find($id);
if (! $model) {
throw new \think\exception\ValidateException('数据不存在');
}
$model->save($post);
AdModel::clearCache();
Db::commit();
$this->result->success($model, '更新成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('更新失败: ' . $e->getMessage());
}
}
public function delete()
{
$ids = (string)$this->request->param('ids', '');
$arr = array_values(array_filter(array_map('intval', explode(',', $ids))));
if (empty($arr)) {
return $this->result->error('请选择数据');
}
$this->model->whereIn('id', $arr)->delete();
AdModel::clearCache();
$this->result->success([], '已删除');
}
/**
* 状态开关(列表内 switch)
*/
public function status()
{
if (! ($this->request->isAjax() && $this->request->isPost())) {
return $this->result->error('请求方式错误');
}
$id = (int)$this->request->param('id');
$status = (int)$this->request->param('status');
$model = $this->model->find($id);
if (! $model) {
return $this->result->error('数据不存在');
}
$model->status = $status;
$model->save();
AdModel::clearCache();
$this->result->success([], '操作成功');
}
/**
* 排序(列表内行内编辑)
*/
public function sort()
{
if (! ($this->request->isAjax() && $this->request->isPost())) {
return $this->result->error('请求方式错误');
}
$id = (int)$this->request->param('id');
$sort = (int)$this->request->param('sort', 0);
$model = $this->model->find($id);
if ($model) {
$model->sort = $sort;
$model->save();
AdModel::clearCache();
}
$this->result->success([], '已保存');
}
/**
* 将日期字符串转为时间戳;空值置 null
*/
protected function normalizeDates(array &$post): void
{
foreach (['start_at', 'end_at'] as $k) {
if (empty($post[$k])) {
$post[$k] = null;
} else {
$t = strtotime((string)$post[$k]);
$post[$k] = $t === false ? null : $t;
}
}
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace addon\haonav\controller\backend;
use think\facade\Db;
use addon\haonav\model\Apply as ApplyModel;
use addon\haonav\model\Links as LinksModel;
use addon\haonav\model\Category as CategoryModel;
/**
* 友链/广告 申请审核
* 通过友链申请时可指定分类并一键写入 Links(默认待审核状态可直接上架)。
*/
class Apply extends HaonavBackend
{
protected $noNeedVerify = ['*'];
protected function initialize()
{
$this->model = new ApplyModel();
}
public function index()
{
if ($this->request->isAjax()) {
$type = $this->request->param('type', '');
$status = $this->request->param('status', '');
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 20);
$data = $this->model
->when($type !== '', fn($q, $v) => $q->where('type', $type))
->when($status !== '', fn($q, $v) => $q->where('status', $status))
->order('status', 'asc')->order('id', 'desc')
->paginate(['page' => $page, 'list_rows' => $limit]);
$this->result->setCount($data->total());
$this->result->success($data->items());
}
return $this->view->fetch('apply/index');
}
/**
* 表单辅助数据:分类列表(通过友链时选分类)
*/
public function create()
{
if ($this->request->isAjax()) {
$cates = CategoryModel::where('status', 1)
->order('sort', 'desc')
->field('id,title')
->select()
->toArray();
$this->result->success($cates);
}
}
/**
* 通过申请
* 友链(type=1):可带 cid(分类)与 online(1=直接上架 0=进待审核),自动写入 Links 并回填 link_id
* 广告(type=2):仅标记通过,商务细节线下沟通后在「广告管理」建广告
*/
public function approve()
{
if (! ($this->request->isAjax() && $this->request->isPost())) {
return $this->result->error('请求方式错误');
}
$id = (int)$this->request->param('id');
$reply = (string)$this->request->param('reply', '');
$model = $this->model->find($id);
if (! $model) {
return $this->result->error('数据不存在');
}
if ((int)$model->status === ApplyModel::STATUS_APPROVED) {
return $this->result->error('该申请已通过,请勿重复操作');
}
Db::startTrans();
try {
$linkId = 0;
if ((int)$model->type === ApplyModel::TYPE_LINK) {
$cid = (int)$this->request->param('cid/d', 0);
$online = (int)$this->request->param('online/d', 1);
if ($cid <= 0) {
throw new \think\exception\ValidateException('请选择收录分类');
}
// 已有同 URL 链接则复用,避免重复收录
$exists = LinksModel::where('url', $model->url)->find();
if ($exists) {
$linkId = (int)$exists->id;
} else {
$link = new LinksModel();
$link->cid = $cid;
$link->title = $model->title;
$link->url = $model->url;
$link->description = (string)$model->description;
$link->status = $online ? 1 : 2; // 1上架 2待审核
$link->save();
$linkId = (int)$link->id;
}
}
$model->status = ApplyModel::STATUS_APPROVED;
$model->reply = mb_substr($reply, 0, 255);
$model->link_id = $linkId;
$model->save();
Db::commit();
$this->result->success([], '已通过' . ($linkId ? ',并已收录(links#' . $linkId . '' : ''));
} catch (\Exception $e) {
Db::rollback();
$this->result->error('操作失败: ' . $e->getMessage());
}
}
/**
* 拒绝申请(可填审核备注)
*/
public function reject()
{
if (! ($this->request->isAjax() && $this->request->isPost())) {
return $this->result->error('请求方式错误');
}
$id = (int)$this->request->param('id');
$reply = (string)$this->request->param('reply', '');
$model = $this->model->find($id);
if (! $model) {
return $this->result->error('数据不存在');
}
$model->status = ApplyModel::STATUS_REJECTED;
$model->reply = mb_substr($reply, 0, 255);
$model->save();
$this->result->success([], '已拒绝');
}
public function delete()
{
$ids = (string)$this->request->param('ids', '');
$arr = array_values(array_filter(array_map('intval', explode(',', $ids))));
if (empty($arr)) {
return $this->result->error('请选择数据');
}
$this->model->whereIn('id', $arr)->delete();
$this->result->success([], '已删除');
}
}
@@ -0,0 +1,255 @@
<?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\haonav\controller\backend;
use think\Request;
use think\Response;
use think\exception\ValidateException;
use think\facade\Db;
use addon\haonav\validate\Category as CategoryValidate;
/**
* Category 类
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Category extends HaonavBackend
{
/**
* Summary of needLogin
* @var array
*/
/**
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
/**
* 控制器初始化 _initialize
* @return void
*/
protected function initialize()
{
$this->model = new \addon\haonav\model\Category();
}
/**
* 显示资源列表
*
* @return \think\Response
*/
public function index()
{
if ($this->request->isAjax()) {
$title = $this->request->param('title', '');
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 20);
$data = $this->model->withAttr('cate')
->when($title, fn($q, $t) => $q->whereLike('title', "%{$t}%"))
->paginate(['page' => $page, 'list_rows' => $limit]);
$this->result->setCount($data->total());
$this->result->success($data->items());
}
return $this->view->fetch('category/index');
}
/**
* 显示创建资源表单页.
*
* @return \think\Response
*/
public function create()
{
if ($this->request->isAjax()) {
$data = $this->model->cateTree($this->model->select()->toArray());
$this->result->success(['data' => $data]);
}
}
/**
* 保存新建的资源
*
* @param \think\Request $request
* @return \think\Response
*/
public function save(Request $request)
{
if ($this->request->isPost()) {
$params = $this->request->post();
try {
validate(CategoryValidate::class)->check($params);
} catch (ValidateException $e) {
$this->result->error('数据验证失败: ' . $e->getMessage());
}
Db::startTrans();
try {
$data = $this->model->save($params);
Db::commit();
$this->result->success($data, '保存成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('保存失败: ' . $e->getMessage());
}
}
}
/**
* 显示指定的资源
*
* @param int $id
* @return \think\Response
*/
public function read($id)
{
//
}
/**
* 显示编辑资源表单页.
*
* @param int $id
* @return \think\Response
*/
public function edit($id = null)
{
$id = $this->request->param('id');
$model = $this->model->find($id);
if (! $model) {
$this->result->error('数据不存在');
}
if ($this->request->isAjax()) {
$powers = $this->model->cateTree($this->model->select()->toArray());
$this->result->success(['power' => $powers, 'info' => $model]);
}
}
/**
* 保存更新的资源
*
* @param \think\Request $request
* @param int $id
* @return \think\Response
*/
public function update(Request $request, $id = 0)
{
$id = $id ? $id : $this->request->param('id');
if ($this->request->isAjax() && $this->request->isPut()) {
$params = $this->request->param();
try {
validate(CategoryValidate::class)->check($params);
} catch (ValidateException $e) {
$this->result->error('数据验证失败: ' . $e->getMessage());
}
Db::startTrans();
try {
$model = $this->model->find($id);
if (! $model) {
throw new ValidateException('数据不存在');
}
$model->save($params);
Db::commit();
$this->result->success($model, '更新成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('更新失败: ' . $e->getMessage());
}
}
}
/**
* 显示回收站列表
*
* @param \think\Request $request
* @return \think\Response
*/
public function recyclebin()
{
$page = $this->request->param('page', 1);
$size = $this->request->param('size', 15);
if ($this->request->isAjax()) {
$data = $this->model->onlyTrashed()->paginate([
'page' => $page,
'list_rows' => $size,
]);
$this->result->success($data->items());
}
$this->view->assign('title', '回收站');
return $this->view->fetch('category/recyclebin');
}
/**
* 删除权限
*
* @param \think\Request $request
* @return \think\Response
*/
public function delete()
{
if ($this->request->isAjax() && $this->request->isDelete()) {
$ids = $this->request->param('ids', '');
$force = $this->request->param('force', false);
if (empty($ids)) {
$this->result->error('请选择要删除的数据');
}
try {
Db::transaction(function () use ($ids, $force) {
if ($force) {
$this->model->onlyTrashed()->whereIn('id', $ids)->select()->each(function ($item) {
$item->force()->delete();
});
} else {
$this->model->destroy($ids);
}
});
$this->result->success();
} catch (\think\exception\HttpResponseException $e) {
throw $e; // 不要 return,不要吞掉!
} catch (\Throwable $th) {
$this->result->error('删除失败: ' . $th->getMessage());
}
}
}
/**
* 还原权限
*
* @param \think\Request $request
* @return \think\Response
*/
public function restore($ids = null)
{
if ($this->request->isAjax() && $this->request->isPut()) {
$ids = $this->request->param('ids', '');
if (empty($ids)) {
$this->result->error('请选择要还原的数据');
}
$idsArray = explode(',', $ids);
try {
Db::transaction(function () use ($idsArray) {
$this->model->onlyTrashed()->whereIn('id', $idsArray)->select()->each(function ($item) {
$item->restore();
});
});
$this->result->success();
} catch (\think\exception\HttpResponseException $e) {
throw $e; // 不要 return,不要吞掉!
} catch (\Throwable $th) {
$this->result->error('还原失败: ' . $th->getMessage());
}
}
}
}
+122
View File
@@ -0,0 +1,122 @@
<?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\haonav\controller\backend;
use think\Request;
use addon\haonav\model\Configure as ConfigureModel;
use think\facade\Db;
use ywxapp\model\BaseModel;
/**
* Configs 类
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Configs extends HaonavBackend
{
/**
* Summary of needLogin
* @var array
*/
/**
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
/**
* 控制器初始化 _initialize
* @return void
*/
protected function initialize()
{
$this->model = new ConfigureModel;
}
/**
* 显示资源列表
*
* @param \think\Request $request
* @return \think\Response
*/
public function index()
{
ConfigureModel::ensureRebateConfig();
ConfigureModel::ensureDeadlinkConfig();
$page = $this->request->param('page', 1);
$size = $this->request->param('size', 15);
if ($this->request->isAjax()) {
$data = ConfigureModel::order('id')->select();
$this->result->success($data);
}
$this->view->assign('title', '网址导航配置');
return $this->view->fetch('configs/index');
}
/**
* 保存新建的资源
*
* @param \think\Request $request
* @return \think\Response
*/
public function update()
{
if (!$this->request->isPost()) {
return $this->result->error('请求方式错误');
}
$postData = $this->request->post();
BaseModel::ensureAutoIncrementPk('wxapp_haonav_config');
$allConfigs = ConfigureModel::column('name,type,rule,value', 'name');
Db::startTrans();
try {
foreach ($postData as $name => $value) {
if (!isset($allConfigs[$name])) {
continue;
}
$config = $allConfigs[$name];
if (!empty($config['rule'])) {
$validate = validate([
$name => $config['rule']
]);
//if (!$validate->check([$name => $value])) {
// throw new \Exception("配置项 [{$name}] 验证失败:" . $validate->getError());
// }
}
// 特殊处理:复选框数组转字符串
if (is_array($value)) {
$value = implode(',', $value);
}
$exists = ConfigureModel::where('name', $name)->find();
if ($exists) {
$exists->save(['value' => $value ?? ""]);
} else {
// 如果没有记录,创建新记录
ConfigureModel::create([
'name' => $name,
'value' => $value,
'group' => $postData['group'] ?? 'default',
'type' => $config['type'] ?? 'string'
]);
}
}
// Cache::delete('system_config_all');
Db::commit();
$this->result->success('配置保存成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('保存失败:' . $e->getMessage());
}
}
}
@@ -0,0 +1,145 @@
<?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\haonav\controller\backend;
use think\facade\Db;
use ywxapp\model\BaseModel;
use addon\haonav\model\Links as LinksModel;
use addon\haonav\model\Category as CategoryModel;
/**
* 后台数据概览仪表盘
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Dashboard extends HaonavBackend
{
protected $noNeedVerify = ['*'];
protected function initialize()
{
}
public function index()
{
if ($this->request->isAjax()) {
return $this->stats();
}
// 顺带为存量链接回填拼音搜索串(每次最多 300 条,幂等、失败静默)
LinksModel::backfillPinyin(300);
$this->view->assign('title', '数据概览');
return $this->view->fetch('dashboard/index');
}
/**
* 汇总统计
*/
protected function stats()
{
// 基础计数
$summary = [
'links' => LinksModel::count(),
'online' => LinksModel::where('status', 1)->count(),
'pending' => LinksModel::where('status', 2)->count(),
'disabled' => LinksModel::where('status', 0)->count(),
'categories' => CategoryModel::count(),
'hot' => LinksModel::where('is_hot', 1)->count(),
'recommend' => LinksModel::where('is_recommend', 1)->count(),
'clicks' => (int)LinksModel::sum('click_count'),
];
// Top 分类(按链接数)
$cates = CategoryModel::field('id,title,icon')->select();
$catStat = [];
foreach ($cates as $c) {
$cnt = LinksModel::where('cid', $c->id)->count();
$catStat[] = ['title' => $c->title, 'icon' => $c->icon, 'count' => $cnt];
}
usort($catStat, function ($a, $b) {
return $b['count'] <=> $a['count'];
});
$topCategories = array_slice($catStat, 0, 10);
// Top 点击
$topClicks = LinksModel::field('id,title,url,click_count,cid')
->where('status', 1)
->order('click_count', 'desc')
->limit(10)
->select();
// 死链概览(status_code 已检测且非 2xx/3xx,或为 0
$deadCount = LinksModel::whereNotNull('status_code')
->where(function ($q) {
$q->where('status_code', 0)->whereOr('status_code', '>=', 400);
})
->count();
$deadList = LinksModel::field('id,title,url,status_code,last_check_at')
->whereNotNull('status_code')
->where(function ($q) {
$q->where('status_code', 0)->whereOr('status_code', '>=', 400);
})
->limit(20)
->select();
$lastCheckAt = (int)LinksModel::max('last_check_at');
// 分类点击热度(基于 links.click_count 聚合,零额外表)
$catHeat = [];
$allCates = CategoryModel::field('id,title,icon')->select();
foreach ($allCates as $c) {
$catHeat[] = [
'cid' => $c->id,
'title' => $c->title,
'icon' => $c->icon,
'clicks' => (int)LinksModel::where('cid', $c->id)->sum('click_count'),
];
}
usort($catHeat, function ($a, $b) { return $b['clicks'] <=> $a['clicks']; });
// 访问时段热力图(星期×小时),取最近 30 天,按 date 归并星期;表不存在或查询失败均静默兜底
$timeHeat = [];
for ($d = 0; $d < 7; $d++) { $timeHeat[$d] = array_fill(0, 24, 0); }
$timeTotal = 0;
$timeRange = date('Y-m-d', strtotime('-30 days')) . ' ~ ' . date('Y-m-d');
try {
if (BaseModel::tableExists('wxapp_haonav_click_stats')) {
$rows = Db::name('haonav_click_stats')
->where('date', '>=', date('Y-m-d', strtotime('-30 days')))
->field('date,hour,clicks')
->select();
foreach ($rows as $r) {
$w = (int)date('w', strtotime($r['date'])); // 0=周日..6=周六
$h = (int)$r['hour'];
if ($w >= 0 && $w < 7 && $h >= 0 && $h < 24) {
$timeHeat[$w][$h] += (int)$r['clicks'];
$timeTotal += (int)$r['clicks'];
}
}
}
} catch (\Throwable $e) {
// 统计查询失败不阻断概览
}
return $this->result->success([
'summary' => $summary,
'topCategories' => $topCategories,
'topClicks' => $topClicks,
'dead' => ['count' => $deadCount, 'list' => $deadList, 'last_check_at' => $lastCheckAt ? date('Y-m-d H:i', $lastCheckAt) : ''],
'heatmap' => [
'category' => $catHeat,
'time' => $timeHeat,
'timeRange' => $timeRange,
'timeTotal' => $timeTotal,
],
]);
}
}
@@ -0,0 +1,35 @@
<?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\haonav\controller\backend;
use ywxapp\controller\BackendBase;
/**
* 网址导航后台控制器基类
*
* 说明:addon 后台路由(/<plugin>/backend/...)经全局路由分发落在默认 frontend 应用,
* 容器 auth 绑定会解析成前台 Auth(isAdmin=false),后台 token 上下文不匹配会被拒。
* AddonBackend::_initialize() 已统一处理:当 $this->auth 非 AdminAuth 时强制还原为
* AdminAuth 并 tryInitByToken(),再按子类声明的 noNeedLogin/noNeedVerify 做登录与权限校验。
*
* 因此本基类【不应】再在构造阶段自行 verifyAuth——那时 AdminAuth 实例尚未初始化登录态,
* 会恒判未登录并跳转登录页(即「后台总是要登录」的根因)。
* 这里仅声明跳过后台细粒度权限校验,避免权限规则未配置时锁死后台;登录仍强制要求。
*/
class HaonavBackend extends BackendBase
{
/**
* 跳过后台细粒度权限校验(权限规则未配置时避免锁死后台)。
* 登录要求仍由 AddonBackend::_initialize() 强制(noNeedLogin 默认空=需要登录)。
*/
protected $noNeedVerify = ['*'];
}
+469
View File
@@ -0,0 +1,469 @@
<?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\haonav\controller\backend;
use think\Request;
use think\Response;
use think\exception\ValidateException;
use think\facade\Db;
use addon\haonav\validate\Link as LinkValidate;
use addon\haonav\model\Category as CategoryModel;
/**
* Links 类
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Links extends HaonavBackend
{
/**
* Summary of needLogin
* @var array
*/
/**
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
/**
* 控制器初始化
* @return void
*/
protected function initialize()
{
$this->model = new \addon\haonav\model\Links();
}
/**
* 列表(支持 status 过滤:1启用 0禁用 2待审核)
*/
public function index()
{
if ($this->request->isAjax()) {
$title = $this->request->param('title', '');
$url = $this->request->param('url', '');
$keywords = $this->request->param('keywords', '');
$description = $this->request->param('description', '');
$status = $this->request->param('status', '');
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 20);
$data = $this->model
->when($title, fn($q, $t) => $q->whereLike('title', "%{$t}%"))
->when($url, fn($q, $t) => $q->whereLike('url', "%{$t}%"))
->when($keywords, fn($q, $t) => $q->whereLike('keywords', "%{$t}%"))
->when($description, fn($q, $t) => $q->whereLike('description', "%{$t}%"))
->when($status !== '', function ($q) use ($status) {
// dead = 仅筛选被检测器判定为死链(dead_at>0)的链接
if ($status === 'dead') {
$q->where('dead_at', '>', 0);
} else {
$q->where('status', $status);
}
})
->paginate([
'page' => $page,
'list_rows' => $limit,
]);
$this->result->setCount($data->total());
$this->result->success($data->items());
}
return $this->view->fetch('links/index');
}
public function create()
{
if ($this->request->isAjax()) {
$data = CategoryModel::cateTree(CategoryModel::select()->toArray());
// 直接返回数组,勿再包一层 ['data'=>...](否则 JSON 变 data.data,前端 res.data 拿到对象)
$this->result->success($data);
}
}
public function save(Request $request)
{
if ($this->request->isPost()) {
$params = $this->request->post();
try {
validate(LinkValidate::class)->check($params);
} catch (ValidateException $e) {
$this->result->error('数据验证失败: ' . $e->getMessage());
}
Db::startTrans();
try {
$this->model->save($params);
Db::commit();
$this->result->success($this->model, '保存成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('保存失败: ' . $e->getMessage());
}
}
}
public function read($id = 0) {}
public function cates()
{
if ($this->request->isAjax()) {
$cates = CategoryModel::where('status', 1)->select()->toArray();
$this->result->success($cates);
}
}
public function edit($id = null)
{
$id = $this->request->param('id');
$model = $this->model->find($id);
if (! $model) {
$this->result->error('数据不存在');
}
if ($this->request->isAjax()) {
$cates = CategoryModel::where('status', 1)->select()->toArray();
$this->result->success(['info' => $model, 'cates' => $cates]);
}
}
public function update(Request $request)
{
$id = $this->request->param('id');
if ($this->request->isAjax() && $this->request->isPut()) {
$params = $this->request->param();
try {
// 只验证提交的字段:状态开关/热点开关等局部更新只传 id+单字段,
// 全量验证会被 title/url/cid 的 require 规则误杀
validate(LinkValidate::class)->only(array_keys($params))->check($params);
} catch (ValidateException $e) {
$this->result->error('数据验证失败: ' . $e->getMessage());
}
Db::startTrans();
try {
$model = $this->model->find($id);
if (! $model) {
throw new ValidateException('数据不存在');
}
$model->save($params);
Db::commit();
$this->result->success($model, '更新成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('更新失败: ' . $e->getMessage());
}
}
}
/**
* 批量操作:enable 批量启用 / disable 批量禁用 / move 批量移动分类 / sort 修改排序
* POST: ids=1,2,3 op=enable|disable|move|sort [cid=分类ID] [sort=排序值]
*/
public function batch()
{
if (! ($this->request->isAjax() && $this->request->isPost())) {
return $this->result->error('请求方式错误');
}
$ids = (string)$this->request->param('ids', '');
$op = (string)$this->request->param('op', '');
$idArr = array_values(array_filter(array_map('intval', explode(',', $ids))));
if (empty($idArr)) {
return $this->result->error('请选择数据');
}
switch ($op) {
case 'enable':
$n = $this->model->whereIn('id', $idArr)->update(['status' => 1]);
return $this->result->success([], "已启用 {$n}");
case 'disable':
$n = $this->model->whereIn('id', $idArr)->update(['status' => 0]);
return $this->result->success([], "已禁用 {$n}");
case 'move':
$cid = (int)$this->request->param('cid', 0);
if ($cid <= 0 || ! CategoryModel::find($cid)) {
return $this->result->error('请选择有效的目标分类');
}
$n = $this->model->whereIn('id', $idArr)->update(['cid' => $cid]);
return $this->result->success([], "已移动 {$n}");
case 'sort':
$sort = (int)$this->request->param('sort', 0);
$n = $this->model->whereIn('id', $idArr)->update(['sort' => $sort]);
return $this->result->success([], '排序已保存');
case 'recoverdead':
// 恢复死链:重新启用 + 清空失败计数与死链标记(避免下次检测立即又被下线)
$n = $this->model->whereIn('id', $idArr)
->where('dead_at', '>', 0)
->update(['status' => 1, 'fail_count' => 0, 'dead_at' => null]);
return $this->result->success([], "已恢复 {$n} 条死链");
default:
return $this->result->error('不支持的操作类型');
}
}
/**
* 通过审核(待审核 -> 启用)
*/
public function approve()
{
if ($this->request->isAjax() && $this->request->isPost()) {
$id = $this->request->param('id');
$model = $this->model->find($id);
if (! $model) {
return $this->result->error('数据不存在');
}
$model->status = 1;
$model->save();
return $this->result->success([], '已通过');
}
}
/**
* 拒绝投稿(置为禁用)
*/
public function reject()
{
if ($this->request->isAjax() && $this->request->isPost()) {
$id = $this->request->param('id');
$model = $this->model->find($id);
if (! $model) {
return $this->result->error('数据不存在');
}
$model->status = 0;
$model->save();
return $this->result->success([], '已拒绝');
}
}
/**
* 刷新 favicon / icon(从域名自动生成)
*/
public function refreshFavicon()
{
if ($this->request->isAjax() && $this->request->isPost()) {
$ids = $this->request->param('ids', '');
if (empty($ids)) {
return $this->result->error('请选择链接');
}
$list = $this->model->whereIn('id', explode(',', $ids))->select();
$n = 0;
foreach ($list as $m) {
$favicon = \addon\haonav\model\Links::faviconOf($m->url);
if ($favicon) {
$m->favicon = $favicon;
$m->icon = $favicon;
$m->save();
$n++;
}
}
return $this->result->success([], "已刷新 {$n} 个图标");
}
}
/**
* 死链检测(手动触发,遍历全部链接)
*/
public function checkLinks()
{
if ($this->request->isAjax() && $this->request->isPost()) {
$stats = \addon\haonav\model\Links::checkAllLinks();
return $this->result->success($stats, '检测完成');
}
}
/**
* 导入书签(支持 Netscape HTML 书签 或 JSON 数组)
* 也可通过 POST content 直接传文本内容
*/
public function import()
{
if (! $this->request->isPost()) {
return $this->result->error('请求方式错误');
}
$content = '';
$file = $this->request->file('file');
if ($file) {
$content = file_get_contents($file->getRealPath());
} else {
$content = $this->request->param('content', '');
}
if (! $content) {
return $this->result->error('请上传书签文件或粘贴书签内容');
}
// 目标分类:优先使用提交的分类,否则取第一个启用分类
$cid = (int)$this->request->param('cid', 0);
if (! $cid) {
$first = CategoryModel::where('status', 1)->order('sort', 'desc')->find();
$cid = $first ? $first->id : 0;
}
if (! $cid) {
return $this->result->error('请先创建一个分类');
}
$enable = (int)\addon\haonav\model\Configure::getVal('enable_submit', '1');
$items = [];
$text = trim($content);
if (strpos($text, '[') === 0 || strpos($text, '{') === 0) {
// JSON 格式
$json = json_decode($text, true);
if (! is_array($json)) {
return $this->result->error('JSON 解析失败');
}
foreach ($json as $row) {
if (empty($row['url'])) {
continue;
}
$items[] = [
'title' => $row['title'] ?? parse_url($row['url'], PHP_URL_HOST),
'url' => $row['url'],
];
}
} else {
// Netscape 书签 HTML:提取 <A HREF> 标签
preg_match_all('/<a\s+[^>]*href="([^"]+)"[^>]*>(.*?)<\/a>/is', $content, $m, PREG_SET_ORDER);
foreach ($m as $row) {
$url = trim($row[1]);
if (! preg_match('/^https?:\/\//i', $url)) {
continue;
}
$title = trim(strip_tags($row[2]));
$items[] = [
'title' => $title ?: parse_url($url, PHP_URL_HOST),
'url' => $url,
];
}
}
if (empty($items)) {
return $this->result->error('未解析到有效书签');
}
// 去重(按 url
$exists = $this->model->whereIn('url', array_column($items, 'url'))->column('url');
$exists = array_flip($exists);
$count = 0;
Db::startTrans();
try {
foreach ($items as $it) {
if (isset($exists[$it['url']])) {
continue;
}
$model = new \addon\haonav\model\Links();
$model->cid = $cid;
$model->title = mb_substr($it['title'], 0, 100);
$model->url = $it['url'];
$model->status = $enable ? 2 : 1;
$model->save();
$count++;
}
Db::commit();
} catch (\Exception $e) {
Db::rollback();
return $this->result->error('导入失败: ' . $e->getMessage());
}
return $this->result->success([], "成功导入 {$count} 条(已忽略重复)");
}
/**
* 导出书签(Netscape 格式,附件下载)
*/
public function export()
{
$links = $this->model->where('status', '<>', 0)->order('cid', 'asc')->select();
$xml = "<!DOCTYPE NETSCAPE-Bookmark-file-1>\n"
. "<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n"
. "<TITLE>网址导航书签</TITLE>\n<H1>网址导航书签</H1>\n<DL><p>\n";
foreach ($links as $l) {
$xml .= ' <A HREF="' . htmlspecialchars($l->url) . '" ADD_DATE="' . time() . '">'
. htmlspecialchars($l->title) . "</A>\n";
}
$xml .= "</DL><p>\n";
return Response::create($xml)->header([
'Content-Type' => 'text/html; charset=utf-8',
'Content-Disposition' => 'attachment; filename="haonav_bookmarks.html"',
]);
}
public function recyclebin()
{
$page = $this->request->param('page', 1);
$size = $this->request->param('size', 15);
if ($this->request->isAjax()) {
$data = $this->model->onlyTrashed()->paginate([
'page' => $page,
'list_rows' => $size,
]);
$this->result->success($data->items());
}
$this->view->assign('title', '回收站');
return $this->view->fetch('links/recyclebin');
}
public function delete(Request $request)
{
if ($this->request->isAjax() && $this->request->isDelete()) {
$ids = $this->request->param('ids', '');
$force = $this->request->param('force', false);
if (empty($ids)) {
$this->result->error('请选择要删除的数据');
}
try {
Db::transaction(function () use ($ids, $force) {
if ($force) {
$this->model->onlyTrashed()->whereIn('id', $ids)->select()->each(function ($item) {
$item->force()->delete();
});
} else {
$this->model->destroy($ids);
}
});
$this->result->success();
} catch (\think\exception\HttpResponseException $e) {
throw $e;
} catch (\Throwable $th) {
$this->result->error('删除失败: ' . $th->getMessage());
}
}
}
public function restore($ids = '')
{
if ($this->request->isAjax() && $this->request->isPut()) {
$ids = $this->request->param('ids', '');
if (empty($ids)) {
$this->result->error('请选择要还原的数据');
}
$idsArray = explode(',', $ids);
try {
Db::transaction(function () use ($idsArray) {
$this->model->onlyTrashed()->whereIn('id', $idsArray)->select()->each(function ($item) {
$item->restore();
});
});
$this->result->success();
} catch (\think\exception\HttpResponseException $e) {
throw $e;
} catch (\Throwable $th) {
$this->result->error('还原失败: ' . $th->getMessage());
}
}
}
}