470 lines
17 KiB
PHP
470 lines
17 KiB
PHP
<?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());
|
||
}
|
||
}
|
||
}
|
||
}
|