// +---------------------------------------------------------------------- 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 */ 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:提取 标签 preg_match_all('/]*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 = "\n" . "\n" . "网址导航书签\n

网址导航书签

\n

\n"; foreach ($links as $l) { $xml .= ' ' . htmlspecialchars($l->title) . "\n"; } $xml .= "

\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()); } } } }