chore: 重写初始提交(清空历史,整理后全量提交)
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
<?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;
|
||||
use ywxapp\controller\FrontendBase;
|
||||
|
||||
use ywxapp\model\MemberUser as UserModel;
|
||||
|
||||
/**
|
||||
* 前台会员登录 / 注册 / 退出 / 资料
|
||||
*
|
||||
* 复用框架前台 Auth(Authorization 头或 access_token Cookie)。
|
||||
* 登录/注册成功后 JwtService 把 token 写入 Result 单例,
|
||||
* 由 Result::applyTokenCookies() 随响应写回非 httpOnly Cookie(path=/),
|
||||
* 前端(整页/iframe)后续请求自动带登录态,无需前端额外存储。
|
||||
*
|
||||
* 注:框架 Auth::register() 内部依赖不存在的 UserModel::checkExists(),
|
||||
* 故注册在此控制器用 Member 模型直接建号,再用 Auth::login() 建立登录态与 token。
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Account extends FrontendBase
|
||||
{
|
||||
protected $noNeedLogin = ['login', 'register'];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
/**
|
||||
* 规整要返回给前端的用户资料
|
||||
*/
|
||||
protected function userInfo(): array
|
||||
{
|
||||
$info = $this->auth->info ?? [];
|
||||
if (is_object($info)) {
|
||||
$info = $info->toArray();
|
||||
}
|
||||
return [
|
||||
'uid' => (int)($info['uid'] ?? 0),
|
||||
'account' => (string)($info['account'] ?? ''),
|
||||
'nickname' => (string)($info['nickname'] ?? ''),
|
||||
'avatar' => (string)($info['avatar'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录
|
||||
* POST /haonav/account/login {account, password}
|
||||
*/
|
||||
public function login()
|
||||
{
|
||||
$account = trim((string)$this->request->post('account', ''));
|
||||
$password = (string)$this->request->post('password', '');
|
||||
if ($account === '' || $password === '') {
|
||||
return $this->result->error('请输入账号和密码');
|
||||
}
|
||||
try {
|
||||
$this->auth->login($account, $password);
|
||||
} catch (\Throwable $e) {
|
||||
// Auth::login 内部已通过 Result 输出错误则不会到这里;此处兜底
|
||||
return $this->result->error('登录失败:' . $e->getMessage());
|
||||
}
|
||||
return $this->result->success($this->userInfo(), '登录成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册
|
||||
* POST /haonav/account/register {account, password, nickname?}
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$account = trim((string)$this->request->post('account', ''));
|
||||
$password = (string)$this->request->post('password', '');
|
||||
$nickname = trim((string)$this->request->post('nickname', ''));
|
||||
if ($account === '' || $password === '') {
|
||||
return $this->result->error('请输入账号和密码');
|
||||
}
|
||||
if (mb_strlen($password) < 6) {
|
||||
return $this->result->error('密码至少 6 位');
|
||||
}
|
||||
if (!preg_match('/^[\x{4e00}-\x{9fa5}a-zA-Z0-9_@.\-]+$/u', $account)) {
|
||||
return $this->result->error('账号仅支持中英文、数字、_@.- 等字符');
|
||||
}
|
||||
// 账号查重(框架 UserModel::checkExists 不存在,这里直接查询)
|
||||
if (UserModel::where('account', $account)->findOrEmpty()->isExists()) {
|
||||
return $this->result->error('该账号已被注册');
|
||||
}
|
||||
$user = new UserModel();
|
||||
$user->account = $account;
|
||||
$user->nickname = $nickname !== '' ? mb_substr($nickname, 0, 30) : ('用户' . mb_substr($account, 0, 4));
|
||||
// email 为 NOT NULL + 唯一:用本地占位邮箱保证唯一且不报错(非真实邮箱)
|
||||
$user->email = $account . '@haonav.local';
|
||||
$user->password = $password; // 触发 Member 模型 setPasswordAttr 自动加盐加密
|
||||
$user->status = 1;
|
||||
$user->gid = (int)config('fastadmin.user_default_group') ?: 0;
|
||||
$user->create_ip = $this->request->ip();
|
||||
$user->update_ip = $this->request->ip();
|
||||
$user->save();
|
||||
// 建号后建立登录态并写回 token Cookie
|
||||
try {
|
||||
$this->auth->login($account, $password);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->result->error('注册成功但自动登录失败,请手动登录');
|
||||
}
|
||||
return $this->result->success($this->userInfo(), '注册成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
* POST /haonav/account/logout
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
$this->auth->logout();
|
||||
return $this->result->success([], '已退出登录');
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录资料(用于前端刷新登录态)
|
||||
* GET /haonav/account/profile
|
||||
*/
|
||||
public function profile()
|
||||
{
|
||||
if (! $this->auth || ! $this->auth->isLogin) {
|
||||
return $this->result->error('未登录', 401);
|
||||
}
|
||||
return $this->result->success($this->userInfo());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
<?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;
|
||||
use ywxapp\controller\FrontendBase;
|
||||
|
||||
use addon\haonav\model\Favorite as FavoriteModel;
|
||||
use addon\haonav\model\FavoriteShare as FavoriteShareModel;
|
||||
use addon\haonav\model\Configure as ConfigureModel;
|
||||
|
||||
/**
|
||||
* 用户云端收藏(登录后「我的导航」跨端同步)
|
||||
*
|
||||
* 认证:复用框架前台 Auth(Authorization 头或 access_token Cookie)。
|
||||
* 未登录时接口返回 code=401,前端自动回退 localStorage。
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Favorite extends FrontendBase
|
||||
{
|
||||
protected $noNeedLogin = ['*'];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录用户ID,未登录返回 0
|
||||
*/
|
||||
protected function uid(): int
|
||||
{
|
||||
if ($this->auth && $this->auth->isLogin) {
|
||||
$info = $this->auth->info;
|
||||
if (is_array($info)) {
|
||||
return (int)($info['id'] ?? 0);
|
||||
}
|
||||
return (int)($info->id ?? 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录态 + 收藏列表
|
||||
* GET /haonav/favorite/list
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$uid = $this->uid();
|
||||
if (!$uid) {
|
||||
return $this->result->error('未登录', 401);
|
||||
}
|
||||
$list = FavoriteModel::where('user_id', $uid)
|
||||
->order('sort', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select();
|
||||
return $this->result->success($list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增收藏
|
||||
* POST /haonav/favorite/add {link_id?,title,url,icon?}
|
||||
*/
|
||||
public function add()
|
||||
{
|
||||
$uid = $this->uid();
|
||||
if (!$uid) {
|
||||
return $this->result->error('请先登录', 401);
|
||||
}
|
||||
$url = trim((string)$this->request->post('url', ''));
|
||||
$title = trim((string)$this->request->post('title', ''));
|
||||
if ($url === '' || $title === '') {
|
||||
return $this->result->error('参数不完整');
|
||||
}
|
||||
if (!preg_match('#^https?://#i', $url) && strpos($url, '/') !== 0) {
|
||||
return $this->result->error('网址格式不正确');
|
||||
}
|
||||
$exists = FavoriteModel::where('user_id', $uid)->where('url', $url)->find();
|
||||
if ($exists) {
|
||||
return $this->result->success([], '已在我的导航');
|
||||
}
|
||||
$maxSort = (int)FavoriteModel::where('user_id', $uid)->max('sort');
|
||||
$model = new FavoriteModel();
|
||||
$model->user_id = $uid;
|
||||
$model->link_id = (int)$this->request->post('link_id', 0);
|
||||
$model->title = mb_substr($title, 0, 100);
|
||||
$model->url = mb_substr($url, 0, 500);
|
||||
$model->icon = mb_substr((string)$this->request->post('icon', ''), 0, 200);
|
||||
$model->sort = $maxSort + 1;
|
||||
$model->save();
|
||||
return $this->result->success(['id' => $model->id], '已收藏');
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除收藏
|
||||
* POST /haonav/favorite/remove {id} 或 {url}
|
||||
*/
|
||||
public function remove()
|
||||
{
|
||||
$uid = $this->uid();
|
||||
if (!$uid) {
|
||||
return $this->result->error('请先登录', 401);
|
||||
}
|
||||
$id = (int)$this->request->post('id', 0);
|
||||
$url = trim((string)$this->request->post('url', ''));
|
||||
$q = FavoriteModel::where('user_id', $uid);
|
||||
if ($id) {
|
||||
$q->where('id', $id);
|
||||
} elseif ($url !== '') {
|
||||
$q->where('url', $url);
|
||||
} else {
|
||||
return $this->result->error('参数不完整');
|
||||
}
|
||||
$q->delete();
|
||||
return $this->result->success([], '已移除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存排序
|
||||
* POST /haonav/favorite/sort {ids:[3,1,2]}
|
||||
*/
|
||||
public function sort()
|
||||
{
|
||||
$uid = $this->uid();
|
||||
if (!$uid) {
|
||||
return $this->result->error('请先登录', 401);
|
||||
}
|
||||
$ids = $this->request->post('ids', []);
|
||||
if (is_string($ids)) {
|
||||
$ids = json_decode($ids, true);
|
||||
}
|
||||
if (!is_array($ids) || !$ids) {
|
||||
return $this->result->error('参数不完整');
|
||||
}
|
||||
$sort = 1;
|
||||
foreach ($ids as $id) {
|
||||
FavoriteModel::where('user_id', $uid)->where('id', (int)$id)->update(['sort' => $sort++]);
|
||||
}
|
||||
return $this->result->success([], '已保存');
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性把本地收藏合并到云端(登录后同步)
|
||||
* POST /haonav/favorite/merge {items:[{title,url,icon},...]}
|
||||
*/
|
||||
public function merge()
|
||||
{
|
||||
$uid = $this->uid();
|
||||
if (!$uid) {
|
||||
return $this->result->error('请先登录', 401);
|
||||
}
|
||||
$items = $this->request->post('items', []);
|
||||
if (is_string($items)) {
|
||||
$items = json_decode($items, true);
|
||||
}
|
||||
if (is_array($items)) {
|
||||
$maxSort = (int)FavoriteModel::where('user_id', $uid)->max('sort');
|
||||
foreach ($items as $it) {
|
||||
$url = trim((string)($it['url'] ?? ''));
|
||||
$title = trim((string)($it['title'] ?? ''));
|
||||
if ($url === '' || $title === '') {
|
||||
continue;
|
||||
}
|
||||
$exists = FavoriteModel::where('user_id', $uid)->where('url', $url)->find();
|
||||
if ($exists) {
|
||||
continue;
|
||||
}
|
||||
$model = new FavoriteModel();
|
||||
$model->user_id = $uid;
|
||||
$model->link_id = (int)($it['id'] ?? 0);
|
||||
$model->title = mb_substr($title, 0, 100);
|
||||
$model->url = mb_substr($url, 0, 500);
|
||||
$model->icon = mb_substr((string)($it['icon'] ?? ''), 0, 200);
|
||||
$model->sort = ++$maxSort;
|
||||
$model->save();
|
||||
}
|
||||
}
|
||||
$list = FavoriteModel::where('user_id', $uid)->order('sort', 'asc')->order('id', 'asc')->select();
|
||||
return $this->result->success($list, '同步完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的收藏夹分享配置(登录)
|
||||
* GET /haonav/favorite/share
|
||||
*/
|
||||
public function share()
|
||||
{
|
||||
$uid = $this->uid();
|
||||
if (!$uid) {
|
||||
return $this->result->error('请先登录', 401);
|
||||
}
|
||||
$row = FavoriteShareModel::forUser($uid);
|
||||
return $this->result->success($this->shareData($row));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存分享配置:开关 / 标题 / 重置令牌(登录)
|
||||
* POST /haonav/favorite/sharesave {enabled,title?,reset?}
|
||||
*/
|
||||
public function shareSave()
|
||||
{
|
||||
$uid = $this->uid();
|
||||
if (!$uid) {
|
||||
return $this->result->error('请先登录', 401);
|
||||
}
|
||||
$row = FavoriteShareModel::forUser($uid);
|
||||
$row->enabled = (int)$this->request->post('enabled', $row->enabled) ? 1 : 0;
|
||||
$title = trim((string)$this->request->post('title', ''));
|
||||
if ($title !== '') {
|
||||
$row->title = mb_substr($title, 0, 100);
|
||||
}
|
||||
if ((int)$this->request->post('reset', 0) === 1) {
|
||||
$row->token = FavoriteShareModel::genToken();
|
||||
}
|
||||
$row->save();
|
||||
return $this->result->success($this->shareData($row), '已保存');
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装分享配置返回数据(含完整分享链接)
|
||||
*/
|
||||
protected function shareData(FavoriteShareModel $row): array
|
||||
{
|
||||
return [
|
||||
'enabled' => (int)$row->enabled,
|
||||
'token' => (string)$row->token,
|
||||
'title' => (string)$row->title,
|
||||
'views' => (int)$row->views,
|
||||
'url' => $this->request->domain() . '/haonav/favorite/shared/' . $row->token . '.html',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开的收藏夹分享页(无需登录,凭 token 访问)
|
||||
* GET /haonav/favorite/shared/:token
|
||||
*/
|
||||
public function shared($token = '')
|
||||
{
|
||||
$token = trim((string)$token);
|
||||
$row = $token !== '' ? FavoriteShareModel::where('token', $token)->find() : null;
|
||||
|
||||
$valid = $row && (int)$row->enabled === 1;
|
||||
$list = [];
|
||||
if ($valid) {
|
||||
$list = FavoriteModel::where('user_id', $row->user_id)
|
||||
->order('sort', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select();
|
||||
// 访问计数(不含所有者本人重复刷新的精确去重,简单自增即可)
|
||||
FavoriteShareModel::where('id', $row->id)->inc('views')->update();
|
||||
}
|
||||
|
||||
$title = $valid ? ($row->title ?: '收藏夹分享') : '收藏夹不存在或未公开';
|
||||
$this->view->assign('valid', $valid);
|
||||
$this->view->assign('shareTitle', $title);
|
||||
$this->view->assign('list', $list);
|
||||
$this->view->assign('count', is_countable($list) ? count($list) : 0);
|
||||
$this->view->assign('seo', [
|
||||
'title' => $title . ' - 网址导航',
|
||||
'keywords' => '收藏夹,分享,网址导航',
|
||||
'description' => (string)ConfigureModel::getVal('seo_description', '简洁实用的网址导航'),
|
||||
]);
|
||||
$this->view->assign('siteUrl', $this->request->domain());
|
||||
return $this->view->fetch('favorite/shared');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
<?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;
|
||||
|
||||
use ywxapp\controller\FrontendBase;
|
||||
|
||||
use think\Request;
|
||||
use think\Response;
|
||||
use think\exception\ValidateException;
|
||||
use think\facade\Db;
|
||||
use addon\haonav\model\Links as LinksModel;
|
||||
use addon\haonav\model\Category as CategoryModel;
|
||||
use addon\haonav\model\Configure as ConfigureModel;
|
||||
use addon\haonav\model\Ad as AdModel;
|
||||
use addon\haonav\model\Apply as ApplyModel;
|
||||
|
||||
/**
|
||||
* Index 类
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Index extends FrontendBase
|
||||
{
|
||||
/**
|
||||
* Summary of needLogin
|
||||
* @var array
|
||||
*/
|
||||
protected $noNeedLogin = ['*'];
|
||||
|
||||
/**
|
||||
* Summary of needRight
|
||||
* @var array
|
||||
*/
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
public function test()
|
||||
{
|
||||
$routes = \think\facade\Route::getRules();
|
||||
dump($routes);
|
||||
}
|
||||
/**
|
||||
* 控制器初始化
|
||||
* @return void
|
||||
*/
|
||||
protected function initialize() {}
|
||||
|
||||
/**
|
||||
* 读取导航相关配置
|
||||
*/
|
||||
protected function siteConfig(): array
|
||||
{
|
||||
return [
|
||||
'default_engine' => ConfigureModel::getVal('default_engine', 'baidu'),
|
||||
'enable_submit' => (int)ConfigureModel::getVal('enable_submit', '1'),
|
||||
'enable_hot_api' => (int)ConfigureModel::getVal('enable_hot_api', '0'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 SEO 配置(后台「导航配置 > seo」分组可改,缺省用内置文案)
|
||||
*/
|
||||
protected function seoConfig(): array
|
||||
{
|
||||
return [
|
||||
'title' => (string)ConfigureModel::getVal('seo_title', '网址导航 - 精选实用网站大全'),
|
||||
'keywords' => (string)ConfigureModel::getVal('seo_keywords', '网址导航,常用网址,网站大全,上网导航'),
|
||||
'description' => (string)ConfigureModel::getVal('seo_description', '简洁实用的网址导航,收录精选优质网站,支持分类浏览、站内搜索、热门排行与网址投稿。'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一注入 SEO 相关模板变量(seo/siteUrl)
|
||||
*/
|
||||
protected function assignSeo(): array
|
||||
{
|
||||
$seo = $this->seoConfig();
|
||||
$this->view->assign('seo', $seo);
|
||||
$this->view->assign('siteUrl', $this->request->domain());
|
||||
return $seo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 首页
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$data = CategoryModel::with([
|
||||
'links' => function ($query) {
|
||||
$query->where('status', 1)->order('click_count', 'desc');
|
||||
},
|
||||
'children' => function ($query) {
|
||||
$query->where('status', 1)->order('sort', 'desc')->with([
|
||||
'links' => function ($q) {
|
||||
$q->where('status', 1)->order('click_count', 'desc');
|
||||
},
|
||||
]);
|
||||
},
|
||||
])->where('status', 1)->order('sort', 'desc')->select();
|
||||
|
||||
$hotspot = LinksModel::where('is_hot', 1)->where('status', 1)->limit(18)->order('click_count', 'desc')->select();
|
||||
$links = LinksModel::where('is_recommend', 1)->where('status', 1)->limit(10)->order('sort', 'desc')->select();
|
||||
|
||||
$this->view->assign('hotspot', $hotspot);
|
||||
$this->view->assign('data', $data);
|
||||
$this->view->assign('links', $links);
|
||||
$this->view->assign('config', $this->siteConfig());
|
||||
$seo = $this->assignSeo();
|
||||
$domain = $this->request->domain();
|
||||
// 首页 JSON-LD:WebSite + SearchAction(搜索引擎站内搜索直达框)
|
||||
$this->view->assign('jsonld', json_encode([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'WebSite',
|
||||
'name' => $seo['title'],
|
||||
'description' => $seo['description'],
|
||||
'url' => $domain . '/haonav/index.html',
|
||||
'potentialAction' => [
|
||||
'@type' => 'SearchAction',
|
||||
'target' => $domain . '/haonav/search.html?q={search_term_string}',
|
||||
'query-input' => 'required name=search_term_string',
|
||||
],
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
$this->assignAds();
|
||||
return $this->view->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 站内搜索
|
||||
* 普通请求渲染搜索结果页;ajax=1 返回 JSON(供前端无刷新搜索)
|
||||
*/
|
||||
public function search()
|
||||
{
|
||||
$q = $this->request->param('q', '');
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limit = 20;
|
||||
|
||||
if ($this->request->isAjax() || $this->request->param('ajax/d', 0)) {
|
||||
$data = LinksModel::search($q, $page, $limit);
|
||||
$this->result->setCount($data->total());
|
||||
$this->result->success($data->items());
|
||||
}
|
||||
|
||||
$results = [];
|
||||
if ($q !== '') {
|
||||
$results = LinksModel::search($q, 1, 50)->items();
|
||||
}
|
||||
$this->view->assign('q', $q);
|
||||
$this->view->assign('results', $results);
|
||||
$this->view->assign('config', $this->siteConfig());
|
||||
$this->assignSeo();
|
||||
return $this->view->fetch('search');
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索建议(自动补全)
|
||||
*/
|
||||
public function suggest()
|
||||
{
|
||||
$q = trim((string)$this->request->param('q', ''));
|
||||
$list = [];
|
||||
if ($q !== '') {
|
||||
// 标题/关键词/网址/拼音(首字母+全拼)均可命中
|
||||
|
||||
$isAlpha = (bool)preg_match('/^[a-zA-Z]+$/', $q);
|
||||
$list = LinksModel::where('status', 1)
|
||||
->where(function ($query) use ($q, $isAlpha) {
|
||||
$query->where('title', 'like', "%{$q}%")
|
||||
->whereOr('keywords', 'like', "%{$q}%")
|
||||
->whereOr('url', 'like', "%{$q}%");
|
||||
if ($isAlpha) {
|
||||
$query->whereOr('pinyin', 'like', '%' . strtolower($q) . '%');
|
||||
}
|
||||
})
|
||||
->field('id,title,url')
|
||||
->order('click_count', 'desc')
|
||||
->limit(10)
|
||||
->select();
|
||||
}
|
||||
return $this->result->success($list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 网站评分(登录后 1-5 星,按用户去重可改分;聚合更新综合评分)
|
||||
* POST /haonav/rate {id, score}
|
||||
*/
|
||||
public function rate()
|
||||
{
|
||||
if (!$this->request->isPost()) {
|
||||
return $this->result->error('请求方式错误');
|
||||
}
|
||||
$uid = $this->uid();
|
||||
if (!$uid) {
|
||||
return $this->result->error('请先登录后再评分', 401);
|
||||
}
|
||||
$id = (int)$this->request->post('id/d', 0);
|
||||
$score = (int)$this->request->post('score/d', 0);
|
||||
if ($id <= 0 || $score < 1 || $score > 5) {
|
||||
return $this->result->error('参数错误');
|
||||
}
|
||||
$link = LinksModel::find($id);
|
||||
if (!$link || $link->status != 1) {
|
||||
return $this->result->error('网站不存在或已下架');
|
||||
}
|
||||
|
||||
// 评分记录表由安装/升级流程(install.sql + Addon::upgrade 钩子)保证存在
|
||||
|
||||
$now = time();
|
||||
$exists = Db::name('haonav_ratings')->where('user_id', $uid)->where('link_id', $id)->find();
|
||||
if ($exists) {
|
||||
Db::name('haonav_ratings')->where('id', $exists['id'])->update([
|
||||
'score' => $score,
|
||||
'update_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
Db::name('haonav_ratings')->insert([
|
||||
'user_id' => $uid,
|
||||
'link_id' => $id,
|
||||
'score' => $score,
|
||||
'create_at' => $now,
|
||||
'update_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
// 重新聚合该站评分,保证 rating / rating_count 精确
|
||||
$agg = Db::name('haonav_ratings')
|
||||
->where('link_id', $id)
|
||||
->field('AVG(score) as avg_score, COUNT(*) as cnt')
|
||||
->find();
|
||||
$rating = ($agg && $agg['cnt']) ? round((float)$agg['avg_score'], 1) : 0.0;
|
||||
$count = $agg ? (int)$agg['cnt'] : 0;
|
||||
LinksModel::where('id', $id)->update([
|
||||
'rating' => $rating,
|
||||
'rating_count' => $count,
|
||||
]);
|
||||
|
||||
return $this->result->success([
|
||||
'rating' => $rating,
|
||||
'rating_count' => $count,
|
||||
'my_score' => $score,
|
||||
], '评分成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录用户ID,未登录返回 0(与 Favorite 控制器一致)
|
||||
*/
|
||||
protected function uid(): int
|
||||
{
|
||||
if ($this->auth && $this->auth->isLogin) {
|
||||
$info = $this->auth->info;
|
||||
if (is_array($info)) {
|
||||
return (int)($info['id'] ?? 0);
|
||||
}
|
||||
return (int)($info->id ?? 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 热门排行榜(按点击量)
|
||||
*/
|
||||
public function rank()
|
||||
{
|
||||
$list = LinksModel::where('status', 1)
|
||||
->order('click_count', 'desc')
|
||||
->limit(50)
|
||||
->select();
|
||||
$this->view->assign('list', $list);
|
||||
$this->view->assign('config', $this->siteConfig());
|
||||
$this->assignSeo();
|
||||
return $this->view->fetch('rank');
|
||||
}
|
||||
|
||||
/**
|
||||
* 网址投稿(公开,提交后进入待审核 status=2)
|
||||
*/
|
||||
public function submit()
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
$params = $this->request->post();
|
||||
try {
|
||||
validate(\addon\haonav\validate\Link::class)->check($params);
|
||||
} catch (ValidateException $e) {
|
||||
return $this->result->error('数据验证失败: ' . $e->getMessage());
|
||||
}
|
||||
$enable = (int)ConfigureModel::getVal('enable_submit', '1');
|
||||
$model = new LinksModel();
|
||||
$model->cid = $params['cid'];
|
||||
$model->title = $params['title'];
|
||||
$model->url = $params['url'];
|
||||
$model->description = $params['description'] ?? '';
|
||||
$model->keywords = $params['keywords'] ?? '';
|
||||
$model->status = $enable ? 2 : 1; // 待审核 or 直接上架
|
||||
$model->save();
|
||||
return $this->result->success([], $enable ? '提交成功,等待管理员审核' : '提交成功');
|
||||
}
|
||||
|
||||
$cates = CategoryModel::where('status', 1)->order('sort', 'desc')->select();
|
||||
$this->view->assign('cates', $cates);
|
||||
$this->view->assign('config', $this->siteConfig());
|
||||
$this->assignSeo();
|
||||
return $this->view->fetch('submit');
|
||||
}
|
||||
|
||||
/**
|
||||
* 友链/广告合作自助申请(公开)
|
||||
* GET 渲染表单页;POST 提交入库(待审核),后台「申请审核」处理。
|
||||
* 防滥用:蜜罐字段 + 同 IP 限频(1小时5条)+ 同 URL 去重。
|
||||
*/
|
||||
public function apply()
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
// 蜜罐:正常用户不可见不填写,机器人常会填
|
||||
if ((string)$this->request->post('website', '') !== '') {
|
||||
return $this->result->success([], '提交成功,等待管理员审核');
|
||||
}
|
||||
$type = (int)$this->request->post('type/d', ApplyModel::TYPE_LINK);
|
||||
$title = trim((string)$this->request->post('title', ''));
|
||||
$url = trim((string)$this->request->post('url', ''));
|
||||
$desc = trim((string)$this->request->post('description', ''));
|
||||
$contact = trim((string)$this->request->post('contact', ''));
|
||||
$slot = trim((string)$this->request->post('slot', ''));
|
||||
|
||||
if (!in_array($type, [ApplyModel::TYPE_LINK, ApplyModel::TYPE_AD], true)) {
|
||||
return $this->result->error('申请类型错误');
|
||||
}
|
||||
if ($title === '' || mb_strlen($title) > 100) {
|
||||
return $this->result->error('请填写正确的名称(100字以内)');
|
||||
}
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL) || !preg_match('#^https?://#i', $url)) {
|
||||
return $this->result->error('请填写正确的网址(http/https 开头)');
|
||||
}
|
||||
if ($contact === '' || mb_strlen($contact) > 100) {
|
||||
return $this->result->error('请留下联系方式,便于审核后联系您');
|
||||
}
|
||||
if ($type === ApplyModel::TYPE_AD && $slot !== '' && !array_key_exists($slot, AdModel::slots())) {
|
||||
return $this->result->error('意向广告位不存在');
|
||||
}
|
||||
|
||||
$ip = (string)$this->request->ip();
|
||||
if (ApplyModel::ipOverLimit($ip)) {
|
||||
return $this->result->error('提交过于频繁,请稍后再试');
|
||||
}
|
||||
if (ApplyModel::urlExists($url)) {
|
||||
return $this->result->error('该网址已提交过申请,请勿重复提交');
|
||||
}
|
||||
|
||||
$model = new ApplyModel();
|
||||
$model->type = $type;
|
||||
$model->title = $title;
|
||||
$model->url = $url;
|
||||
$model->description = mb_substr($desc, 0, 500);
|
||||
$model->contact = $contact;
|
||||
$model->slot = $type === ApplyModel::TYPE_AD ? $slot : '';
|
||||
$model->ip = $ip;
|
||||
$model->status = ApplyModel::STATUS_PENDING;
|
||||
$model->save();
|
||||
return $this->result->success([], '提交成功,管理员审核后会通过您留下的联系方式回复');
|
||||
}
|
||||
|
||||
$this->view->assign('slots', AdModel::slots());
|
||||
$this->view->assign('config', $this->siteConfig());
|
||||
$this->assignSeo();
|
||||
return $this->view->fetch('apply');
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转中间页(累加点击)
|
||||
*/
|
||||
public function site($id = 0)
|
||||
{
|
||||
|
||||
$data = LinksModel::find($id);
|
||||
if ($data) {
|
||||
LinksModel::where('id', $id)
|
||||
->inc('click_count')
|
||||
->update(['last_click_at' => time()]);
|
||||
$data = LinksModel::find($id);
|
||||
$this->recordClickStat((int)$data->id, (int)$data->cid);
|
||||
}
|
||||
$this->view->assign('info', $data);
|
||||
$this->assignAds();
|
||||
$this->assignSeo();
|
||||
// 详情页 JSON-LD:WebPage
|
||||
if ($data) {
|
||||
$this->view->assign('jsonld', json_encode([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'WebPage',
|
||||
'name' => (string)$data->title,
|
||||
'description' => (string)($data->description ?: $data->title),
|
||||
'url' => $this->request->domain() . '/haonav/site/' . $data->id . '.html',
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
return $this->view->fetch('detail');
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击时段聚合(供后台「点击热力图」)。表结构首次访问自愈,写入失败静默不影响跳转。
|
||||
* 仅按 (link_id, date, hour) 聚合,全站每天最多 N链接×24 行,规模可控。
|
||||
*/
|
||||
protected function recordClickStat(int $linkId, int $cid): void
|
||||
{
|
||||
$table = 'wxapp_haonav_click_stats';
|
||||
try {
|
||||
$ymd = date('Y-m-d');
|
||||
$hr = (int)date('G');
|
||||
\think\facade\Db::execute(
|
||||
"INSERT INTO `$table` (`link_id`,`cid`,`date`,`hour`,`clicks`) VALUES (?,?,?,?,1) " .
|
||||
"ON DUPLICATE KEY UPDATE `clicks` = `clicks` + 1",
|
||||
[$linkId, $cid, $ymd, $hr]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// 统计写入失败不影响跳转
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一注入各广告位(按 slot 分组),前台模板用 {volist name="ads.home_top"} 等渲染
|
||||
*/
|
||||
protected function assignAds()
|
||||
{
|
||||
$this->view->assign('ads', AdModel::allActiveBySlot());
|
||||
}
|
||||
|
||||
/**
|
||||
* 广告点击中转(仅图片广告有跳转,累加 click_count 后 302 到目标)
|
||||
* /haonav/ad/click?id=广告ID
|
||||
*/
|
||||
public function adClick()
|
||||
{
|
||||
$id = (int)$this->request->param('id/d', 0);
|
||||
$ad = $id ? AdModel::find($id) : null;
|
||||
if (!$ad || (int)$ad->type !== 1 || !$ad->url) {
|
||||
return redirect('/haonav/index.html');
|
||||
}
|
||||
AdModel::where('id', $id)->inc('click_count')->update();
|
||||
return redirect($ad->url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类页
|
||||
*/
|
||||
public function category($id = 0)
|
||||
{
|
||||
$data = CategoryModel::with([
|
||||
'links' => function ($query) {
|
||||
$query->where('status', 1)->order('click_count', 'desc');
|
||||
},
|
||||
'children' => function ($query) {
|
||||
$query->where('status', 1)->order('sort', 'desc')->with([
|
||||
'links' => function ($q) {
|
||||
$q->where('status', 1)->order('click_count', 'desc');
|
||||
},
|
||||
]);
|
||||
},
|
||||
])->find($id);
|
||||
$this->view->assign('info', $data);
|
||||
$this->assignSeo();
|
||||
return $this->view->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点地图(SEO)
|
||||
* /haonav/sitemap.xml 输出标准 sitemap 协议 XML:
|
||||
* 首页/排行/投稿 + 全部启用分类页 + 全部启用链接详情页;缓存 1 小时。
|
||||
*/
|
||||
public function sitemap()
|
||||
{
|
||||
$domain = $this->request->domain();
|
||||
$cacheKey = 'haonav_sitemap_' . md5($domain);
|
||||
$xml = \think\facade\Cache::get($cacheKey);
|
||||
if (! $xml) {
|
||||
$urls = [
|
||||
['loc' => $domain . '/haonav/index.html', 'priority' => '1.0', 'changefreq' => 'daily'],
|
||||
['loc' => $domain . '/haonav/rank.html', 'priority' => '0.8', 'changefreq' => 'daily'],
|
||||
['loc' => $domain . '/haonav/submit.html', 'priority' => '0.5', 'changefreq' => 'monthly'],
|
||||
];
|
||||
$cates = CategoryModel::where('status', 1)->field('id,update_at')->select();
|
||||
foreach ($cates as $c) {
|
||||
$urls[] = [
|
||||
'loc' => $domain . '/haonav/category/' . $c->id . '.html',
|
||||
'priority' => '0.8',
|
||||
'changefreq' => 'weekly',
|
||||
'lastmod' => $c->update_at ? date('Y-m-d', strtotime((string)$c->update_at)) : '',
|
||||
];
|
||||
}
|
||||
$links = LinksModel::where('status', 1)->field('id,update_at')->order('id', 'asc')->limit(5000)->select();
|
||||
foreach ($links as $l) {
|
||||
$urls[] = [
|
||||
'loc' => $domain . '/haonav/site/' . $l->id . '.html',
|
||||
'priority' => '0.6',
|
||||
'changefreq' => 'weekly',
|
||||
'lastmod' => $l->update_at ? date('Y-m-d', strtotime((string)$l->update_at)) : '',
|
||||
];
|
||||
}
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
|
||||
. '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
|
||||
foreach ($urls as $u) {
|
||||
$xml .= " <url>\n <loc>" . htmlspecialchars($u['loc']) . "</loc>\n";
|
||||
if (! empty($u['lastmod'])) {
|
||||
$xml .= ' <lastmod>' . $u['lastmod'] . "</lastmod>\n";
|
||||
}
|
||||
$xml .= ' <changefreq>' . $u['changefreq'] . "</changefreq>\n"
|
||||
. ' <priority>' . $u['priority'] . "</priority>\n </url>\n";
|
||||
}
|
||||
$xml .= '</urlset>';
|
||||
\think\facade\Cache::set($cacheKey, $xml, 3600);
|
||||
}
|
||||
return Response::create($xml)->header([
|
||||
'Content-Type' => 'application/xml; charset=utf-8',
|
||||
'Cache-Control' => 'public, max-age=3600',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程抓取网页元信息(后台「提取信息」按钮使用)
|
||||
*/
|
||||
public function read()
|
||||
{
|
||||
$url = $this->request->param('url', '');
|
||||
if (! $url) {
|
||||
return $this->result->error('缺少 url 参数');
|
||||
}
|
||||
$meta = LinksModel::fetchMeta($url);
|
||||
if ($meta) {
|
||||
return $this->result->success($meta);
|
||||
}
|
||||
return $this->result->error('无法获取网页信息');
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地 favicon 代理 + 缓存
|
||||
* 浏览器统一请求 /haonav/favicon.html?url=xxx,由服务端抓取并缓存到
|
||||
* public/static/haonav/favicons/,避免直接依赖第三方服务、提升加载速度。
|
||||
*/
|
||||
public function favicon()
|
||||
{
|
||||
$url = $this->request->param('url', '');
|
||||
$host = $url ? parse_url($url, PHP_URL_HOST) : '';
|
||||
if (! $host) {
|
||||
return $this->serveDefault();
|
||||
}
|
||||
|
||||
$root = app()->getRootPath() . 'public/static/haonav/favicons/';
|
||||
if (! is_dir($root)) {
|
||||
@mkdir($root, 0755, true);
|
||||
}
|
||||
$key = md5($host);
|
||||
|
||||
// 失败负缓存:24 小时内不再尝试远程抓取,直接返回默认图
|
||||
$failFile = $root . $key . '.fail';
|
||||
if (is_file($failFile) && (time() - filemtime($failFile)) < 86400) {
|
||||
return $this->serveDefault();
|
||||
}
|
||||
|
||||
// 已缓存则直接返回
|
||||
foreach (['png', 'ico', 'jpg', 'jpeg', 'svg', 'webp', 'gif'] as $ext) {
|
||||
if (is_file($root . $key . '.' . $ext)) {
|
||||
return $this->serveFile($root . $key . '.' . $ext);
|
||||
}
|
||||
}
|
||||
|
||||
$img = $this->fetchFavicon($host);
|
||||
if ($img) {
|
||||
$path = $root . $key . '.' . $img['ext'];
|
||||
@file_put_contents($path, $img['data']);
|
||||
return $this->serveFile($path);
|
||||
}
|
||||
|
||||
@touch($failFile);
|
||||
return $this->serveDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* 网站截图缩略图代理 + 缓存
|
||||
* 请求 /haonav/snapshot.html?id=链接ID(以 id 换 url,避免 SSRF),
|
||||
* 服务端经 WordPress mshots 免费截图服务生成,缓存 7 天到
|
||||
* public/static/haonav/snapshots/。生成中/失败时返回默认小图,
|
||||
* 前端以 naturalWidth 判断是否为真实截图。
|
||||
*/
|
||||
public function snapshot()
|
||||
{
|
||||
$id = (int)$this->request->param('id/d', 0);
|
||||
$link = $id > 0 ? LinksModel::find($id) : null;
|
||||
$url = $link ? (string)$link->url : '';
|
||||
if (!$url) {
|
||||
return $this->serveDefault();
|
||||
}
|
||||
|
||||
$root = app()->getRootPath() . 'public/static/haonav/snapshots/';
|
||||
if (! is_dir($root)) {
|
||||
@mkdir($root, 0755, true);
|
||||
}
|
||||
$key = md5($url);
|
||||
$file = $root . $key . '.jpg';
|
||||
if (is_file($file) && (time() - filemtime($file)) < 7 * 86400) {
|
||||
return $this->serveFile($file);
|
||||
}
|
||||
|
||||
// 失败/生成中负缓存 1 小时,避免每次 hover 都打远程
|
||||
$failFile = $root . $key . '.fail';
|
||||
if (is_file($failFile) && (time() - filemtime($failFile)) < 3600) {
|
||||
return is_file($file) ? $this->serveFile($file) : $this->serveDefault();
|
||||
}
|
||||
|
||||
$img = $this->fetchSnapshot($url);
|
||||
if ($img) {
|
||||
@file_put_contents($file, $img);
|
||||
return $this->serveFile($file);
|
||||
}
|
||||
@touch($failFile);
|
||||
return is_file($file) ? $this->serveFile($file) : $this->serveDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* 抓取 mshots 截图;生成中(返回 loading gif)或失败返回 false
|
||||
*/
|
||||
protected function fetchSnapshot(string $url)
|
||||
{
|
||||
if (! class_exists(\GuzzleHttp\Client::class)) {
|
||||
return false;
|
||||
}
|
||||
$api = 'https://s0.wp.com/mshots/v1/' . urlencode($url) . '?w=480';
|
||||
try {
|
||||
$client = new \GuzzleHttp\Client(['timeout' => 8, 'verify' => false]);
|
||||
$resp = $client->get($api, ['headers' => ['Member-Agent' => 'Mozilla/5.0']]);
|
||||
$body = (string)$resp->getBody();
|
||||
$ct = strtolower($resp->getHeaderLine('Content-Type'));
|
||||
// mshots 首次请求会 307 到 loading gif,表示截图排队生成中,不缓存
|
||||
if (stripos($ct, 'gif') !== false || strlen($body) < 2048) {
|
||||
return false;
|
||||
}
|
||||
return $body;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service Worker 出口(PWA)
|
||||
* SW 的作用域由其 URL 目录决定,静态目录 /static/... 无法覆盖 /haonav/,
|
||||
* 故经路由 /haonav/sw.html 输出 JS,使作用域为 /haonav/。
|
||||
*/
|
||||
public function sw()
|
||||
{
|
||||
$path = app()->getRootPath() . 'public/static/haonav/sw.js';
|
||||
$js = is_file($path) ? (string)file_get_contents($path) : '';
|
||||
return Response::create($js)->header([
|
||||
'Content-Type' => 'application/javascript; charset=utf-8',
|
||||
'Service-Worker-Allowed' => '/haonav/',
|
||||
'Cache-Control' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程抓取 favicon。
|
||||
* 抓取顺序(可靠性从高到低、对网络环境最友好):
|
||||
* 1) 目标站点根路径 /favicon.ico(绝大多数站点自带,不依赖第三方)
|
||||
* 2) 抓取首页 HTML,解析 <link rel="icon/shortcut icon/apple-touch-icon"> 指示的图标
|
||||
* 3) 第三方兜底(icon.horse / Google,国内常被墙/慢,仅作保底)
|
||||
* 任一步成功即返回 ['ext'=>, 'data'=>],全部失败返回 false。
|
||||
*/
|
||||
protected function fetchFavicon(string $host)
|
||||
{
|
||||
if (! class_exists(\GuzzleHttp\Client::class)) {
|
||||
return false;
|
||||
}
|
||||
$ua = ['Member-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'];
|
||||
$client = new \GuzzleHttp\Client(['timeout' => 4, 'verify' => false]);
|
||||
|
||||
// 1) 站点自有 /favicon.ico(先 https 后 http)
|
||||
foreach (['https://' . $host, 'http://' . $host] as $base) {
|
||||
try {
|
||||
$resp = $client->get($base . '/favicon.ico', ['headers' => $ua]);
|
||||
if ($resp->getStatusCode() === 200) {
|
||||
$ct = strtolower($resp->getHeaderLine('Content-Type'));
|
||||
$body = (string)$resp->getBody();
|
||||
// 服务器可能把 404 页面当 html 返回,需排除
|
||||
if (strlen($body) >= 32 && stripos($ct, 'html') === false) {
|
||||
return ['ext' => $this->extFromCt($ct, 'ico'), 'data' => $body];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 继续尝试
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 抓首页 HTML,解析 <link rel="icon*"> 拿到真实图标地址
|
||||
foreach (['https://' . $host, 'http://' . $host] as $base) {
|
||||
try {
|
||||
$resp = $client->get($base . '/', ['headers' => $ua]);
|
||||
if ($resp->getStatusCode() !== 200) {
|
||||
continue;
|
||||
}
|
||||
$html = (string)$resp->getBody();
|
||||
$icon = $this->parseIconHref($html, $base);
|
||||
if ($icon) {
|
||||
$resp2 = $client->get($icon, ['headers' => $ua]);
|
||||
if ($resp2->getStatusCode() === 200) {
|
||||
$ct = strtolower($resp2->getHeaderLine('Content-Type'));
|
||||
$body = (string)$resp2->getBody();
|
||||
if (strlen($body) >= 32 && stripos($ct, 'html') === false) {
|
||||
return ['ext' => $this->extFromCt($ct, 'ico'), 'data' => $body];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 继续尝试
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 第三方兜底(可能被墙/慢,仅保底)
|
||||
$sources = [
|
||||
'https://www.google.com/s2/favicons?domain=' . $host . '&sz=64',
|
||||
'https://icon.horse/icon/' . $host,
|
||||
];
|
||||
foreach ($sources as $u) {
|
||||
try {
|
||||
$resp = $client->get($u, ['headers' => $ua]);
|
||||
$body = (string)$resp->getBody();
|
||||
if (strlen($body) < 32) {
|
||||
continue;
|
||||
}
|
||||
$ct = strtolower($resp->getHeaderLine('Content-Type'));
|
||||
$ext = 'png';
|
||||
if (stripos($ct, 'svg') !== false) {
|
||||
$ext = 'svg';
|
||||
} elseif (stripos($ct, 'ico') !== false) {
|
||||
$ext = 'ico';
|
||||
} elseif (stripos($ct, 'jpeg') !== false) {
|
||||
$ext = 'jpg';
|
||||
} elseif (stripos($ct, 'webp') !== false) {
|
||||
$ext = 'webp';
|
||||
} elseif (stripos($ct, 'gif') !== false) {
|
||||
$ext = 'gif';
|
||||
}
|
||||
return ['ext' => $ext, 'data' => $body];
|
||||
} catch (\Throwable $e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 Content-Type 推断图片扩展名
|
||||
*/
|
||||
protected function extFromCt(string $ct, string $default = 'ico'): string
|
||||
{
|
||||
if (stripos($ct, 'svg') !== false) {
|
||||
return 'svg';
|
||||
}
|
||||
if (stripos($ct, 'png') !== false) {
|
||||
return 'png';
|
||||
}
|
||||
if (stripos($ct, 'jpeg') !== false) {
|
||||
return 'jpg';
|
||||
}
|
||||
if (stripos($ct, 'webp') !== false) {
|
||||
return 'webp';
|
||||
}
|
||||
if (stripos($ct, 'gif') !== false) {
|
||||
return 'gif';
|
||||
}
|
||||
if (stripos($ct, 'ico') !== false || stripos($ct, 'x-icon') !== false) {
|
||||
return 'ico';
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从首页 HTML 中解析图标地址,兼容相对/绝对/协议相对路径与 apple-touch-icon。
|
||||
* 优先返回普通 icon(体积小),apple-touch-icon 作为兜底。
|
||||
*/
|
||||
protected function parseIconHref(string $html, string $base): string
|
||||
{
|
||||
if (! preg_match_all('/<link\b[^>]*rel=["\'][^"\']*icon[^"\']*["\'][^>]*>/is', $html, $matches)) {
|
||||
return '';
|
||||
}
|
||||
$candidates = [];
|
||||
foreach ($matches[0] as $tag) {
|
||||
if (! preg_match('/href=["\']([^"\']+)["\']/i', $tag, $m)) {
|
||||
continue;
|
||||
}
|
||||
$candidates[] = [
|
||||
'href' => $m[1],
|
||||
'apple' => stripos($tag, 'apple-touch-icon') !== false,
|
||||
];
|
||||
}
|
||||
usort($candidates, fn($a, $b) => ($a['apple'] <=> $b['apple']));
|
||||
$base = rtrim($base, '/');
|
||||
foreach ($candidates as $c) {
|
||||
$href = $c['href'];
|
||||
if (preg_match('/^https?:\/\//i', $href)) {
|
||||
return $href;
|
||||
}
|
||||
if (strpos($href, '//') === 0) {
|
||||
return 'https:' . $href;
|
||||
}
|
||||
return $base . ($href[0] === '/' ? $href : '/' . $href);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出本地图片文件
|
||||
*/
|
||||
protected function serveFile(string $path)
|
||||
{
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
$map = [
|
||||
'png' => 'image/png',
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
'gif' => 'image/gif',
|
||||
'ico' => 'image/x-icon',
|
||||
'svg' => 'image/svg+xml',
|
||||
'webp' => 'image/webp',
|
||||
];
|
||||
$ct = $map[$ext] ?? 'image/png';
|
||||
$data = file_get_contents($path);
|
||||
return Response::create($data)->header([
|
||||
'Content-Type' => $ct,
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
'Expires' => gmdate('D, d M Y H:i:s', time() + 86400) . ' GMT',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出默认图标(文件不存在时回退到 1x1 透明图)
|
||||
*/
|
||||
protected function serveDefault()
|
||||
{
|
||||
$path = app()->getRootPath() . 'public/static/haonav/img/default.png';
|
||||
if (is_file($path)) {
|
||||
return $this->serveFile($path);
|
||||
}
|
||||
$gif = base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
|
||||
return Response::create($gif)->header([
|
||||
'Content-Type' => 'image/gif',
|
||||
'Cache-Control' => 'public, max-age=3600',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function edit($id = null)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
|
||||
public function delete($id)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?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;
|
||||
use ywxapp\controller\FrontendBase;
|
||||
|
||||
use addon\haonav\model\Links as LinksModel;
|
||||
use addon\haonav\model\Configure as ConfigureModel;
|
||||
|
||||
/**
|
||||
* 计划任务入口(可通过服务器 cron 调用)
|
||||
*
|
||||
* 示例:curl "https://你的域名/haonav/task/checklinks?token=你的检测令牌"
|
||||
* 令牌在「导航配置」中设置 check_token,为空则拒绝执行。
|
||||
*/
|
||||
class Task extends FrontendBase
|
||||
{
|
||||
protected $noNeedLogin = ['*'];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 死链检测(供 cron 调用)
|
||||
*/
|
||||
public function checkLinks()
|
||||
{
|
||||
$token = $this->request->param('token', '');
|
||||
$realToken = ConfigureModel::getVal('check_token', '');
|
||||
|
||||
if ($realToken === '' || $token !== $realToken) {
|
||||
return $this->result->error('token 校验失败', 403);
|
||||
}
|
||||
|
||||
$stats = LinksModel::checkAllLinks();
|
||||
return $this->result->success($stats, '检测完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 惰性自动巡检(前台页面加载后异步 ping,无 cron 环境的兜底方案)
|
||||
*
|
||||
* - 需在「导航配置」开启 enable_auto_check
|
||||
* - 缓存锁限频:每小时最多执行一批(5 条,超时 5s),不影响页面渲染
|
||||
*/
|
||||
public function autoCheck()
|
||||
{
|
||||
if ((int)ConfigureModel::getVal('enable_auto_check', '0') !== 1) {
|
||||
return $this->result->success(['skipped' => 'disabled']);
|
||||
}
|
||||
$lockKey = 'haonav_autocheck_lock';
|
||||
if (\think\facade\Cache::get($lockKey)) {
|
||||
return $this->result->success(['skipped' => 'locked']);
|
||||
}
|
||||
\think\facade\Cache::set($lockKey, 1, 3600);
|
||||
|
||||
ignore_user_abort(true);
|
||||
@set_time_limit(60);
|
||||
$stats = LinksModel::checkBatch(5, 5);
|
||||
return $this->result->success($stats, '巡检完成');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?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;
|
||||
use ywxapp\controller\FrontendBase;
|
||||
|
||||
use think\facade\Cache;
|
||||
use addon\haonav\model\Configure as ConfigureModel;
|
||||
|
||||
/**
|
||||
* 首页小组件:实时热搜榜 + 天气(服务端代理 + 缓存,规避跨域并保护第三方接口)
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Widget extends FrontendBase
|
||||
{
|
||||
protected $noNeedLogin = ['*'];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时热搜榜(缓存 10 分钟)
|
||||
* GET /haonav/widget/hot?source=baidu|weibo|zhihu
|
||||
*/
|
||||
public function hot()
|
||||
{
|
||||
if ((int)ConfigureModel::getVal('enable_hot_api', '0') !== 1) {
|
||||
return $this->result->success([], '未开启');
|
||||
}
|
||||
$source = $this->request->param('source', ConfigureModel::getVal('hot_api_source', 'baidu'));
|
||||
$source = in_array($source, ['baidu', 'weibo', 'zhihu'], true) ? $source : 'baidu';
|
||||
|
||||
$cacheKey = 'haonav_hot_' . $source;
|
||||
$data = Cache::get($cacheKey);
|
||||
if (is_array($data)) {
|
||||
return $this->result->success($data, '缓存');
|
||||
}
|
||||
|
||||
$map = [
|
||||
'baidu' => 'https://api.vvhan.com/api/hotlist/baiduRD',
|
||||
'weibo' => 'https://api.vvhan.com/api/hotlist/wbHot',
|
||||
'zhihu' => 'https://api.vvhan.com/api/hotlist/zhihuHot',
|
||||
];
|
||||
$list = $this->fetchHot($map[$source]);
|
||||
// 即便为空也短缓存,避免频繁打第三方
|
||||
Cache::set($cacheKey, $list, $list ? 600 : 120);
|
||||
return $this->result->success($list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 天气(缓存 30 分钟)
|
||||
* GET /haonav/widget/weather?city=北京
|
||||
*/
|
||||
public function weather()
|
||||
{
|
||||
if ((int)ConfigureModel::getVal('enable_weather', '0') !== 1) {
|
||||
return $this->result->success([], '未开启');
|
||||
}
|
||||
$city = trim((string)$this->request->param('city', ConfigureModel::getVal('weather_city', '')));
|
||||
|
||||
$cacheKey = 'haonav_weather_' . ($city !== '' ? md5($city) : 'auto');
|
||||
$data = Cache::get($cacheKey);
|
||||
if (is_array($data) && $data) {
|
||||
return $this->result->success($data, '缓存');
|
||||
}
|
||||
|
||||
$info = $this->fetchWeather($city);
|
||||
if ($info) {
|
||||
Cache::set($cacheKey, $info, 1800);
|
||||
return $this->result->success($info);
|
||||
}
|
||||
return $this->result->success([], '暂无数据');
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取并归一化热搜列表 -> [ {title,url,hot}, ... ]
|
||||
*/
|
||||
protected function fetchHot(string $api): array
|
||||
{
|
||||
$json = $this->httpGet($api);
|
||||
if (!$json) {
|
||||
return [];
|
||||
}
|
||||
$arr = json_decode($json, true);
|
||||
if (!is_array($arr)) {
|
||||
return [];
|
||||
}
|
||||
$rows = $arr['data'] ?? ($arr['result'] ?? []);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
if (!is_array($r)) {
|
||||
continue;
|
||||
}
|
||||
$title = $r['title'] ?? ($r['name'] ?? '');
|
||||
if ($title === '') {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'title' => (string)$title,
|
||||
'url' => (string)($r['url'] ?? ($r['mobil_url'] ?? '')),
|
||||
'hot' => (string)($r['hot'] ?? ($r['num'] ?? '')),
|
||||
];
|
||||
if (count($out) >= 20) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取并归一化天气 -> {city,type,low,high,tips,...}
|
||||
*/
|
||||
protected function fetchWeather(string $city)
|
||||
{
|
||||
$api = 'https://api.vvhan.com/api/weather';
|
||||
if ($city !== '') {
|
||||
$api .= '?city=' . urlencode($city);
|
||||
}
|
||||
$json = $this->httpGet($api);
|
||||
if (!$json) {
|
||||
return false;
|
||||
}
|
||||
$arr = json_decode($json, true);
|
||||
if (!is_array($arr) || empty($arr['success'])) {
|
||||
return false;
|
||||
}
|
||||
$today = $arr['data'][0] ?? [];
|
||||
return [
|
||||
'city' => (string)($arr['city'] ?? $city),
|
||||
'date' => (string)($today['date'] ?? ''),
|
||||
'type' => (string)($today['type'] ?? ''),
|
||||
'low' => (string)($today['low'] ?? ''),
|
||||
'high' => (string)($today['high'] ?? ''),
|
||||
'fx' => (string)($today['fengxiang'] ?? ''),
|
||||
'tips' => (string)($arr['tip'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量 GET(优先 Guzzle,失败回退 file_get_contents),失败返回 ''
|
||||
*/
|
||||
protected function httpGet(string $url): string
|
||||
{
|
||||
try {
|
||||
if (class_exists(\GuzzleHttp\Client::class)) {
|
||||
$client = new \GuzzleHttp\Client(['timeout' => 5, 'verify' => false]);
|
||||
$resp = $client->get($url, ['headers' => ['Member-Agent' => 'Mozilla/5.0']]);
|
||||
return (string)$resp->getBody();
|
||||
}
|
||||
$ctx = stream_context_create(['http' => ['timeout' => 5, 'header' => "Member-Agent: Mozilla/5.0\r\n"]]);
|
||||
$body = @file_get_contents($url, false, $ctx);
|
||||
return $body === false ? '' : (string)$body;
|
||||
} catch (\Throwable $e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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 = ['*'];
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user