init
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled

This commit is contained in:
jiangdong.cheng
2026-02-11 17:28:36 +08:00
parent dcb82557c7
commit aa16c9f8c2
162 changed files with 22333 additions and 0 deletions

View 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;
}
}

View 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;
}
}

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

View 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]));
}
}

View 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(),
]);
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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' => '![]('.$mainUrl.')',
]);
} 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;
}
}