init
This commit is contained in:
125
app/Http/Controllers/Admin/AiModelController.php
Normal file
125
app/Http/Controllers/Admin/AiModelController.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\EntityStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\AiModelRequest;
|
||||
use App\Models\AiModel;
|
||||
use App\Models\Category;
|
||||
use App\Models\Source;
|
||||
use App\Services\ChangeLogService;
|
||||
use App\Services\ModelScoringService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AiModelController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ChangeLogService $changeLogService,
|
||||
private readonly ModelScoringService $modelScoringService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$items = AiModel::query()
|
||||
->with(['category', 'source'])
|
||||
->when($request->filled('q'), function ($query) use ($request): void {
|
||||
$query->whereFullText(['name', 'summary', 'description'], (string) $request->string('q'));
|
||||
})
|
||||
->latest('updated_at')
|
||||
->paginate(20)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.models.index', [
|
||||
'items' => $items,
|
||||
'filters' => $request->only(['q']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.models.form', [
|
||||
'item' => new AiModel(),
|
||||
'categories' => Category::query()->where('type', 'model')->orderBy('name')->get(),
|
||||
'sources' => Source::query()->where('is_whitelisted', true)->orderBy('name')->get(),
|
||||
'statusOptions' => EntityStatus::cases(),
|
||||
'submitRoute' => route('admin.models.store'),
|
||||
'method' => 'POST',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(AiModelRequest $request): RedirectResponse
|
||||
{
|
||||
$item = new AiModel($this->normalizePayload($request->validated()));
|
||||
$this->modelScoringService->apply($item);
|
||||
$item->save();
|
||||
|
||||
$this->changeLogService->log('created', $item);
|
||||
|
||||
return redirect()->route('admin.models.edit', $item)->with('status', '模型已创建');
|
||||
}
|
||||
|
||||
public function edit(AiModel $model): View
|
||||
{
|
||||
return view('admin.models.form', [
|
||||
'item' => $model,
|
||||
'categories' => Category::query()->where('type', 'model')->orderBy('name')->get(),
|
||||
'sources' => Source::query()->where('is_whitelisted', true)->orderBy('name')->get(),
|
||||
'statusOptions' => EntityStatus::cases(),
|
||||
'submitRoute' => route('admin.models.update', $model),
|
||||
'method' => 'PUT',
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(AiModelRequest $request, AiModel $model): RedirectResponse
|
||||
{
|
||||
$before = $model->getAttributes();
|
||||
$model->fill($this->normalizePayload($request->validated()));
|
||||
$this->modelScoringService->apply($model);
|
||||
$model->save();
|
||||
|
||||
$this->changeLogService->log('updated', $model, $before);
|
||||
|
||||
return redirect()->route('admin.models.edit', $model)->with('status', '模型已更新');
|
||||
}
|
||||
|
||||
public function publish(AiModel $model): RedirectResponse
|
||||
{
|
||||
$before = $model->getAttributes();
|
||||
$model->status = EntityStatus::Published;
|
||||
$model->published_at = $model->published_at ?? now();
|
||||
$model->save();
|
||||
|
||||
$this->changeLogService->log('published', $model, $before);
|
||||
|
||||
return redirect()->route('admin.models.edit', $model)->with('status', '模型已发布');
|
||||
}
|
||||
|
||||
public function markStale(AiModel $model): RedirectResponse
|
||||
{
|
||||
$before = $model->getAttributes();
|
||||
$model->is_stale = true;
|
||||
$model->status = EntityStatus::Stale;
|
||||
$model->save();
|
||||
|
||||
$this->changeLogService->log('marked_stale', $model, $before);
|
||||
|
||||
return redirect()->route('admin.models.edit', $model)->with('status', '模型已标记失效');
|
||||
}
|
||||
|
||||
private function normalizePayload(array $data): array
|
||||
{
|
||||
$data['is_stale'] = (bool) ($data['is_stale'] ?? false);
|
||||
|
||||
if (($data['status'] ?? null) === EntityStatus::Published->value && empty($data['published_at'])) {
|
||||
$data['published_at'] = now();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
119
app/Http/Controllers/Admin/ArticleController.php
Normal file
119
app/Http/Controllers/Admin/ArticleController.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\EntityStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\ArticleRequest;
|
||||
use App\Models\Article;
|
||||
use App\Models\Category;
|
||||
use App\Models\Source;
|
||||
use App\Services\ChangeLogService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ArticleController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ChangeLogService $changeLogService)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$items = Article::query()
|
||||
->with(['category', 'source'])
|
||||
->when($request->filled('q'), function ($query) use ($request): void {
|
||||
$query->whereFullText(['title', 'excerpt', 'body'], (string) $request->string('q'));
|
||||
})
|
||||
->latest('updated_at')
|
||||
->paginate(20)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.articles.index', [
|
||||
'items' => $items,
|
||||
'filters' => $request->only(['q']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.articles.form', [
|
||||
'item' => new Article(),
|
||||
'categories' => Category::query()->where('type', 'news')->orderBy('name')->get(),
|
||||
'sources' => Source::query()->where('is_whitelisted', true)->orderBy('name')->get(),
|
||||
'statusOptions' => EntityStatus::cases(),
|
||||
'submitRoute' => route('admin.articles.store'),
|
||||
'method' => 'POST',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(ArticleRequest $request): RedirectResponse
|
||||
{
|
||||
$item = Article::query()->create($this->normalizePayload($request->validated()));
|
||||
|
||||
$this->changeLogService->log('created', $item);
|
||||
|
||||
return redirect()->route('admin.articles.edit', $item)->with('status', '资讯已创建');
|
||||
}
|
||||
|
||||
public function edit(Article $article): View
|
||||
{
|
||||
return view('admin.articles.form', [
|
||||
'item' => $article,
|
||||
'categories' => Category::query()->where('type', 'news')->orderBy('name')->get(),
|
||||
'sources' => Source::query()->where('is_whitelisted', true)->orderBy('name')->get(),
|
||||
'statusOptions' => EntityStatus::cases(),
|
||||
'submitRoute' => route('admin.articles.update', $article),
|
||||
'method' => 'PUT',
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(ArticleRequest $request, Article $article): RedirectResponse
|
||||
{
|
||||
$before = $article->getAttributes();
|
||||
$article->fill($this->normalizePayload($request->validated()));
|
||||
$article->save();
|
||||
|
||||
$this->changeLogService->log('updated', $article, $before);
|
||||
|
||||
return redirect()->route('admin.articles.edit', $article)->with('status', '资讯已更新');
|
||||
}
|
||||
|
||||
public function publish(Article $article): RedirectResponse
|
||||
{
|
||||
$before = $article->getAttributes();
|
||||
$article->status = EntityStatus::Published;
|
||||
$article->published_at = $article->published_at ?? now();
|
||||
$article->save();
|
||||
|
||||
$this->changeLogService->log('published', $article, $before);
|
||||
|
||||
return redirect()->route('admin.articles.edit', $article)->with('status', '资讯已发布');
|
||||
}
|
||||
|
||||
public function markStale(Article $article): RedirectResponse
|
||||
{
|
||||
$before = $article->getAttributes();
|
||||
$article->is_stale = true;
|
||||
$article->status = EntityStatus::Stale;
|
||||
$article->save();
|
||||
|
||||
$this->changeLogService->log('marked_stale', $article, $before);
|
||||
|
||||
return redirect()->route('admin.articles.edit', $article)->with('status', '资讯已标记失效');
|
||||
}
|
||||
|
||||
private function normalizePayload(array $data): array
|
||||
{
|
||||
$data['is_stale'] = (bool) ($data['is_stale'] ?? false);
|
||||
|
||||
if (($data['status'] ?? null) === EntityStatus::Published->value && empty($data['published_at'])) {
|
||||
$data['published_at'] = now();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
161
app/Http/Controllers/Admin/AuthController.php
Normal file
161
app/Http/Controllers/Admin/AuthController.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
private const LOGIN_MAX_ATTEMPTS = 5;
|
||||
|
||||
private const LOGIN_DECAY_SECONDS = 120;
|
||||
|
||||
public function showLogin(): View
|
||||
{
|
||||
return view('admin.auth.login');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function login(Request $request): RedirectResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'username' => ['required', 'string', 'max:100'],
|
||||
'password' => ['required', 'string', 'max:255'],
|
||||
'captcha' => ['required', 'string', 'size:5'],
|
||||
], [
|
||||
'username.required' => '请输入后台账号。',
|
||||
'password.required' => '请输入后台密码。',
|
||||
'captcha.required' => '请输入验证码。',
|
||||
'captcha.size' => '验证码格式不正确。',
|
||||
]);
|
||||
|
||||
$rateLimiterKey = $this->loginRateLimiterKey($request, $payload['username']);
|
||||
|
||||
if (RateLimiter::tooManyAttempts($rateLimiterKey, self::LOGIN_MAX_ATTEMPTS)) {
|
||||
$seconds = RateLimiter::availableIn($rateLimiterKey);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'username' => "尝试次数过多,请在 {$seconds} 秒后重试。",
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $this->isCaptchaValid($request, $payload['captcha'])) {
|
||||
RateLimiter::hit($rateLimiterKey, self::LOGIN_DECAY_SECONDS);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'captcha' => '验证码错误,请重试。',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $this->isCredentialValid($payload['username'], $payload['password'])) {
|
||||
RateLimiter::hit($rateLimiterKey, self::LOGIN_DECAY_SECONDS);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'username' => '账号或密码错误。',
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($rateLimiterKey);
|
||||
|
||||
$request->session()->regenerate();
|
||||
$request->session()->put('admin_authenticated', true);
|
||||
$request->session()->put('admin_username', $payload['username']);
|
||||
$request->session()->forget('admin_captcha_hash');
|
||||
|
||||
return redirect()->route('admin.dashboard');
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
$request->session()->forget(['admin_authenticated', 'admin_username', 'admin_captcha_hash']);
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('admin.login')->with('status', '已安全退出后台。');
|
||||
}
|
||||
|
||||
public function captcha(Request $request): Response
|
||||
{
|
||||
$captchaText = strtoupper(Str::random(5));
|
||||
$request->session()->put('admin_captcha_hash', hash('sha256', strtolower($captchaText)));
|
||||
|
||||
$imageWidth = 140;
|
||||
$imageHeight = 46;
|
||||
|
||||
$image = imagecreatetruecolor($imageWidth, $imageHeight);
|
||||
|
||||
$background = imagecolorallocate($image, 241, 245, 249);
|
||||
$textColor = imagecolorallocate($image, 15, 23, 42);
|
||||
$lineColor = imagecolorallocate($image, 148, 163, 184);
|
||||
$noiseColor = imagecolorallocate($image, 100, 116, 139);
|
||||
|
||||
imagefilledrectangle($image, 0, 0, $imageWidth, $imageHeight, $background);
|
||||
|
||||
for ($index = 0; $index < 4; $index++) {
|
||||
imageline(
|
||||
$image,
|
||||
random_int(0, $imageWidth),
|
||||
random_int(0, $imageHeight),
|
||||
random_int(0, $imageWidth),
|
||||
random_int(0, $imageHeight),
|
||||
$lineColor,
|
||||
);
|
||||
}
|
||||
|
||||
for ($index = 0; $index < 90; $index++) {
|
||||
imagesetpixel($image, random_int(0, $imageWidth - 1), random_int(0, $imageHeight - 1), $noiseColor);
|
||||
}
|
||||
|
||||
imagestring($image, 5, 24, 14, $captchaText, $textColor);
|
||||
|
||||
ob_start();
|
||||
imagepng($image);
|
||||
$binary = (string) ob_get_clean();
|
||||
imagedestroy($image);
|
||||
|
||||
return response($binary, 200, [
|
||||
'Content-Type' => 'image/png',
|
||||
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
|
||||
'Pragma' => 'no-cache',
|
||||
'Expires' => '0',
|
||||
]);
|
||||
}
|
||||
|
||||
private function isCredentialValid(string $username, string $password): bool
|
||||
{
|
||||
$configuredUser = (string) config('app.admin_user', 'admin');
|
||||
$configuredPassword = (string) config('app.admin_password', 'change-me');
|
||||
|
||||
return hash_equals($configuredUser, $username)
|
||||
&& hash_equals($configuredPassword, $password);
|
||||
}
|
||||
|
||||
private function isCaptchaValid(Request $request, string $input): bool
|
||||
{
|
||||
$storedHash = (string) $request->session()->get('admin_captcha_hash', '');
|
||||
|
||||
$request->session()->forget('admin_captcha_hash');
|
||||
|
||||
if ($storedHash === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($storedHash, hash('sha256', strtolower(trim($input))));
|
||||
}
|
||||
|
||||
private function loginRateLimiterKey(Request $request, string $username): string
|
||||
{
|
||||
return Str::lower($username).'|'.$request->ip();
|
||||
}
|
||||
}
|
||||
91
app/Http/Controllers/Admin/CategoryController.php
Normal file
91
app/Http/Controllers/Admin/CategoryController.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\CategoryRequest;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$items = Category::query()
|
||||
->when($request->filled('q'), function ($query) use ($request): void {
|
||||
$keyword = (string) $request->string('q');
|
||||
$query->where('name', 'like', "%{$keyword}%")
|
||||
->orWhere('slug', 'like', "%{$keyword}%")
|
||||
->orWhere('description', 'like', "%{$keyword}%");
|
||||
})
|
||||
->when($request->filled('type'), function ($query) use ($request): void {
|
||||
$query->where('type', (string) $request->string('type'));
|
||||
})
|
||||
->orderBy('type')
|
||||
->orderBy('name')
|
||||
->paginate(30)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.categories.index', [
|
||||
'items' => $items,
|
||||
'filters' => $request->only(['q', 'type']),
|
||||
'typeOptions' => $this->typeOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.categories.form', [
|
||||
'item' => new Category(),
|
||||
'submitRoute' => route('admin.categories.store'),
|
||||
'method' => 'POST',
|
||||
'typeOptions' => $this->typeOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(CategoryRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$data['is_active'] = (bool) ($data['is_active'] ?? false);
|
||||
|
||||
$category = Category::query()->create($data);
|
||||
|
||||
return redirect()->route('admin.categories.edit', $category)->with('status', '分类已创建');
|
||||
}
|
||||
|
||||
public function edit(Category $category): View
|
||||
{
|
||||
return view('admin.categories.form', [
|
||||
'item' => $category,
|
||||
'submitRoute' => route('admin.categories.update', $category),
|
||||
'method' => 'PUT',
|
||||
'typeOptions' => $this->typeOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(CategoryRequest $request, Category $category): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$data['is_active'] = (bool) ($data['is_active'] ?? false);
|
||||
|
||||
$category->fill($data);
|
||||
$category->save();
|
||||
|
||||
return redirect()->route('admin.categories.edit', $category)->with('status', '分类已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function typeOptions(): array
|
||||
{
|
||||
$defaults = ['tool', 'model', 'news', 'guide'];
|
||||
$existing = Category::query()->distinct()->pluck('type')->filter()->values()->all();
|
||||
|
||||
return array_values(array_unique([...$defaults, ...$existing]));
|
||||
}
|
||||
}
|
||||
31
app/Http/Controllers/Admin/DashboardController.php
Normal file
31
app/Http/Controllers/Admin/DashboardController.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiModel;
|
||||
use App\Models\Article;
|
||||
use App\Models\Guide;
|
||||
use App\Models\Tool;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke(): View
|
||||
{
|
||||
return view('admin.dashboard', [
|
||||
'counts' => [
|
||||
'tools' => Tool::query()->count(),
|
||||
'models' => AiModel::query()->count(),
|
||||
'articles' => Article::query()->count(),
|
||||
'guides' => Guide::query()->count(),
|
||||
],
|
||||
'recentTools' => Tool::query()->latest('updated_at')->limit(5)->get(),
|
||||
'recentModels' => AiModel::query()->latest('updated_at')->limit(5)->get(),
|
||||
'recentArticles' => Article::query()->latest('updated_at')->limit(5)->get(),
|
||||
'recentGuides' => Guide::query()->latest('updated_at')->limit(5)->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
102
app/Http/Controllers/Admin/GuideController.php
Normal file
102
app/Http/Controllers/Admin/GuideController.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\EntityStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\GuideRequest;
|
||||
use App\Models\Category;
|
||||
use App\Models\Guide;
|
||||
use App\Services\ChangeLogService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class GuideController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ChangeLogService $changeLogService)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$items = Guide::query()
|
||||
->with(['category'])
|
||||
->when($request->filled('q'), function ($query) use ($request): void {
|
||||
$query->whereFullText(['title', 'excerpt', 'body'], (string) $request->string('q'));
|
||||
})
|
||||
->latest('updated_at')
|
||||
->paginate(20)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.guides.index', [
|
||||
'items' => $items,
|
||||
'filters' => $request->only(['q']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.guides.form', [
|
||||
'item' => new Guide(),
|
||||
'categories' => Category::query()->where('type', 'guide')->orderBy('name')->get(),
|
||||
'statusOptions' => EntityStatus::cases(),
|
||||
'submitRoute' => route('admin.guides.store'),
|
||||
'method' => 'POST',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(GuideRequest $request): RedirectResponse
|
||||
{
|
||||
$item = Guide::query()->create($this->normalizePayload($request->validated()));
|
||||
|
||||
$this->changeLogService->log('created', $item);
|
||||
|
||||
return redirect()->route('admin.guides.edit', $item)->with('status', '教程已创建');
|
||||
}
|
||||
|
||||
public function edit(Guide $guide): View
|
||||
{
|
||||
return view('admin.guides.form', [
|
||||
'item' => $guide,
|
||||
'categories' => Category::query()->where('type', 'guide')->orderBy('name')->get(),
|
||||
'statusOptions' => EntityStatus::cases(),
|
||||
'submitRoute' => route('admin.guides.update', $guide),
|
||||
'method' => 'PUT',
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(GuideRequest $request, Guide $guide): RedirectResponse
|
||||
{
|
||||
$before = $guide->getAttributes();
|
||||
$guide->fill($this->normalizePayload($request->validated()));
|
||||
$guide->save();
|
||||
|
||||
$this->changeLogService->log('updated', $guide, $before);
|
||||
|
||||
return redirect()->route('admin.guides.edit', $guide)->with('status', '教程已更新');
|
||||
}
|
||||
|
||||
public function publish(Guide $guide): RedirectResponse
|
||||
{
|
||||
$before = $guide->getAttributes();
|
||||
$guide->status = EntityStatus::Published;
|
||||
$guide->published_at = $guide->published_at ?? now();
|
||||
$guide->save();
|
||||
|
||||
$this->changeLogService->log('published', $guide, $before);
|
||||
|
||||
return redirect()->route('admin.guides.edit', $guide)->with('status', '教程已发布');
|
||||
}
|
||||
|
||||
private function normalizePayload(array $data): array
|
||||
{
|
||||
if (($data['status'] ?? null) === EntityStatus::Published->value && empty($data['published_at'])) {
|
||||
$data['published_at'] = now();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
83
app/Http/Controllers/Admin/SourceController.php
Normal file
83
app/Http/Controllers/Admin/SourceController.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\SourceRequest;
|
||||
use App\Models\Source;
|
||||
use App\Services\ChangeLogService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SourceController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ChangeLogService $changeLogService)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$items = Source::query()
|
||||
->when($request->filled('q'), function ($query) use ($request): void {
|
||||
$keyword = '%'.trim((string) $request->string('q')).'%';
|
||||
$query->where('name', 'like', $keyword)->orWhere('domain', 'like', $keyword);
|
||||
})
|
||||
->orderByDesc('is_whitelisted')
|
||||
->orderBy('name')
|
||||
->paginate(30)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.sources.index', [
|
||||
'items' => $items,
|
||||
'filters' => $request->only(['q']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.sources.form', [
|
||||
'item' => new Source(),
|
||||
'submitRoute' => route('admin.sources.store'),
|
||||
'method' => 'POST',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(SourceRequest $request): RedirectResponse
|
||||
{
|
||||
$item = Source::query()->create($this->normalizePayload($request->validated()));
|
||||
$this->changeLogService->log('created', $item);
|
||||
|
||||
return redirect()->route('admin.sources.edit', $item)->with('status', '来源已创建');
|
||||
}
|
||||
|
||||
public function edit(Source $source): View
|
||||
{
|
||||
return view('admin.sources.form', [
|
||||
'item' => $source,
|
||||
'submitRoute' => route('admin.sources.update', $source),
|
||||
'method' => 'PUT',
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(SourceRequest $request, Source $source): RedirectResponse
|
||||
{
|
||||
$before = $source->getAttributes();
|
||||
$source->fill($this->normalizePayload($request->validated()));
|
||||
$source->save();
|
||||
|
||||
$this->changeLogService->log('updated', $source, $before);
|
||||
|
||||
return redirect()->route('admin.sources.edit', $source)->with('status', '来源已更新');
|
||||
}
|
||||
|
||||
private function normalizePayload(array $payload): array
|
||||
{
|
||||
$payload['is_whitelisted'] = (bool) ($payload['is_whitelisted'] ?? false);
|
||||
$payload['crawl_allowed'] = (bool) ($payload['crawl_allowed'] ?? false);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
120
app/Http/Controllers/Admin/ToolController.php
Normal file
120
app/Http/Controllers/Admin/ToolController.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\EntityStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\ToolRequest;
|
||||
use App\Models\Category;
|
||||
use App\Models\Source;
|
||||
use App\Models\Tool;
|
||||
use App\Services\ChangeLogService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ToolController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ChangeLogService $changeLogService)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$items = Tool::query()
|
||||
->with(['category', 'source'])
|
||||
->when($request->filled('q'), function ($query) use ($request): void {
|
||||
$query->whereFullText(['name', 'summary', 'description'], (string) $request->string('q'));
|
||||
})
|
||||
->latest('updated_at')
|
||||
->paginate(20)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.tools.index', [
|
||||
'items' => $items,
|
||||
'filters' => $request->only(['q']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.tools.form', [
|
||||
'item' => new Tool(),
|
||||
'categories' => Category::query()->where('type', 'tool')->orderBy('name')->get(),
|
||||
'sources' => Source::query()->where('is_whitelisted', true)->orderBy('name')->get(),
|
||||
'statusOptions' => EntityStatus::cases(),
|
||||
'submitRoute' => route('admin.tools.store'),
|
||||
'method' => 'POST',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(ToolRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $this->normalizePayload($request->validated());
|
||||
$item = Tool::query()->create($data);
|
||||
$this->changeLogService->log('created', $item);
|
||||
|
||||
return redirect()->route('admin.tools.edit', $item)->with('status', '工具已创建');
|
||||
}
|
||||
|
||||
public function edit(Tool $tool): View
|
||||
{
|
||||
return view('admin.tools.form', [
|
||||
'item' => $tool,
|
||||
'categories' => Category::query()->where('type', 'tool')->orderBy('name')->get(),
|
||||
'sources' => Source::query()->where('is_whitelisted', true)->orderBy('name')->get(),
|
||||
'statusOptions' => EntityStatus::cases(),
|
||||
'submitRoute' => route('admin.tools.update', $tool),
|
||||
'method' => 'PUT',
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(ToolRequest $request, Tool $tool): RedirectResponse
|
||||
{
|
||||
$before = $tool->getAttributes();
|
||||
$tool->fill($this->normalizePayload($request->validated()));
|
||||
$tool->save();
|
||||
|
||||
$this->changeLogService->log('updated', $tool, $before);
|
||||
|
||||
return redirect()->route('admin.tools.edit', $tool)->with('status', '工具已更新');
|
||||
}
|
||||
|
||||
public function publish(Tool $tool): RedirectResponse
|
||||
{
|
||||
$before = $tool->getAttributes();
|
||||
$tool->status = EntityStatus::Published;
|
||||
$tool->published_at = $tool->published_at ?? now();
|
||||
$tool->save();
|
||||
|
||||
$this->changeLogService->log('published', $tool, $before);
|
||||
|
||||
return redirect()->route('admin.tools.edit', $tool)->with('status', '工具已发布');
|
||||
}
|
||||
|
||||
public function markStale(Tool $tool): RedirectResponse
|
||||
{
|
||||
$before = $tool->getAttributes();
|
||||
$tool->is_stale = true;
|
||||
$tool->status = EntityStatus::Stale;
|
||||
$tool->save();
|
||||
|
||||
$this->changeLogService->log('marked_stale', $tool, $before);
|
||||
|
||||
return redirect()->route('admin.tools.edit', $tool)->with('status', '工具已标记失效');
|
||||
}
|
||||
|
||||
private function normalizePayload(array $data): array
|
||||
{
|
||||
$data['has_api'] = (bool) ($data['has_api'] ?? false);
|
||||
$data['is_stale'] = (bool) ($data['is_stale'] ?? false);
|
||||
|
||||
if (($data['status'] ?? null) === EntityStatus::Published->value && empty($data['published_at'])) {
|
||||
$data['published_at'] = now();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
179
app/Http/Controllers/Admin/UploadController.php
Normal file
179
app/Http/Controllers/Admin/UploadController.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use GdImage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class UploadController extends Controller
|
||||
{
|
||||
private const MAX_FILE_KB = 6144;
|
||||
|
||||
private const MAX_MAIN_WIDTH = 2400;
|
||||
|
||||
private const THUMB_WIDTH = 320;
|
||||
|
||||
private const MAIN_WEBP_QUALITY = 86;
|
||||
|
||||
private const THUMB_WEBP_QUALITY = 80;
|
||||
|
||||
public function markdownImage(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'image' => ['required', 'file', 'image', 'mimes:jpg,jpeg,png,webp,gif', 'max:'.self::MAX_FILE_KB],
|
||||
], [
|
||||
'image.required' => '请选择要上传的图片。',
|
||||
'image.image' => '仅支持图片文件上传。',
|
||||
'image.mimes' => '图片格式仅支持 jpg、jpeg、png、webp、gif。',
|
||||
'image.max' => '图片大小不能超过 6MB。',
|
||||
]);
|
||||
|
||||
if (! function_exists('imagecreatefromstring') || ! function_exists('imagewebp')) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '服务器不支持图片处理能力(GD/WebP)。',
|
||||
], 500);
|
||||
}
|
||||
|
||||
/** @var UploadedFile $file */
|
||||
$file = $validated['image'];
|
||||
|
||||
$sourceImage = null;
|
||||
$mainImage = null;
|
||||
$thumbImage = null;
|
||||
|
||||
try {
|
||||
[$sourceImage, $sourceWidth, $sourceHeight] = $this->createImageFromUpload($file);
|
||||
|
||||
$mainWidth = min(self::MAX_MAIN_WIDTH, $sourceWidth);
|
||||
$mainImage = $this->resizeImage($sourceImage, $sourceWidth, $sourceHeight, $mainWidth);
|
||||
|
||||
$thumbWidth = min(self::THUMB_WIDTH, imagesx($mainImage));
|
||||
$thumbImage = $this->resizeImage($mainImage, imagesx($mainImage), imagesy($mainImage), $thumbWidth);
|
||||
|
||||
$mainBinary = $this->encodeWebp($mainImage, self::MAIN_WEBP_QUALITY);
|
||||
$thumbBinary = $this->encodeWebp($thumbImage, self::THUMB_WEBP_QUALITY);
|
||||
|
||||
$subDir = now()->format('Y/m/d');
|
||||
$baseName = now()->format('YmdHis').'-'.Str::lower(Str::random(8));
|
||||
$mainPath = "markdown-images/{$subDir}/{$baseName}.webp";
|
||||
$thumbPath = "markdown-images/{$subDir}/{$baseName}_thumb.webp";
|
||||
|
||||
Storage::disk('public')->put($mainPath, $mainBinary);
|
||||
Storage::disk('public')->put($thumbPath, $thumbBinary);
|
||||
|
||||
$mainUrl = Storage::disk('public')->url($mainPath);
|
||||
$thumbUrl = Storage::disk('public')->url($thumbPath);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'url' => $mainUrl,
|
||||
'thumb_url' => $thumbUrl,
|
||||
'filename' => basename($mainPath),
|
||||
'markdown' => '',
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '图片上传失败,请稍后重试。',
|
||||
], 422);
|
||||
} finally {
|
||||
if ($sourceImage instanceof GdImage) {
|
||||
imagedestroy($sourceImage);
|
||||
}
|
||||
|
||||
if ($mainImage instanceof GdImage) {
|
||||
imagedestroy($mainImage);
|
||||
}
|
||||
|
||||
if ($thumbImage instanceof GdImage) {
|
||||
imagedestroy($thumbImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:GdImage,1:int,2:int}
|
||||
*/
|
||||
private function createImageFromUpload(UploadedFile $file): array
|
||||
{
|
||||
$path = $file->getRealPath();
|
||||
if ($path === false || $path === '') {
|
||||
throw new RuntimeException('上传临时文件不可用。');
|
||||
}
|
||||
|
||||
$binary = file_get_contents($path);
|
||||
if ($binary === false) {
|
||||
throw new RuntimeException('读取上传文件失败。');
|
||||
}
|
||||
|
||||
$image = @imagecreatefromstring($binary);
|
||||
if (! $image instanceof GdImage) {
|
||||
throw new RuntimeException('无法解析图片内容。');
|
||||
}
|
||||
|
||||
if (! imageistruecolor($image)) {
|
||||
imagepalettetotruecolor($image);
|
||||
}
|
||||
|
||||
$width = imagesx($image);
|
||||
$height = imagesy($image);
|
||||
|
||||
if ($width < 1 || $height < 1) {
|
||||
imagedestroy($image);
|
||||
throw new RuntimeException('图片尺寸无效。');
|
||||
}
|
||||
|
||||
return [$image, $width, $height];
|
||||
}
|
||||
|
||||
private function resizeImage(GdImage $source, int $sourceWidth, int $sourceHeight, int $targetWidth): GdImage
|
||||
{
|
||||
$targetWidth = max(1, $targetWidth);
|
||||
|
||||
if ($sourceWidth === $targetWidth) {
|
||||
$targetHeight = $sourceHeight;
|
||||
} else {
|
||||
$targetHeight = max(1, (int) round($sourceHeight * ($targetWidth / $sourceWidth)));
|
||||
}
|
||||
|
||||
$canvas = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
if (! $canvas instanceof GdImage) {
|
||||
throw new RuntimeException('创建图片画布失败。');
|
||||
}
|
||||
|
||||
$background = imagecolorallocate($canvas, 255, 255, 255);
|
||||
imagefill($canvas, 0, 0, $background);
|
||||
|
||||
if (! imagecopyresampled($canvas, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $sourceWidth, $sourceHeight)) {
|
||||
imagedestroy($canvas);
|
||||
throw new RuntimeException('图片缩放失败。');
|
||||
}
|
||||
|
||||
return $canvas;
|
||||
}
|
||||
|
||||
private function encodeWebp(GdImage $image, int $quality): string
|
||||
{
|
||||
ob_start();
|
||||
$saved = imagewebp($image, null, $quality);
|
||||
$binary = ob_get_clean();
|
||||
|
||||
if (! $saved || ! is_string($binary) || $binary === '') {
|
||||
throw new RuntimeException('WebP 编码失败。');
|
||||
}
|
||||
|
||||
return $binary;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user