Files
ai-web/resources/views/public/tools/index.blade.php
jiangdong.cheng a795b2c896
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
优化功能
2026-02-12 17:10:36 +08:00

346 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@extends('layouts.site')
@section('page_class', 'page-tools')
@section('title', 'AI工具集 - AIWeb')
@section('meta_description', 'AI工具集首页按分类分块浏览工具左侧菜单可定位到对应模块。')
@section('canonical', route('tools.index'))
@section('head')
<style>
html { scroll-behavior: smooth; }
.tool-home { display: grid; grid-template-columns: 208px minmax(0, 1fr); gap: .72rem; align-items: start; }
.tool-side { position: sticky; top: .72rem; border: 1px solid var(--line); border-radius: 12px; background: var(--bg-surface); box-shadow: var(--shadow-sm); padding: .6rem .48rem; max-height: calc(100vh - 1.44rem); overflow: auto; }
.tool-side-logo { display: flex; align-items: center; gap: .38rem; padding: .22rem .3rem .56rem; border-bottom: 1px solid var(--line); margin-bottom: .36rem; font-family: "Outfit", "Noto Sans SC", sans-serif; color: var(--text-main); font-size: 1.12rem; font-weight: 800; }
.tool-side-logo-dot { width: 1.35rem; height: 1.35rem; border-radius: .38rem; background: linear-gradient(135deg, #a777ff, #6e77ff); color: #fff; font-size: .68rem; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; }
.tool-side-links { display: grid; gap: .16rem; }
.tool-side-link { display: grid; grid-template-columns: 1rem minmax(0, 1fr) auto; align-items: center; gap: .42rem; border: 1px solid transparent; border-radius: .58rem; text-decoration: none; color: #4e6189; padding: .38rem .42rem; font-size: .82rem; transition: .16s ease; }
.tool-side-link:hover, .tool-side-link.active, .tool-side-link.is-active { border-color: var(--line-strong); background: var(--brand-soft); color: #21345f; box-shadow: inset 3px 0 0 #5f72ff; }
.tool-side-link i { color: #687ba2; text-align: center; }
.tool-side-link small { color: #97a9c8; font-size: .7rem; }
.tool-main { min-width: 0; display: grid; gap: .62rem; }
.tool-hero { border: 1px solid var(--line); border-radius: 12px; background: linear-gradient(180deg, #f5f8ff 0, #eef3fb 100%); box-shadow: var(--shadow-sm); padding: .95rem .95rem .82rem; text-align: center; }
.tool-chip { display: inline-flex; border: 1px solid var(--line-strong); border-radius: 999px; background: var(--bg-surface); color: #6f81a7; font-size: .68rem; font-weight: 700; padding: .14rem .5rem; }
.tool-title { margin: .34rem 0 .18rem; font-family: "Outfit", "Noto Sans SC", sans-serif; font-size: clamp(1.6rem, 2.8vw, 2.45rem); font-weight: 800; color: var(--text-main); }
.tool-sub { margin: 0; color: var(--text-muted); font-size: .84rem; }
:root[data-theme="dark"] .tool-hero { background: linear-gradient(180deg, #1b2849 0, #16223f 100%); }
.tool-search { margin: .62rem auto .38rem; width: min(860px, 100%); display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .38rem; border: 1px solid var(--line-strong); border-radius: 999px; background: var(--bg-surface); padding: .3rem; }
.tool-search input { border: 0; box-shadow: none; height: 2.16rem; padding: 0 .82rem; background: transparent; color: var(--text-main); }
.tool-search button { border-radius: 999px; min-width: 90px; height: 2.16rem; font-size: .82rem; }
.tool-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .5rem; margin-top: .52rem; }
.tool-kpi { border: 1px solid var(--line); border-radius: .7rem; background: var(--bg-surface); padding: .46rem .5rem; }
.tool-kpi .label { display: block; color: #7b8fb2; font-size: .72rem; }
.tool-kpi .value { margin-top: .12rem; font-family: "Outfit", "Noto Sans SC", sans-serif; font-size: 1.08rem; font-weight: 800; color: var(--text-main); }
.tool-channel { border: 1px solid var(--line); border-radius: 12px; background: var(--bg-surface); box-shadow: var(--shadow-sm); padding: .52rem; display: grid; grid-template-columns: 62px repeat(5, minmax(0, 1fr)); gap: .46rem; }
.tool-channel-mini { border: 1px solid var(--line); border-radius: 10px; background: var(--bg-soft); display: grid; gap: .34rem; padding: .44rem .3rem; }
.tool-channel-mini a { text-decoration: none; color: #5f7399; font-size: .72rem; text-align: center; }
.tool-channel-card { border: 1px solid var(--line); border-radius: 10px; min-height: 104px; display: flex; align-items: flex-end; justify-content: flex-start; text-decoration: none; font-family: "Outfit", "Noto Sans SC", sans-serif; font-size: .92rem; font-weight: 700; color: #fff; background-size: cover; background-position: center; position: relative; overflow: hidden; padding: .52rem; }
.tool-channel-card::after { content: ''; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(20,32,56,.08), rgba(20,32,56,.68)); }
.tool-channel-card span { position: relative; z-index: 1; }
.tool-banner-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .52rem; }
.tool-banner { border: 1px solid var(--line); border-radius: 10px; background: linear-gradient(90deg, #e8f3ff, #e4f7ef); min-height: 72px; display: flex; align-items: center; justify-content: space-between; gap: .52rem; padding: .58rem .72rem; text-decoration: none; }
.tool-banner.alt { background: linear-gradient(90deg, #e6f6ff, #dff8dd); }
.tool-banner b { color: var(--text-main); font-size: 1.06rem; font-family: "Outfit", "Noto Sans SC", sans-serif; }
.tool-banner small { border: 1px solid var(--line-strong); border-radius: 999px; background: var(--bg-surface); color: #5f749a; font-size: .72rem; padding: .14rem .48rem; }
.tool-section { border: 1px solid var(--line); border-radius: 12px; background: var(--bg-surface); box-shadow: var(--shadow-sm); padding: .62rem; scroll-margin-top: 1rem; }
.tool-section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: .48rem; }
.tool-section-head h2 { margin: 0; color: var(--text-main); font-size: 1rem; font-family: "Outfit", "Noto Sans SC", sans-serif; font-weight: 700; }
.tool-section-head a { color: var(--brand-strong); text-decoration: none; font-size: .76rem; font-weight: 600; }
.tool-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .52rem; }
.tool-card { border: 1px solid var(--line); border-radius: 10px; background: var(--bg-surface); box-shadow: var(--shadow-sm); padding: .56rem .62rem; text-decoration: none; transition: .16s ease; }
.tool-card:hover { transform: translateY(-2px); border-color: var(--line-strong); box-shadow: 0 12px 22px rgba(31,54,112,.14); }
.tool-card-name { color: var(--text-main); font-size: .92rem; line-height: 1.26; font-weight: 700; }
.tool-card-meta { margin-top: .2rem; color: var(--text-muted); font-size: .76rem; }
.tool-card-tag { margin-top: .42rem; display: inline-flex; border: 1px solid var(--line-strong); border-radius: 999px; padding: .13rem .48rem; color: var(--brand-strong); background: var(--brand-soft); font-size: .7rem; }
@media (max-width: 1199px) {
.tool-home { grid-template-columns: 1fr; }
.tool-side { position: static; max-height: none; }
}
@media (max-width: 767px) {
.tool-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.tool-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.tool-search { grid-template-columns: 1fr; border-radius: 12px; }
.tool-search button { width: 100%; border-radius: 10px; }
.tool-channel { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.tool-banner-row { grid-template-columns: 1fr; }
}
</style>
@endsection
@section('content')
@php
$icons = ['bi-stars', 'bi-pencil', 'bi-image', 'bi-camera-video', 'bi-briefcase', 'bi-cpu', 'bi-chat-dots', 'bi-code-slash', 'bi-kanban'];
$channelModule = $modules['channel_cards'] ?? null;
$bannerModule = $modules['promo_banners'] ?? null;
$hotModule = $modules['hot_tools'] ?? null;
$latestModule = $modules['latest_tools'] ?? null;
$categoryModule = $modules['category_sections'] ?? null;
$moduleOrder = collect($modules)->sortBy('sort_order')->pluck('module_key')->values();
$channelItems = collect(data_get($channelModule, 'items', []))->take((int) data_get($channelModule, 'limit', 5));
$bannerItems = collect(data_get($bannerModule, 'items', []))->take((int) data_get($bannerModule, 'limit', 2));
@endphp
<div class="tool-home">
<aside class="tool-side" aria-label="工具分类侧边栏">
<div class="tool-side-logo"><span class="tool-side-logo-dot">AI</span>{{ data_get($categoryModule, 'extra.side_title', 'AI工具集') }}</div>
<nav class="tool-side-links">
@if(data_get($channelModule, 'enabled', true) === true)
<a class="tool-side-link" href="#section-channel">
<i class="bi bi-grid"></i>
<span>{{ data_get($channelModule, 'title', '频道卡片') }}</span>
<small>{{ $channelItems->count() }}</small>
</a>
@endif
@if(data_get($bannerModule, 'enabled', true) === true)
<a class="tool-side-link" href="#section-banner">
<i class="bi bi-megaphone"></i>
<span>{{ data_get($bannerModule, 'title', '横幅推荐') }}</span>
<small>{{ $bannerItems->count() }}</small>
</a>
@endif
@if(data_get($hotModule, 'enabled', true) === true)
<a class="tool-side-link" href="#section-hot">
<i class="bi bi-fire"></i>
<span>{{ data_get($hotModule, 'title', '热门工具') }}</span>
<small>{{ $hotTools->count() }}</small>
</a>
@endif
@if(data_get($latestModule, 'enabled', true) === true)
<a class="tool-side-link" href="#section-latest">
<i class="bi bi-clock-history"></i>
<span>{{ data_get($latestModule, 'title', '最新收录') }}</span>
<small>{{ $latestTools->count() }}</small>
</a>
@endif
@if(data_get($categoryModule, 'enabled', true) === true)
@foreach($categorySections as $index => $section)
<a class="tool-side-link" href="#section-{{ $section['slug'] }}">
<i class="bi {{ $icons[$index % count($icons)] }}"></i>
<span>{{ $section['name'] }}</span>
<small>{{ $section['count'] }}</small>
</a>
@endforeach
@endif
</nav>
</aside>
<section class="tool-main">
<section class="tool-hero">
<span class="tool-chip">AI-BOT.CN</span>
<h1 class="tool-title">{{ data_get($hotModule, 'extra.side_title', 'AI工具集') }}</h1>
<p class="tool-sub">{{ data_get($hotModule, 'subtitle', '左侧菜单点击后可直接定位到对应工具区块。') }}</p>
<form class="tool-search" method="get" action="{{ route('tools.list') }}" role="search" aria-label="站内 AI 工具搜索">
<input type="hidden" name="tab" value="{{ $activeTab }}">
<input type="search" name="q" value="{{ $filters['q'] ?? '' }}" placeholder="站内 AI 工具搜索,如:写作、图像、自动化" autocomplete="off" spellcheck="false">
<button class="btn btn-primary" type="submit"><i class="bi bi-search"></i> 搜索</button>
</form>
<nav class="channel-tabs justify-content-center" aria-label="筛选模式">
@foreach($tabOptions as $tab)
<a class="channel-tab @if($activeTab === $tab['key']) active @endif" href="{{ route('tools.index', array_filter(array_merge($filters, ['tab' => $tab['key']]))) }}">{{ $tab['label'] }}</a>
@endforeach
</nav>
<x-portal.stat-grid
grid-class="tool-kpis"
item-class="tool-kpi"
:stats="[
['label' => '收录工具', 'value' => $toolStats['total'] ?? 0],
['label' => '支持 API', 'value' => $toolStats['api'] ?? 0],
['label' => '免费可用', 'value' => $toolStats['free'] ?? 0],
['label' => '7天更新', 'value' => $toolStats['updated_7d'] ?? 0],
]"
/>
</section>
@foreach($moduleOrder as $moduleKey)
@if($moduleKey === 'channel_cards' && data_get($channelModule, 'enabled', true) === true)
@php
$miniItems = $channelItems->take(4);
@endphp
<section id="section-channel" class="tool-section" aria-label="频道入口">
<header class="tool-section-head">
<h2><i class="bi bi-grid text-primary"></i> {{ data_get($channelModule, 'title', '频道卡片') }}</h2>
<a href="{{ route('tools.list') }}">查看更多</a>
</header>
<div class="tool-channel">
<aside class="tool-channel-mini">
@foreach($miniItems as $item)
<a href="{{ $item['url'] ?: 'javascript:void(0)' }}">{{ $item['title'] ?: '频道' }}</a>
@endforeach
</aside>
@foreach($channelItems as $item)
<a class="tool-channel-card" href="{{ $item['url'] ?: 'javascript:void(0)' }}" @if(!empty($item['image_path'])) style="background-image:url('{{ $item['image_path'] }}');" @endif>
<span>{{ $item['title'] ?: '频道入口' }}</span>
</a>
@endforeach
</div>
</section>
@endif
@if($moduleKey === 'promo_banners' && data_get($bannerModule, 'enabled', true) === true)
<section id="section-banner" class="tool-section" aria-label="横幅推荐">
<header class="tool-section-head">
<h2><i class="bi bi-megaphone text-primary"></i> {{ data_get($bannerModule, 'title', '横幅推荐') }}</h2>
<a href="{{ route('tools.list') }}">查看更多</a>
</header>
<div class="tool-banner-row">
@forelse($bannerItems as $banner)
<a class="tool-banner @if($loop->index % 2 === 1) alt @endif" href="{{ $banner['url'] ?: 'javascript:void(0)' }}" @if(!empty($banner['image_path'])) style="background-image:url('{{ $banner['image_path'] }}'); background-size:cover;" @endif>
<b>{{ $banner['title'] ?: '精选推荐' }}</b>
<small>{{ $banner['subtitle'] ?: '查看详情' }}</small>
</a>
@empty
<article class="tool-banner"><b>暂无横幅推荐</b><small>请在后台配置</small></article>
@endforelse
</div>
</section>
@endif
@if($moduleKey === 'hot_tools' && data_get($hotModule, 'enabled', true) === true)
<section id="section-hot" class="tool-section" aria-label="热门工具">
<header class="tool-section-head">
<h2><i class="bi bi-fire text-danger"></i> {{ data_get($hotModule, 'title', '热门工具') }}</h2>
@php($hotMore = data_get($hotModule, 'more_url') ?: route('tools.list', ['tab' => 'recommended']))
<a href="{{ $hotMore }}">查看更多</a>
</header>
<x-portal.tool-grid :tools="$hotTools" />
</section>
@endif
@if($moduleKey === 'latest_tools' && data_get($latestModule, 'enabled', true) === true)
<section id="section-latest" class="tool-section" aria-label="最新收录">
<header class="tool-section-head">
<h2><i class="bi bi-clock-history text-primary"></i> {{ data_get($latestModule, 'title', '最新收录') }}</h2>
@php($latestMore = data_get($latestModule, 'more_url') ?: route('tools.list', ['tab' => 'latest']))
<a href="{{ $latestMore }}">查看更多</a>
</header>
<x-portal.tool-grid :tools="$latestTools" />
</section>
@endif
@if($moduleKey === 'category_sections' && data_get($categoryModule, 'enabled', true) === true)
@foreach($categorySections as $section)
<section id="section-{{ $section['slug'] }}" class="tool-section" aria-label="{{ $section['name'] }}工具">
<header class="tool-section-head">
<h2><i class="bi bi-grid-3x3-gap text-primary"></i> {{ $section['name'] }}</h2>
<a href="{{ route('tools.list', ['category' => $section['slug']]) }}">查看更多</a>
</header>
@if($section['tools']->isNotEmpty())
<x-portal.tool-grid :tools="$section['tools']" />
@else
<p class="text-muted-soft mb-0">该分类暂未收录工具</p>
@endif
</section>
@endforeach
@endif
@endforeach
</section>
</div>
@endsection
@section('scripts')
<script>
(() => {
const links = Array.from(document.querySelectorAll('.tool-side-link[href^="#section-"]'));
const sections = Array.from(document.querySelectorAll('[id^="section-"]'));
if (!links.length || !sections.length || !('IntersectionObserver' in window)) {
return;
}
const setActive = (activeId) => {
links.forEach((link) => {
const id = link.getAttribute('href')?.replace('#', '') || '';
link.classList.toggle('is-active', id === activeId);
});
};
const syncHash = (id) => {
const hash = `#${id}`;
if (window.location.hash !== hash) {
history.replaceState(null, '', hash);
}
};
links.forEach((link) => {
link.addEventListener('click', (event) => {
const id = link.getAttribute('href')?.replace('#', '');
if (!id) {
return;
}
const target = document.getElementById(id);
if (!target) {
return;
}
event.preventDefault();
setActive(id);
syncHash(id);
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
const observer = new IntersectionObserver((entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
if (!visible.length) {
return;
}
const top = visible[0].target;
if (top instanceof HTMLElement) {
setActive(top.id);
syncHash(top.id);
}
}, {
rootMargin: '-18% 0px -64% 0px',
threshold: [0.2, 0.45, 0.7],
});
sections.forEach((section) => observer.observe(section));
if (window.location.hash) {
const hashId = window.location.hash.replace('#', '');
const target = document.getElementById(hashId);
if (target) {
setActive(hashId);
window.requestAnimationFrame(() => {
target.scrollIntoView({ behavior: 'auto', block: 'start' });
});
} else {
setActive(sections[0].id);
syncHash(sections[0].id);
}
} else {
setActive(sections[0].id);
syncHash(sections[0].id);
}
})();
</script>
@endsection