diff --git a/backend/app/Http/Controllers/Admin/Kb/KbArticleController.php b/backend/app/Http/Controllers/Admin/Kb/KbArticleController.php new file mode 100644 index 0000000..9bf7d55 --- /dev/null +++ b/backend/app/Http/Controllers/Admin/Kb/KbArticleController.php @@ -0,0 +1,83 @@ +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); + } +} diff --git a/backend/app/Http/Controllers/Admin/Kb/KbCategoryController.php b/backend/app/Http/Controllers/Admin/Kb/KbCategoryController.php new file mode 100644 index 0000000..ad31dcf --- /dev/null +++ b/backend/app/Http/Controllers/Admin/Kb/KbCategoryController.php @@ -0,0 +1,63 @@ +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); + } +} diff --git a/backend/app/Http/Controllers/Admin/Office/AnnouncementController.php b/backend/app/Http/Controllers/Admin/Office/AnnouncementController.php new file mode 100644 index 0000000..ef8cccc --- /dev/null +++ b/backend/app/Http/Controllers/Admin/Office/AnnouncementController.php @@ -0,0 +1,70 @@ +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); + } +} diff --git a/backend/app/Http/Controllers/Admin/Office/ApprovalController.php b/backend/app/Http/Controllers/Admin/Office/ApprovalController.php new file mode 100644 index 0000000..f6d7505 --- /dev/null +++ b/backend/app/Http/Controllers/Admin/Office/ApprovalController.php @@ -0,0 +1,127 @@ +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); + } +} diff --git a/backend/app/Http/Controllers/Admin/Office/ApprovalTemplateController.php b/backend/app/Http/Controllers/Admin/Office/ApprovalTemplateController.php new file mode 100644 index 0000000..93d7b27 --- /dev/null +++ b/backend/app/Http/Controllers/Admin/Office/ApprovalTemplateController.php @@ -0,0 +1,60 @@ +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); + } +} diff --git a/backend/app/Http/Controllers/Admin/Office/HandoverController.php b/backend/app/Http/Controllers/Admin/Office/HandoverController.php new file mode 100644 index 0000000..6914ba4 --- /dev/null +++ b/backend/app/Http/Controllers/Admin/Office/HandoverController.php @@ -0,0 +1,52 @@ +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); + } +} diff --git a/backend/app/Http/Controllers/Admin/Office/NotificationController.php b/backend/app/Http/Controllers/Admin/Office/NotificationController.php new file mode 100644 index 0000000..e23fa77 --- /dev/null +++ b/backend/app/Http/Controllers/Admin/Office/NotificationController.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/backend/app/Http/Controllers/Admin/Report/ReportController.php b/backend/app/Http/Controllers/Admin/Report/ReportController.php new file mode 100644 index 0000000..8e3d9f4 --- /dev/null +++ b/backend/app/Http/Controllers/Admin/Report/ReportController.php @@ -0,0 +1,66 @@ + 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); + } +} diff --git a/backend/app/Http/Controllers/Admin/Report/ReportExportController.php b/backend/app/Http/Controllers/Admin/Report/ReportExportController.php new file mode 100644 index 0000000..412700d --- /dev/null +++ b/backend/app/Http/Controllers/Admin/Report/ReportExportController.php @@ -0,0 +1,34 @@ +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); + } +} diff --git a/backend/app/Models/Kb/KbArticle.php b/backend/app/Models/Kb/KbArticle.php new file mode 100644 index 0000000..ebdfb87 --- /dev/null +++ b/backend/app/Models/Kb/KbArticle.php @@ -0,0 +1,42 @@ + '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'); + } +} diff --git a/backend/app/Models/Kb/KbCategory.php b/backend/app/Models/Kb/KbCategory.php new file mode 100644 index 0000000..5ed3dd3 --- /dev/null +++ b/backend/app/Models/Kb/KbCategory.php @@ -0,0 +1,40 @@ + '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'); + } +} diff --git a/backend/app/Models/Office/Announcement.php b/backend/app/Models/Office/Announcement.php new file mode 100644 index 0000000..c9da731 --- /dev/null +++ b/backend/app/Models/Office/Announcement.php @@ -0,0 +1,33 @@ + 'integer', + 'publish_status' => 'integer', + 'is_top' => 'boolean', + 'published_at' => 'datetime', + ]; + } + + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'author_id'); + } +} diff --git a/backend/app/Models/Office/Approval.php b/backend/app/Models/Office/Approval.php new file mode 100644 index 0000000..dd3eea1 --- /dev/null +++ b/backend/app/Models/Office/Approval.php @@ -0,0 +1,43 @@ + '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); + } +} diff --git a/backend/app/Models/Office/ApprovalNode.php b/backend/app/Models/Office/ApprovalNode.php new file mode 100644 index 0000000..03d0084 --- /dev/null +++ b/backend/app/Models/Office/ApprovalNode.php @@ -0,0 +1,35 @@ + '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'); + } +} diff --git a/backend/app/Models/Office/ApprovalTemplate.php b/backend/app/Models/Office/ApprovalTemplate.php new file mode 100644 index 0000000..ded23ed --- /dev/null +++ b/backend/app/Models/Office/ApprovalTemplate.php @@ -0,0 +1,26 @@ + 'integer', + 'status' => 'integer', + 'sort' => 'integer', + 'form_fields' => 'array', + 'flow_nodes' => 'array', + ]; + } +} diff --git a/backend/app/Models/Office/Handover.php b/backend/app/Models/Office/Handover.php new file mode 100644 index 0000000..50640de --- /dev/null +++ b/backend/app/Models/Office/Handover.php @@ -0,0 +1,40 @@ + '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'); + } +} diff --git a/backend/app/Models/Office/Notification.php b/backend/app/Models/Office/Notification.php new file mode 100644 index 0000000..7c545f5 --- /dev/null +++ b/backend/app/Models/Office/Notification.php @@ -0,0 +1,36 @@ + 'integer', + 'is_read' => 'boolean', + 'read_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/backend/app/Models/Report/ReportExport.php b/backend/app/Models/Report/ReportExport.php new file mode 100644 index 0000000..15db12f --- /dev/null +++ b/backend/app/Models/Report/ReportExport.php @@ -0,0 +1,32 @@ + 'array', + 'status' => 'integer', + ]; + } + + public function operator(): BelongsTo + { + return $this->belongsTo(User::class, 'operator_id'); + } +} diff --git a/backend/database/migrations/2024_01_05_000001_create_office_tables.php b/backend/database/migrations/2024_01_05_000001_create_office_tables.php new file mode 100644 index 0000000..5d49b59 --- /dev/null +++ b/backend/database/migrations/2024_01_05_000001_create_office_tables.php @@ -0,0 +1,105 @@ +id(); + $table->unsignedBigInteger('store_id')->index(); + $table->string('name', 100); + $table->tinyInteger('type')->comment('1=请假 2=采购 3=合同 4=退款 5=换房'); + $table->json('form_fields')->nullable(); + $table->json('flow_nodes')->nullable(); + $table->tinyInteger('status')->default(1); + $table->integer('sort')->default(0); + $table->timestamps(); + }); + + // 审批记录 + Schema::create('approvals', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('store_id')->index(); + $table->unsignedBigInteger('template_id')->nullable(); + $table->string('title'); + $table->unsignedBigInteger('applicant_id')->index(); + $table->json('form_data')->nullable(); + $table->tinyInteger('status')->default(0)->comment('0=待审批 1=审批中 2=已通过 3=已驳回 4=已撤回'); + $table->integer('current_node')->default(0); + $table->string('related_type', 50)->nullable(); + $table->unsignedBigInteger('related_id')->nullable(); + $table->timestamps(); + }); + + // 审批节点 + Schema::create('approval_nodes', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('approval_id')->index(); + $table->integer('node_order')->default(0); + $table->unsignedBigInteger('approver_id'); + $table->tinyInteger('action')->default(0)->comment('0=待处理 1=通过 2=驳回'); + $table->string('comment')->nullable(); + $table->timestamp('acted_at')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + + // 公告 + Schema::create('announcements', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('store_id')->index(); + $table->string('title'); + $table->text('content'); + $table->tinyInteger('type')->default(1)->comment('1=通知 2=公告 3=制度'); + $table->boolean('is_top')->default(false); + $table->tinyInteger('publish_status')->default(0)->comment('0=草稿 1=已发布'); + $table->timestamp('published_at')->nullable(); + $table->unsignedBigInteger('author_id')->nullable(); + $table->timestamps(); + }); + + // 交接班 + Schema::create('handovers', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('store_id')->index(); + $table->date('shift_date'); + $table->tinyInteger('shift_type')->comment('1=早班 2=中班 3=晚班 4=夜班'); + $table->unsignedBigInteger('handover_user_id'); + $table->unsignedBigInteger('receiver_user_id')->nullable(); + $table->text('content'); + $table->text('issues')->nullable(); + $table->tinyInteger('status')->default(0)->comment('0=待确认 1=已确认'); + $table->timestamp('confirmed_at')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + + // 消息通知 + Schema::create('notifications', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('store_id')->index(); + $table->unsignedBigInteger('user_id')->index(); + $table->string('title'); + $table->string('content')->nullable(); + $table->tinyInteger('type')->default(1)->comment('1=系统 2=审批 3=待办 4=提醒'); + $table->string('related_type', 50)->nullable(); + $table->unsignedBigInteger('related_id')->nullable(); + $table->boolean('is_read')->default(false); + $table->timestamp('read_at')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + Schema::dropIfExists('handovers'); + Schema::dropIfExists('announcements'); + Schema::dropIfExists('approval_nodes'); + Schema::dropIfExists('approvals'); + Schema::dropIfExists('approval_templates'); + } +}; diff --git a/backend/database/migrations/2024_01_05_000002_create_report_tables.php b/backend/database/migrations/2024_01_05_000002_create_report_tables.php new file mode 100644 index 0000000..aa87227 --- /dev/null +++ b/backend/database/migrations/2024_01_05_000002_create_report_tables.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('store_id')->index(); + $table->string('name'); + $table->string('type', 50)->comment('report type key'); + $table->json('params')->nullable(); + $table->string('file_path')->nullable(); + $table->tinyInteger('status')->default(0)->comment('0=生成中 1=已完成 2=失败'); + $table->unsignedBigInteger('operator_id')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('report_exports'); + } +}; diff --git a/backend/database/migrations/2024_01_05_000003_create_kb_tables.php b/backend/database/migrations/2024_01_05_000003_create_kb_tables.php new file mode 100644 index 0000000..4018fb9 --- /dev/null +++ b/backend/database/migrations/2024_01_05_000003_create_kb_tables.php @@ -0,0 +1,46 @@ +id(); + $table->unsignedBigInteger('store_id')->index(); + $table->unsignedBigInteger('parent_id')->default(0)->index(); + $table->string('name', 100); + $table->integer('sort')->default(0); + $table->tinyInteger('status')->default(1); + $table->timestamps(); + }); + + Schema::create('kb_articles', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('store_id')->index(); + $table->unsignedBigInteger('category_id')->nullable()->index(); + $table->string('title'); + $table->longText('content')->nullable(); + $table->unsignedBigInteger('author_id')->nullable(); + $table->tinyInteger('status')->default(0)->comment('0=草稿 1=待审核 2=已发布 3=已下架'); + $table->tinyInteger('visibility')->default(1)->comment('1=内部 2=公开'); + $table->string('cover_image')->nullable(); + $table->json('attachments')->nullable(); + $table->unsignedInteger('views')->default(0); + $table->unsignedInteger('likes')->default(0); + $table->unsignedInteger('favorites')->default(0); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + $table->fullText(['title', 'content']); + }); + } + + public function down(): void + { + Schema::dropIfExists('kb_articles'); + Schema::dropIfExists('kb_categories'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 3fa0096..fb575e3 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -55,6 +55,15 @@ use App\Http\Controllers\Admin\Hr\ScheduleController; use App\Http\Controllers\Admin\Hr\AttendanceRecordController; use App\Http\Controllers\Admin\Hr\LeaveRequestController; use App\Http\Controllers\Admin\Hr\SalaryRecordController; +use App\Http\Controllers\Admin\Office\ApprovalTemplateController; +use App\Http\Controllers\Admin\Office\ApprovalController; +use App\Http\Controllers\Admin\Office\AnnouncementController; +use App\Http\Controllers\Admin\Office\HandoverController; +use App\Http\Controllers\Admin\Office\NotificationController; +use App\Http\Controllers\Admin\Report\ReportController; +use App\Http\Controllers\Admin\Report\ReportExportController; +use App\Http\Controllers\Admin\Kb\KbCategoryController; +use App\Http\Controllers\Admin\Kb\KbArticleController; /* |-------------------------------------------------------------------------- @@ -308,4 +317,55 @@ Route::middleware(['auth:sanctum', 'store', 'oplog'])->group(function () { Route::put('salaries/{salaryRecord}/confirm', [SalaryRecordController::class, 'confirm']); Route::put('salaries/{salaryRecord}/pay', [SalaryRecordController::class, 'pay']); }); + + // --- 办公协同模块 --- + Route::prefix('office')->group(function () { + // 审批模板 + Route::apiResource('approval-templates', ApprovalTemplateController::class); + + // 审批管理 + Route::apiResource('approvals', ApprovalController::class)->except(['update']); + Route::put('approvals/{approval}/approve', [ApprovalController::class, 'approve']); + Route::put('approvals/{approval}/reject', [ApprovalController::class, 'reject']); + Route::put('approvals/{approval}/withdraw', [ApprovalController::class, 'withdraw']); + + // 公告管理 + Route::apiResource('announcements', AnnouncementController::class); + Route::put('announcements/{announcement}/publish', [AnnouncementController::class, 'publish']); + + // 交接班 + Route::get('handovers', [HandoverController::class, 'index']); + Route::post('handovers', [HandoverController::class, 'store']); + Route::get('handovers/{handover}', [HandoverController::class, 'show']); + Route::put('handovers/{handover}/confirm', [HandoverController::class, 'confirm']); + + // 消息通知 + Route::get('notifications', [NotificationController::class, 'index']); + Route::put('notifications/{notification}/read', [NotificationController::class, 'markRead']); + Route::put('notifications/read-all', [NotificationController::class, 'markAllRead']); + }); + + // --- 统计报表模块 --- + Route::prefix('report')->group(function () { + // 聚合报表 + Route::get('overview', [ReportController::class, 'overview']); + Route::get('revenue', [ReportController::class, 'revenue']); + Route::get('occupancy', [ReportController::class, 'occupancy']); + Route::get('crm-conversion', [ReportController::class, 'crmConversion']); + + // 导出记录 + Route::get('exports', [ReportExportController::class, 'index']); + Route::post('exports', [ReportExportController::class, 'store']); + }); + + // --- 知识库模块 --- + Route::prefix('kb')->group(function () { + // 知识分类 + Route::apiResource('categories', KbCategoryController::class); + + // 知识文章 + Route::apiResource('articles', KbArticleController::class); + Route::put('articles/{kbArticle}/publish', [KbArticleController::class, 'publish']); + Route::put('articles/{kbArticle}/offline', [KbArticleController::class, 'offline']); + }); }); diff --git a/frontend/src/api/kb.js b/frontend/src/api/kb.js new file mode 100644 index 0000000..c233673 --- /dev/null +++ b/frontend/src/api/kb.js @@ -0,0 +1,21 @@ +import request from '@/utils/request.js' + +// ─── KB Categories ─────────────────────────────────────────────────────────── +export const kbCategoryApi = { + getList: (params) => request.get('/kb/categories', { params }), + getDetail: (id) => request.get(`/kb/categories/${id}`), + create: (data) => request.post('/kb/categories', data), + update: (id, data) => request.put(`/kb/categories/${id}`, data), + delete: (id) => request.delete(`/kb/categories/${id}`) +} + +// ─── KB Articles ───────────────────────────────────────────────────────────── +export const kbArticleApi = { + getList: (params) => request.get('/kb/articles', { params }), + getDetail: (id) => request.get(`/kb/articles/${id}`), + create: (data) => request.post('/kb/articles', data), + update: (id, data) => request.put(`/kb/articles/${id}`, data), + delete: (id) => request.delete(`/kb/articles/${id}`), + publish: (id) => request.put(`/kb/articles/${id}/publish`), + offline: (id) => request.put(`/kb/articles/${id}/offline`) +} diff --git a/frontend/src/api/office.js b/frontend/src/api/office.js new file mode 100644 index 0000000..9d02382 --- /dev/null +++ b/frontend/src/api/office.js @@ -0,0 +1,44 @@ +import request from '@/utils/request.js' + +// ─── Approval Templates ────────────────────────────────────────────────────── +export const approvalTemplateApi = { + getList: (params) => request.get('/office/approval-templates', { params }), + getDetail: (id) => request.get(`/office/approval-templates/${id}`), + create: (data) => request.post('/office/approval-templates', data), + update: (id, data) => request.put(`/office/approval-templates/${id}`, data), + delete: (id) => request.delete(`/office/approval-templates/${id}`) +} + +// ─── Approvals ─────────────────────────────────────────────────────────────── +export const approvalApi = { + getList: (params) => request.get('/office/approvals', { params }), + getDetail: (id) => request.get(`/office/approvals/${id}`), + create: (data) => request.post('/office/approvals', data), + approve: (id, data) => request.put(`/office/approvals/${id}/approve`, data), + reject: (id, data) => request.put(`/office/approvals/${id}/reject`, data), + withdraw: (id) => request.put(`/office/approvals/${id}/withdraw`) +} + +// ─── Announcements ─────────────────────────────────────────────────────────── +export const announcementApi = { + getList: (params) => request.get('/office/announcements', { params }), + getDetail: (id) => request.get(`/office/announcements/${id}`), + create: (data) => request.post('/office/announcements', data), + update: (id, data) => request.put(`/office/announcements/${id}`, data), + delete: (id) => request.delete(`/office/announcements/${id}`), + publish: (id) => request.put(`/office/announcements/${id}/publish`) +} + +// ─── Handovers ─────────────────────────────────────────────────────────────── +export const handoverApi = { + getList: (params) => request.get('/office/handovers', { params }), + create: (data) => request.post('/office/handovers', data), + confirm: (id) => request.put(`/office/handovers/${id}/confirm`) +} + +// ─── Notifications ─────────────────────────────────────────────────────────── +export const notificationApi = { + getList: (params) => request.get('/office/notifications', { params }), + markRead: (id) => request.put(`/office/notifications/${id}/read`), + markAllRead: () => request.put('/office/notifications/read-all') +} diff --git a/frontend/src/api/report.js b/frontend/src/api/report.js new file mode 100644 index 0000000..11553a8 --- /dev/null +++ b/frontend/src/api/report.js @@ -0,0 +1,15 @@ +import request from '@/utils/request.js' + +// ─── Report Dashboards ─────────────────────────────────────────────────────── +export const reportApi = { + overview: (params) => request.get('/report/overview', { params }), + revenue: (params) => request.get('/report/revenue', { params }), + occupancy: (params) => request.get('/report/occupancy', { params }), + crmConversion: (params) => request.get('/report/crm-conversion', { params }) +} + +// ─── Report Exports ────────────────────────────────────────────────────────── +export const reportExportApi = { + getList: (params) => request.get('/report/exports', { params }), + create: (data) => request.post('/report/exports', data) +} diff --git a/frontend/src/views/kb/articles/index.vue b/frontend/src/views/kb/articles/index.vue new file mode 100644 index 0000000..e3b4df2 --- /dev/null +++ b/frontend/src/views/kb/articles/index.vue @@ -0,0 +1,174 @@ + + + diff --git a/frontend/src/views/kb/categories/index.vue b/frontend/src/views/kb/categories/index.vue new file mode 100644 index 0000000..baf0328 --- /dev/null +++ b/frontend/src/views/kb/categories/index.vue @@ -0,0 +1,127 @@ + + + diff --git a/frontend/src/views/office/announcements/index.vue b/frontend/src/views/office/announcements/index.vue new file mode 100644 index 0000000..7c0d568 --- /dev/null +++ b/frontend/src/views/office/announcements/index.vue @@ -0,0 +1,148 @@ + + + diff --git a/frontend/src/views/office/approval-templates/index.vue b/frontend/src/views/office/approval-templates/index.vue new file mode 100644 index 0000000..b4141e4 --- /dev/null +++ b/frontend/src/views/office/approval-templates/index.vue @@ -0,0 +1,174 @@ + + + diff --git a/frontend/src/views/office/approvals/index.vue b/frontend/src/views/office/approvals/index.vue new file mode 100644 index 0000000..0db6473 --- /dev/null +++ b/frontend/src/views/office/approvals/index.vue @@ -0,0 +1,149 @@ + + + diff --git a/frontend/src/views/office/handovers/index.vue b/frontend/src/views/office/handovers/index.vue new file mode 100644 index 0000000..94faa02 --- /dev/null +++ b/frontend/src/views/office/handovers/index.vue @@ -0,0 +1,133 @@ + + + diff --git a/frontend/src/views/office/notifications/index.vue b/frontend/src/views/office/notifications/index.vue new file mode 100644 index 0000000..da3d9e5 --- /dev/null +++ b/frontend/src/views/office/notifications/index.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/frontend/src/views/report/exports/index.vue b/frontend/src/views/report/exports/index.vue new file mode 100644 index 0000000..4974dd6 --- /dev/null +++ b/frontend/src/views/report/exports/index.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/frontend/src/views/report/overview/index.vue b/frontend/src/views/report/overview/index.vue new file mode 100644 index 0000000..7d638c8 --- /dev/null +++ b/frontend/src/views/report/overview/index.vue @@ -0,0 +1,138 @@ + + + + +