初始化
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Article;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ArticleController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$articles = Article::with('category')->orderByDesc('created_at')->paginate(20);
|
||||
|
||||
return view('admin.articles.index', compact('articles'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$categories = Category::ofType('article')->orderBy('sort')->orderBy('name')->get();
|
||||
|
||||
return view('admin.articles.form', ['article' => null, 'categories' => $categories]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'category_id' => ['nullable', 'exists:categories,id'],
|
||||
'title' => ['required', 'string', 'max:160'],
|
||||
'slug' => ['required', 'string', 'max:160', 'unique:articles,slug'],
|
||||
'summary' => ['nullable', 'string', 'max:255'],
|
||||
'body' => ['nullable', 'string'],
|
||||
'cover' => ['nullable', 'string', 'max:255'],
|
||||
'author' => ['nullable', 'string', 'max:60'],
|
||||
'status' => ['required', 'in:approved,draft'],
|
||||
'template' => ['required', 'in:standard,tech,feature'],
|
||||
'seo_title' => ['nullable', 'string', 'max:160'],
|
||||
'seo_description' => ['nullable', 'string', 'max:255'],
|
||||
'published_at' => ['nullable', 'date'],
|
||||
]);
|
||||
|
||||
if (! empty($data['body'])) {
|
||||
$data['body'] = \Purifier::clean($data['body'], 'article');
|
||||
}
|
||||
|
||||
if (empty($data['published_at']) && $data['status'] === 'approved') {
|
||||
$data['published_at'] = now();
|
||||
}
|
||||
|
||||
Article::create($data);
|
||||
|
||||
return redirect()->route('admin.articles.index')->with('success', '文章已创建');
|
||||
}
|
||||
|
||||
public function edit(Article $article): View
|
||||
{
|
||||
$categories = Category::ofType('article')->orderBy('sort')->orderBy('name')->get();
|
||||
|
||||
return view('admin.articles.form', compact('article', 'categories'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Article $article)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'category_id' => ['nullable', 'exists:categories,id'],
|
||||
'title' => ['required', 'string', 'max:160'],
|
||||
'slug' => ['required', 'string', 'max:160', 'unique:articles,slug,' . $article->id],
|
||||
'summary' => ['nullable', 'string', 'max:255'],
|
||||
'body' => ['nullable', 'string'],
|
||||
'cover' => ['nullable', 'string', 'max:255'],
|
||||
'author' => ['nullable', 'string', 'max:60'],
|
||||
'status' => ['required', 'in:approved,draft'],
|
||||
'template' => ['required', 'in:standard,tech,feature'],
|
||||
'seo_title' => ['nullable', 'string', 'max:160'],
|
||||
'seo_description' => ['nullable', 'string', 'max:255'],
|
||||
'published_at' => ['nullable', 'date'],
|
||||
]);
|
||||
|
||||
if (! empty($data['body'])) {
|
||||
$data['body'] = \Purifier::clean($data['body'], 'article');
|
||||
}
|
||||
|
||||
if (empty($data['published_at']) && $data['status'] === 'approved' && $article->published_at === null) {
|
||||
$data['published_at'] = now();
|
||||
}
|
||||
|
||||
$article->update($data);
|
||||
|
||||
return redirect()->route('admin.articles.index')->with('success', '文章已更新');
|
||||
}
|
||||
|
||||
public function destroy(Article $article)
|
||||
{
|
||||
$article->delete();
|
||||
|
||||
return redirect()->route('admin.articles.index')->with('success', '文章已删除');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$categories = Category::orderBy('type')->orderBy('sort')->orderBy('name')->paginate(20);
|
||||
|
||||
return view('admin.categories.index', compact('categories'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.categories.form', ['category' => null]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:60'],
|
||||
'slug' => ['required', 'string', 'max:80', 'unique:categories,slug'],
|
||||
'type' => ['required', 'in:product,article'],
|
||||
'parent_id' => ['nullable', 'integer'],
|
||||
'sort' => ['integer'],
|
||||
]);
|
||||
|
||||
Category::create($data);
|
||||
|
||||
return redirect()->route('admin.categories.index')->with('success', '分类已创建');
|
||||
}
|
||||
|
||||
public function edit(Category $category): View
|
||||
{
|
||||
return view('admin.categories.form', compact('category'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Category $category)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:60'],
|
||||
'slug' => ['required', 'string', 'max:80', 'unique:categories,slug,' . $category->id],
|
||||
'type' => ['required', 'in:product,article'],
|
||||
'parent_id' => ['nullable', 'integer'],
|
||||
'sort' => ['integer'],
|
||||
]);
|
||||
|
||||
$category->update($data);
|
||||
|
||||
return redirect()->route('admin.categories.index')->with('success', '分类已更新');
|
||||
}
|
||||
|
||||
public function destroy(Category $category)
|
||||
{
|
||||
$category->delete();
|
||||
|
||||
return redirect()->route('admin.categories.index')->with('success', '分类已删除');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ContactMessage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ContactController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$messages = ContactMessage::orderByDesc('created_at')->paginate(20);
|
||||
|
||||
return view('admin.contacts.index', compact('messages'));
|
||||
}
|
||||
|
||||
public function show(ContactMessage $contactMessage): View
|
||||
{
|
||||
return view('admin.contacts.show', compact('contactMessage'));
|
||||
}
|
||||
|
||||
public function destroy(ContactMessage $contactMessage)
|
||||
{
|
||||
$contactMessage->delete();
|
||||
|
||||
return redirect()->route('admin.contacts.index')->with('success', '留言已删除');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Article;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContactMessage;
|
||||
use App\Models\ForumThread;
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$stats = [
|
||||
'products' => Product::count(),
|
||||
'articles' => Article::count(),
|
||||
'categories' => \App\Models\Category::count(),
|
||||
'glossary' => \App\Models\GlossaryTerm::count(),
|
||||
'threads' => ForumThread::count(),
|
||||
'users' => User::count(),
|
||||
'messages' => ContactMessage::count(),
|
||||
];
|
||||
|
||||
$recentMessages = ContactMessage::orderByDesc('created_at')->limit(5)->get();
|
||||
$recentThreads = ForumThread::with('user', 'board')->orderByDesc('created_at')->limit(5)->get();
|
||||
|
||||
return view('admin.dashboard', compact('stats', 'recentMessages', 'recentThreads'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 后台数据库管理:查看迁移状态(已执行 / 待执行),并可一键执行迁移升级。
|
||||
* 满足「数据库可以随时在后台升级」——代码上线后无需 SSH,点按钮即可 migrate。
|
||||
*/
|
||||
class DatabaseController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$connection = config('database.default');
|
||||
$dbName = config("database.connections.{$connection}.database");
|
||||
|
||||
$ran = [];
|
||||
if (Schema::hasTable('migrations')) {
|
||||
$ran = DB::table('migrations')->pluck('migration')->toArray();
|
||||
}
|
||||
|
||||
$files = glob(database_path('migrations/*.php'));
|
||||
$all = array_map(fn ($f) => basename($f, '.php'), $files);
|
||||
sort($all);
|
||||
|
||||
$done = array_values(array_intersect($all, $ran));
|
||||
$pending = array_values(array_diff($all, $ran));
|
||||
|
||||
return view('admin.database', compact('connection', 'dbName', 'pending', 'done'));
|
||||
}
|
||||
|
||||
public function upgrade(Request $request): RedirectResponse
|
||||
{
|
||||
try {
|
||||
Artisan::call('migrate', ['--force' => true]);
|
||||
$output = Artisan::output();
|
||||
|
||||
return redirect()->route('admin.database')
|
||||
->with('success', '数据库升级已执行完成。')
|
||||
->with('migrate_output', $output);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('admin.database')
|
||||
->with('error', '升级失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ForumBoard;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ForumBoardController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$boards = ForumBoard::orderBy('sort')->orderBy('name')->paginate(20);
|
||||
|
||||
return view('admin.forum.boards', compact('boards'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.forum.board-form', ['board' => null]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:60'],
|
||||
'slug' => ['required', 'string', 'max:80', 'unique:forum_boards,slug'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'sort' => ['integer'],
|
||||
]);
|
||||
|
||||
ForumBoard::create($data);
|
||||
|
||||
return redirect()->route('admin.forum.boards.index')->with('success', '版块已创建');
|
||||
}
|
||||
|
||||
public function edit(ForumBoard $board): View
|
||||
{
|
||||
return view('admin.forum.board-form', compact('board'));
|
||||
}
|
||||
|
||||
public function update(Request $request, ForumBoard $board)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:60'],
|
||||
'slug' => ['required', 'string', 'max:80', 'unique:forum_boards,slug,' . $board->id],
|
||||
'description' => ['nullable', 'string'],
|
||||
'sort' => ['integer'],
|
||||
]);
|
||||
|
||||
$board->update($data);
|
||||
|
||||
return redirect()->route('admin.forum.boards.index')->with('success', '版块已更新');
|
||||
}
|
||||
|
||||
public function destroy(ForumBoard $board)
|
||||
{
|
||||
$board->delete();
|
||||
|
||||
return redirect()->route('admin.forum.boards.index')->with('success', '版块已删除');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ForumThread;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ForumThreadController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$threads = ForumThread::with('user', 'board')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('admin.forum.threads', compact('threads'));
|
||||
}
|
||||
|
||||
public function destroy(ForumThread $thread)
|
||||
{
|
||||
$thread->delete();
|
||||
|
||||
return redirect()->route('admin.forum.threads.index')->with('success', '帖子已删除');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GlossaryTerm;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class GlossaryController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$terms = GlossaryTerm::orderBy('term')->paginate(20);
|
||||
|
||||
return view('admin.glossary.index', compact('terms'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.glossary.form', ['term' => null]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'term' => ['required', 'string', 'max:80'],
|
||||
'slug' => ['required', 'string', 'max:90', 'unique:glossary_terms,slug'],
|
||||
'definition' => ['required', 'string'],
|
||||
'aliases' => ['nullable', 'string', 'max:255'],
|
||||
'category' => ['nullable', 'string', 'max:60'],
|
||||
]);
|
||||
|
||||
GlossaryTerm::create($data);
|
||||
|
||||
return redirect()->route('admin.glossary.index')->with('success', '术语已创建');
|
||||
}
|
||||
|
||||
public function edit(GlossaryTerm $glossaryTerm): View
|
||||
{
|
||||
return view('admin.glossary.form', ['term' => $glossaryTerm]);
|
||||
}
|
||||
|
||||
public function update(Request $request, GlossaryTerm $glossaryTerm)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'term' => ['required', 'string', 'max:80'],
|
||||
'slug' => ['required', 'string', 'max:90', 'unique:glossary_terms,slug,' . $glossaryTerm->id],
|
||||
'definition' => ['required', 'string'],
|
||||
'aliases' => ['nullable', 'string', 'max:255'],
|
||||
'category' => ['nullable', 'string', 'max:60'],
|
||||
]);
|
||||
|
||||
$glossaryTerm->update($data);
|
||||
|
||||
return redirect()->route('admin.glossary.index')->with('success', '术语已更新');
|
||||
}
|
||||
|
||||
public function destroy(GlossaryTerm $glossaryTerm)
|
||||
{
|
||||
$glossaryTerm->delete();
|
||||
|
||||
return redirect()->route('admin.glossary.index')->with('success', '术语已删除');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HeroSlide;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class HeroSlideController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$slides = HeroSlide::where('location', 'home')->ordered()->get();
|
||||
$mode = Setting::get('hero_mode', 'carousel');
|
||||
|
||||
return view('admin.hero-slides.index', compact('slides', 'mode'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.hero-slides.form', ['slide' => null]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => ['required', 'string', 'max:120'],
|
||||
'content' => ['nullable', 'string'],
|
||||
'cta_text' => ['nullable', 'string', 'max:80'],
|
||||
'cta_url' => ['nullable', 'string', 'max:255'],
|
||||
'cta2_text' => ['nullable', 'string', 'max:80'],
|
||||
'cta2_url' => ['nullable', 'string', 'max:255'],
|
||||
'is_active' => ['boolean'],
|
||||
'sort_order' => ['integer'],
|
||||
]);
|
||||
|
||||
$data['location'] = 'home';
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$data['sort_order'] = $data['sort_order'] ?? 0;
|
||||
|
||||
HeroSlide::create($data);
|
||||
|
||||
return redirect()->route('admin.hero-slides.index')->with('success', '轮播项已添加');
|
||||
}
|
||||
|
||||
public function edit(HeroSlide $hero_slide): View
|
||||
{
|
||||
return view('admin.hero-slides.form', ['slide' => $hero_slide]);
|
||||
}
|
||||
|
||||
public function update(Request $request, HeroSlide $hero_slide): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => ['required', 'string', 'max:120'],
|
||||
'content' => ['nullable', 'string'],
|
||||
'cta_text' => ['nullable', 'string', 'max:80'],
|
||||
'cta_url' => ['nullable', 'string', 'max:255'],
|
||||
'cta2_text' => ['nullable', 'string', 'max:80'],
|
||||
'cta2_url' => ['nullable', 'string', 'max:255'],
|
||||
'is_active' => ['boolean'],
|
||||
'sort_order' => ['integer'],
|
||||
]);
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$data['sort_order'] = $data['sort_order'] ?? $hero_slide->sort_order;
|
||||
|
||||
$hero_slide->update($data);
|
||||
|
||||
return redirect()->route('admin.hero-slides.index')->with('success', '轮播项已更新');
|
||||
}
|
||||
|
||||
public function destroy(HeroSlide $hero_slide): RedirectResponse
|
||||
{
|
||||
$hero_slide->delete();
|
||||
|
||||
return redirect()->route('admin.hero-slides.index')->with('success', '轮播项已删除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 上移 / 下移排序。
|
||||
*/
|
||||
public function move(Request $request, HeroSlide $hero_slide): RedirectResponse
|
||||
{
|
||||
$direction = $request->input('direction');
|
||||
|
||||
if ($direction === 'up') {
|
||||
$sibling = HeroSlide::where('location', 'home')->ordered()
|
||||
->where('sort_order', '<', $hero_slide->sort_order)
|
||||
->orderByDesc('sort_order')
|
||||
->first();
|
||||
if ($sibling) {
|
||||
[$hero_slide->sort_order, $sibling->sort_order] = [$sibling->sort_order, $hero_slide->sort_order];
|
||||
$hero_slide->save();
|
||||
$sibling->save();
|
||||
}
|
||||
} elseif ($direction === 'down') {
|
||||
$sibling = HeroSlide::where('location', 'home')->ordered()
|
||||
->where('sort_order', '>', $hero_slide->sort_order)
|
||||
->first();
|
||||
if ($sibling) {
|
||||
[$hero_slide->sort_order, $sibling->sort_order] = [$sibling->sort_order, $hero_slide->sort_order];
|
||||
$hero_slide->save();
|
||||
$sibling->save();
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('admin.hero-slides.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键启用 / 停用。
|
||||
*/
|
||||
public function toggle(HeroSlide $hero_slide): RedirectResponse
|
||||
{
|
||||
$hero_slide->update(['is_active' => ! $hero_slide->is_active]);
|
||||
|
||||
return redirect()->route('admin.hero-slides.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局 Hero 显示模式:carousel(轮播)/ single(独立单图)。
|
||||
*/
|
||||
public function setMode(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'hero_mode' => ['required', 'in:carousel,single'],
|
||||
]);
|
||||
|
||||
Setting::set('hero_mode', $data['hero_mode'], 'appearance');
|
||||
|
||||
return redirect()->route('admin.hero-slides.index')->with('success', 'Hero 显示模式已更新');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\NavigationMenu;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MenuController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$menus = NavigationMenu::ordered()->get();
|
||||
|
||||
return view('admin.menus.index', compact('menus'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.menus.form', ['menu' => null]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'label' => ['required', 'string', 'max:60'],
|
||||
'url' => ['required', 'string', 'max:255'],
|
||||
'is_visible' => ['boolean'],
|
||||
'sort_order' => ['integer'],
|
||||
]);
|
||||
|
||||
$data['is_visible'] = $request->boolean('is_visible');
|
||||
$data['sort_order'] = $data['sort_order'] ?? 0;
|
||||
|
||||
NavigationMenu::create($data);
|
||||
|
||||
return redirect()->route('admin.menus.index')->with('success', '菜单项已添加');
|
||||
}
|
||||
|
||||
public function edit(NavigationMenu $menu): View
|
||||
{
|
||||
return view('admin.menus.form', compact('menu'));
|
||||
}
|
||||
|
||||
public function update(Request $request, NavigationMenu $menu): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'label' => ['required', 'string', 'max:60'],
|
||||
'url' => ['required', 'string', 'max:255'],
|
||||
'is_visible' => ['boolean'],
|
||||
'sort_order' => ['integer'],
|
||||
]);
|
||||
|
||||
$data['is_visible'] = $request->boolean('is_visible');
|
||||
$data['sort_order'] = $data['sort_order'] ?? $menu->sort_order;
|
||||
|
||||
$menu->update($data);
|
||||
|
||||
return redirect()->route('admin.menus.index')->with('success', '菜单项已更新');
|
||||
}
|
||||
|
||||
public function destroy(NavigationMenu $menu): RedirectResponse
|
||||
{
|
||||
$menu->delete();
|
||||
|
||||
return redirect()->route('admin.menus.index')->with('success', '菜单项已删除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 上移 / 下移排序。
|
||||
*/
|
||||
public function move(Request $request, NavigationMenu $menu): RedirectResponse
|
||||
{
|
||||
$direction = $request->input('direction');
|
||||
|
||||
if ($direction === 'up') {
|
||||
$sibling = NavigationMenu::ordered()
|
||||
->where('sort_order', '<', $menu->sort_order)
|
||||
->orderByDesc('sort_order')
|
||||
->first();
|
||||
if ($sibling) {
|
||||
[$menu->sort_order, $sibling->sort_order] = [$sibling->sort_order, $menu->sort_order];
|
||||
$menu->save();
|
||||
$sibling->save();
|
||||
}
|
||||
} elseif ($direction === 'down') {
|
||||
$sibling = NavigationMenu::ordered()
|
||||
->where('sort_order', '>', $menu->sort_order)
|
||||
->first();
|
||||
if ($sibling) {
|
||||
[$menu->sort_order, $sibling->sort_order] = [$sibling->sort_order, $menu->sort_order];
|
||||
$menu->save();
|
||||
$sibling->save();
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('admin.menus.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键显示 / 隐藏。
|
||||
*/
|
||||
public function toggle(NavigationMenu $menu): RedirectResponse
|
||||
{
|
||||
$menu->update(['is_visible' => ! $menu->is_visible]);
|
||||
|
||||
return redirect()->route('admin.menus.index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Article;
|
||||
use App\Models\ForumReply;
|
||||
use App\Models\ForumThread;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 统一审核队列:文章投稿 / 论坛帖子 / 论坛回复 的待审内容。
|
||||
* 入口按权限展示对应 Tab(articles.moderate / forum.moderate)。
|
||||
*/
|
||||
class ModerationController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$tab = $request->query('tab', 'articles');
|
||||
|
||||
$canArticles = $user->canPerm('articles.moderate');
|
||||
$canForum = $user->canPerm('forum.moderate');
|
||||
|
||||
if (! $canArticles && ! $canForum) {
|
||||
abort(403, '无审核权限');
|
||||
}
|
||||
|
||||
if ($tab === 'threads' && ! $canForum) {
|
||||
$tab = $canArticles ? 'articles' : 'replies';
|
||||
}
|
||||
if ($tab === 'replies' && ! $canForum) {
|
||||
$tab = $canArticles ? 'articles' : 'threads';
|
||||
}
|
||||
|
||||
$articles = $canArticles
|
||||
? Article::where('status', 'pending')->with('user', 'category')->orderByDesc('created_at')->paginate(10, ['*'], 'ap')
|
||||
: null;
|
||||
|
||||
$threads = $canForum
|
||||
? ForumThread::where('status', 'pending')->with('user', 'board')->orderByDesc('created_at')->paginate(10, ['*'], 'tp')
|
||||
: null;
|
||||
|
||||
$replies = $canForum
|
||||
? ForumReply::where('status', 'pending')->with('user', 'thread')->orderByDesc('created_at')->paginate(10, ['*'], 'rp')
|
||||
: null;
|
||||
|
||||
return view('admin.moderation.index', compact('tab', 'articles', 'threads', 'replies', 'canArticles', 'canForum'));
|
||||
}
|
||||
|
||||
public function approveArticle(Article $article)
|
||||
{
|
||||
$article->update([
|
||||
'status' => 'approved',
|
||||
'published_at' => $article->published_at ?? now(),
|
||||
]);
|
||||
|
||||
return back()->with('success', "已通过文章《{$article->title}》");
|
||||
}
|
||||
|
||||
public function rejectArticle(Article $article)
|
||||
{
|
||||
$article->update(['status' => 'rejected']);
|
||||
|
||||
return back()->with('success', "已驳回文章《{$article->title}》");
|
||||
}
|
||||
|
||||
public function approveThread(ForumThread $thread)
|
||||
{
|
||||
$thread->update(['status' => ForumThread::STATUS_APPROVED]);
|
||||
|
||||
return back()->with('success', "已通过帖子《{$thread->title}》");
|
||||
}
|
||||
|
||||
public function rejectThread(ForumThread $thread)
|
||||
{
|
||||
$thread->update(['status' => ForumThread::STATUS_REJECTED]);
|
||||
|
||||
return back()->with('success', "已驳回帖子《{$thread->title}》");
|
||||
}
|
||||
|
||||
public function approveReply(ForumReply $reply)
|
||||
{
|
||||
$reply->update(['status' => ForumReply::STATUS_APPROVED]);
|
||||
|
||||
return back()->with('success', '已通过该回复');
|
||||
}
|
||||
|
||||
public function rejectReply(ForumReply $reply)
|
||||
{
|
||||
$reply->update(['status' => ForumReply::STATUS_REJECTED]);
|
||||
|
||||
return back()->with('success', '已驳回该回复');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\News;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewsController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$news = News::orderByDesc('published_at')->paginate(20);
|
||||
|
||||
return view('admin.news.index', compact('news'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.news.form', ['news' => null]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => ['required', 'string', 'max:160'],
|
||||
'slug' => ['required', 'string', 'max:160', 'unique:news,slug'],
|
||||
'category' => ['required', 'in:industry,company,event'],
|
||||
'summary' => ['nullable', 'string', 'max:255'],
|
||||
'body' => ['nullable', 'string'],
|
||||
'cover' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['required', 'in:published,draft'],
|
||||
'published_at' => ['nullable', 'date'],
|
||||
]);
|
||||
|
||||
$data['published_at'] = $data['published_at'] ?? now();
|
||||
|
||||
News::create($data);
|
||||
|
||||
return redirect()->route('admin.news.index')->with('success', '新闻已创建');
|
||||
}
|
||||
|
||||
public function edit(News $news): View
|
||||
{
|
||||
return view('admin.news.form', compact('news'));
|
||||
}
|
||||
|
||||
public function update(Request $request, News $news)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => ['required', 'string', 'max:160'],
|
||||
'slug' => ['required', 'string', 'max:160', 'unique:news,slug,' . $news->id],
|
||||
'category' => ['required', 'in:industry,company,event'],
|
||||
'summary' => ['nullable', 'string', 'max:255'],
|
||||
'body' => ['nullable', 'string'],
|
||||
'cover' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['required', 'in:published,draft'],
|
||||
'published_at' => ['nullable', 'date'],
|
||||
]);
|
||||
|
||||
$news->update($data);
|
||||
|
||||
return redirect()->route('admin.news.index')->with('success', '新闻已更新');
|
||||
}
|
||||
|
||||
public function destroy(News $news)
|
||||
{
|
||||
$news->delete();
|
||||
|
||||
return redirect()->route('admin.news.index')->with('success', '新闻已删除');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$products = Product::with('category')->orderBy('sort')->orderBy('name')->paginate(20);
|
||||
|
||||
return view('admin.products.index', compact('products'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$categories = Category::ofType('product')->orderBy('sort')->orderBy('name')->get();
|
||||
|
||||
return view('admin.products.form', ['product' => null, 'categories' => $categories]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'category_id' => ['nullable', 'exists:categories,id'],
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'slug' => ['required', 'string', 'max:120', 'unique:products,slug'],
|
||||
'model' => ['nullable', 'string', 'max:60'],
|
||||
'summary' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'specs' => ['nullable', 'string'],
|
||||
'cover' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['required', 'in:published,draft'],
|
||||
'sort' => ['integer'],
|
||||
]);
|
||||
|
||||
Product::create($data);
|
||||
|
||||
return redirect()->route('admin.products.index')->with('success', '产品已创建');
|
||||
}
|
||||
|
||||
public function edit(Product $product): View
|
||||
{
|
||||
$categories = Category::ofType('product')->orderBy('sort')->orderBy('name')->get();
|
||||
|
||||
return view('admin.products.form', compact('product', 'categories'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Product $product)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'category_id' => ['nullable', 'exists:categories,id'],
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'slug' => ['required', 'string', 'max:120', 'unique:products,slug,' . $product->id],
|
||||
'model' => ['nullable', 'string', 'max:60'],
|
||||
'summary' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'specs' => ['nullable', 'string'],
|
||||
'cover' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['required', 'in:published,draft'],
|
||||
'sort' => ['integer'],
|
||||
]);
|
||||
|
||||
$product->update($data);
|
||||
|
||||
return redirect()->route('admin.products.index')->with('success', '产品已更新');
|
||||
}
|
||||
|
||||
public function destroy(Product $product)
|
||||
{
|
||||
$product->delete();
|
||||
|
||||
return redirect()->route('admin.products.index')->with('success', '产品已删除');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$settings = Setting::pluck('value', 'key')->toArray();
|
||||
|
||||
return view('admin.settings', compact('settings'));
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'site_name' => ['required', 'string', 'max:80'],
|
||||
'site_description' => ['nullable', 'string', 'max:255'],
|
||||
'seo_title_default' => ['nullable', 'string', 'max:160'],
|
||||
'seo_description_default' => ['nullable', 'string', 'max:255'],
|
||||
'icp_no' => ['nullable', 'string', 'max:60'],
|
||||
'security_filing_no' => ['nullable', 'string', 'max:60'],
|
||||
'logo_url' => ['nullable', 'string', 'max:255'],
|
||||
'favicon_url' => ['nullable', 'string', 'max:255'],
|
||||
'contact_email' => ['nullable', 'email', 'max:120'],
|
||||
'theme' => ['nullable', 'string', 'in:azure,slate,indigo,teal,amber'],
|
||||
'footer_content' => ['nullable', 'string', 'max:20000'],
|
||||
]);
|
||||
|
||||
$seoKeys = ['site_description', 'seo_title_default', 'seo_description_default'];
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
$group = in_array($key, $seoKeys, true) ? 'seo' : 'general';
|
||||
Setting::set($key, $value ?: null, $group);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.settings')->with('success', '站点设置已保存');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Solution;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SolutionController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$solutions = Solution::orderBy('sort')->orderBy('name')->paginate(20);
|
||||
|
||||
return view('admin.solutions.index', compact('solutions'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.solutions.form', ['solution' => null]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'slug' => ['required', 'string', 'max:120', 'unique:solutions,slug'],
|
||||
'power_segment' => ['nullable', 'in:lt30,30-65,65-100,gt100'],
|
||||
'application' => ['nullable', 'in:charger,powerbank,car,dock'],
|
||||
'chip_platform' => ['nullable', 'in:protocol,soc,rectifier'],
|
||||
'power' => ['nullable', 'string', 'max:40'],
|
||||
'summary' => ['nullable', 'string', 'max:255'],
|
||||
'advantages' => ['nullable', 'string'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'cover' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['required', 'in:published,draft'],
|
||||
'sort' => ['integer'],
|
||||
]);
|
||||
|
||||
Solution::create($data);
|
||||
|
||||
return redirect()->route('admin.solutions.index')->with('success', '方案已创建');
|
||||
}
|
||||
|
||||
public function edit(Solution $solution): View
|
||||
{
|
||||
return view('admin.solutions.form', compact('solution'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Solution $solution)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'slug' => ['required', 'string', 'max:120', 'unique:solutions,slug,' . $solution->id],
|
||||
'power_segment' => ['nullable', 'in:lt30,30-65,65-100,gt100'],
|
||||
'application' => ['nullable', 'in:charger,powerbank,car,dock'],
|
||||
'chip_platform' => ['nullable', 'in:protocol,soc,rectifier'],
|
||||
'power' => ['nullable', 'string', 'max:40'],
|
||||
'summary' => ['nullable', 'string', 'max:255'],
|
||||
'advantages' => ['nullable', 'string'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'cover' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['required', 'in:published,draft'],
|
||||
'sort' => ['integer'],
|
||||
]);
|
||||
|
||||
$solution->update($data);
|
||||
|
||||
return redirect()->route('admin.solutions.index')->with('success', '方案已更新');
|
||||
}
|
||||
|
||||
public function destroy(Solution $solution)
|
||||
{
|
||||
$solution->delete();
|
||||
|
||||
return redirect()->route('admin.solutions.index')->with('success', '方案已删除');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
|
||||
$users = User::query()
|
||||
->when($q, fn ($query) => $query->where(function ($sub) use ($q) {
|
||||
$sub->where('name', 'like', "%{$q}%")->orWhere('email', 'like', "%{$q}%");
|
||||
}))
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.users.index', compact('users', 'q'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建用户表单(角色 + 权限分配)。
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
$roles = config('permissions.roles');
|
||||
$groups = config('permissions.groups');
|
||||
$permissions = config('permissions.permissions');
|
||||
|
||||
return view('admin.users.create', compact('roles', 'groups', 'permissions'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建用户,受层级约束:不能创建权限高于或等于自身角色的用户。
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$current = $request->user();
|
||||
|
||||
$allPermKeys = implode(',', array_keys(config('permissions.permissions')));
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:60'],
|
||||
'email' => ['required', 'email', 'unique:users,email'],
|
||||
'password' => ['required', 'string', 'min:8'],
|
||||
'role' => ['required', 'in:super_admin,admin,forum_admin,staff,user'],
|
||||
'permissions' => ['nullable', 'array'],
|
||||
'permissions.*' => ['string', "in:{$allPermKeys}"],
|
||||
]);
|
||||
|
||||
// 层级防护:不能创建高于或等于自身权限的角色
|
||||
if (! $current->isSuperAdmin() && $current->roleLevel() <= $this->roleLevelOf($data['role'])) {
|
||||
return back()->withErrors(['role' => '您只能创建权限低于自身的用户'])->withInput();
|
||||
}
|
||||
if ($data['role'] === 'super_admin' && ! $current->isSuperAdmin()) {
|
||||
return back()->withErrors(['role' => '只有超级管理员可以创建超级管理员'])->withInput();
|
||||
}
|
||||
|
||||
$permissions = $data['role'] === 'staff'
|
||||
? array_values(array_unique($data['permissions'] ?? []))
|
||||
: null;
|
||||
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => Hash::make($data['password']),
|
||||
'role' => $data['role'],
|
||||
'is_admin' => $data['role'] !== 'user',
|
||||
'permissions' => $permissions,
|
||||
'points' => 0,
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.users.index')
|
||||
->with('success', "已创建用户 {$user->name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑用户角色与权限(内部员工可勾选细粒度权限)。
|
||||
*/
|
||||
public function edit(User $user)
|
||||
{
|
||||
$roles = config('permissions.roles');
|
||||
$groups = config('permissions.groups');
|
||||
$permissions = config('permissions.permissions');
|
||||
$userPerms = is_array($user->permissions) ? $user->permissions : [];
|
||||
|
||||
$canManage = $this->canManage($user);
|
||||
|
||||
return view('admin.users.edit', compact('user', 'roles', 'groups', 'permissions', 'userPerms', 'canManage'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户角色与权限,受层级约束。
|
||||
*/
|
||||
public function update(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$current = $request->user();
|
||||
|
||||
// 防锁死:禁止改动自己
|
||||
if ($user->id === $current->id) {
|
||||
return back()->withErrors(['role' => '不能修改当前登录账号的角色/权限']);
|
||||
}
|
||||
|
||||
// 层级防护:非超管不能触碰同级或更高权限用户
|
||||
if (! $current->isSuperAdmin() && $current->roleLevel() <= $user->roleLevel()) {
|
||||
return back()->withErrors(['role' => '您只能修改权限低于自身的用户']);
|
||||
}
|
||||
if ($user->isSuperAdmin() && ! $current->isSuperAdmin()) {
|
||||
return back()->withErrors(['role' => '只有超级管理员可以调整超级管理员']);
|
||||
}
|
||||
|
||||
$allPermKeys = implode(',', array_keys(config('permissions.permissions')));
|
||||
$data = $request->validate([
|
||||
'role' => ['required', 'in:super_admin,admin,forum_admin,staff,user'],
|
||||
'permissions' => ['nullable', 'array'],
|
||||
'permissions.*' => ['string', "in:{$allPermKeys}"],
|
||||
]);
|
||||
|
||||
// 越权防护:不能分配高于或等于自身权限的角色
|
||||
if ($data['role'] === 'super_admin' && ! $current->isSuperAdmin()) {
|
||||
return back()->withErrors(['role' => '只有超级管理员可以分配超级管理员角色']);
|
||||
}
|
||||
if (! $current->isSuperAdmin() && $current->roleLevel() <= $this->roleLevelOf($data['role'])) {
|
||||
return back()->withErrors(['role' => '您只能分配权限低于自身的角色']);
|
||||
}
|
||||
|
||||
$permissions = $data['role'] === 'staff'
|
||||
? array_values(array_unique($data['permissions'] ?? []))
|
||||
: null;
|
||||
|
||||
$user->update([
|
||||
'role' => $data['role'],
|
||||
'is_admin' => $data['role'] !== 'user',
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.users.index')
|
||||
->with('success', "已更新 {$user->name} 的角色与权限");
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置指定用户密码(管理员代操作):可指定新密码,留空则生成随机密码并回显。
|
||||
* 受层级约束,且禁止重置当前登录账号(请用「修改密码」)。
|
||||
*/
|
||||
public function resetPassword(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$current = $request->user();
|
||||
|
||||
if ($user->id === $current->id) {
|
||||
return back()->withErrors(['password' => '不能重置当前登录账号的密码,请到「修改密码」操作']);
|
||||
}
|
||||
if (! $current->isSuperAdmin() && $current->roleLevel() <= $user->roleLevel()) {
|
||||
return back()->withErrors(['password' => '您只能重置权限低于自身的用户密码']);
|
||||
}
|
||||
if ($user->isSuperAdmin() && ! $current->isSuperAdmin()) {
|
||||
return back()->withErrors(['password' => '只有超级管理员可以重置超级管理员密码']);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'password' => ['nullable', 'string', 'min:8', 'confirmed'],
|
||||
]);
|
||||
|
||||
if (! empty($data['password'])) {
|
||||
$new = $data['password'];
|
||||
$msg = '密码已重置';
|
||||
} else {
|
||||
$new = $this->generatePassword(12);
|
||||
$msg = '密码已重置为随机密码:' . $new . '(请妥善告知用户,并建议其尽快修改)';
|
||||
}
|
||||
|
||||
$user->update(['password' => Hash::make($new)]);
|
||||
|
||||
return redirect()->route('admin.users.edit', $user)->with('success', $msg);
|
||||
}
|
||||
|
||||
public function destroy(User $user): RedirectResponse
|
||||
{
|
||||
$current = request()->user();
|
||||
|
||||
if ($user->id === $current->id) {
|
||||
return back()->withErrors(['name' => '不能删除当前登录账号']);
|
||||
}
|
||||
if (! $current->isSuperAdmin() && $current->roleLevel() <= $user->roleLevel()) {
|
||||
return back()->withErrors(['name' => '您只能删除权限低于自身的用户']);
|
||||
}
|
||||
if ($user->isSuperAdmin() && ! $current->isSuperAdmin()) {
|
||||
return back()->withErrors(['name' => '只有超级管理员可以删除超级管理员']);
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('admin.users.index')->with('success', '用户已删除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户是否可管理目标用户(用于视图显隐操作按钮)。
|
||||
*/
|
||||
protected function canManage(User $user): bool
|
||||
{
|
||||
$current = request()->user();
|
||||
if ($current->isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
return $current->roleLevel() > $user->roleLevel();
|
||||
}
|
||||
|
||||
protected function roleLevelOf(string $role): int
|
||||
{
|
||||
return (int) config("permissions.roles.{$role}.level", 1);
|
||||
}
|
||||
|
||||
protected function generatePassword(int $len): string
|
||||
{
|
||||
$pool = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
$out = '';
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$out .= $pool[random_int(0, strlen($pool) - 1)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ArticleController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$categoryId = $request->query('category');
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
|
||||
$categories = Category::ofType('article')->orderBy('sort')->orderBy('name')->get();
|
||||
|
||||
$articles = Article::published()
|
||||
->when($categoryId, fn ($query) => $query->where('category_id', $categoryId))
|
||||
->when($q, function ($query) use ($q) {
|
||||
$query->where(function ($sub) use ($q) {
|
||||
$sub->where('title', 'like', "%{$q}%")
|
||||
->orWhere('summary', 'like', "%{$q}%");
|
||||
});
|
||||
})
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
return view('articles.index', compact('articles', 'categories', 'q', 'categoryId'));
|
||||
}
|
||||
|
||||
public function show(Article $article): View
|
||||
{
|
||||
$user = request()->user();
|
||||
|
||||
// 公开:已审且已发布;作者本人或审核员:可看自己投稿(含待审/驳回)
|
||||
$canView = $article->status === 'approved' && $article->published_at !== null;
|
||||
if (! $canView && $user) {
|
||||
$canView = $article->user_id === $user->id || $user->canPerm('articles.moderate');
|
||||
}
|
||||
if (! $canView) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('articles.show', compact('article'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户投稿表单(需登录)。
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
$categories = Category::ofType('article')->orderBy('sort')->orderBy('name')->get();
|
||||
|
||||
return view('articles.submit', compact('categories'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交用户投稿:写入 articles(user_id + status=pending),审核前仅作者可见。
|
||||
*/
|
||||
public function storeSubmission(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'category_id' => ['nullable', 'exists:categories,id'],
|
||||
'title' => ['required', 'string', 'max:160'],
|
||||
'summary' => ['nullable', 'string', 'max:255'],
|
||||
'body' => ['required', 'string'],
|
||||
'template' => ['required', 'in:standard,tech,feature'],
|
||||
'cover' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$data['body'] = \Purifier::clean($data['body'], 'article');
|
||||
|
||||
$base = Str::slug(Str::ascii($data['title'])) ?: ('article-' . date('YmdHis'));
|
||||
$slug = $base;
|
||||
$i = 1;
|
||||
// withTrashed:软删除行仍占用 slug 唯一索引,须一并检查避免 Duplicate entry
|
||||
while (Article::withTrashed()->where('slug', $slug)->exists()) {
|
||||
$slug = $base . '-' . $i++;
|
||||
}
|
||||
|
||||
Article::create([
|
||||
'category_id' => $data['category_id'] ?? null,
|
||||
'user_id' => Auth::id(),
|
||||
'title' => $data['title'],
|
||||
'slug' => $slug,
|
||||
'summary' => $data['summary'] ?? null,
|
||||
'body' => $data['body'],
|
||||
'template' => $data['template'],
|
||||
'cover' => $data['cover'] ?? null,
|
||||
'author' => Auth::user()->name,
|
||||
'status' => 'pending',
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
return redirect()->route('articles.mine')
|
||||
->with('success', '投稿已提交,管理员审核通过后将公开展示');
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的投稿:当前用户自己的全部文章(含待审/驳回)。
|
||||
*/
|
||||
public function mine(): View
|
||||
{
|
||||
$articles = Article::where('user_id', Auth::id())
|
||||
->orderByDesc('created_at')
|
||||
->paginate(10);
|
||||
|
||||
return view('articles.mine', compact('articles'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 管理员独立登录:与普通用户登录(/login)分离到不同页面。
|
||||
* 仅允许具备后台角色(isAdmin)的账户登录,其余账户直接拒绝。
|
||||
* 含图形验证码 + 10 分钟 5 次错误锁定的人机/防暴破管控。
|
||||
*/
|
||||
class AdminLoginController extends Controller
|
||||
{
|
||||
protected const MAX_ATTEMPTS = 5;
|
||||
protected const DECAY_SECONDS = 600;
|
||||
|
||||
public function show(): View
|
||||
{
|
||||
return view('auth.admin-login');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$key = $this->throttleKey($request);
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, self::MAX_ATTEMPTS)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
$request->session()->forget('captcha');
|
||||
throw ValidationException::withMessages([
|
||||
'email' => '登录尝试过于频繁,出于安全考虑已临时锁定。请在约 '.ceil($seconds / 60).' 分钟('.ceil($seconds).' 秒)后重试。',
|
||||
]);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
'captcha' => ['required', 'string', function ($attribute, $value, $fail) use ($request) {
|
||||
if (strtolower(trim((string) $value)) !== strtolower((string) $request->session()->get('captcha', ''))) {
|
||||
$fail('图形验证码不正确,请重新输入。');
|
||||
}
|
||||
}],
|
||||
]);
|
||||
|
||||
if (! Auth::attempt(['email' => $data['email'], 'password' => $data['password']], $request->boolean('remember'))) {
|
||||
RateLimiter::hit($key, self::DECAY_SECONDS);
|
||||
$attempts = RateLimiter::attempts($key);
|
||||
$left = max(0, self::MAX_ATTEMPTS - $attempts);
|
||||
$request->session()->forget('captcha');
|
||||
|
||||
$msg = '账号或密码不正确。';
|
||||
if ($left > 0) {
|
||||
$msg .= "(已错误 {$attempts} 次,再错 {$left} 次将锁定 10 分钟)";
|
||||
} else {
|
||||
$msg .= '(错误次数过多,已锁定 10 分钟)';
|
||||
}
|
||||
|
||||
return back()->withErrors([
|
||||
'email' => $msg,
|
||||
])->onlyInput('email');
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->isAdmin()) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
$request->session()->forget('captcha');
|
||||
|
||||
return back()->withErrors([
|
||||
'email' => '该账户没有后台管理权限。',
|
||||
])->onlyInput('email');
|
||||
}
|
||||
|
||||
RateLimiter::clear($key);
|
||||
$request->session()->forget('captcha');
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended('/admin');
|
||||
}
|
||||
|
||||
protected function throttleKey(Request $request): string
|
||||
{
|
||||
return 'admin-login:'.strtolower($request->input('email', '')).':'.$request->ip();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
/**
|
||||
* 图形验证码(轻量、零依赖,基于 GD 绘制)。
|
||||
* - GET /captcha 生成一张 4 位字符的图片,并把答案写入 session('captcha')。
|
||||
* - 登录接口校验用户输入与 session 中的值(大小写不敏感),校验后清空,防止复用。
|
||||
* - 仅用于「人/机」区分,配合登录限流构成基础安全管控。
|
||||
*/
|
||||
class CaptchaController extends Controller
|
||||
{
|
||||
public function show(Request $request)
|
||||
{
|
||||
$code = $this->generateCode(4);
|
||||
Session::put('captcha', $code);
|
||||
|
||||
$width = 120;
|
||||
$height = 44;
|
||||
$img = imagecreatetruecolor($width, $height);
|
||||
|
||||
// 背景
|
||||
$bg = imagecolorallocate($img, 244, 247, 250);
|
||||
imagefilledrectangle($img, 0, 0, $width, $height, $bg);
|
||||
|
||||
// 干扰线
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$c = imagecolorallocate($img, rand(160, 210), rand(160, 210), rand(160, 210));
|
||||
imageline($img, rand(0, $width), rand(0, $height), rand(0, $width), rand(0, $height), $c);
|
||||
}
|
||||
|
||||
// 字符(随机色 + 轻微纵向抖动)
|
||||
$palette = [[74, 138, 244], [110, 140, 176], [43, 182, 164], [123, 126, 240], [200, 120, 60]];
|
||||
$len = strlen($code);
|
||||
$step = (int) ($width / ($len + 1));
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$col = $palette[array_rand($palette)];
|
||||
$c = imagecolorallocate($img, $col[0], $col[1], $col[2]);
|
||||
$x = $step * ($i + 1) - 10 + rand(-3, 3);
|
||||
$y = rand(8, 18);
|
||||
imagestring($img, 5, $x, $y, $code[$i], $c);
|
||||
}
|
||||
|
||||
// 噪点
|
||||
for ($i = 0; $i < 50; $i++) {
|
||||
$c = imagecolorallocate($img, rand(180, 225), rand(180, 225), rand(180, 225));
|
||||
imagesetpixel($img, rand(0, $width), rand(0, $height), $c);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
imagepng($img);
|
||||
$data = ob_get_clean();
|
||||
imagedestroy($img);
|
||||
|
||||
return response($data, 200)
|
||||
->header('Content-Type', 'image/png')
|
||||
->header('Cache-Control', 'no-store, no-cache, must-revalidate, private')
|
||||
->header('Pragma', 'no-cache');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成验证码字符(去除易混淆的 0/O/1/I/L)。
|
||||
*/
|
||||
protected function generateCode(int $len): string
|
||||
{
|
||||
$pool = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
$out = '';
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$out .= $pool[random_int(0, strlen($pool) - 1)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
// 限流参数:10 分钟内最多 5 次错误,超出后锁定至窗口结束(≈10 分钟)
|
||||
protected const MAX_ATTEMPTS = 5;
|
||||
protected const DECAY_SECONDS = 600;
|
||||
|
||||
public function show(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$key = $this->throttleKey($request);
|
||||
|
||||
// 锁定检查:10 分钟内错误满 5 次即锁定,提示等待时长
|
||||
if (RateLimiter::tooManyAttempts($key, self::MAX_ATTEMPTS)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
$request->session()->forget('captcha');
|
||||
throw ValidationException::withMessages([
|
||||
'email' => '登录尝试过于频繁,出于安全考虑已临时锁定。请在约 '.ceil($seconds / 60).' 分钟('.ceil($seconds).' 秒)后重试。',
|
||||
]);
|
||||
}
|
||||
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
'captcha' => ['required', 'string', function ($attribute, $value, $fail) use ($request) {
|
||||
if (strtolower(trim((string) $value)) !== strtolower((string) $request->session()->get('captcha', ''))) {
|
||||
$fail('图形验证码不正确,请重新输入。');
|
||||
}
|
||||
}],
|
||||
]);
|
||||
|
||||
if (Auth::attempt(['email' => $credentials['email'], 'password' => $credentials['password']], $request->boolean('remember'))) {
|
||||
RateLimiter::clear($key);
|
||||
$request->session()->forget('captcha');
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended('/');
|
||||
}
|
||||
|
||||
// 失败:记录一次错误(10 分钟滚动窗口),并给出剩余次数提醒
|
||||
RateLimiter::hit($key, self::DECAY_SECONDS);
|
||||
$attempts = RateLimiter::attempts($key);
|
||||
$left = max(0, self::MAX_ATTEMPTS - $attempts);
|
||||
$request->session()->forget('captcha');
|
||||
|
||||
$msg = '账号或密码不正确。';
|
||||
if ($left > 0) {
|
||||
$msg .= "(已错误 {$attempts} 次,再错 {$left} 次将锁定 10 分钟)";
|
||||
} else {
|
||||
$msg .= '(错误次数过多,已锁定 10 分钟)';
|
||||
}
|
||||
|
||||
return back()->withErrors([
|
||||
'email' => $msg,
|
||||
])->onlyInput('email');
|
||||
}
|
||||
|
||||
protected function throttleKey(Request $request): string
|
||||
{
|
||||
return 'login:'.strtolower($request->input('email', '')).':'.$request->ip();
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录。定义为控制器方法而非闭包,
|
||||
* 以保证生产环境 `php artisan route:cache` 可用。
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
public function show(): View
|
||||
{
|
||||
return view('auth.register');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:40'],
|
||||
'email' => ['required', 'email', 'max:120', 'unique:users,email'],
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
'real_name' => ['nullable', 'string', 'max:40'],
|
||||
'phone' => ['nullable', 'string', 'max:30'],
|
||||
'company' => ['nullable', 'string', 'max:120'],
|
||||
'industry' => ['nullable', 'string', 'max:60'],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => $data['password'],
|
||||
'real_name' => $data['real_name'] ?? null,
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'company' => $data['company'] ?? null,
|
||||
'industry' => $data['industry'] ?? null,
|
||||
]);
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
$user->awardPoints(20);
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
return view('auth.settings');
|
||||
}
|
||||
|
||||
public function updatePassword(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', 'confirmed', Password::min(8)],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($data['password']),
|
||||
]);
|
||||
|
||||
return redirect()->route('auth.settings')->with('success', '密码已修改');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ContactMessage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ContactController extends Controller
|
||||
{
|
||||
public function show(): View
|
||||
{
|
||||
return view('contact');
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse|RedirectResponse
|
||||
{
|
||||
$key = 'contact:'.$request->ip();
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, 5, 60)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
$msg = "提交过于频繁,请 {$seconds} 秒后再试。";
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['ok' => false, 'message' => $msg], 429);
|
||||
}
|
||||
|
||||
return back()->withErrors(['message' => $msg])->withInput();
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:40'],
|
||||
'email' => ['required', 'email', 'max:120'],
|
||||
'phone' => ['nullable', 'string', 'max:20', 'regex:/^[0-9+\-()\s]+$/'],
|
||||
'company' => ['nullable', 'string', 'max:120'],
|
||||
'subject' => ['required', 'string', 'max:80'],
|
||||
'message' => ['required', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
ContactMessage::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'company' => $data['company'] ?? null,
|
||||
'subject' => $data['subject'],
|
||||
'message' => $data['message'],
|
||||
'ip' => $request->ip(),
|
||||
]);
|
||||
|
||||
RateLimiter::hit($key, 60);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['ok' => true, 'message' => '提交成功,我们会尽快与您联系。']);
|
||||
}
|
||||
|
||||
return back()->with('success', '提交成功,我们会尽快与您联系。');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class Controller extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ForumBoard;
|
||||
use App\Models\ForumReply;
|
||||
use App\Models\ForumThread;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ForumController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$boards = ForumBoard::orderBy('sort')->orderBy('name')
|
||||
->withCount(['threads' => fn ($q) => $q->where('status', ForumThread::STATUS_APPROVED)])
|
||||
->get();
|
||||
|
||||
return view('forum.index', compact('boards'));
|
||||
}
|
||||
|
||||
public function board(ForumBoard $board): View
|
||||
{
|
||||
$threads = $board->threads()
|
||||
->visible(Auth::user())
|
||||
->with('user')
|
||||
->orderByDesc('is_pinned')
|
||||
->orderByDesc('last_reply_at')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(15);
|
||||
|
||||
return view('forum.board', compact('board', 'threads'));
|
||||
}
|
||||
|
||||
public function thread(ForumThread $thread): View
|
||||
{
|
||||
if ($thread->trashed()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$isMod = $user && $user->canPerm('forum.moderate');
|
||||
$isOwner = $user && $thread->user_id === $user->id;
|
||||
|
||||
// 待审且非作者、非审核员:不可见
|
||||
if ($thread->status !== ForumThread::STATUS_APPROVED && ! $isMod && ! $isOwner) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$thread->increment('views');
|
||||
|
||||
$replies = $thread->replies()
|
||||
->visible($user)
|
||||
->with('user')
|
||||
->orderBy('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('forum.thread', compact('thread', 'replies'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'board_id' => ['required', 'exists:forum_boards,id'],
|
||||
'title' => ['required', 'string', 'max:120'],
|
||||
'body' => ['required', 'string', 'max:8000'],
|
||||
]);
|
||||
|
||||
$thread = ForumThread::create([
|
||||
'board_id' => $data['board_id'],
|
||||
'user_id' => Auth::id(),
|
||||
'title' => $data['title'],
|
||||
'body' => $data['body'],
|
||||
'status' => ForumThread::STATUS_PENDING,
|
||||
'last_reply_at' => now(),
|
||||
]);
|
||||
|
||||
$request->user()->awardPoints(10);
|
||||
|
||||
return redirect()->route('forum.thread', $thread)
|
||||
->with('success', '帖子已提交,管理员审核通过后将公开展示');
|
||||
}
|
||||
|
||||
public function reply(Request $request, ForumThread $thread)
|
||||
{
|
||||
if ($thread->is_locked) {
|
||||
return back()->withErrors(['body' => '该帖子已锁定,无法回复。']);
|
||||
}
|
||||
|
||||
// 待审帖子的回复入口对普通用户不可见(thread() 已拦截),此处仅作兜底
|
||||
if ($thread->status !== ForumThread::STATUS_APPROVED && ! Auth::user()?->canPerm('forum.moderate')) {
|
||||
return back()->withErrors(['body' => '该帖子尚未通过审核,暂不可回复。']);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'body' => ['required', 'string', 'max:8000'],
|
||||
]);
|
||||
|
||||
ForumReply::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => Auth::id(),
|
||||
'body' => $data['body'],
|
||||
'status' => ForumReply::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
$thread->update(['last_reply_at' => now()]);
|
||||
|
||||
$request->user()->awardPoints(5);
|
||||
|
||||
return back()->with('success', '回复已提交,审核通过后将公开展示');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\GlossaryTerm;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class GlossaryController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
|
||||
$terms = GlossaryTerm::ordered()
|
||||
->when($q, function ($query) use ($q) {
|
||||
$query->where(function ($sub) use ($q) {
|
||||
$sub->where('term', 'like', "%{$q}%")
|
||||
->orWhere('definition', 'like', "%{$q}%")
|
||||
->orWhere('aliases', 'like', "%{$q}%");
|
||||
});
|
||||
})
|
||||
->paginate(20)
|
||||
->withQueryString();
|
||||
|
||||
return view('glossary.index', compact('terms', 'q'));
|
||||
}
|
||||
|
||||
public function show(GlossaryTerm $glossaryTerm): View
|
||||
{
|
||||
return view('glossary.show', compact('glossaryTerm'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class HealthController extends Controller
|
||||
{
|
||||
/**
|
||||
* 健康检查端点,供部署脚本与监控探针使用。
|
||||
* 定义为控制器而非闭包,保证 route:cache 无隐患。
|
||||
*/
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$dbOk = true;
|
||||
try {
|
||||
DB::connection()->getPdo();
|
||||
} catch (\Throwable) {
|
||||
$dbOk = false;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => $dbOk ? 'ok' : 'degraded',
|
||||
'database' => $dbOk ? 'up' : 'down',
|
||||
'time' => now()->toIso8601String(),
|
||||
], $dbOk ? 200 : 503);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\News;
|
||||
use App\Models\Product;
|
||||
use App\Models\Solution;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$products = Product::published()->take(3)->get();
|
||||
|
||||
$articles = Article::published()->take(3)->get();
|
||||
|
||||
$solutions = Solution::published()->orderBy('sort')->take(4)->get();
|
||||
|
||||
$news = News::published()->take(3)->get();
|
||||
|
||||
// 首页 Hero 轮播(后台「首页轮播」管理;mode=single 时为独立单图)
|
||||
$heroMode = \App\Models\Setting::get('hero_mode', 'carousel');
|
||||
$heroSlides = \App\Models\HeroSlide::location('home')->active()->ordered()->get();
|
||||
|
||||
return view('home', compact('products', 'articles', 'solutions', 'news', 'heroSlides', 'heroMode'));
|
||||
}
|
||||
|
||||
public function about(): View
|
||||
{
|
||||
return view('about');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\News;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewsController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$category = $request->query('category');
|
||||
|
||||
$news = News::published()
|
||||
->when($category, fn ($q) => $q->where('category', $category))
|
||||
->paginate(9)
|
||||
->withQueryString();
|
||||
|
||||
return view('news.index', compact('news', 'category'));
|
||||
}
|
||||
|
||||
public function show(News $news): View
|
||||
{
|
||||
if ($news->status !== 'published' || $news->published_at === null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('news.show', compact('news'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$categoryId = $request->query('category');
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
|
||||
$categories = Category::ofType('product')->orderBy('sort')->orderBy('name')->get();
|
||||
|
||||
$products = Product::published()
|
||||
->when($categoryId, fn ($query) => $query->where('category_id', $categoryId))
|
||||
->when($q, function ($query) use ($q) {
|
||||
$query->where(function ($sub) use ($q) {
|
||||
$sub->where('name', 'like', "%{$q}%")
|
||||
->orWhere('model', 'like', "%{$q}%")
|
||||
->orWhere('summary', 'like', "%{$q}%");
|
||||
});
|
||||
})
|
||||
->paginate(12)
|
||||
->withQueryString();
|
||||
|
||||
return view('products.index', compact('products', 'categories', 'q', 'categoryId'));
|
||||
}
|
||||
|
||||
public function show(Product $product): View
|
||||
{
|
||||
if ($product->status !== 'published') {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('products.show', compact('product'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\ForumThread;
|
||||
use App\Models\GlossaryTerm;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
/**
|
||||
* 全站搜索:跨 文章 / 产品 / 术语库 / 技术论坛 检索。
|
||||
* 各数据源遵循其前台可见性作用域(游客仅看已审内容)。
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
$user = $request->user();
|
||||
|
||||
$results = [
|
||||
'articles' => collect(),
|
||||
'products' => collect(),
|
||||
'glossary' => collect(),
|
||||
'threads' => collect(),
|
||||
];
|
||||
$total = 0;
|
||||
|
||||
if ($q !== '') {
|
||||
$like = '%' . $q . '%';
|
||||
|
||||
$results['articles'] = Article::visiblePublic()
|
||||
->where(function ($query) use ($like) {
|
||||
$query->where('title', 'like', $like)
|
||||
->orWhere('summary', 'like', $like)
|
||||
->orWhere('body', 'like', $like);
|
||||
})
|
||||
->orderByDesc('published_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$results['products'] = Product::published()
|
||||
->where(function ($query) use ($like) {
|
||||
$query->where('name', 'like', $like)
|
||||
->orWhere('summary', 'like', $like)
|
||||
->orWhere('description', 'like', $like)
|
||||
->orWhere('model', 'like', $like);
|
||||
})
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$results['glossary'] = GlossaryTerm::query()
|
||||
->where(function ($query) use ($like) {
|
||||
$query->where('term', 'like', $like)
|
||||
->orWhere('definition', 'like', $like)
|
||||
->orWhere('aliases', 'like', $like);
|
||||
})
|
||||
->orderBy('term')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$results['threads'] = ForumThread::visible($user)
|
||||
->where(function ($query) use ($like) {
|
||||
$query->where('title', 'like', $like)
|
||||
->orWhere('body', 'like', $like);
|
||||
})
|
||||
->orderByDesc('last_reply_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$total = $results['articles']->count()
|
||||
+ $results['products']->count()
|
||||
+ $results['glossary']->count()
|
||||
+ $results['threads']->count();
|
||||
}
|
||||
|
||||
return view('search.index', compact('q', 'results', 'total'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\GlossaryTerm;
|
||||
use App\Models\Product;
|
||||
use App\Models\Solution;
|
||||
use App\Models\News;
|
||||
|
||||
class SitemapController extends Controller
|
||||
{
|
||||
/**
|
||||
* 机读站点地图(SEO:供百度 / Google 等搜索引擎提交)。
|
||||
* 访问 /sitemap.xml 获取。
|
||||
*/
|
||||
public function xml()
|
||||
{
|
||||
$urls = [];
|
||||
|
||||
// ── 核心静态页面 ──────────────────────────────
|
||||
$core = [
|
||||
'/' => ['freq' => 'daily', 'pri' => '1.0'],
|
||||
'/about' => ['freq' => 'monthly', 'pri' => '0.8'],
|
||||
'/products' => ['freq' => 'weekly', 'pri' => '0.8'],
|
||||
'/articles' => ['freq' => 'weekly', 'pri' => '0.8'],
|
||||
'/solutions' => ['freq' => 'weekly', 'pri' => '0.8'],
|
||||
'/news' => ['freq' => 'weekly', 'pri' => '0.8'],
|
||||
'/glossary' => ['freq' => 'weekly', 'pri' => '0.8'],
|
||||
'/forum' => ['freq' => 'daily', 'pri' => '0.7'],
|
||||
'/contact' => ['freq' => 'yearly', 'pri' => '0.6'],
|
||||
'/sitemap' => ['freq' => 'monthly', 'pri' => '0.3'],
|
||||
];
|
||||
|
||||
foreach ($core as $path => $meta) {
|
||||
$urls[] = [
|
||||
'loc' => url($path),
|
||||
'freq' => $meta['freq'],
|
||||
'pri' => $meta['pri'],
|
||||
];
|
||||
}
|
||||
|
||||
// ── 动态内容:产品 ────────────────────────────
|
||||
foreach (Product::published()->get() as $p) {
|
||||
$urls[] = [
|
||||
'loc' => route('products.show', $p->slug),
|
||||
'last' => $p->updated_at?->toW3CString(),
|
||||
'freq' => 'weekly',
|
||||
'pri' => '0.7',
|
||||
];
|
||||
}
|
||||
|
||||
// ── 动态内容:技术文章 ────────────────────────
|
||||
foreach (Article::published()->get() as $a) {
|
||||
$urls[] = [
|
||||
'loc' => route('articles.show', $a->slug),
|
||||
'last' => $a->published_at?->toW3CString(),
|
||||
'freq' => 'monthly',
|
||||
'pri' => '0.6',
|
||||
];
|
||||
}
|
||||
|
||||
// ── 动态内容:术语库 ──────────────────────────
|
||||
foreach (GlossaryTerm::ordered()->get() as $g) {
|
||||
$urls[] = [
|
||||
'loc' => route('glossary.show', $g->slug),
|
||||
'freq' => 'monthly',
|
||||
'pri' => '0.4',
|
||||
];
|
||||
}
|
||||
|
||||
// ── 动态内容:解决方案 ────────────────────────
|
||||
foreach (Solution::published()->get() as $s) {
|
||||
$urls[] = [
|
||||
'loc' => route('solutions.show', $s->slug),
|
||||
'last' => $s->updated_at?->toW3CString(),
|
||||
'freq' => 'weekly',
|
||||
'pri' => '0.7',
|
||||
];
|
||||
}
|
||||
|
||||
// ── 动态内容:新闻动态 ────────────────────────
|
||||
foreach (News::published()->get() as $n) {
|
||||
$urls[] = [
|
||||
'loc' => route('news.show', $n->slug),
|
||||
'last' => $n->published_at?->toW3CString(),
|
||||
'freq' => 'monthly',
|
||||
'pri' => '0.6',
|
||||
];
|
||||
}
|
||||
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
|
||||
|
||||
foreach ($urls as $u) {
|
||||
$xml .= ' <url>' . "\n";
|
||||
$xml .= ' <loc>' . e($u['loc']) . '</loc>' . "\n";
|
||||
|
||||
if (!empty($u['last'])) {
|
||||
$xml .= ' <lastmod>' . e($u['last']) . '</lastmod>' . "\n";
|
||||
}
|
||||
|
||||
$xml .= ' <changefreq>' . e($u['freq']) . '</changefreq>' . "\n";
|
||||
$xml .= ' <priority>' . e($u['pri']) . '</priority>' . "\n";
|
||||
$xml .= ' </url>' . "\n";
|
||||
}
|
||||
|
||||
$xml .= '</urlset>';
|
||||
|
||||
return response($xml, 200, [
|
||||
'Content-Type' => 'application/xml; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* robots.txt:开放前台收录,屏蔽后台与用户接口,并指向 sitemap.xml。
|
||||
* 访问 /robots.txt 获取(动态读取 APP_URL,域名随环境自动变化)。
|
||||
*/
|
||||
public function robots()
|
||||
{
|
||||
$base = rtrim(config('app.url'), '/');
|
||||
|
||||
$content = "User-agent: *\n";
|
||||
$content .= "Allow: /\n\n";
|
||||
$content .= "# 后台与管理 / 用户接口不向搜索引擎开放\n";
|
||||
$content .= "Disallow: /admin/\n";
|
||||
$content .= "Disallow: /login\n";
|
||||
$content .= "Disallow: /register\n";
|
||||
$content .= "Disallow: /logout\n";
|
||||
$content .= "Disallow: /settings\n";
|
||||
$content .= "Disallow: /upload\n";
|
||||
$content .= "Disallow: /forum/threads\n";
|
||||
$content .= "Disallow: /api/\n\n";
|
||||
$content .= "Sitemap: {$base}/sitemap.xml\n";
|
||||
|
||||
return response($content, 200, [
|
||||
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可视化「网站地图」页面(用户可见,列出全站主要页面与内容)。
|
||||
* 访问 /sitemap 获取。
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$products = Product::published()->get();
|
||||
$articles = Article::published()->get();
|
||||
$terms = GlossaryTerm::ordered()->get();
|
||||
$solutions = Solution::published()->get();
|
||||
$news = News::published()->get();
|
||||
|
||||
return view('sitemap', compact('products', 'articles', 'terms', 'solutions', 'news'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Solution;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SolutionsController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$power = $request->query('power');
|
||||
$application = $request->query('application');
|
||||
$platform = $request->query('platform');
|
||||
|
||||
$solutions = Solution::published()
|
||||
->when($power, fn ($q) => $q->where('power_segment', $power))
|
||||
->when($application, fn ($q) => $q->where('application', $application))
|
||||
->when($platform, fn ($q) => $q->where('chip_platform', $platform))
|
||||
->get();
|
||||
|
||||
return view('solutions.index', compact('solutions', 'power', 'application', 'platform'));
|
||||
}
|
||||
|
||||
public function show(Solution $solution): View
|
||||
{
|
||||
if ($solution->status !== 'published') {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('solutions.show', compact('solution'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* 通用图片上传:登录用户可用(文章正文插图、封面图)。
|
||||
* 存入 storage/app/public/uploads/{Y}/{m}/,经已建立的 storage:link 暴露为 /storage/uploads/...
|
||||
*/
|
||||
class UploadController extends Controller
|
||||
{
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'file' => ['required', 'file', 'image', 'mimes:jpg,jpeg,png,gif,webp', 'max:5120'],
|
||||
]);
|
||||
|
||||
$path = $request->file('file')->store('uploads/' . date('Y/m'), 'public');
|
||||
|
||||
return response()->json([
|
||||
'url' => Storage::disk('public')->url($path),
|
||||
'path' => $path,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user