Files
YwxAppThink/addon/haonav/controller/backend/Links.php
T

470 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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());
}
}
}
}