middleware('can:view_articles')->only(['index', 'show']); $this->middleware('can:create_articles')->only(['create', 'store']); $this->middleware('can:edit_articles')->only(['edit', 'update']); $this->middleware('can:delete_articles')->only(['destroy']); $this->middleware('can:publish_articles')->only(['publish', 'archive']); } public function index(Request $request) { $query = Article::with(['creator', 'categories']) ->orderByDesc('is_pinned') ->orderByDesc('created_at'); if ($request->filled('status')) { $query->where('status', $request->status); } if ($request->filled('content_type')) { $query->where('content_type', $request->content_type); } if ($request->filled('category')) { $query->whereHas('categories', function ($q) use ($request) { $q->where('article_categories.id', $request->category); }); } if ($request->filled('search')) { $search = $request->search; $query->where(function ($q) use ($search) { $q->where('title', 'like', "%{$search}%") ->orWhere('content', 'like', "%{$search}%"); }); } $articles = $query->paginate(20); $stats = [ 'total' => Article::count(), 'draft' => Article::draft()->count(), 'published' => Article::published()->count(), 'archived' => Article::archived()->count(), 'pinned' => Article::pinned()->count(), ]; $categories = ArticleCategory::orderBy('sort_order')->get(); return view('admin.articles.index', compact('articles', 'stats', 'categories')); } public function create() { $categories = ArticleCategory::orderBy('sort_order')->get(); $tags = ArticleTag::orderBy('name')->get(); return view('admin.articles.create', compact('categories', 'tags')); } public function store(Request $request) { $validated = $request->validate([ 'title' => 'required|string|max:255', 'slug' => 'nullable|string|max:255|unique:articles,slug', 'summary' => 'nullable|string|max:1000', 'content' => 'required|string', 'content_type' => 'required|in:blog,notice,document,related_news', 'access_level' => 'required|in:public,members,board,admin', 'author_name' => 'nullable|string|max:255', 'meta_description' => 'nullable|string|max:500', 'published_at' => 'nullable|date', 'expires_at' => 'nullable|date|after:published_at', 'is_pinned' => 'boolean', 'display_order' => 'nullable|integer', 'categories' => 'nullable|array', 'categories.*' => 'exists:article_categories,id', 'tags_input' => 'nullable|string', 'featured_image' => 'nullable|image|max:5120', 'featured_image_alt' => 'nullable|string|max:255', 'save_action' => 'required|in:draft,publish', ]); $articleData = [ 'title' => $validated['title'], 'slug' => $validated['slug'] ?? null, 'summary' => $validated['summary'] ?? null, 'content' => $validated['content'], 'content_type' => $validated['content_type'], 'access_level' => $validated['access_level'], 'author_name' => $validated['author_name'] ?? null, 'meta_description' => $validated['meta_description'] ?? null, 'status' => $validated['save_action'] === 'publish' ? Article::STATUS_PUBLISHED : Article::STATUS_DRAFT, 'published_at' => $validated['save_action'] === 'publish' ? ($validated['published_at'] ?? now()) : ($validated['published_at'] ?? null), 'expires_at' => $validated['expires_at'] ?? null, 'is_pinned' => $validated['is_pinned'] ?? false, 'display_order' => $validated['display_order'] ?? 0, 'created_by_user_id' => auth()->id(), 'last_updated_by_user_id' => auth()->id(), 'featured_image_alt' => $validated['featured_image_alt'] ?? null, ]; if ($request->hasFile('featured_image')) { $storagePath = $request->file('featured_image')->store('articles/images', 'public'); $nextPublicPath = SiteAssetSyncService::syncPublicDiskFileToNext($storagePath); if ($nextPublicPath) { $articleData['featured_image_path'] = $nextPublicPath; $articleData['featured_image_storage_path'] = $storagePath; } else { $articleData['featured_image_path'] = $storagePath; } } $article = Article::create($articleData); // Sync categories if (! empty($validated['categories'])) { $article->categories()->sync($validated['categories']); } // Handle tags if (! empty($validated['tags_input'])) { $tagNames = array_filter(array_map('trim', explode(',', $validated['tags_input']))); $tagIds = []; foreach ($tagNames as $tagName) { $tag = ArticleTag::firstOrCreate( ['name' => $tagName], ['slug' => Str::slug($tagName) ?: 'tag-'.time()] ); $tagIds[] = $tag->id; } $article->tags()->sync($tagIds); } AuditLog::create([ 'user_id' => auth()->id(), 'action' => 'article.created', 'description' => "建立文章:{$article->title} (類型:{$article->getContentTypeLabel()},狀態:{$article->getStatusLabel()})", 'ip_address' => request()->ip(), ]); if ($validated['save_action'] === 'publish') { SiteRevalidationService::revalidateArticle($article->slug); } $message = $validated['save_action'] === 'publish' ? '文章已成功發布' : '文章已儲存為草稿'; return redirect() ->route('admin.articles.show', $article) ->with('status', $message); } public function show(Article $article) { if (! $article->canBeViewedBy(auth()->user())) { abort(403, '您沒有權限查看此文章'); } $article->load(['creator', 'lastUpdatedBy', 'categories', 'tags', 'attachments']); return view('admin.articles.show', compact('article')); } public function edit(Article $article) { if (! $article->canBeEditedBy(auth()->user())) { abort(403, '您沒有權限編輯此文章'); } $article->load(['categories', 'tags', 'attachments']); $categories = ArticleCategory::orderBy('sort_order')->get(); $tags = ArticleTag::orderBy('name')->get(); return view('admin.articles.edit', compact('article', 'categories', 'tags')); } public function update(Request $request, Article $article) { if (! $article->canBeEditedBy(auth()->user())) { abort(403, '您沒有權限編輯此文章'); } $validated = $request->validate([ 'title' => 'required|string|max:255', 'slug' => 'nullable|string|max:255|unique:articles,slug,'.$article->id, 'summary' => 'nullable|string|max:1000', 'content' => 'required|string', 'content_type' => 'required|in:blog,notice,document,related_news', 'access_level' => 'required|in:public,members,board,admin', 'author_name' => 'nullable|string|max:255', 'meta_description' => 'nullable|string|max:500', 'published_at' => 'nullable|date', 'expires_at' => 'nullable|date|after:published_at', 'is_pinned' => 'boolean', 'display_order' => 'nullable|integer', 'categories' => 'nullable|array', 'categories.*' => 'exists:article_categories,id', 'tags_input' => 'nullable|string', 'featured_image' => 'nullable|image|max:5120', 'featured_image_alt' => 'nullable|string|max:255', 'remove_featured_image' => 'boolean', ]); $updateData = [ 'title' => $validated['title'], 'summary' => $validated['summary'] ?? null, 'content' => $validated['content'], 'content_type' => $validated['content_type'], 'access_level' => $validated['access_level'], 'author_name' => $validated['author_name'] ?? null, 'meta_description' => $validated['meta_description'] ?? null, 'published_at' => $validated['published_at'], 'expires_at' => $validated['expires_at'] ?? null, 'is_pinned' => $validated['is_pinned'] ?? false, 'display_order' => $validated['display_order'] ?? 0, 'last_updated_by_user_id' => auth()->id(), 'featured_image_alt' => $validated['featured_image_alt'] ?? null, ]; if (! empty($validated['slug']) && $validated['slug'] !== $article->slug) { $updateData['slug'] = $validated['slug']; } if ($request->hasFile('featured_image')) { // Remove old copies (best-effort). If we synced to Next.js, the DB path is relative (uploads/...). if ($article->featured_image_storage_path) { Storage::disk('public')->delete($article->featured_image_storage_path); } elseif ($article->featured_image_path && str_starts_with($article->featured_image_path, 'articles/')) { Storage::disk('public')->delete($article->featured_image_path); } SiteAssetSyncService::deleteNextPublicFile($article->featured_image_path); $storagePath = $request->file('featured_image')->store('articles/images', 'public'); $nextPublicPath = SiteAssetSyncService::syncPublicDiskFileToNext($storagePath); if ($nextPublicPath) { $updateData['featured_image_path'] = $nextPublicPath; $updateData['featured_image_storage_path'] = $storagePath; } else { $updateData['featured_image_path'] = $storagePath; $updateData['featured_image_storage_path'] = null; } } elseif ($request->boolean('remove_featured_image') && $article->featured_image_path) { if ($article->featured_image_storage_path) { Storage::disk('public')->delete($article->featured_image_storage_path); } elseif (str_starts_with($article->featured_image_path, 'articles/')) { Storage::disk('public')->delete($article->featured_image_path); } SiteAssetSyncService::deleteNextPublicFile($article->featured_image_path); $updateData['featured_image_path'] = null; $updateData['featured_image_storage_path'] = null; } $article->update($updateData); // Sync categories $article->categories()->sync($validated['categories'] ?? []); // Handle tags if (isset($validated['tags_input'])) { $tagNames = array_filter(array_map('trim', explode(',', $validated['tags_input']))); $tagIds = []; foreach ($tagNames as $tagName) { $tag = ArticleTag::firstOrCreate( ['name' => $tagName], ['slug' => Str::slug($tagName) ?: 'tag-'.time()] ); $tagIds[] = $tag->id; } $article->tags()->sync($tagIds); } else { $article->tags()->sync([]); } AuditLog::create([ 'user_id' => auth()->id(), 'action' => 'article.updated', 'description' => "更新文章:{$article->title}", 'ip_address' => request()->ip(), ]); if ($article->isPublished()) { SiteRevalidationService::revalidateArticle($article->slug); } return redirect() ->route('admin.articles.show', $article) ->with('status', '文章已成功更新'); } public function destroy(Article $article) { if (! $article->canBeEditedBy(auth()->user())) { abort(403, '您沒有權限刪除此文章'); } $title = $article->title; $slug = $article->slug; $wasPublished = $article->isPublished(); $article->delete(); AuditLog::create([ 'user_id' => auth()->id(), 'action' => 'article.deleted', 'description' => "刪除文章:{$title}", 'ip_address' => request()->ip(), ]); if ($wasPublished) { SiteRevalidationService::revalidateArticle($slug); } return redirect() ->route('admin.articles.index') ->with('status', '文章已成功刪除'); } public function publish(Article $article) { if (! auth()->user()->can('publish_articles')) { abort(403, '您沒有權限發布文章'); } if (! $article->canBeEditedBy(auth()->user())) { abort(403, '您沒有權限發布此文章'); } if ($article->isPublished()) { return back()->with('error', '此文章已經發布'); } $article->publish(auth()->user()); AuditLog::create([ 'user_id' => auth()->id(), 'action' => 'article.published', 'description' => "發布文章:{$article->title}", 'ip_address' => request()->ip(), ]); SiteRevalidationService::revalidateArticle($article->slug); return back()->with('status', '文章已成功發布'); } public function archive(Article $article) { if (! auth()->user()->can('publish_articles')) { abort(403, '您沒有權限歸檔文章'); } if (! $article->canBeEditedBy(auth()->user())) { abort(403, '您沒有權限歸檔此文章'); } if ($article->isArchived()) { return back()->with('error', '此文章已經歸檔'); } $article->archive(auth()->user()); AuditLog::create([ 'user_id' => auth()->id(), 'action' => 'article.archived', 'description' => "歸檔文章:{$article->title}", 'ip_address' => request()->ip(), ]); SiteRevalidationService::revalidateArticle($article->slug); return back()->with('status', '文章已成功歸檔'); } public function pin(Request $request, Article $article) { if (! auth()->user()->can('edit_articles')) { abort(403, '您沒有權限置頂文章'); } if (! $article->canBeEditedBy(auth()->user())) { abort(403, '您沒有權限置頂此文章'); } $validated = $request->validate([ 'display_order' => 'nullable|integer', ]); $article->pin($validated['display_order'] ?? 0, auth()->user()); AuditLog::create([ 'user_id' => auth()->id(), 'action' => 'article.pinned', 'description' => "置頂文章:{$article->title}", 'ip_address' => request()->ip(), ]); return back()->with('status', '文章已成功置頂'); } public function unpin(Article $article) { if (! auth()->user()->can('edit_articles')) { abort(403, '您沒有權限取消置頂文章'); } if (! $article->canBeEditedBy(auth()->user())) { abort(403, '您沒有權限取消置頂此文章'); } $article->unpin(auth()->user()); AuditLog::create([ 'user_id' => auth()->id(), 'action' => 'article.unpinned', 'description' => "取消置頂文章:{$article->title}", 'ip_address' => request()->ip(), ]); return back()->with('status', '文章已取消置頂'); } public function uploadAttachment(Request $request, Article $article) { if (! $article->canBeEditedBy(auth()->user())) { abort(403, '您沒有權限上傳附件'); } $validated = $request->validate([ 'attachment' => 'required|file|max:20480', 'description' => 'nullable|string|max:255', ]); $file = $request->file('attachment'); $path = $file->store('articles/attachments', 'public'); $attachment = $article->attachments()->create([ 'file_path' => $path, 'original_filename' => $file->getClientOriginalName(), 'mime_type' => $file->getMimeType(), 'file_size' => $file->getSize(), 'description' => $validated['description'] ?? null, ]); AuditLog::create([ 'user_id' => auth()->id(), 'action' => 'article.attachment_uploaded', 'description' => "上傳附件至文章「{$article->title}」:{$attachment->original_filename}", 'ip_address' => request()->ip(), ]); return back()->with('status', '附件已成功上傳'); } public function deleteAttachment(Article $article, ArticleAttachment $attachment) { if (! $article->canBeEditedBy(auth()->user())) { abort(403, '您沒有權限刪除附件'); } if ($attachment->article_id !== $article->id) { abort(404); } Storage::disk('public')->delete($attachment->file_path); $filename = $attachment->original_filename; $attachment->delete(); AuditLog::create([ 'user_id' => auth()->id(), 'action' => 'article.attachment_deleted', 'description' => "刪除文章「{$article->title}」的附件:{$filename}", 'ip_address' => request()->ip(), ]); return back()->with('status', '附件已成功刪除'); } }