feat: 第五阶段办公协同/统计报表/知识库模块

- 新增3个migration(9张表): office(6表)/report(1表)/kb(2表)
- 新增9个Model + 9个Controller
- 新增43条API路由(总计305条)
- 新增3个前端API模块 + 10个管理页面
- 迁移运行通过, vite build验证通过
This commit is contained in:
li
2026-03-13 21:43:10 +08:00
parent fb5e0daf1a
commit 058ee507fa
34 changed files with 2595 additions and 0 deletions
@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Admin\Kb;
use App\Http\Controllers\Controller;
use App\Models\Kb\KbArticle;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class KbArticleController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = KbArticle::query()->with(['category', 'author']);
$query->when($request->category_id, fn($q, $v) => $q->where('category_id', $v));
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
$query->when($request->filled('visibility'), fn($q) => $q->where('visibility', $request->visibility));
$query->when($request->keyword, fn($q, $v) => $q->where('title', 'like', "%{$v}%"));
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'nullable|string',
'category_id' => 'nullable|exists:kb_categories,id',
'visibility' => 'nullable|integer|in:1,2',
'cover_image' => 'nullable|string|max:255',
'attachments' => 'nullable|array',
]);
$validated['author_id'] = auth()->id();
$validated['status'] = 0;
$article = KbArticle::create($validated);
return $this->success($article->load(['category', 'author']));
}
public function show(KbArticle $kbArticle): JsonResponse
{
$kbArticle->increment('views');
return $this->success($kbArticle->load(['category', 'author']));
}
public function update(Request $request, KbArticle $kbArticle): JsonResponse
{
$validated = $request->validate([
'title' => 'sometimes|string|max:255',
'content' => 'nullable|string',
'category_id' => 'nullable|exists:kb_categories,id',
'visibility' => 'nullable|integer|in:1,2',
'cover_image' => 'nullable|string|max:255',
'attachments' => 'nullable|array',
]);
$kbArticle->update($validated);
return $this->success($kbArticle);
}
public function destroy(KbArticle $kbArticle): JsonResponse
{
$kbArticle->delete();
return $this->success(null);
}
public function publish(KbArticle $kbArticle): JsonResponse
{
if ($kbArticle->status >= 2) {
return $this->error('当前状态不允许发布');
}
$kbArticle->update(['status' => 2, 'published_at' => now()]);
return $this->success($kbArticle);
}
public function offline(KbArticle $kbArticle): JsonResponse
{
if ($kbArticle->status !== 2) {
return $this->error('仅已发布文章可下架');
}
$kbArticle->update(['status' => 3]);
return $this->success($kbArticle);
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Admin\Kb;
use App\Http\Controllers\Controller;
use App\Models\Kb\KbCategory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class KbCategoryController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = KbCategory::query()->with('children');
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
$query->where('parent_id', 0);
return $this->success($query->orderBy('sort')->orderBy('id')->get());
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:100',
'parent_id' => 'nullable|integer|min:0',
'sort' => 'nullable|integer',
'status' => 'nullable|integer|in:0,1',
]);
$validated['parent_id'] = $validated['parent_id'] ?? 0;
$category = KbCategory::create($validated);
return $this->success($category);
}
public function show(KbCategory $kbCategory): JsonResponse
{
return $this->success($kbCategory->load('children'));
}
public function update(Request $request, KbCategory $kbCategory): JsonResponse
{
$validated = $request->validate([
'name' => 'sometimes|string|max:100',
'parent_id' => 'nullable|integer|min:0',
'sort' => 'nullable|integer',
'status' => 'nullable|integer|in:0,1',
]);
$kbCategory->update($validated);
return $this->success($kbCategory);
}
public function destroy(KbCategory $kbCategory): JsonResponse
{
if ($kbCategory->children()->exists()) {
return $this->error('请先删除子分类');
}
if ($kbCategory->articles()->exists()) {
return $this->error('该分类下还有文章,无法删除');
}
$kbCategory->delete();
return $this->success(null);
}
}
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Admin\Office;
use App\Http\Controllers\Controller;
use App\Models\Office\Announcement;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AnnouncementController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Announcement::query()->with('author');
$query->when($request->type, fn($q, $v) => $q->where('type', $v));
$query->when($request->has('publish_status'), fn($q) => $q->where('publish_status', $request->publish_status));
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'type' => 'sometimes|integer|in:1,2,3',
'is_top' => 'sometimes|boolean',
]);
$validated['author_id'] = auth()->id();
return $this->success(Announcement::create($validated));
}
public function show(Announcement $announcement): JsonResponse
{
return $this->success($announcement->load('author'));
}
public function update(Request $request, Announcement $announcement): JsonResponse
{
$validated = $request->validate([
'title' => 'sometimes|string|max:255',
'content' => 'sometimes|string',
'type' => 'sometimes|integer|in:1,2,3',
'is_top' => 'sometimes|boolean',
]);
$announcement->update($validated);
return $this->success($announcement);
}
public function destroy(Announcement $announcement): JsonResponse
{
$announcement->delete();
return $this->success(null);
}
public function publish(Announcement $announcement): JsonResponse
{
if ($announcement->publish_status === 1) {
return $this->error('该公告已发布', 40001);
}
$announcement->update([
'publish_status' => 1,
'published_at' => now(),
]);
return $this->success($announcement);
}
}
@@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers\Admin\Office;
use App\Http\Controllers\Controller;
use App\Models\Office\Approval;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ApprovalController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Approval::query()->with(['template', 'applicant']);
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
$query->when($request->applicant_id, fn($q, $v) => $q->where('applicant_id', $v));
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'template_id' => 'nullable|exists:approval_templates,id',
'form_data' => 'nullable|array',
'related_type' => 'nullable|string|max:50',
'related_id' => 'nullable|integer',
]);
$validated['applicant_id'] = auth()->id();
$validated['status'] = 0;
return $this->success(Approval::create($validated));
}
public function show(Approval $approval): JsonResponse
{
return $this->success($approval->load(['template', 'applicant', 'nodes.approver']));
}
public function update(Request $request, Approval $approval): JsonResponse
{
if ($approval->status > 1) {
return $this->error('该审批已完结,无法修改', 40001);
}
$validated = $request->validate([
'title' => 'sometimes|string|max:255',
'form_data' => 'nullable|array',
]);
$approval->update($validated);
return $this->success($approval);
}
public function destroy(Approval $approval): JsonResponse
{
if ($approval->status > 1) {
return $this->error('该审批已完结,无法删除', 40001);
}
$approval->delete();
return $this->success(null);
}
public function approve(Approval $approval): JsonResponse
{
$node = $approval->nodes()
->where('approver_id', auth()->id())
->where('action', 0)
->where('node_order', $approval->current_node)
->first();
if (!$node) {
return $this->error('无待处理的审批节点', 40001);
}
$node->update(['action' => 1, 'acted_at' => now()]);
$pendingNodes = $approval->nodes()->where('action', 0)->exists();
if (!$pendingNodes) {
$approval->update(['status' => 2]);
} else {
$approval->update([
'status' => 1,
'current_node' => $approval->current_node + 1,
]);
}
return $this->success($approval->load('nodes'));
}
public function reject(Request $request, Approval $approval): JsonResponse
{
$node = $approval->nodes()
->where('approver_id', auth()->id())
->where('action', 0)
->where('node_order', $approval->current_node)
->first();
if (!$node) {
return $this->error('无待处理的审批节点', 40001);
}
$validated = $request->validate([
'comment' => 'nullable|string|max:255',
]);
$node->update([
'action' => 2,
'comment' => $validated['comment'] ?? null,
'acted_at' => now(),
]);
$approval->update(['status' => 3]);
return $this->success($approval->load('nodes'));
}
public function withdraw(Approval $approval): JsonResponse
{
if ($approval->status > 1) {
return $this->error('该审批已完结,无法撤回', 40001);
}
$approval->update(['status' => 4]);
return $this->success($approval);
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Admin\Office;
use App\Http\Controllers\Controller;
use App\Models\Office\ApprovalTemplate;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ApprovalTemplateController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = ApprovalTemplate::query();
$query->when($request->type, fn($q, $v) => $q->where('type', $v));
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
return $this->paginate($query->orderBy('sort')->latest()->paginate($request->input('per_page', 15)));
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:100',
'type' => 'required|integer|in:1,2,3,4,5',
'form_fields' => 'nullable|array',
'flow_nodes' => 'nullable|array',
'status' => 'sometimes|integer|in:0,1',
'sort' => 'sometimes|integer',
]);
return $this->success(ApprovalTemplate::create($validated));
}
public function show(ApprovalTemplate $approvalTemplate): JsonResponse
{
return $this->success($approvalTemplate);
}
public function update(Request $request, ApprovalTemplate $approvalTemplate): JsonResponse
{
$validated = $request->validate([
'name' => 'sometimes|string|max:100',
'type' => 'sometimes|integer|in:1,2,3,4,5',
'form_fields' => 'nullable|array',
'flow_nodes' => 'nullable|array',
'status' => 'sometimes|integer|in:0,1',
'sort' => 'sometimes|integer',
]);
$approvalTemplate->update($validated);
return $this->success($approvalTemplate);
}
public function destroy(ApprovalTemplate $approvalTemplate): JsonResponse
{
$approvalTemplate->delete();
return $this->success(null);
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Admin\Office;
use App\Http\Controllers\Controller;
use App\Models\Office\Handover;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class HandoverController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Handover::query()->with(['handoverUser', 'receiverUser']);
$query->when($request->shift_date, fn($q, $v) => $q->where('shift_date', $v));
$query->when($request->shift_type, fn($q, $v) => $q->where('shift_type', $v));
return $this->paginate($query->latest('created_at')->paginate($request->input('per_page', 15)));
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'shift_date' => 'required|date',
'shift_type' => 'required|integer|in:1,2,3,4',
'content' => 'required|string',
'issues' => 'nullable|string',
]);
$validated['handover_user_id'] = auth()->id();
return $this->success(Handover::create($validated));
}
public function show(Handover $handover): JsonResponse
{
return $this->success($handover->load(['handoverUser', 'receiverUser']));
}
public function confirm(Handover $handover): JsonResponse
{
if ($handover->status === 1) {
return $this->error('该交接班已确认', 40001);
}
$handover->update([
'receiver_user_id' => auth()->id(),
'status' => 1,
'confirmed_at' => now(),
]);
return $this->success($handover);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Admin\Office;
use App\Http\Controllers\Controller;
use App\Models\Office\Notification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Notification::query()->where('user_id', auth()->id());
$query->when($request->type, fn($q, $v) => $q->where('type', $v));
$query->when($request->has('is_read'), fn($q) => $q->where('is_read', $request->boolean('is_read')));
return $this->paginate($query->latest('created_at')->paginate($request->input('per_page', 15)));
}
public function markRead(Notification $notification): JsonResponse
{
if ($notification->user_id !== auth()->id()) {
return $this->error('无权操作', 40003, 403);
}
$notification->update(['is_read' => true, 'read_at' => now()]);
return $this->success($notification);
}
public function markAllRead(): JsonResponse
{
Notification::query()
->where('user_id', auth()->id())
->where('is_read', false)
->update(['is_read' => true, 'read_at' => now()]);
return $this->success(null);
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Admin\Report;
use App\Http\Controllers\Controller;
use App\Models\Crm\Contract;
use App\Models\Crm\Customer;
use App\Models\Crm\Lead;
use App\Models\Finance\FinanceRecord;
use App\Models\Room\Room;
use App\Models\Room\RoomType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ReportController extends Controller
{
public function overview(Request $request): JsonResponse
{
$data = [
'customer_count' => Customer::count(),
'contract_amount' => Contract::where('status', 2)->sum('actual_amount'),
'room_count' => Room::count(),
'occupied_count' => Room::where('status', 3)->count(),
'today_revenue' => FinanceRecord::where('type', 1)->where('audit_status', 1)
->whereDate('record_date', today())->sum('amount'),
'month_revenue' => FinanceRecord::where('type', 1)->where('audit_status', 1)
->whereMonth('record_date', now()->month)
->whereYear('record_date', now()->year)->sum('amount'),
];
return $this->success($data);
}
public function revenue(Request $request): JsonResponse
{
$year = $request->input('year', now()->year);
$records = FinanceRecord::where('audit_status', 1)
->whereYear('record_date', $year)
->selectRaw('MONTH(record_date) as month, type, SUM(amount) as total')
->groupBy('month', 'type')
->get();
return $this->success($records);
}
public function occupancy(Request $request): JsonResponse
{
$data = RoomType::withCount([
'rooms',
'rooms as occupied_count' => fn($q) => $q->where('status', 3),
])->get();
return $this->success($data);
}
public function crmConversion(Request $request): JsonResponse
{
$data = [
'total_leads' => Lead::count(),
'following' => Lead::where('status', 2)->count(),
'contracted' => Lead::where('status', 5)->count(),
'by_channel' => Lead::selectRaw('channel_id, COUNT(*) as total, SUM(CASE WHEN status=5 THEN 1 ELSE 0 END) as converted')
->groupBy('channel_id')
->with('channel:id,name')
->get(),
];
return $this->success($data);
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Admin\Report;
use App\Http\Controllers\Controller;
use App\Models\Report\ReportExport;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ReportExportController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = ReportExport::query()->with('operator');
$query->when($request->type, fn($q, $v) => $q->where('type', $v));
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'type' => 'required|string|max:50',
'params' => 'nullable|array',
]);
$validated['operator_id'] = auth()->id();
$validated['status'] = 0;
$export = ReportExport::create($validated);
return $this->success($export);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models\Kb;
use App\Models\User;
use App\Traits\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class KbArticle extends Model
{
use BelongsToStore;
protected $fillable = [
'store_id', 'category_id', 'title', 'content', 'author_id',
'status', 'visibility', 'cover_image', 'attachments',
'views', 'likes', 'favorites', 'published_at',
];
protected function casts(): array
{
return [
'status' => 'integer',
'visibility' => 'integer',
'views' => 'integer',
'likes' => 'integer',
'favorites' => 'integer',
'attachments' => 'array',
'published_at' => 'datetime',
];
}
public function category(): BelongsTo
{
return $this->belongsTo(KbCategory::class, 'category_id');
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Models\Kb;
use App\Traits\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class KbCategory extends Model
{
use BelongsToStore;
protected $fillable = [
'store_id', 'parent_id', 'name', 'sort', 'status',
];
protected function casts(): array
{
return [
'sort' => 'integer',
'status' => 'integer',
];
}
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function articles(): HasMany
{
return $this->hasMany(KbArticle::class, 'category_id');
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Models\Office;
use App\Models\User;
use App\Traits\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Announcement extends Model
{
use BelongsToStore;
protected $fillable = [
'store_id', 'title', 'content', 'type', 'is_top',
'publish_status', 'published_at', 'author_id',
];
protected function casts(): array
{
return [
'type' => 'integer',
'publish_status' => 'integer',
'is_top' => 'boolean',
'published_at' => 'datetime',
];
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models\Office;
use App\Models\User;
use App\Traits\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Approval extends Model
{
use BelongsToStore;
protected $fillable = [
'store_id', 'template_id', 'title', 'applicant_id', 'form_data',
'status', 'current_node', 'related_type', 'related_id',
];
protected function casts(): array
{
return [
'status' => 'integer',
'current_node' => 'integer',
'form_data' => 'array',
];
}
public function template(): BelongsTo
{
return $this->belongsTo(ApprovalTemplate::class, 'template_id');
}
public function applicant(): BelongsTo
{
return $this->belongsTo(User::class, 'applicant_id');
}
public function nodes(): HasMany
{
return $this->hasMany(ApprovalNode::class);
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Models\Office;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ApprovalNode extends Model
{
const UPDATED_AT = null;
protected $fillable = [
'approval_id', 'node_order', 'approver_id', 'action', 'comment', 'acted_at',
];
protected function casts(): array
{
return [
'node_order' => 'integer',
'action' => 'integer',
'acted_at' => 'datetime',
];
}
public function approval(): BelongsTo
{
return $this->belongsTo(Approval::class);
}
public function approver(): BelongsTo
{
return $this->belongsTo(User::class, 'approver_id');
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Models\Office;
use App\Traits\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
class ApprovalTemplate extends Model
{
use BelongsToStore;
protected $fillable = [
'store_id', 'name', 'type', 'form_fields', 'flow_nodes', 'status', 'sort',
];
protected function casts(): array
{
return [
'type' => 'integer',
'status' => 'integer',
'sort' => 'integer',
'form_fields' => 'array',
'flow_nodes' => 'array',
];
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Models\Office;
use App\Models\User;
use App\Traits\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Handover extends Model
{
use BelongsToStore;
const UPDATED_AT = null;
protected $fillable = [
'store_id', 'shift_date', 'shift_type', 'handover_user_id',
'receiver_user_id', 'content', 'issues', 'status', 'confirmed_at',
];
protected function casts(): array
{
return [
'shift_type' => 'integer',
'status' => 'integer',
'shift_date' => 'date',
'confirmed_at' => 'datetime',
];
}
public function handoverUser(): BelongsTo
{
return $this->belongsTo(User::class, 'handover_user_id');
}
public function receiverUser(): BelongsTo
{
return $this->belongsTo(User::class, 'receiver_user_id');
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Models\Office;
use App\Models\User;
use App\Traits\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Notification extends Model
{
use BelongsToStore;
const UPDATED_AT = null;
protected $table = 'notifications';
protected $fillable = [
'store_id', 'user_id', 'title', 'content', 'type',
'related_type', 'related_id', 'is_read', 'read_at',
];
protected function casts(): array
{
return [
'type' => 'integer',
'is_read' => 'boolean',
'read_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Models\Report;
use App\Models\User;
use App\Traits\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportExport extends Model
{
use BelongsToStore;
const UPDATED_AT = null;
protected $fillable = [
'store_id', 'name', 'type', 'params', 'file_path', 'status', 'operator_id',
];
protected function casts(): array
{
return [
'params' => 'array',
'status' => 'integer',
];
}
public function operator(): BelongsTo
{
return $this->belongsTo(User::class, 'operator_id');
}
}