Compare commits
36
Commits
ee2b1757d0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c778639f05 | ||
|
|
a29faa3b47 | ||
|
|
a163ac1766 | ||
|
|
d104286a37 | ||
|
|
f97fe4d9d3 | ||
|
|
893191880f | ||
|
|
d3f3076db8 | ||
|
|
5474ebe96c | ||
|
|
21d70c40c6 | ||
|
|
2327209e95 | ||
|
|
1141486129 | ||
|
|
1080f6bd2c | ||
|
|
740fe251e3 | ||
|
|
9aa23638a9 | ||
|
|
862705847e | ||
|
|
4cdfeb034c | ||
|
|
1c1e44ade7 | ||
|
|
29d5b9e7db | ||
|
|
66ea2f558d | ||
|
|
6bf79d6e65 | ||
|
|
c3011d0636 | ||
|
|
78bebe134e | ||
|
|
b5c493333c | ||
|
|
0e974da09c | ||
|
|
54d2f617bb | ||
|
|
94b46611d3 | ||
|
|
f7adb5f609 | ||
|
|
444065d496 | ||
|
|
f12cb4a2e0 | ||
|
|
12a1be5571 | ||
|
|
b590fc88a4 | ||
|
|
39234a0f4d | ||
|
|
0f721bcc09 | ||
|
|
f7eaedd022 | ||
|
|
7d2b906e77 | ||
|
|
5cc8df4d68 |
@@ -25,3 +25,4 @@ docker/redis/data/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
.ace-tool/
|
||||
|
||||
@@ -86,6 +86,25 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取门店部门列表(公开接口,注册时使用)
|
||||
*/
|
||||
public function departments(Request $request): JsonResponse
|
||||
{
|
||||
$storeId = $request->input('store_id');
|
||||
if (!$storeId) {
|
||||
return $this->error('请选择门店', 42200);
|
||||
}
|
||||
|
||||
$departments = \App\Models\Department::withoutGlobalScope('store')
|
||||
->where('store_id', $storeId)
|
||||
->where('status', 1)
|
||||
->orderBy('sort')
|
||||
->get(['id', 'name', 'parent_id']);
|
||||
|
||||
return $this->success($departments);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用注册套餐列表(公开接口)
|
||||
*/
|
||||
@@ -118,6 +137,7 @@ class AuthController extends Controller
|
||||
'password' => 'required|string|min:6',
|
||||
'name' => 'required|string|max:50',
|
||||
'phone' => 'required|string|max:20',
|
||||
'department_id' => 'nullable|exists:departments,id',
|
||||
]);
|
||||
|
||||
// 验证套餐属于该门店
|
||||
@@ -133,6 +153,7 @@ class AuthController extends Controller
|
||||
'password' => $data['password'],
|
||||
'name' => $data['name'],
|
||||
'phone' => $data['phone'],
|
||||
'department_id' => $data['department_id'] ?? null,
|
||||
'status' => 2, // Pending
|
||||
]);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class CareExceptionController extends Controller
|
||||
$query = CareException::query()->with(['profile.customer', 'reporter', 'handler']);
|
||||
$query->when($request->care_profile_id, fn($q, $v) => $q->where('care_profile_id', $v));
|
||||
$query->when($request->level, fn($q, $v) => $q->where('level', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class CarePlanController extends Controller
|
||||
$query->when($request->care_profile_id, fn($q, $v) => $q->where('care_profile_id', $v));
|
||||
$query->when($request->plan_date, fn($q, $v) => $q->whereDate('plan_date', $v));
|
||||
$query->when($request->nurse_id, fn($q, $v) => $q->where('nurse_id', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class ChannelController extends Controller
|
||||
$query = Channel::query();
|
||||
$query->when($request->name, fn($q, $v) => $q->where('name', 'like', "%{$v}%"));
|
||||
$query->when($request->type, fn($q, $v) => $q->where('type', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class ComplaintController extends Controller
|
||||
$query = Complaint::query()->with(['customer', 'handler']);
|
||||
$query->when($request->customer_id, fn($q, $v) => $q->where('customer_id', $v));
|
||||
$query->when($request->type, fn($q, $v) => $q->where('type', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class ContractController extends Controller
|
||||
$query = Contract::query()->with(['customer', 'auditor']);
|
||||
$query->when($request->contract_no, fn($q, $v) => $q->where('contract_no', 'like', "%{$v}%"));
|
||||
$query->when($request->customer_id, fn($q, $v) => $q->where('customer_id', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
@@ -83,4 +83,16 @@ class CustomerController extends Controller
|
||||
$customer->delete();
|
||||
return $this->success(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置/重置客户端登录密码
|
||||
*/
|
||||
public function setPassword(Request $request, Customer $customer): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'password' => 'required|string|min:6',
|
||||
]);
|
||||
$customer->update(['password' => $request->password]);
|
||||
return $this->success(null, '密码设置成功');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class QuestionnaireController extends Controller
|
||||
$query = QuestionnaireTemplate::query();
|
||||
$query->when($request->title, fn($q, $v) => $q->where('title', 'like', "%{$v}%"));
|
||||
$query->when($request->type, fn($q, $v) => $q->where('type', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class FinanceCategoryController extends Controller
|
||||
{
|
||||
$query = FinanceCategory::query();
|
||||
$query->when($request->type, fn($q, $v) => $q->where('type', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
$categories = $query->orderBy('sort')->get();
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class FinanceRecordController extends Controller
|
||||
$query = FinanceRecord::query()->with(['category']);
|
||||
$query->when($request->type, fn($q, $v) => $q->where('type', $v));
|
||||
$query->when($request->category_id, fn($q, $v) => $q->where('category_id', $v));
|
||||
$query->when($request->has('audit_status'), fn($q) => $q->where('audit_status', $request->audit_status));
|
||||
$query->when($request->filled('audit_status'), fn($q) => $q->where('audit_status', $request->audit_status));
|
||||
$query->when($request->start_date, fn($q, $v) => $q->where('record_date', '>=', $v));
|
||||
$query->when($request->end_date, fn($q, $v) => $q->where('record_date', '<=', $v));
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class InvoiceController extends Controller
|
||||
$query = Invoice::query()->with(['customer']);
|
||||
$query->when($request->invoice_no, fn($q, $v) => $q->where('invoice_no', 'like', "%{$v}%"));
|
||||
$query->when($request->customer_id, fn($q, $v) => $q->where('customer_id', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->type, fn($q, $v) => $q->where('type', $v));
|
||||
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
|
||||
@@ -13,7 +13,7 @@ class PrepaidCardController extends Controller
|
||||
{
|
||||
$query = PrepaidCard::query();
|
||||
$query->when($request->name, fn($q, $v) => $q->where('name', 'like', "%{$v}%"));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
return $this->paginate($query->orderBy('sort')->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class AttendanceRecordController extends Controller
|
||||
$query->when($request->user_id, fn($q, $v) => $q->where('user_id', $v));
|
||||
$query->when($request->start_date, fn($q, $v) => $q->where('attendance_date', '>=', $v));
|
||||
$query->when($request->end_date, fn($q, $v) => $q->where('attendance_date', '<=', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
return $this->paginate($query->latest('attendance_date')->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class EmployeeProfileController extends Controller
|
||||
{
|
||||
$query = EmployeeProfile::query()->with('user');
|
||||
$query->when($request->user_id, fn($q, $v) => $q->where('user_id', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class LeaveRequestController extends Controller
|
||||
$query = LeaveRequest::query()->with('user');
|
||||
$query->when($request->user_id, fn($q, $v) => $q->where('user_id', $v));
|
||||
$query->when($request->type, fn($q, $v) => $q->where('type', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class SalaryRecordController extends Controller
|
||||
$query = SalaryRecord::query()->with('user');
|
||||
$query->when($request->user_id, fn($q, $v) => $q->where('user_id', $v));
|
||||
$query->when($request->year_month, fn($q, $v) => $q->where('year_month', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class InventoryCheckController extends Controller
|
||||
$query = InventoryCheck::query()
|
||||
->with(['warehouse', 'operator'])
|
||||
->when($request->warehouse_id, fn($q, $v) => $q->where('warehouse_id', $v))
|
||||
->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ class PurchaseOrderController extends Controller
|
||||
$query = PurchaseOrder::query()->with(['supplier','auditor']);
|
||||
$query->when($request->order_no, fn($q, $v) => $q->where('order_no', 'like', "%{$v}%"));
|
||||
$query->when($request->supplier_id, fn($q, $v) => $q->where('supplier_id', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$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
|
||||
|
||||
@@ -13,7 +13,7 @@ class StockMovementController extends Controller
|
||||
$query = StockMovement::query()->with('warehouse');
|
||||
$query->when($request->warehouse_id, fn($q, $v) => $q->where('warehouse_id', $v));
|
||||
$query->when($request->type, fn($q, $v) => $q->where('type', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$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
|
||||
|
||||
@@ -14,7 +14,7 @@ class TransferOrderController extends Controller
|
||||
$query = TransferOrder::query()
|
||||
->with(['fromWarehouse', 'toWarehouse', 'operator'])
|
||||
->when($request->transfer_no, fn($q, $v) => $q->where('transfer_no', 'like', "%{$v}%"))
|
||||
->when($request->has('status'), fn($q) => $q->where('status', $request->status))
|
||||
->when($request->filled('status'), fn($q) => $q->where('status', $request->status))
|
||||
->when($request->from_warehouse_id, fn($q, $v) => $q->where('from_warehouse_id', $v));
|
||||
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
|
||||
@@ -15,7 +15,7 @@ class DailyMealPlanController extends Controller
|
||||
$query->when($request->customer_id, fn($q, $v) => $q->where('customer_id', $v));
|
||||
$query->when($request->plan_date, fn($q, $v) => $q->whereDate('plan_date', $v));
|
||||
$query->when($request->meal_type, fn($q, $v) => $q->where('meal_type', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 30)));
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class DishController extends Controller
|
||||
$query = Dish::query();
|
||||
$query->when($request->name, fn($q, $v) => $q->where('name', 'like', "%{$v}%"));
|
||||
$query->when($request->category, fn($q, $v) => $q->where('category', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class MealPlanTemplateController extends Controller
|
||||
$query = MealPlanTemplate::query();
|
||||
$query->when($request->name, fn($q, $v) => $q->where('name', 'like', "%{$v}%"));
|
||||
$query->when($request->stage, fn($q, $v) => $q->where('stage', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ class NannyOrderController extends Controller
|
||||
$query = NannyOrder::query()->with(['customer','nanny']);
|
||||
$query->when($request->customer_id, fn($q, $v) => $q->where('customer_id', $v));
|
||||
$query->when($request->nanny_id, fn($q, $v) => $q->where('nanny_id', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ 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->filled('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)));
|
||||
|
||||
@@ -13,7 +13,7 @@ class ApprovalTemplateController extends Controller
|
||||
{
|
||||
$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));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
return $this->paginate($query->orderBy('sort')->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class ReservationController extends Controller
|
||||
$query = Reservation::query()->with(['customer', 'room.roomType']);
|
||||
$query->when($request->customer_id, fn($q, $v) => $q->where('customer_id', $v));
|
||||
$query->when($request->room_id, fn($q, $v) => $q->where('room_id', $v));
|
||||
$query->when($request->status, fn($q, $v) => $q->where('status', $v));
|
||||
$query->when($request->filled('status'), fn($q, $v) => $q->where('status', $v));
|
||||
$query->when($request->check_in_date, fn($q, $v) => $q->whereDate('check_in_date', $v));
|
||||
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
|
||||
@@ -13,7 +13,7 @@ class RoomTypeController extends Controller
|
||||
{
|
||||
$query = RoomType::query()->withCount('rooms');
|
||||
$query->when($request->name, fn($q, $v) => $q->where('name', 'like', "%{$v}%"));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
|
||||
return $this->paginate($query->orderBy('sort')->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ class ServiceExecutionController extends Controller
|
||||
$query = ServiceExecution::query()->with(['order','serviceItem','customer','technician']);
|
||||
$query->when($request->service_order_id, fn($q, $v) => $q->where('service_order_id', $v));
|
||||
$query->when($request->technician_id, fn($q, $v) => $q->where('technician_id', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ class ServiceItemController extends Controller
|
||||
$query = ServiceItem::query();
|
||||
$query->when($request->name, fn($q, $v) => $q->where('name', 'like', "%{$v}%"));
|
||||
$query->when($request->category, fn($q, $v) => $q->where('category', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class ServiceOrderController extends Controller
|
||||
$query->when($request->order_no, fn($q, $v) => $q->where('order_no', 'like', "%{$v}%"));
|
||||
$query->when($request->customer_id, fn($q, $v) => $q->where('customer_id', $v));
|
||||
$query->when($request->type, fn($q, $v) => $q->where('type', $v));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class ServicePackageController extends Controller
|
||||
{
|
||||
$query = ServicePackage::query();
|
||||
$query->when($request->name, fn($q, $v) => $q->where('name', 'like', "%{$v}%"));
|
||||
$query->when($request->has('status'), fn($q) => $q->where('status', $request->status));
|
||||
$query->when($request->filled('status'), fn($q) => $q->where('status', $request->status));
|
||||
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
|
||||
}
|
||||
|
||||
|
||||
@@ -22,12 +22,14 @@ class DepartmentController extends Controller
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'parent_id' => 'integer',
|
||||
'parent_id' => 'nullable|integer',
|
||||
'name' => 'required|string|max:50',
|
||||
'sort' => 'integer',
|
||||
'sort' => 'nullable|integer',
|
||||
'status' => 'in:0,1',
|
||||
]);
|
||||
|
||||
$data['parent_id'] = $data['parent_id'] ?? 0;
|
||||
$data['sort'] = $data['sort'] ?? 0;
|
||||
$data['store_id'] = $data['store_id'] ?? auth()->user()->store_id;
|
||||
$department = Department::create($data);
|
||||
return $this->success($department, '创建成功');
|
||||
@@ -36,9 +38,9 @@ class DepartmentController extends Controller
|
||||
public function update(Request $request, Department $department): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'parent_id' => 'integer',
|
||||
'parent_id' => 'nullable|integer',
|
||||
'name' => 'string|max:50',
|
||||
'sort' => 'integer',
|
||||
'sort' => 'nullable|integer',
|
||||
'status' => 'in:0,1',
|
||||
]);
|
||||
|
||||
|
||||
@@ -44,11 +44,12 @@ class DictionaryController extends Controller
|
||||
$data = $request->validate([
|
||||
'label' => 'required|string|max:100',
|
||||
'value' => 'required|string|max:100',
|
||||
'sort' => 'integer',
|
||||
'sort' => 'nullable|integer',
|
||||
'status' => 'in:0,1',
|
||||
]);
|
||||
|
||||
$data['dictionary_id'] = $dictionary->id;
|
||||
$data['sort'] = $data['sort'] ?? 0;
|
||||
$item = DictionaryItem::create($data);
|
||||
return $this->success($item, '创建成功');
|
||||
}
|
||||
@@ -61,7 +62,7 @@ class DictionaryController extends Controller
|
||||
$data = $request->validate([
|
||||
'label' => 'string|max:100',
|
||||
'value' => 'string|max:100',
|
||||
'sort' => 'integer',
|
||||
'sort' => 'nullable|integer',
|
||||
'status' => 'in:0,1',
|
||||
]);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class MenuController extends Controller
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'parent_id' => 'integer',
|
||||
'parent_id' => 'nullable|integer',
|
||||
'name' => 'required|string|max:50',
|
||||
'path' => 'nullable|string|max:255',
|
||||
'component' => 'nullable|string|max:255',
|
||||
@@ -28,10 +28,12 @@ class MenuController extends Controller
|
||||
'permission_code' => 'nullable|string|max:100',
|
||||
'type' => 'required|in:1,2,3',
|
||||
'visible' => 'in:0,1',
|
||||
'sort' => 'integer',
|
||||
'sort' => 'nullable|integer',
|
||||
'status' => 'in:0,1',
|
||||
]);
|
||||
|
||||
$data['parent_id'] = $data['parent_id'] ?? 0;
|
||||
$data['sort'] = $data['sort'] ?? 0;
|
||||
$menu = Menu::create($data);
|
||||
return $this->success($menu, '创建成功');
|
||||
}
|
||||
@@ -39,7 +41,7 @@ class MenuController extends Controller
|
||||
public function update(Request $request, Menu $menu): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'parent_id' => 'integer',
|
||||
'parent_id' => 'nullable|integer',
|
||||
'name' => 'string|max:50',
|
||||
'path' => 'nullable|string|max:255',
|
||||
'component' => 'nullable|string|max:255',
|
||||
@@ -47,7 +49,7 @@ class MenuController extends Controller
|
||||
'permission_code' => 'nullable|string|max:100',
|
||||
'type' => 'in:1,2,3',
|
||||
'visible' => 'in:0,1',
|
||||
'sort' => 'integer',
|
||||
'sort' => 'nullable|integer',
|
||||
'status' => 'in:0,1',
|
||||
]);
|
||||
|
||||
|
||||
@@ -20,15 +20,17 @@ class PermissionController extends Controller
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'parent_id' => 'integer',
|
||||
'parent_id' => 'nullable|integer',
|
||||
'name' => 'required|string|max:50',
|
||||
'code' => 'required|string|max:100|unique:permissions,code',
|
||||
'module' => 'nullable|string|max:50',
|
||||
'type' => 'required|in:1,2,3',
|
||||
'description' => 'nullable|string|max:255',
|
||||
'sort' => 'integer',
|
||||
'sort' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$data['parent_id'] = $data['parent_id'] ?? 0;
|
||||
$data['sort'] = $data['sort'] ?? 0;
|
||||
$permission = Permission::create($data);
|
||||
return $this->success($permission, '创建成功');
|
||||
}
|
||||
@@ -36,13 +38,13 @@ class PermissionController extends Controller
|
||||
public function update(Request $request, Permission $permission): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'parent_id' => 'integer',
|
||||
'parent_id' => 'nullable|integer',
|
||||
'name' => 'string|max:50',
|
||||
'code' => 'string|max:100|unique:permissions,code,' . $permission->id,
|
||||
'module' => 'nullable|string|max:50',
|
||||
'type' => 'in:1,2,3',
|
||||
'description' => 'nullable|string|max:255',
|
||||
'sort' => 'integer',
|
||||
'sort' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$permission->update($data);
|
||||
|
||||
@@ -21,10 +21,14 @@ class PositionController extends Controller
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'sort' => 'integer',
|
||||
'code' => 'nullable|string|max:50',
|
||||
'sort' => 'nullable|integer',
|
||||
'status' => 'in:0,1',
|
||||
'description' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$data['store_id'] = auth()->user()->store_id;
|
||||
$data['sort'] = $data['sort'] ?? 0;
|
||||
$position = Position::create($data);
|
||||
return $this->success($position, '创建成功');
|
||||
}
|
||||
@@ -33,7 +37,10 @@ class PositionController extends Controller
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'string|max:50',
|
||||
'sort' => 'integer',
|
||||
'code' => 'nullable|string|max:50',
|
||||
'sort' => 'nullable|integer',
|
||||
'status' => 'in:0,1',
|
||||
'description' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$position->update($data);
|
||||
|
||||
@@ -13,7 +13,7 @@ class RegistrationPackageController extends Controller
|
||||
{
|
||||
$packages = RegistrationPackage::when($request->keyword, fn($q) =>
|
||||
$q->where('name', 'like', "%{$request->keyword}%"))
|
||||
->when($request->has('status'), fn($q) =>
|
||||
->when($request->filled('status'), fn($q) =>
|
||||
$q->where('status', $request->status))
|
||||
->orderBy('sort')->orderByDesc('id')
|
||||
->paginate($request->input('page_size', 20));
|
||||
|
||||
@@ -24,7 +24,7 @@ class UserController extends Controller
|
||||
if ($request->department_id) {
|
||||
$query->where('department_id', $request->department_id);
|
||||
}
|
||||
if ($request->has('status')) {
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
@@ -47,12 +47,20 @@ class UserController extends Controller
|
||||
'status' => 'in:0,1,2',
|
||||
'role_ids' => 'array',
|
||||
'role_ids.*' => 'exists:roles,id',
|
||||
'store_id' => 'nullable|exists:stores,id',
|
||||
]);
|
||||
|
||||
$roleIds = $data['role_ids'] ?? [];
|
||||
unset($data['role_ids']);
|
||||
|
||||
$data['store_id'] = auth()->user()->store_id;
|
||||
$authUser = auth()->user();
|
||||
// 超级管理员可指定 store_id,普通管理员只能在自己门店下创建
|
||||
if ($authUser->is_super && !empty($data['store_id'])) {
|
||||
// use provided store_id
|
||||
} else {
|
||||
$data['store_id'] = $authUser->store_id;
|
||||
}
|
||||
|
||||
$user = User::create($data);
|
||||
|
||||
if ($roleIds) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Client;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Crm\Customer;
|
||||
use App\Models\Finance\AccountTransaction;
|
||||
use App\Models\Finance\CustomerAccount;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AccountController extends Controller
|
||||
{
|
||||
/**
|
||||
* 我的账户列表
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
/** @var Customer $customer */
|
||||
$customer = $request->user();
|
||||
|
||||
$accounts = CustomerAccount::withoutGlobalScope('store')
|
||||
->where('customer_id', $customer->id)
|
||||
->get();
|
||||
|
||||
return $this->success($accounts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户流水记录(分页)
|
||||
*/
|
||||
public function transactions(Request $request): JsonResponse
|
||||
{
|
||||
/** @var Customer $customer */
|
||||
$customer = $request->user();
|
||||
|
||||
$query = AccountTransaction::withoutGlobalScope('store')
|
||||
->whereHas('account', fn ($q) => $q->where('customer_id', $customer->id));
|
||||
|
||||
if ($request->filled('account_id')) {
|
||||
$query->where('customer_account_id', (int) $request->input('account_id'));
|
||||
}
|
||||
|
||||
return $this->paginate(
|
||||
$query->orderByDesc('id')->paginate(20)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Client;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Crm\Customer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
/**
|
||||
* 客户端登录(手机号 + 密码)
|
||||
*/
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'phone' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
$customer = Customer::withoutGlobalScope('store')
|
||||
->where('phone', $request->phone)
|
||||
->first();
|
||||
|
||||
if (!$customer || !$customer->password) {
|
||||
return $this->error('手机号或密码错误', 40100, 401);
|
||||
}
|
||||
|
||||
if (!\Hash::check($request->password, $customer->password)) {
|
||||
return $this->error('手机号或密码错误', 40100, 401);
|
||||
}
|
||||
|
||||
if (in_array($customer->status, [4, 5])) {
|
||||
return $this->error('账号已停用,请联系门店', 40101, 403);
|
||||
}
|
||||
|
||||
// 清除旧 client token
|
||||
$customer->tokens()->where('name', 'client')->delete();
|
||||
|
||||
$token = $customer->createToken('client')->plainTextToken;
|
||||
$customer->update(['last_login_at' => now()]);
|
||||
|
||||
return $this->success([
|
||||
'token' => $token,
|
||||
'customer' => $this->formatCustomer($customer),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
*/
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
return $this->success(null, '退出成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前客户信息
|
||||
*/
|
||||
public function me(Request $request): JsonResponse
|
||||
{
|
||||
$customer = $request->user()->load(['families', 'contracts']);
|
||||
return $this->success($this->formatCustomer($customer));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
public function changePassword(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'old_password' => 'required|string',
|
||||
'new_password' => 'required|string|min:6',
|
||||
]);
|
||||
|
||||
$customer = $request->user();
|
||||
if (!\Hash::check($request->old_password, $customer->password)) {
|
||||
return $this->error('原密码不正确', 40001);
|
||||
}
|
||||
|
||||
$customer->update(['password' => $request->new_password]);
|
||||
return $this->success(null, '密码修改成功');
|
||||
}
|
||||
|
||||
private function formatCustomer(Customer $c): array
|
||||
{
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'phone' => $c->phone,
|
||||
'wechat' => $c->wechat,
|
||||
'store_id' => $c->store_id,
|
||||
'expected_date' => $c->expected_date?->toDateString(),
|
||||
'actual_date' => $c->actual_date?->toDateString(),
|
||||
'baby_count' => $c->baby_count,
|
||||
'status' => $c->status,
|
||||
'families' => $c->relationLoaded('families') ? $c->families : [],
|
||||
'contracts' => $c->relationLoaded('contracts') ? $c->contracts : [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Client;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Care\CareProfile;
|
||||
use App\Models\Care\CareRecord;
|
||||
use App\Models\Care\HealthMetric;
|
||||
use App\Models\Crm\Customer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CareController extends Controller
|
||||
{
|
||||
/**
|
||||
* 获取当前客户护理档案
|
||||
*/
|
||||
public function profile(Request $request): JsonResponse
|
||||
{
|
||||
/** @var Customer $customer */
|
||||
$customer = $request->user();
|
||||
|
||||
$profile = CareProfile::withoutGlobalScope('store')
|
||||
->where('customer_id', $customer->id)
|
||||
->first();
|
||||
|
||||
if ($profile) {
|
||||
$profile->load(['plans']);
|
||||
}
|
||||
|
||||
return $this->success($profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 护理记录列表(分页)
|
||||
*/
|
||||
public function records(Request $request): JsonResponse
|
||||
{
|
||||
/** @var Customer $customer */
|
||||
$customer = $request->user();
|
||||
|
||||
$profile = CareProfile::withoutGlobalScope('store')
|
||||
->where('customer_id', $customer->id)
|
||||
->first();
|
||||
|
||||
if (!$profile) {
|
||||
return $this->paginate(
|
||||
CareRecord::withoutGlobalScope('store')->whereRaw('0=1')->paginate(10)
|
||||
);
|
||||
}
|
||||
|
||||
$query = CareRecord::withoutGlobalScope('store')
|
||||
->where('care_profile_id', $profile->id)
|
||||
->with('nurse:id,name');
|
||||
|
||||
if ($request->filled('type')) {
|
||||
$query->where('type', (int) $request->input('type'));
|
||||
}
|
||||
|
||||
return $this->paginate($query->latest('recorded_at')->paginate(10));
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康指标列表(最近30条)
|
||||
*/
|
||||
public function metrics(Request $request): JsonResponse
|
||||
{
|
||||
/** @var Customer $customer */
|
||||
$customer = $request->user();
|
||||
|
||||
$profile = CareProfile::withoutGlobalScope('store')
|
||||
->where('customer_id', $customer->id)
|
||||
->first();
|
||||
|
||||
if (!$profile) {
|
||||
return $this->success([]);
|
||||
}
|
||||
|
||||
$query = HealthMetric::withoutGlobalScope('store')
|
||||
->where('care_profile_id', $profile->id);
|
||||
|
||||
if ($request->filled('metric_type')) {
|
||||
$query->where('metric_type', (string) $request->input('metric_type'));
|
||||
}
|
||||
|
||||
$limit = (int) $request->input('limit', 30);
|
||||
$limit = max(1, min($limit, 50));
|
||||
|
||||
$list = $query->orderByDesc('recorded_at')->limit($limit)->get();
|
||||
|
||||
return $this->success($list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Client;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Crm\Complaint;
|
||||
use App\Models\Crm\Customer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ComplaintController extends Controller
|
||||
{
|
||||
/**
|
||||
* 我的投诉列表(分页)
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
/** @var Customer $customer */
|
||||
$customer = $request->user();
|
||||
|
||||
$query = Complaint::withoutGlobalScope('store')
|
||||
->where('customer_id', $customer->id);
|
||||
|
||||
return $this->paginate(
|
||||
$query->orderByDesc('id')->paginate(10)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交投诉
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
/** @var Customer $customer */
|
||||
$customer = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'type' => 'required|integer|in:1,2,3,4',
|
||||
'content' => 'required|string|max:1000',
|
||||
'images' => 'nullable|array',
|
||||
'images.*' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$complaint = Complaint::create([
|
||||
'customer_id' => $customer->id,
|
||||
'store_id' => $customer->store_id,
|
||||
'type' => (int) $validated['type'],
|
||||
'content' => $validated['content'],
|
||||
'images' => $validated['images'] ?? null,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
return $this->success($complaint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Client;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Care\CareProfile;
|
||||
use App\Models\Care\CareRecord;
|
||||
use App\Models\Crm\Contract;
|
||||
use App\Models\Crm\Customer;
|
||||
use App\Models\Meal\DailyMealPlan;
|
||||
use App\Models\Nanny\NannyOrder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
/**
|
||||
* 首页汇总数据
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
/** @var Customer $customer */
|
||||
$customer = $request->user();
|
||||
|
||||
// 当前客户基本信息
|
||||
$customerInfo = [
|
||||
'name' => $customer->name,
|
||||
'phone' => $customer->phone,
|
||||
'expected_date' => $customer->expected_date,
|
||||
'actual_date' => $customer->actual_date,
|
||||
];
|
||||
|
||||
// 护理档案 → 最新护理记录3条
|
||||
$careProfile = CareProfile::withoutGlobalScope('store')
|
||||
->where('customer_id', $customer->id)
|
||||
->first();
|
||||
|
||||
$careRecords = [];
|
||||
if ($careProfile) {
|
||||
$careRecords = CareRecord::withoutGlobalScope('store')
|
||||
->where('care_profile_id', $careProfile->id)
|
||||
->with('nurse:id,name')
|
||||
->latest('recorded_at')
|
||||
->limit(3)
|
||||
->get();
|
||||
}
|
||||
|
||||
// 今日膳食
|
||||
$todayMeals = DailyMealPlan::withoutGlobalScope('store')
|
||||
->where('customer_id', $customer->id)
|
||||
->whereDate('plan_date', today())
|
||||
->get();
|
||||
|
||||
// 当前合同(最新1条)
|
||||
$contract = Contract::withoutGlobalScope('store')
|
||||
->where('customer_id', $customer->id)
|
||||
->latest()
|
||||
->first(['id', 'contract_no', 'status', 'check_in_date', 'check_out_date', 'total_amount']);
|
||||
|
||||
// 月嫂信息(进行中派单)
|
||||
$nannyOrder = NannyOrder::withoutGlobalScope('store')
|
||||
->where('customer_id', $customer->id)
|
||||
->where('status', 2)
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($nannyOrder) {
|
||||
$nannyOrder->load('nanny:id,name,level,phone');
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'customer' => $customerInfo,
|
||||
'care_records' => $careRecords,
|
||||
'today_meals' => $todayMeals,
|
||||
'contract' => $contract,
|
||||
'nanny_order' => $nannyOrder,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Client;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Crm\Customer;
|
||||
use App\Models\Meal\DailyMealPlan;
|
||||
use App\Models\Meal\MealReview;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class MealController extends Controller
|
||||
{
|
||||
/**
|
||||
* 我的用膳列表(分页)
|
||||
*/
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
/** @var Customer $customer */
|
||||
$customer = $request->user();
|
||||
|
||||
$query = DailyMealPlan::withoutGlobalScope('store')
|
||||
->where('customer_id', $customer->id)
|
||||
->with('review');
|
||||
|
||||
// 按指定日期筛选
|
||||
if ($request->filled('date')) {
|
||||
$query->whereDate('plan_date', $request->input('date'));
|
||||
}
|
||||
|
||||
// 最近N天
|
||||
if ($request->filled('days') && !$request->filled('date')) {
|
||||
$days = (int) $request->input('days', 7);
|
||||
$query->where('plan_date', '>=', Carbon::today()->subDays($days - 1));
|
||||
}
|
||||
|
||||
return $this->paginate(
|
||||
$query->orderByDesc('plan_date')->paginate(20)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 今日用膳
|
||||
*/
|
||||
public function today(Request $request): JsonResponse
|
||||
{
|
||||
/** @var Customer $customer */
|
||||
$customer = $request->user();
|
||||
|
||||
$list = DailyMealPlan::withoutGlobalScope('store')
|
||||
->where('customer_id', $customer->id)
|
||||
->whereDate('plan_date', today())
|
||||
->get();
|
||||
|
||||
return $this->success($list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交膳食点评
|
||||
*/
|
||||
public function review(Request $request, int $planId): JsonResponse
|
||||
{
|
||||
/** @var Customer $customer */
|
||||
$customer = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'rating' => 'required|integer|min:1|max:5',
|
||||
'content' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
// 确认该膳食计划属于当前客户
|
||||
$plan = DailyMealPlan::withoutGlobalScope('store')
|
||||
->where('id', $planId)
|
||||
->where('customer_id', $customer->id)
|
||||
->first();
|
||||
|
||||
if (!$plan) {
|
||||
return $this->error('膳食记录不存在或无权操作', 40401, 404);
|
||||
}
|
||||
|
||||
$review = MealReview::updateOrCreate(
|
||||
['daily_meal_plan_id' => $planId],
|
||||
[
|
||||
'score' => $validated['rating'],
|
||||
'content' => $validated['content'] ?? null,
|
||||
'customer_id' => $customer->id,
|
||||
]
|
||||
);
|
||||
|
||||
return $this->success($review);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Client;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Crm\Customer;
|
||||
use App\Models\Nanny\Nanny;
|
||||
use App\Models\Nanny\NannyOrder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NannyController extends Controller
|
||||
{
|
||||
/**
|
||||
* 当前派单月嫂信息
|
||||
*/
|
||||
public function myNanny(Request $request): JsonResponse
|
||||
{
|
||||
/** @var Customer $customer */
|
||||
$customer = $request->user();
|
||||
|
||||
$order = NannyOrder::withoutGlobalScope('store')
|
||||
->where('customer_id', $customer->id)
|
||||
->whereIn('status', [2, 3])
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if (!$order) {
|
||||
return $this->success(null);
|
||||
}
|
||||
|
||||
$order->load('nanny');
|
||||
|
||||
return $this->success([
|
||||
'order' => $order,
|
||||
'nanny' => $order->nanny,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 月嫂订单列表(分页)
|
||||
*/
|
||||
public function orders(Request $request): JsonResponse
|
||||
{
|
||||
/** @var Customer $customer */
|
||||
$customer = $request->user();
|
||||
|
||||
$query = NannyOrder::withoutGlobalScope('store')
|
||||
->where('customer_id', $customer->id)
|
||||
->with('nanny:id,name,level,phone');
|
||||
|
||||
return $this->paginate(
|
||||
$query->orderByDesc('id')->paginate(15)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Client;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Crm\Customer;
|
||||
use App\Models\Service\ServiceExecution;
|
||||
use App\Models\Service\ServiceOrder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ServiceController extends Controller
|
||||
{
|
||||
/**
|
||||
* 我的服务订单(分页)
|
||||
*/
|
||||
public function orders(Request $request): JsonResponse
|
||||
{
|
||||
/** @var Customer $customer */
|
||||
$customer = $request->user();
|
||||
|
||||
$query = ServiceOrder::withoutGlobalScope('store')
|
||||
->where('customer_id', $customer->id);
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', (int) $request->input('status'));
|
||||
}
|
||||
|
||||
return $this->paginate(
|
||||
$query->orderByDesc('id')->paginate(15)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务执行记录(分页)
|
||||
*/
|
||||
public function executions(Request $request): JsonResponse
|
||||
{
|
||||
/** @var Customer $customer */
|
||||
$customer = $request->user();
|
||||
|
||||
$query = ServiceExecution::withoutGlobalScope('store')
|
||||
->whereHas('order', fn ($q) => $q->where('customer_id', $customer->id))
|
||||
->with('technician:id,name', 'order:id,status');
|
||||
|
||||
return $this->paginate(
|
||||
$query->orderByDesc('executed_at')->paginate(15)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Auth\Middleware\Authenticate as Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class Authenticate extends Middleware
|
||||
{
|
||||
/**
|
||||
* API-only project — never redirect, always return null so Laravel
|
||||
* responds with a 401 JSON response instead of looking for a "login" route.
|
||||
*/
|
||||
protected function redirectTo(Request $request): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,24 @@ namespace App\Models\Crm;
|
||||
|
||||
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;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class Customer extends Model
|
||||
class Customer extends Authenticatable
|
||||
{
|
||||
use BelongsToStore, SoftDeletes;
|
||||
use BelongsToStore, SoftDeletes, HasApiTokens;
|
||||
|
||||
protected $fillable = [
|
||||
'store_id', 'lead_id', 'name', 'phone', 'id_card', 'wechat',
|
||||
'birthday', 'expected_date', 'actual_date', 'baby_count',
|
||||
'tags', 'status', 'owner_id', 'remark',
|
||||
'store_id', 'lead_id', 'name', 'phone', 'password', 'open_id',
|
||||
'id_card', 'wechat', 'birthday', 'expected_date', 'actual_date',
|
||||
'baby_count', 'tags', 'status', 'owner_id', 'remark', 'last_login_at',
|
||||
];
|
||||
|
||||
protected $hidden = ['password'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
@@ -28,6 +31,8 @@ class Customer extends Model
|
||||
'baby_count' => 'integer',
|
||||
'tags' => 'array',
|
||||
'status' => 'integer',
|
||||
'last_login_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -9,5 +9,5 @@ class Position extends Model
|
||||
{
|
||||
use BelongsToStore;
|
||||
|
||||
protected $fillable = ['store_id', 'name', 'sort'];
|
||||
protected $fillable = ['store_id', 'name', 'code', 'sort', 'status', 'description'];
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ trait BelongsToStore
|
||||
{
|
||||
// 查询自动过滤门店
|
||||
static::addGlobalScope('store', function (Builder $builder) {
|
||||
if ($storeId = self::getCurrentStoreId()) {
|
||||
// 超管:不带 X-Store-Id 时默认“总览”(不加 store 过滤)
|
||||
// 普通用户:按自身 store_id 过滤
|
||||
if ($storeId = self::getQueryStoreId()) {
|
||||
$builder->where($builder->getModel()->getTable() . '.store_id', $storeId);
|
||||
}
|
||||
});
|
||||
@@ -25,7 +27,10 @@ trait BelongsToStore
|
||||
// 创建时自动填入 store_id
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->store_id)) {
|
||||
$model->store_id = self::getCurrentStoreId();
|
||||
// 创建时:超管若指定 X-Store-Id 则写入该门店;否则落到自身 store_id
|
||||
if ($storeId = self::getCreateStoreId()) {
|
||||
$model->store_id = $storeId;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -35,12 +40,35 @@ trait BelongsToStore
|
||||
return $this->belongsTo(Store::class);
|
||||
}
|
||||
|
||||
private static function getCurrentStoreId(): ?int
|
||||
private static function getQueryStoreId(): ?int
|
||||
{
|
||||
$user = auth()->user();
|
||||
if ($user && $user->is_super && request()->header('X-Store-Id')) {
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 超管默认总览:不传 X-Store-Id 则不做门店过滤
|
||||
if ($user->is_super) {
|
||||
if (request()->header('X-Store-Id')) {
|
||||
return (int) request()->header('X-Store-Id');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user?->store_id;
|
||||
}
|
||||
|
||||
private static function getCreateStoreId(): ?int
|
||||
{
|
||||
$user = auth()->user();
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($user->is_super && request()->header('X-Store-Id')) {
|
||||
return (int) request()->header('X-Store-Id');
|
||||
}
|
||||
|
||||
return $user?->store_id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->alias([
|
||||
'auth' => \App\Http\Middleware\Authenticate::class,
|
||||
'store' => \App\Http\Middleware\StoreIsolation::class,
|
||||
'permission' => \App\Http\Middleware\CheckPermission::class,
|
||||
'oplog' => \App\Http\Middleware\OperationLog::class,
|
||||
@@ -22,5 +23,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
// Removed statefulApi() — app uses Bearer token auth, not cookie SPA auth
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
$exceptions->render(function (\Illuminate\Auth\AuthenticationException $e, $request) {
|
||||
if ($request->expectsJson() || $request->is('api/*')) {
|
||||
return response()->json(['message' => '未认证,请先登录'], 401);
|
||||
}
|
||||
});
|
||||
})->create();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('positions', function (Blueprint $table) {
|
||||
$table->string('code', 50)->nullable()->after('name')->comment('职务编码');
|
||||
$table->tinyInteger('status')->default(1)->after('sort')->comment('1启用 0停用');
|
||||
$table->string('description', 255)->nullable()->after('status')->comment('备注');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('positions', function (Blueprint $table) {
|
||||
$table->dropColumn(['code', 'status', 'description']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('customers', function (Blueprint $table) {
|
||||
$table->string('password')->nullable()->after('phone')->comment('客户端登录密码');
|
||||
$table->string('open_id', 100)->nullable()->unique()->after('password')->comment('微信 openid');
|
||||
$table->timestamp('last_login_at')->nullable()->after('open_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('customers', function (Blueprint $table) {
|
||||
$table->dropColumn(['password', 'open_id', 'last_login_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class DemoClientSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$store = DB::table('stores')->first();
|
||||
if (!$store) {
|
||||
$this->command->warn('No store found. Run InitSeeder first.');
|
||||
return;
|
||||
}
|
||||
$storeId = $store->id;
|
||||
|
||||
// ── 1. Demo 客户 ──────────────────────────────────────────────────────
|
||||
$customerId = DB::table('customers')->insertGetId([
|
||||
'store_id' => $storeId,
|
||||
'name' => '李婉婷',
|
||||
'phone' => '13800138001',
|
||||
'password' => Hash::make('123456'),
|
||||
'wechat' => 'liwanting88',
|
||||
'expected_date' => now()->subDays(14)->toDateString(),
|
||||
'actual_date' => now()->subDays(14)->toDateString(),
|
||||
'baby_count' => 1,
|
||||
'status' => 3, // 在住
|
||||
'remark' => '顺产,宝宝健康,无过敏史',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// ── 2. 护理档案 ───────────────────────────────────────────────────────
|
||||
$profileId = DB::table('care_profiles')->insertGetId([
|
||||
'customer_id' => $customerId,
|
||||
'type' => 1,
|
||||
'birth_method' => 1, // 顺产
|
||||
'risk_level' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// ── 3. 护理记录(近7天)─────────────────────────────────────────────
|
||||
$careTypes = [
|
||||
[1, '产后按摩,疏通经络,妈妈表示很舒适'],
|
||||
[1, '乳腺疏通护理,双侧通畅'],
|
||||
[2, '宝宝抚触操,脐带护理正常'],
|
||||
[1, '产后盆底肌恢复训练,第3次'],
|
||||
[2, '宝宝沐浴、脐带护理、黄疸监测'],
|
||||
[1, '子宫复旧按摩,复旧良好'],
|
||||
[2, '宝宝生长评估,体重增长正常'],
|
||||
];
|
||||
foreach ($careTypes as $i => [$type, $remark]) {
|
||||
DB::table('care_records')->insert([
|
||||
'care_profile_id' => $profileId,
|
||||
'type' => $type,
|
||||
'remark' => $remark,
|
||||
'nurse_id' => 1,
|
||||
'recorded_at' => now()->subDays($i)->setHour(10)->setMinute(0),
|
||||
'created_at' => now()->subDays($i),
|
||||
'updated_at' => now()->subDays($i),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 4. 健康指标(近10条)──────────────────────────────────────────────
|
||||
$metricData = [
|
||||
[1, 36.5, '℃'],
|
||||
[2, 58.2, 'kg'],
|
||||
[1, 36.7, '℃'],
|
||||
[4, 8.2, 'mg/dL'],
|
||||
[5, 3.65, 'kg'],
|
||||
[6, 36.8, '℃'],
|
||||
[1, 36.4, '℃'],
|
||||
[2, 57.9, 'kg'],
|
||||
[3, 110, 'mmHg'],
|
||||
[5, 3.72, 'kg'],
|
||||
];
|
||||
foreach ($metricData as $i => [$type, $value, $unit]) {
|
||||
DB::table('health_metrics')->insert([
|
||||
'care_profile_id' => $profileId,
|
||||
'metric_type' => $type,
|
||||
'value' => $value,
|
||||
'unit' => $unit,
|
||||
'recorded_at' => now()->subDays((int)($i / 2))->setHour(8 + $i % 3),
|
||||
'created_at' => now()->subDays((int)($i / 2)),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 5. 今日 & 近7天膳食计划 ───────────────────────────────────────────
|
||||
$mealsByDay = [
|
||||
// 今天
|
||||
[0, 1, [['name'=>'小米红枣粥'],['name'=>'鸡蛋羹'],['name'=>'红糖姜水']]],
|
||||
[0, 2, [['name'=>'清蒸鲈鱼'],['name'=>'猪肝菠菜汤'],['name'=>'米饭']]],
|
||||
[0, 3, [['name'=>'枸杞红枣炖乌鸡'],['name'=>'核桃露']]],
|
||||
[0, 4, [['name'=>'番茄蛋花汤'],['name'=>'清炒时蔬'],['name'=>'糙米饭']]],
|
||||
// 昨天
|
||||
[1, 1, [['name'=>'燕麦南瓜粥'],['name'=>'水煮蛋']]],
|
||||
[1, 2, [['name'=>'鲫鱼豆腐汤'],['name'=>'香菇蒸鸡'],['name'=>'米饭']]],
|
||||
[1, 4, [['name'=>'花生猪脚汤'],['name'=>'蒜蓉青菜']]],
|
||||
[1, 5, [['name'=>'红豆薏米水']]],
|
||||
// 前天
|
||||
[2, 1, [['name'=>'黑米红豆粥'],['name'=>'荷包蛋']]],
|
||||
[2, 2, [['name'=>'冬瓜排骨汤'],['name'=>'清炒豆芽'],['name'=>'米饭']]],
|
||||
[2, 4, [['name'=>'参鸡汤'],['name'=>'蒸南瓜']]],
|
||||
// 3天前
|
||||
[3, 1, [['name'=>'紫米粥'],['name'=>'银耳莲子羹']]],
|
||||
[3, 2, [['name'=>'红枣枸杞炖排骨'],['name'=>'米饭']]],
|
||||
[3, 3, [['name'=>'核桃芝麻糊']]],
|
||||
[3, 4, [['name'=>'番茄豆腐汤'],['name'=>'清蒸时蔬']]],
|
||||
];
|
||||
foreach ($mealsByDay as [$daysAgo, $mealType, $dishes]) {
|
||||
DB::table('daily_meal_plans')->insert([
|
||||
'store_id' => $storeId,
|
||||
'customer_id' => $customerId,
|
||||
'plan_date' => now()->subDays($daysAgo)->toDateString(),
|
||||
'meal_type' => $mealType,
|
||||
'dishes' => json_encode($dishes),
|
||||
'status' => $daysAgo > 0 ? 2 : 1, // 历史=已送达, 今天=已备餐
|
||||
'created_at' => now()->subDays($daysAgo),
|
||||
'updated_at' => now()->subDays($daysAgo),
|
||||
]);
|
||||
}
|
||||
|
||||
// 给昨天2条膳食添加评价
|
||||
$planIds = DB::table('daily_meal_plans')
|
||||
->where('customer_id', $customerId)
|
||||
->where('plan_date', now()->subDay()->toDateString())
|
||||
->pluck('id');
|
||||
foreach ($planIds->take(2) as $planId) {
|
||||
DB::table('meal_reviews')->insert([
|
||||
'daily_meal_plan_id' => $planId,
|
||||
'customer_id' => $customerId,
|
||||
'score' => rand(4, 5),
|
||||
'content' => '味道很好,营养均衡,非常感谢厨师团队!',
|
||||
'created_at' => now()->subDay(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 6. 月嫂 + 派单 ────────────────────────────────────────────────────
|
||||
$nannyId = DB::table('nannies')->insertGetId([
|
||||
'store_id' => $storeId,
|
||||
'name' => '王秀珍',
|
||||
'phone' => '13900139001',
|
||||
'level' => 3, // 高级
|
||||
'experience_years' => 8,
|
||||
'skills' => json_encode(['产后护理', '宝宝沐浴', '乳腺疏通', '产后恢复']),
|
||||
'introduction' => '从业8年,持高级育婴师证书,服务过200余位妈妈,口碑优秀',
|
||||
'status' => 2, // 服务中
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('nanny_orders')->insert([
|
||||
'store_id' => $storeId,
|
||||
'customer_id' => $customerId,
|
||||
'nanny_id' => $nannyId,
|
||||
'order_no' => 'NO' . date('YmdHis'),
|
||||
'service_type'=> 1,
|
||||
'start_date' => now()->subDays(14)->toDateString(),
|
||||
'end_date' => now()->addDays(16)->toDateString(),
|
||||
'days' => 30,
|
||||
'price' => 12000.00,
|
||||
'status' => 2, // 已签约/服务中
|
||||
'created_at' => now()->subDays(14),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// ── 7. 服务订单 ───────────────────────────────────────────────────────
|
||||
$serviceOrders = [
|
||||
['产后修复套餐', 2, 3980.00],
|
||||
['宝宝游泳抚触课程', 2, 1200.00],
|
||||
['气血能量房体验', 2, 680.00],
|
||||
];
|
||||
foreach ($serviceOrders as [$name, $status, $amount]) {
|
||||
DB::table('service_orders')->insert([
|
||||
'store_id' => $storeId,
|
||||
'customer_id' => $customerId,
|
||||
'order_no' => 'SO' . date('YmdHis') . rand(100, 999),
|
||||
'type' => 1,
|
||||
'items' => json_encode([['name' => $name, 'qty' => 1, 'price' => $amount]]),
|
||||
'total_amount' => $amount,
|
||||
'actual_amount' => $amount,
|
||||
'status' => $status,
|
||||
'pay_method' => 1,
|
||||
'paid_at' => now()->subDays(14),
|
||||
'created_at' => now()->subDays(14),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 8. 客户账户 + 流水 ────────────────────────────────────────────────
|
||||
$accountId = DB::table('customer_accounts')->insertGetId([
|
||||
'store_id' => $storeId,
|
||||
'customer_id' => $customerId,
|
||||
'cash_balance' => 3200.00,
|
||||
'card_balance' => 8800.00,
|
||||
'points' => 1580,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$txs = [
|
||||
[1, 20000.00, 20000.00, '签约充值'],
|
||||
[2, 3980.00, 16020.00, '产后修复套餐'],
|
||||
[2, 1200.00, 14820.00, '宝宝游泳课程'],
|
||||
[3, 500.00, 15320.00, '退款:项目未使用'],
|
||||
[2, 680.00, 14640.00, '气血能量房体验'],
|
||||
[2, 2640.00, 12000.00, '月嫂服务首期'],
|
||||
];
|
||||
foreach ($txs as $i => [$type, $amount, $balanceAfter, $desc]) {
|
||||
DB::table('account_transactions')->insert([
|
||||
'store_id' => $storeId,
|
||||
'customer_account_id' => $accountId,
|
||||
'customer_id' => $customerId,
|
||||
'type' => $type,
|
||||
'amount' => $amount,
|
||||
'balance_after' => $balanceAfter,
|
||||
'description' => $desc,
|
||||
'created_at' => now()->subDays(14 - $i * 2),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 9. 投诉反馈 ───────────────────────────────────────────────────────
|
||||
DB::table('complaints')->insert([
|
||||
'store_id' => $storeId,
|
||||
'customer_id' => $customerId,
|
||||
'type' => 1,
|
||||
'content' => '昨晚空调温度偏低,宝宝有点凉,希望能保持在26度左右',
|
||||
'status' => 2,
|
||||
'handle_result' => '已通知客房调整空调,确保室温维持在25-26℃,感谢您的反馈!',
|
||||
'handle_at' => now()->subDays(1),
|
||||
'created_at' => now()->subDays(2),
|
||||
'updated_at' => now()->subDays(1),
|
||||
]);
|
||||
DB::table('complaints')->insert([
|
||||
'store_id' => $storeId,
|
||||
'customer_id' => $customerId,
|
||||
'type' => 2,
|
||||
'content' => '今日午餐的汤偏咸,建议减少盐量,产后饮食应清淡',
|
||||
'status' => 1,
|
||||
'created_at' => now()->subHours(5),
|
||||
'updated_at' => now()->subHours(5),
|
||||
]);
|
||||
|
||||
$this->command->info("✅ Demo 客户创建成功!");
|
||||
$this->command->info(" 手机号:13800138001 密码:123456");
|
||||
$this->command->info(" 姓名:李婉婷 状态:在住第14天");
|
||||
}
|
||||
}
|
||||
+67
-10
@@ -80,6 +80,7 @@ use App\Http\Controllers\Admin\System\RegistrationPackageController;
|
||||
Route::post('/auth/login', [AuthController::class, 'login']);
|
||||
Route::post('/auth/register', [AuthController::class, 'register']);
|
||||
Route::get('/auth/register/packages', [AuthController::class, 'registrationPackages']);
|
||||
Route::get('/auth/register/departments', [AuthController::class, 'departments']);
|
||||
Route::get('/auth/stores', [\App\Http\Controllers\Admin\System\StoreController::class, 'publicList']);
|
||||
|
||||
// ===== 需要认证的路由 =====
|
||||
@@ -144,6 +145,7 @@ Route::middleware(['auth:sanctum', 'store', 'oplog'])->group(function () {
|
||||
|
||||
// 客户管理
|
||||
Route::apiResource('customers', CustomerController::class);
|
||||
Route::post('customers/{customer}/set-password', [CustomerController::class, 'setPassword']);
|
||||
|
||||
// 合同管理
|
||||
Route::apiResource('contracts', ContractController::class);
|
||||
@@ -187,16 +189,16 @@ Route::middleware(['auth:sanctum', 'store', 'oplog'])->group(function () {
|
||||
// --- 护理管理模块 ---
|
||||
Route::prefix('care')->group(function () {
|
||||
// 护理档案
|
||||
Route::apiResource('profiles', CareProfileController::class);
|
||||
Route::apiResource('profiles', CareProfileController::class)->names('care.profiles');
|
||||
|
||||
// 护理计划
|
||||
Route::apiResource('plans', CarePlanController::class);
|
||||
Route::apiResource('plans', CarePlanController::class)->names('care.plans');
|
||||
|
||||
// 护理记录
|
||||
Route::apiResource('records', CareRecordController::class);
|
||||
Route::apiResource('records', CareRecordController::class)->names('care.records');
|
||||
|
||||
// 护理异常
|
||||
Route::apiResource('exceptions', CareExceptionController::class)->except(['destroy']);
|
||||
Route::apiResource('exceptions', CareExceptionController::class)->except(['destroy'])->names('care.exceptions');
|
||||
Route::put('exceptions/{careException}/handle', [CareExceptionController::class, 'handle']);
|
||||
|
||||
// 健康指标
|
||||
@@ -231,7 +233,7 @@ Route::middleware(['auth:sanctum', 'store', 'oplog'])->group(function () {
|
||||
Route::apiResource('packages', ServicePackageController::class);
|
||||
|
||||
// 服务订单
|
||||
Route::apiResource('orders', ServiceOrderController::class);
|
||||
Route::apiResource('orders', ServiceOrderController::class)->names('service.orders');
|
||||
Route::put('orders/{serviceOrder}/pay', [ServiceOrderController::class, 'pay']);
|
||||
Route::put('orders/{serviceOrder}/complete', [ServiceOrderController::class, 'complete']);
|
||||
|
||||
@@ -248,7 +250,7 @@ Route::middleware(['auth:sanctum', 'store', 'oplog'])->group(function () {
|
||||
Route::apiResource('nannies', NannyController::class);
|
||||
|
||||
// 月嫂订单
|
||||
Route::apiResource('orders', NannyOrderController::class);
|
||||
Route::apiResource('orders', NannyOrderController::class)->names('nanny.orders');
|
||||
|
||||
// 月嫂排班
|
||||
Route::get('schedules', [NannyScheduleController::class, 'index']);
|
||||
@@ -300,10 +302,10 @@ Route::middleware(['auth:sanctum', 'store', 'oplog'])->group(function () {
|
||||
// --- 财务管理模块 ---
|
||||
Route::prefix('finance')->group(function () {
|
||||
// 收支分类
|
||||
Route::apiResource('categories', FinanceCategoryController::class);
|
||||
Route::apiResource('categories', FinanceCategoryController::class)->names('finance.categories');
|
||||
|
||||
// 收支记录
|
||||
Route::apiResource('records', FinanceRecordController::class);
|
||||
Route::apiResource('records', FinanceRecordController::class)->names('finance.records');
|
||||
Route::put('records/{financeRecord}/audit', [FinanceRecordController::class, 'audit']);
|
||||
|
||||
// 客户账户
|
||||
@@ -325,7 +327,7 @@ Route::middleware(['auth:sanctum', 'store', 'oplog'])->group(function () {
|
||||
// --- 人事薪资模块 ---
|
||||
Route::prefix('hr')->group(function () {
|
||||
// 员工档案
|
||||
Route::apiResource('profiles', EmployeeProfileController::class);
|
||||
Route::apiResource('profiles', EmployeeProfileController::class)->names('hr.profiles');
|
||||
|
||||
// 排班管理
|
||||
Route::get('schedules', [ScheduleController::class, 'index']);
|
||||
@@ -390,7 +392,7 @@ Route::middleware(['auth:sanctum', 'store', 'oplog'])->group(function () {
|
||||
// --- 知识库模块 ---
|
||||
Route::prefix('kb')->group(function () {
|
||||
// 知识分类
|
||||
Route::apiResource('categories', KbCategoryController::class);
|
||||
Route::apiResource('categories', KbCategoryController::class)->names('kb.categories');
|
||||
|
||||
// 知识文章
|
||||
Route::apiResource('articles', KbArticleController::class);
|
||||
@@ -398,3 +400,58 @@ Route::middleware(['auth:sanctum', 'store', 'oplog'])->group(function () {
|
||||
Route::put('articles/{kbArticle}/offline', [KbArticleController::class, 'offline']);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// 客户端(微客宝 H5)路由 prefix: /api/v1/client/
|
||||
// ===========================================================================
|
||||
Route::prefix('client')->group(function () {
|
||||
// ----- 公开路由 -----
|
||||
Route::post('auth/login', [\App\Http\Controllers\Client\AuthController::class, 'login']);
|
||||
|
||||
// ----- 需要认证(Customer Sanctum token)-----
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
// 认证
|
||||
Route::post('auth/logout', [\App\Http\Controllers\Client\AuthController::class, 'logout']);
|
||||
Route::get('auth/me', [\App\Http\Controllers\Client\AuthController::class, 'me']);
|
||||
Route::post('auth/change-password', [\App\Http\Controllers\Client\AuthController::class, 'changePassword']);
|
||||
|
||||
// 首页汇总
|
||||
Route::get('home', [\App\Http\Controllers\Client\HomeController::class, 'index']);
|
||||
|
||||
// 护理
|
||||
Route::prefix('care')->group(function () {
|
||||
Route::get('profile', [\App\Http\Controllers\Client\CareController::class, 'profile']);
|
||||
Route::get('records', [\App\Http\Controllers\Client\CareController::class, 'records']);
|
||||
Route::get('metrics', [\App\Http\Controllers\Client\CareController::class, 'metrics']);
|
||||
});
|
||||
|
||||
// 膳食
|
||||
Route::prefix('meal')->group(function () {
|
||||
Route::get('list', [\App\Http\Controllers\Client\MealController::class, 'list']);
|
||||
Route::get('today', [\App\Http\Controllers\Client\MealController::class, 'today']);
|
||||
Route::post('review/{planId}', [\App\Http\Controllers\Client\MealController::class, 'review']);
|
||||
});
|
||||
|
||||
// 财务账户
|
||||
Route::prefix('account')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Client\AccountController::class, 'index']);
|
||||
Route::get('transactions', [\App\Http\Controllers\Client\AccountController::class, 'transactions']);
|
||||
});
|
||||
|
||||
// 服务订单
|
||||
Route::prefix('service')->group(function () {
|
||||
Route::get('orders', [\App\Http\Controllers\Client\ServiceController::class, 'orders']);
|
||||
Route::get('executions', [\App\Http\Controllers\Client\ServiceController::class, 'executions']);
|
||||
});
|
||||
|
||||
// 月嫂
|
||||
Route::prefix('nanny')->group(function () {
|
||||
Route::get('my', [\App\Http\Controllers\Client\NannyController::class, 'myNanny']);
|
||||
Route::get('orders', [\App\Http\Controllers\Client\NannyController::class, 'orders']);
|
||||
});
|
||||
|
||||
// 投诉反馈
|
||||
Route::get('complaints', [\App\Http\Controllers\Client\ComplaintController::class, 'index']);
|
||||
Route::post('complaints', [\App\Http\Controllers\Client\ComplaintController::class, 'store']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.DS_Store
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#f5a0b0" />
|
||||
<title>宫中有喜 · 微客宝</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1901
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "yuezi-client-h5",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.2",
|
||||
"pinia": "^2.1.7",
|
||||
"vant": "^4.9.0",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"vite": "^5.2.0",
|
||||
"vite-plugin-style-import": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useCustomerStore } from '@/stores/customer'
|
||||
|
||||
const store = useCustomerStore()
|
||||
|
||||
onMounted(async () => {
|
||||
if (store.token && !store.info) {
|
||||
await store.fetchMe()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { clientRequest } from './request'
|
||||
|
||||
export const authApi = {
|
||||
login: (data) => clientRequest.post('/auth/login', data),
|
||||
logout: () => clientRequest.post('/auth/logout'),
|
||||
me: () => clientRequest.get('/auth/me'),
|
||||
changePassword: (data) => clientRequest.post('/auth/change-password', data),
|
||||
}
|
||||
|
||||
export const homeApi = {
|
||||
index: () => clientRequest.get('/home'),
|
||||
}
|
||||
|
||||
export const careApi = {
|
||||
profile: () => clientRequest.get('/care/profile'),
|
||||
records: (params) => clientRequest.get('/care/records', { params }),
|
||||
metrics: (params) => clientRequest.get('/care/metrics', { params }),
|
||||
}
|
||||
|
||||
export const mealApi = {
|
||||
list: (params) => clientRequest.get('/meal/list', { params }),
|
||||
today: () => clientRequest.get('/meal/today'),
|
||||
review: (planId, data) => clientRequest.post(`/meal/review/${planId}`, data),
|
||||
}
|
||||
|
||||
export const accountApi = {
|
||||
index: () => clientRequest.get('/account'),
|
||||
transactions: (params) => clientRequest.get('/account/transactions', { params }),
|
||||
}
|
||||
|
||||
export const serviceApi = {
|
||||
orders: (params) => clientRequest.get('/service/orders', { params }),
|
||||
executions: (params) => clientRequest.get('/service/executions', { params }),
|
||||
}
|
||||
|
||||
export const nannyApi = {
|
||||
myNanny: () => clientRequest.get('/nanny/my'),
|
||||
orders: (params) => clientRequest.get('/nanny/orders', { params }),
|
||||
}
|
||||
|
||||
export const complaintApi = {
|
||||
list: (params) => clientRequest.get('/complaints', { params }),
|
||||
create: (data) => clientRequest.post('/complaints', data),
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import axios from 'axios'
|
||||
import { useCustomerStore } from '@/stores/customer'
|
||||
import router from '@/router'
|
||||
|
||||
export const clientRequest = axios.create({
|
||||
baseURL: '/api/v1/client',
|
||||
timeout: 15000,
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
})
|
||||
|
||||
clientRequest.interceptors.request.use((config) => {
|
||||
const store = useCustomerStore()
|
||||
if (store.token) {
|
||||
config.headers.Authorization = `Bearer ${store.token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
clientRequest.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
const store = useCustomerStore()
|
||||
store.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Vant from 'vant'
|
||||
import 'vant/lib/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(Vant)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useCustomerStore } from '@/stores/customer'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: { auth: false },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/views/layout/TabLayout.vue'),
|
||||
meta: { auth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: '/home',
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/home/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'grow',
|
||||
name: 'Grow',
|
||||
component: () => import('@/views/grow/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'meal',
|
||||
name: 'Meal',
|
||||
component: () => import('@/views/meal/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'my',
|
||||
name: 'My',
|
||||
component: () => import('@/views/my/index.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
// 子页面(不在 Tab 内)
|
||||
{
|
||||
path: '/care/records',
|
||||
name: 'CareRecords',
|
||||
component: () => import('@/views/grow/CareRecords.vue'),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: '/care/metrics',
|
||||
name: 'HealthMetrics',
|
||||
component: () => import('@/views/grow/HealthMetrics.vue'),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: '/account',
|
||||
name: 'Account',
|
||||
component: () => import('@/views/my/Account.vue'),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: '/account/transactions',
|
||||
name: 'Transactions',
|
||||
component: () => import('@/views/my/Transactions.vue'),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: '/service/orders',
|
||||
name: 'ServiceOrders',
|
||||
component: () => import('@/views/my/ServiceOrders.vue'),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: '/nanny',
|
||||
name: 'Nanny',
|
||||
component: () => import('@/views/my/Nanny.vue'),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: '/complaints',
|
||||
name: 'Complaints',
|
||||
component: () => import('@/views/my/Complaints.vue'),
|
||||
meta: { auth: true },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/wkb/'),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const store = useCustomerStore()
|
||||
if (to.meta.auth !== false && !store.token) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { clientRequest } from '@/api/request'
|
||||
|
||||
export const useCustomerStore = defineStore('customer', () => {
|
||||
const token = ref(localStorage.getItem('client_token') || '')
|
||||
const info = ref(null)
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const name = computed(() => info.value?.name || '')
|
||||
|
||||
function setToken(t) {
|
||||
token.value = t
|
||||
localStorage.setItem('client_token', t)
|
||||
}
|
||||
|
||||
function setInfo(data) {
|
||||
info.value = data
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
try {
|
||||
const res = await clientRequest.get('/auth/me')
|
||||
info.value = res.data?.data
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
info.value = null
|
||||
localStorage.removeItem('client_token')
|
||||
}
|
||||
|
||||
return { token, info, isLoggedIn, name, setToken, setInfo, fetchMe, logout }
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
background: #f8f8f8;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
|
||||
color: #323233;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-primary: #e8718d;
|
||||
--color-primary-light: #fde8ee;
|
||||
--color-brand: linear-gradient(135deg, #f5a0b0 0%, #e8718d 100%);
|
||||
}
|
||||
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { careApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const records = ref([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const finished = ref(false)
|
||||
|
||||
const typeLabel = { 1: '妈妈护理', 2: '宝宝护理', 3: '加项护理' }
|
||||
|
||||
async function onLoad() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await careApi.records({ page: page.value, per_page: 15 })
|
||||
const list = res.data?.data?.list || []
|
||||
records.value.push(...list)
|
||||
if (records.value.length >= (res.data?.data?.total || 0)) finished.value = true
|
||||
page.value++
|
||||
} catch { finished.value = true } finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(onLoad)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<van-nav-bar title="护理记录" left-arrow @click-left="router.back()" />
|
||||
<div class="content">
|
||||
<van-list :loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
|
||||
<div v-for="r in records" :key="r.id" class="record-card">
|
||||
<div class="rec-header">
|
||||
<span class="rec-type">{{ typeLabel[r.type] || '护理' }}</span>
|
||||
<span class="rec-time">{{ r.recorded_at?.slice(0, 16) }}</span>
|
||||
</div>
|
||||
<p class="rec-nurse">护理师:{{ r.nurse?.name || '-' }}</p>
|
||||
<p class="rec-remark" v-if="r.remark">{{ r.remark }}</p>
|
||||
</div>
|
||||
</van-list>
|
||||
<van-empty v-if="!loading && !records.length" description="暂无护理记录" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f0f4ff; min-height: 100vh; }
|
||||
.content { padding: 16px; }
|
||||
.record-card { background: white; border-radius: 16px; padding: 16px; margin-bottom: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.04); }
|
||||
.rec-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.rec-type { font-size: 13px; font-weight: 600; color: #5b8ff9; background: #eff4ff; padding: 2px 10px; border-radius: 10px; }
|
||||
.rec-time { font-size: 12px; color: #999; }
|
||||
.rec-nurse { font-size: 13px; color: #555; margin: 4px 0; }
|
||||
.rec-remark { font-size: 12px; color: #888; margin: 4px 0 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { careApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const metrics = ref([])
|
||||
const loading = ref(true)
|
||||
const metricTypeLabel = { 1: '体温', 2: '体重', 3: '血压', 4: '黄疸', 5: '宝宝体重', 6: '宝宝体温' }
|
||||
const metricUnit = { 1: '℃', 2: 'kg', 3: 'mmHg', 4: 'mg/dL', 5: 'kg', 6: '℃' }
|
||||
const typeColor = { 1: '#f87171', 2: '#60a5fa', 3: '#a78bfa', 4: '#fbbf24', 5: '#34d399', 6: '#f472b6' }
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await careApi.metrics()
|
||||
metrics.value = res.data?.data || []
|
||||
} catch { } finally { loading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<van-nav-bar title="健康指标" left-arrow @click-left="router.back()" />
|
||||
<div class="content">
|
||||
<van-loading v-if="loading" vertical color="#e8718d" class="center-load" />
|
||||
<van-empty v-else-if="!metrics.length" description="暂无健康记录" />
|
||||
<div v-else class="metrics-list">
|
||||
<div v-for="m in metrics" :key="m.id" class="metric-card">
|
||||
<div class="metric-icon" :style="{ background: typeColor[m.metric_type] + '22' }">
|
||||
<van-icon name="chart-trending-o" :color="typeColor[m.metric_type]" size="20" />
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<span class="metric-type">{{ metricTypeLabel[m.metric_type] || '指标' }}</span>
|
||||
<span class="metric-time">{{ m.recorded_at?.slice(0, 16) }}</span>
|
||||
</div>
|
||||
<span class="metric-val" :style="{ color: typeColor[m.metric_type] }">
|
||||
{{ m.value }} {{ metricUnit[m.metric_type] || '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f0f4ff; min-height: 100vh; }
|
||||
.content { padding: 16px; }
|
||||
.center-load { display: flex; justify-content: center; padding: 60px 0; }
|
||||
.metrics-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.metric-card { background: white; border-radius: 16px; padding: 14px; display: flex; align-items: center; gap: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.04); }
|
||||
.metric-icon { width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.metric-body { flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
||||
.metric-type { font-size: 14px; font-weight: 600; color: #333; }
|
||||
.metric-time { font-size: 11px; color: #999; }
|
||||
.metric-val { font-size: 18px; font-weight: 700; white-space: nowrap; }
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { careApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const profile = ref(null)
|
||||
const metrics = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const metricTypeLabel = { 1: '体温', 2: '体重', 3: '血压', 4: '黄疸', 5: '宝宝体重', 6: '宝宝体温' }
|
||||
const metricUnit = { 1: '℃', 2: 'kg', 3: 'mmHg', 4: 'mg/dL', 5: 'kg', 6: '℃' }
|
||||
const metricColor = { 1: '#f43f5e', 2: '#3b82f6', 3: '#8b5cf6', 4: '#f59e0b', 5: '#10b981', 6: '#ec4899' }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [pRes, mRes] = await Promise.all([careApi.profile(), careApi.metrics({ limit: 10 })])
|
||||
profile.value = pRes.data?.data
|
||||
metrics.value = mRes.data?.data || []
|
||||
} catch { } finally { loading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<!-- 头部 -->
|
||||
<div class="top-header">
|
||||
<h2 class="page-title">共伴成长</h2>
|
||||
<p class="page-sub">母婴护理记录中心</p>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<!-- 护理档案 -->
|
||||
<div class="profile-card" v-if="profile">
|
||||
<div class="profile-top">
|
||||
<div class="profile-avatar">
|
||||
<van-icon name="award-o" size="24" color="#3b82f6" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="profile-name">在院档案</p>
|
||||
<p class="profile-status">入住中</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-cell" v-if="profile.admission_date">
|
||||
<span class="info-val">{{ profile.admission_date }}</span>
|
||||
<span class="info-label">入住日期</span>
|
||||
</div>
|
||||
<div class="info-cell" v-if="profile.expected_discharge">
|
||||
<span class="info-val">{{ profile.expected_discharge }}</span>
|
||||
<span class="info-label">预计离院</span>
|
||||
</div>
|
||||
<div class="info-cell" v-if="profile.delivery_type">
|
||||
<span class="info-val">{{ profile.delivery_type === 1 ? '顺产' : '剖腹产' }}</span>
|
||||
<span class="info-label">分娩方式</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<van-empty v-else-if="!loading" description="暂无护理档案" image="search" />
|
||||
|
||||
<!-- 快捷入口 -->
|
||||
<div class="quick-row">
|
||||
<div class="quick-card rose" @click="router.push('/care/records')">
|
||||
<van-icon name="orders-o" size="28" color="#f43f5e" />
|
||||
<span>护理记录</span>
|
||||
</div>
|
||||
<div class="quick-card blue" @click="router.push('/care/metrics')">
|
||||
<van-icon name="chart-trending-o" size="28" color="#3b82f6" />
|
||||
<span>健康指标</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最新健康数据 -->
|
||||
<div v-if="metrics.length" class="metrics-section">
|
||||
<div class="sec-head">
|
||||
<h5 class="sec-title">最新健康数据</h5>
|
||||
<span class="sec-more" @click="router.push('/care/metrics')">全部 ›</span>
|
||||
</div>
|
||||
<div class="metric-list">
|
||||
<div v-for="m in metrics" :key="m.id" class="metric-row">
|
||||
<div class="m-dot" :style="{ background: metricColor[m.metric_type] || '#ccc' }" />
|
||||
<div class="m-info">
|
||||
<span class="m-type">{{ metricTypeLabel[m.metric_type] || '指标' }}</span>
|
||||
<span class="m-time">{{ m.measured_at?.slice(0, 16) }}</span>
|
||||
</div>
|
||||
<span class="m-val" :style="{ color: metricColor[m.metric_type] || '#333' }">
|
||||
{{ m.value }} {{ metricUnit[m.metric_type] || '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f0f6ff; min-height: 100vh; padding-bottom: 90px; }
|
||||
|
||||
/* Header */
|
||||
.top-header {
|
||||
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||||
padding: 52px 22px 28px;
|
||||
border-radius: 0 0 28px 28px;
|
||||
}
|
||||
.page-title { font-size: 22px; font-weight: 700; color: white; margin: 0 0 4px; }
|
||||
.page-sub { font-size: 13px; color: rgba(255,255,255,0.75); margin: 0; }
|
||||
|
||||
.body { padding: 16px; }
|
||||
|
||||
/* Profile card */
|
||||
.profile-card {
|
||||
background: white; border-radius: 22px; padding: 18px;
|
||||
margin-bottom: 14px;
|
||||
box-shadow: 0 2px 14px rgba(0,0,0,0.06);
|
||||
}
|
||||
.profile-top { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||
.profile-avatar {
|
||||
width: 44px; height: 44px;
|
||||
background: #eff6ff; border-radius: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.profile-name { font-size: 15px; font-weight: 700; color: #1f2937; margin: 0 0 3px; }
|
||||
.profile-status { font-size: 12px; color: #10b981; margin: 0; }
|
||||
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.info-cell {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
background: #f8faff; border-radius: 14px; padding: 10px 8px;
|
||||
}
|
||||
.info-val { font-size: 13px; font-weight: 700; color: #1f2937; margin-bottom: 3px; }
|
||||
.info-label { font-size: 11px; color: #9ca3af; }
|
||||
|
||||
/* Quick entries */
|
||||
.quick-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; }
|
||||
.quick-card {
|
||||
border-radius: 20px; padding: 20px 16px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||
cursor: pointer; font-size: 13px; font-weight: 600; color: #374151;
|
||||
}
|
||||
.quick-card.rose { background: #fff0f4; }
|
||||
.quick-card.blue { background: #eff6ff; }
|
||||
|
||||
/* Metrics */
|
||||
.metrics-section { }
|
||||
.sec-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.sec-title { font-size: 15px; font-weight: 700; color: #1f2937; margin: 0; }
|
||||
.sec-more { font-size: 13px; color: #3b82f6; cursor: pointer; }
|
||||
.metric-list {
|
||||
background: white; border-radius: 20px; overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
.metric-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f6ff;
|
||||
}
|
||||
.metric-row:last-child { border: none; }
|
||||
.m-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.m-info { flex: 1; }
|
||||
.m-type { display: block; font-size: 14px; font-weight: 600; color: #1f2937; }
|
||||
.m-time { display: block; font-size: 11px; color: #9ca3af; margin-top: 2px; }
|
||||
.m-val { font-size: 16px; font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,345 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { homeApi } from '@/api/index'
|
||||
import { useCustomerStore } from '@/stores/customer'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useCustomerStore()
|
||||
const homeData = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await homeApi.index()
|
||||
homeData.value = res.data?.data
|
||||
} catch { } finally { loading.value = false }
|
||||
})
|
||||
|
||||
const customer = computed(() => homeData.value?.customer || store.info)
|
||||
const todayMeals = computed(() => homeData.value?.today_meals || [])
|
||||
const latestRecords = computed(() => homeData.value?.care_records || [])
|
||||
const nannyOrder = computed(() => homeData.value?.nanny_order)
|
||||
|
||||
// 膳食时间映射
|
||||
const mealTime = { 1: '07:30', 2: '12:00', 3: '15:00', 4: '18:00', 5: '21:00' }
|
||||
const mealLabel = { 1: '早餐', 2: '午餐', 3: '下午茶', 4: '晚餐', 5: '宵夜' }
|
||||
|
||||
// 入住天数
|
||||
const checkinDays = computed(() => {
|
||||
const d = customer.value?.actual_date
|
||||
if (!d) return null
|
||||
const diff = Math.floor((Date.now() - new Date(d).getTime()) / 86400000)
|
||||
return diff > 0 ? diff : null
|
||||
})
|
||||
|
||||
|
||||
const today = new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<!-- ── 顶部问候 ── -->
|
||||
<div class="header">
|
||||
<div>
|
||||
<p class="greet-sub">欢迎回来,</p>
|
||||
<h2 class="greet-name">
|
||||
{{ customer?.name || '亲爱的' }}妈妈
|
||||
<van-icon name="shield-o" color="#f43f5e" size="16" style="vertical-align:middle;margin-left:2px" />
|
||||
</h2>
|
||||
</div>
|
||||
<div class="avatar-wrap">
|
||||
<van-icon name="contact" size="32" color="#f43f5e" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
|
||||
<!-- ── 宝宝监控卡(暗色摄像风格) ── -->
|
||||
<div class="cam-card">
|
||||
<!-- 暗色网格背景 -->
|
||||
<div class="cam-grid" />
|
||||
<!-- LIVE badge -->
|
||||
<div class="cam-topbar">
|
||||
<span class="live-dot">● LIVE</span>
|
||||
<span class="cam-label">Cam 02 (室内)</span>
|
||||
</div>
|
||||
<!-- 播放按钮 -->
|
||||
<div class="play-wrap">
|
||||
<div class="play-btn">
|
||||
<svg width="22" height="24" viewBox="0 0 22 24" fill="none">
|
||||
<path d="M2 2L20 12L2 22V2Z" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 底部文字 -->
|
||||
<div class="cam-bottom">
|
||||
<span class="cam-title">宝宝安防监控 · 实时画面</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 6 格功能入口(静态 van-icon,3×2) ── -->
|
||||
<div class="features-row">
|
||||
<div class="feat-item" @click="router.push('/meal')">
|
||||
<div class="feat-circle" style="background:#fff0f4">
|
||||
<van-icon name="fire-o" color="#f43f5e" size="26" />
|
||||
</div>
|
||||
<span class="feat-label">月子餐</span>
|
||||
</div>
|
||||
<div class="feat-item" @click="router.push('/care/records')">
|
||||
<div class="feat-circle" style="background:#fffbeb">
|
||||
<van-icon name="notes-o" color="#f59e0b" size="26" />
|
||||
</div>
|
||||
<span class="feat-label">护理记录</span>
|
||||
</div>
|
||||
<div class="feat-item" @click="router.push('/grow')">
|
||||
<div class="feat-circle" style="background:#f0fdf4">
|
||||
<van-icon name="records-o" color="#10b981" size="26" />
|
||||
</div>
|
||||
<span class="feat-label">健康档案</span>
|
||||
</div>
|
||||
<div class="feat-item" @click="router.push('/service/orders')">
|
||||
<div class="feat-circle" style="background:#eff6ff">
|
||||
<van-icon name="gift-o" color="#3b82f6" size="26" />
|
||||
</div>
|
||||
<span class="feat-label">服务包</span>
|
||||
</div>
|
||||
<div class="feat-item" @click="router.push('/nanny')">
|
||||
<div class="feat-circle" style="background:#f5f3ff">
|
||||
<van-icon name="manager-o" color="#8b5cf6" size="26" />
|
||||
</div>
|
||||
<span class="feat-label">我的月嫂</span>
|
||||
</div>
|
||||
<div class="feat-item" @click="router.push('/account')">
|
||||
<div class="feat-circle" style="background:#fff7ed">
|
||||
<van-icon name="balance-o" color="#f97316" size="26" />
|
||||
</div>
|
||||
<span class="feat-label">账户余额</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 今日服务排期(teal 渐变卡) ── -->
|
||||
<div class="schedule-card">
|
||||
<div class="sch-head">
|
||||
<div>
|
||||
<h5 class="sch-title">今日服务排期</h5>
|
||||
<p class="sch-date">{{ today }}(共 {{ todayMeals.length }} 项)</p>
|
||||
</div>
|
||||
<span class="sch-day-badge" v-if="checkinDays">入住第{{ checkinDays }}天</span>
|
||||
<span class="sch-more-btn" v-else @click="router.push('/meal')">查看全部</span>
|
||||
</div>
|
||||
<div class="sch-list" v-if="todayMeals.length">
|
||||
<div v-for="m in todayMeals.slice(0, 3)" :key="m.id" class="sch-item">
|
||||
<span class="sch-time">{{ mealTime[m.meal_type] || '--:--' }}</span>
|
||||
<div class="sch-sep" />
|
||||
<span class="sch-content">
|
||||
{{ mealLabel[m.meal_type] || '用餐' }}
|
||||
<span v-if="m.dishes?.length">:{{ m.dishes.map(d => d.name || d).join('、') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="sch-empty" v-else>今日暂无服务安排</p>
|
||||
</div>
|
||||
|
||||
<!-- ── 最近监测数据 ── -->
|
||||
<div class="data-section">
|
||||
<h5 class="sec-title">最近监测数据</h5>
|
||||
<div class="data-grid">
|
||||
<div class="data-card" @click="router.push('/care/records')">
|
||||
<p class="data-label">最近护理</p>
|
||||
<p class="data-val">{{ latestRecords.length ? (latestRecords[0]?.nurse?.name || '已护理') : '暂无记录' }}</p>
|
||||
<p class="data-ext">{{ latestRecords.length ? latestRecords[0]?.recorded_at?.slice(5, 10) : '--' }}</p>
|
||||
</div>
|
||||
<div class="data-card" @click="router.push('/nanny')">
|
||||
<p class="data-label">我的月嫂</p>
|
||||
<p class="data-val">{{ nannyOrder?.nanny?.name || '待分配' }}</p>
|
||||
<p class="data-ext blue">{{ nannyOrder?.nanny?.level ? '高级月嫂' : '---' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 护理动态 ── -->
|
||||
<div v-if="latestRecords.length" class="records-section">
|
||||
<div class="rec-head">
|
||||
<h5 class="sec-title">护理动态</h5>
|
||||
<span class="rec-more" @click="router.push('/care/records')">全部 ›</span>
|
||||
</div>
|
||||
<div class="rec-list">
|
||||
<div v-for="r in latestRecords" :key="r.id" class="rec-item">
|
||||
<div class="rec-emoji">💆</div>
|
||||
<div class="rec-info">
|
||||
<p class="rec-nurse">{{ r.nurse?.name || '护理师' }}</p>
|
||||
<p class="rec-time">{{ r.recorded_at?.slice(0, 16) || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f5f5f7; min-height: 100vh; padding-bottom: 90px; }
|
||||
|
||||
/* ── Header ── */
|
||||
.header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 52px 22px 18px;
|
||||
background: white;
|
||||
}
|
||||
.greet-sub { font-size: 13px; color: #9ca3af; margin: 0 0 2px; }
|
||||
.greet-name { font-size: 22px; font-weight: 800; color: #111827; margin: 0; letter-spacing: -0.3px; }
|
||||
.avatar-wrap {
|
||||
width: 52px; height: 52px;
|
||||
background: #fff0f4;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fecdd3;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Body ── */
|
||||
.body { padding: 14px 16px; }
|
||||
|
||||
/* ── Camera Card ── */
|
||||
.cam-card {
|
||||
border-radius: 24px;
|
||||
height: 200px;
|
||||
margin-bottom: 22px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #0d1117;
|
||||
box-shadow: 0 10px 32px rgba(0,0,0,0.35);
|
||||
}
|
||||
.cam-grid {
|
||||
position: absolute; inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
/* 暗色影调 */
|
||||
.cam-card::after {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background: radial-gradient(ellipse at 50% 50%, rgba(20,184,166,0.12) 0%, rgba(0,0,0,0.55) 100%);
|
||||
}
|
||||
.cam-topbar {
|
||||
position: absolute; top: 14px; left: 14px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
z-index: 2;
|
||||
}
|
||||
.live-dot {
|
||||
background: #ef4444;
|
||||
color: white; font-size: 11px; font-weight: 700;
|
||||
padding: 3px 10px; border-radius: 20px;
|
||||
letter-spacing: 0.5px;
|
||||
animation: blink 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes blink { 0%,100%{ opacity:1 } 50%{ opacity:0.55 } }
|
||||
.cam-label { color: rgba(255,255,255,0.75); font-size: 12px; }
|
||||
.play-wrap {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
.play-btn {
|
||||
width: 64px; height: 64px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.18);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 2px solid rgba(255,255,255,0.35);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding-left: 4px;
|
||||
}
|
||||
.cam-bottom {
|
||||
position: absolute; bottom: 16px; left: 0; right: 0;
|
||||
text-align: center; z-index: 2;
|
||||
}
|
||||
.cam-title { color: rgba(255,255,255,0.88); font-size: 14px; font-weight: 600; letter-spacing: 0.5px; }
|
||||
|
||||
/* ── Features (圆形 3×2) ── */
|
||||
.features-row {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px 8px;
|
||||
margin-bottom: 22px;
|
||||
background: white;
|
||||
border-radius: 22px;
|
||||
padding: 18px 8px 16px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
.feat-item {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.feat-item:active .feat-circle { transform: scale(0.92); }
|
||||
.feat-circle {
|
||||
width: 56px; height: 56px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.08);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.feat-label { font-size: 12px; font-weight: 600; color: #374151; text-align: center; }
|
||||
|
||||
/* ── Schedule Card ── */
|
||||
.schedule-card {
|
||||
background: linear-gradient(135deg, #0d9488, #0891b2);
|
||||
border-radius: 24px;
|
||||
padding: 20px 18px;
|
||||
margin-bottom: 20px;
|
||||
color: white;
|
||||
box-shadow: 0 6px 24px rgba(13,148,136,0.38);
|
||||
}
|
||||
.sch-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; }
|
||||
.sch-title { font-size: 18px; font-weight: 800; margin: 0 0 4px; letter-spacing: -0.2px; }
|
||||
.sch-date { font-size: 12px; color: rgba(255,255,255,0.72); margin: 0; }
|
||||
.sch-day-badge {
|
||||
background: rgba(255,255,255,0.22);
|
||||
padding: 5px 14px; border-radius: 20px;
|
||||
font-size: 12px; font-weight: 600; white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.sch-more-btn {
|
||||
background: rgba(255,255,255,0.22);
|
||||
padding: 5px 12px; border-radius: 20px;
|
||||
font-size: 11px; cursor: pointer; white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.sch-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.sch-item {
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-radius: 14px; padding: 11px 14px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.sch-time { font-size: 13px; font-weight: 700; white-space: nowrap; min-width: 42px; }
|
||||
.sch-sep { width: 1px; height: 20px; background: rgba(255,255,255,0.3); flex-shrink: 0; }
|
||||
.sch-content { font-size: 13px; font-weight: 500; }
|
||||
.sch-empty { text-align: center; font-size: 13px; color: rgba(255,255,255,0.6); margin: 4px 0 0; }
|
||||
|
||||
/* ── Data Grid ── */
|
||||
.data-section { margin-bottom: 20px; }
|
||||
.sec-title { font-size: 16px; font-weight: 700; color: #1f2937; margin: 0 0 12px; }
|
||||
.data-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.data-card {
|
||||
background: white; border-radius: 20px; padding: 16px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.05); cursor: pointer;
|
||||
}
|
||||
.data-label { font-size: 11px; color: #9ca3af; margin: 0 0 6px; }
|
||||
.data-val { font-size: 17px; font-weight: 700; color: #1f2937; margin: 0 0 3px; }
|
||||
.data-ext { font-size: 12px; color: #10b981; margin: 0; }
|
||||
.data-ext.blue { color: #6366f1; }
|
||||
|
||||
/* ── Records ── */
|
||||
.records-section { }
|
||||
.rec-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.rec-more { font-size: 13px; color: #f43f5e; cursor: pointer; }
|
||||
.rec-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.rec-item {
|
||||
background: white; border-radius: 18px; padding: 12px 16px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
|
||||
}
|
||||
.rec-emoji { font-size: 22px; }
|
||||
.rec-nurse { font-size: 14px; font-weight: 600; color: #1f2937; margin: 0 0 2px; }
|
||||
.rec-time { font-size: 12px; color: #9ca3af; margin: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const active = ref('home')
|
||||
|
||||
watch(() => route.name, (n) => {
|
||||
if (n) {
|
||||
const nm = n.toLowerCase()
|
||||
if (['home', 'meal', 'grow', 'my'].includes(nm)) active.value = nm
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function go(name) {
|
||||
router.push({ name: name.charAt(0).toUpperCase() + name.slice(1) })
|
||||
}
|
||||
|
||||
function onCall() {
|
||||
showToast({ message: '如需帮助请联系前台工作人员 📞', position: 'bottom', duration: 2000 })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<router-view />
|
||||
<div class="tabbar">
|
||||
<div class="tab-item" :class="{ active: active === 'home' }" @click="go('home')">
|
||||
<van-icon name="home-o" size="23" />
|
||||
<span>首页</span>
|
||||
</div>
|
||||
<div class="tab-item" :class="{ active: active === 'meal' }" @click="go('meal')">
|
||||
<van-icon name="fire-o" size="23" />
|
||||
<span>用膳</span>
|
||||
</div>
|
||||
<!-- 中央呼叫按钮 -->
|
||||
<div class="tab-center">
|
||||
<div class="center-btn" @click="onCall">
|
||||
<van-icon name="phone-o" size="25" color="white" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-item" :class="{ active: active === 'grow' }" @click="go('grow')">
|
||||
<van-icon name="flower-o" size="23" />
|
||||
<span>成长</span>
|
||||
</div>
|
||||
<div class="tab-item" :class="{ active: active === 'my' }" @click="go('my')">
|
||||
<van-icon name="contact-o" size="23" />
|
||||
<span>我的</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
.tabbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 68px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
z-index: 999;
|
||||
}
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 6px 0 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.tab-item.active { color: #f43f5e; }
|
||||
.tab-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.center-btn {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
background: linear-gradient(145deg, #f87171, #f43f5e);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 6px 20px rgba(244, 63, 94, 0.45);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
bottom: 8px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.center-btn:active { transform: scale(0.93); }
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { showToast, showLoadingToast, closeToast } from 'vant'
|
||||
import { authApi } from '@/api/index'
|
||||
import { useCustomerStore } from '@/stores/customer'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useCustomerStore()
|
||||
|
||||
const form = reactive({ phone: '', password: '' })
|
||||
const loading = ref(false)
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.phone || !form.password) {
|
||||
showToast('请填写手机号和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
showLoadingToast({ message: '登录中...', forbidClick: true })
|
||||
try {
|
||||
const res = await authApi.login(form)
|
||||
const { token, customer } = res.data.data
|
||||
store.setToken(token)
|
||||
store.setInfo(customer)
|
||||
closeToast()
|
||||
showToast({ type: 'success', message: '登录成功' })
|
||||
const redirect = route.query.redirect || '/home'
|
||||
setTimeout(() => router.replace(redirect), 800)
|
||||
} catch (e) {
|
||||
closeToast()
|
||||
showToast(e.response?.data?.message || '登录失败,请检查手机号和密码')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<!-- 头部装饰 -->
|
||||
<div class="login-header">
|
||||
<div class="logo-circle">🏠</div>
|
||||
<h1 class="app-name">宫中有喜</h1>
|
||||
<p class="app-sub">月子会所 · 微客宝</p>
|
||||
</div>
|
||||
|
||||
<!-- 表单卡片 -->
|
||||
<div class="login-card">
|
||||
<h2 class="card-title">客户登录</h2>
|
||||
<van-form @submit="onSubmit">
|
||||
<van-cell-group inset>
|
||||
<van-field
|
||||
v-model="form.phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
label="手机号"
|
||||
placeholder="请输入手机号"
|
||||
:rules="[{ required: true, message: '请输入手机号' }]"
|
||||
left-icon="phone-o"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.password"
|
||||
name="password"
|
||||
type="password"
|
||||
label="密码"
|
||||
placeholder="请输入密码"
|
||||
:rules="[{ required: true, message: '请输入密码' }]"
|
||||
left-icon="lock"
|
||||
/>
|
||||
</van-cell-group>
|
||||
<div class="btn-wrap">
|
||||
<van-button
|
||||
round
|
||||
block
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
color="linear-gradient(135deg, #f5a0b0, #e8718d)"
|
||||
:loading="loading"
|
||||
>
|
||||
登录
|
||||
</van-button>
|
||||
</div>
|
||||
</van-form>
|
||||
<p class="tip-text">初次登录请联系门店工作人员获取密码</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(160deg, #fde8ee 0%, #fce4f3 40%, #f8f0ff 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 80px;
|
||||
}
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.logo-circle {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: white;
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 38px;
|
||||
margin: 0 auto 16px;
|
||||
box-shadow: 0 8px 24px rgba(232, 113, 141, 0.25);
|
||||
}
|
||||
.app-name {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #c0395d;
|
||||
margin: 0;
|
||||
}
|
||||
.app-sub {
|
||||
font-size: 13px;
|
||||
color: #e8718d;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 24px 24px 0 0;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
padding: 32px 0 40px;
|
||||
box-shadow: 0 -4px 20px rgba(0,0,0,0.06);
|
||||
}
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
text-align: center;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
.btn-wrap {
|
||||
margin: 24px 16px 0;
|
||||
}
|
||||
.tip-text {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #969799;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { mealApi } from '@/api/index'
|
||||
import { showToast } from 'vant'
|
||||
|
||||
const plans = ref([])
|
||||
const loading = ref(true)
|
||||
const reviewVisible = ref(false)
|
||||
const currentPlan = ref(null)
|
||||
const reviewForm = ref({ rating: 5, content: '' })
|
||||
const submitting = ref(false)
|
||||
|
||||
const mealTypeLabel = { 1: '早餐', 2: '午餐', 3: '下午茶', 4: '晚餐', 5: '宵夜' }
|
||||
const mealTypeBg = { 1: '#fff0f4', 2: '#f0fdf4', 3: '#f0f9ff', 4: '#eff6ff', 5: '#faf5ff' }
|
||||
const mealTypeColor = { 1: '#f43f5e', 2: '#10b981', 3: '#0ea5e9', 4: '#3b82f6', 5: '#8b5cf6' }
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await mealApi.list({ days: 7 })
|
||||
plans.value = res.data?.data?.list || []
|
||||
} catch { } finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openReview(plan) {
|
||||
currentPlan.value = plan
|
||||
reviewForm.value = { rating: plan.review?.score || 5, content: plan.review?.content || '' }
|
||||
reviewVisible.value = true
|
||||
}
|
||||
|
||||
async function submitReview() {
|
||||
if (!currentPlan.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await mealApi.review(currentPlan.value.id, { rating: reviewForm.value.rating, content: reviewForm.value.content })
|
||||
showToast({ type: 'success', message: '评价成功' })
|
||||
reviewVisible.value = false
|
||||
fetchList()
|
||||
} catch { showToast('评价失败,请重试') }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
const grouped = computed(() => {
|
||||
const map = {}
|
||||
plans.value.forEach(p => {
|
||||
const d = p.plan_date
|
||||
if (!map[d]) map[d] = []
|
||||
map[d].push(p)
|
||||
})
|
||||
return Object.entries(map).sort((a, b) => b[0].localeCompare(a[0]))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="top-header">
|
||||
<h2 class="page-title">我的用膳</h2>
|
||||
<p class="page-sub">最近 7 天膳食安排</p>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<van-loading v-if="loading" vertical color="#f43f5e" class="center-loader">加载中...</van-loading>
|
||||
|
||||
<template v-else>
|
||||
<van-empty v-if="!grouped.length" description="暂无膳食记录" />
|
||||
|
||||
<div v-for="[date, dayPlans] in grouped" :key="date" class="day-group">
|
||||
<div class="day-label">{{ date }}</div>
|
||||
|
||||
<div v-for="plan in dayPlans" :key="plan.id" class="meal-card">
|
||||
<div class="meal-top">
|
||||
<div
|
||||
class="meal-type-chip"
|
||||
:style="{ background: mealTypeBg[plan.meal_type], color: mealTypeColor[plan.meal_type] }"
|
||||
>
|
||||
{{ mealTypeLabel[plan.meal_type] || '用餐' }}
|
||||
</div>
|
||||
<span v-if="plan.review" class="reviewed-tag">⭐ 已评价</span>
|
||||
<span
|
||||
v-else-if="plan.status === 2"
|
||||
class="review-btn"
|
||||
@click="openReview(plan)"
|
||||
>去评价</span>
|
||||
</div>
|
||||
|
||||
<div class="dishes" v-if="plan.dishes?.length">
|
||||
<span
|
||||
v-for="(dish, i) in plan.dishes"
|
||||
:key="i"
|
||||
class="dish-tag"
|
||||
>{{ dish.name || dish }}</span>
|
||||
</div>
|
||||
<p class="no-dish" v-else>待排餐</p>
|
||||
|
||||
<p v-if="plan.special_note" class="note-text">📝 {{ plan.special_note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 评价弹窗 -->
|
||||
<van-popup v-model:show="reviewVisible" round position="bottom" :style="{ padding: '28px 20px 44px' }">
|
||||
<h3 class="popup-title">膳食评价</h3>
|
||||
<div class="rate-wrap">
|
||||
<van-rate v-model="reviewForm.rating" :size="32" color="#f43f5e" void-icon="star" void-color="#eee" />
|
||||
</div>
|
||||
<van-field
|
||||
v-model="reviewForm.content"
|
||||
type="textarea"
|
||||
placeholder="分享您对这餐的感受..."
|
||||
:rows="3"
|
||||
style="margin-top:14px; background:#f8f8f8; border-radius:16px;"
|
||||
/>
|
||||
<van-button
|
||||
block round type="primary"
|
||||
color="linear-gradient(135deg, #f87171, #f43f5e)"
|
||||
style="margin-top:18px"
|
||||
:loading="submitting"
|
||||
@click="submitReview"
|
||||
>提交评价</van-button>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #fff8fc; min-height: 100vh; padding-bottom: 90px; }
|
||||
|
||||
.top-header {
|
||||
background: linear-gradient(135deg, #f87171, #f43f5e);
|
||||
padding: 52px 22px 28px;
|
||||
border-radius: 0 0 28px 28px;
|
||||
}
|
||||
.page-title { font-size: 22px; font-weight: 700; color: white; margin: 0 0 4px; }
|
||||
.page-sub { font-size: 13px; color: rgba(255,255,255,0.78); margin: 0; }
|
||||
|
||||
.body { padding: 16px; }
|
||||
.center-loader { display: flex; justify-content: center; padding: 60px 0; }
|
||||
|
||||
.day-group { margin-bottom: 18px; }
|
||||
.day-label { font-size: 13px; font-weight: 600; color: #9ca3af; margin-bottom: 10px; }
|
||||
|
||||
.meal-card {
|
||||
background: white; border-radius: 22px; padding: 16px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 2px 14px rgba(0,0,0,0.05);
|
||||
}
|
||||
.meal-top { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.meal-type-chip {
|
||||
font-size: 12px; font-weight: 700;
|
||||
padding: 4px 12px; border-radius: 20px;
|
||||
}
|
||||
.reviewed-tag { font-size: 12px; color: #f59e0b; margin-left: auto; }
|
||||
.review-btn {
|
||||
font-size: 12px; color: #f43f5e;
|
||||
border: 1px solid #f43f5e;
|
||||
padding: 3px 10px; border-radius: 12px;
|
||||
cursor: pointer; margin-left: auto;
|
||||
}
|
||||
|
||||
.dishes { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.dish-tag {
|
||||
background: #fff0f4; color: #f43f5e;
|
||||
font-size: 12px; padding: 4px 12px; border-radius: 12px;
|
||||
}
|
||||
.no-dish { font-size: 13px; color: #d1d5db; margin: 0; }
|
||||
.note-text { font-size: 12px; color: #9ca3af; margin: 10px 0 0; }
|
||||
|
||||
.popup-title { font-size: 17px; font-weight: 700; text-align: center; margin: 0 0 20px; color: #1f2937; }
|
||||
.rate-wrap { display: flex; justify-content: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const account = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await accountApi.index()
|
||||
const list = res.data?.data || []
|
||||
account.value = list[0] || null
|
||||
} catch { } finally { loading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<van-nav-bar title="账户余额" left-arrow @click-left="router.back()" />
|
||||
<div class="content">
|
||||
<van-loading v-if="loading" vertical color="#f43f5e" class="center-load" />
|
||||
<van-empty v-else-if="!account" description="暂无账户信息" />
|
||||
<div v-else>
|
||||
<!-- 现金余额 -->
|
||||
<div class="bal-card rose">
|
||||
<p class="bal-label">现金余额</p>
|
||||
<p class="bal-val">¥ {{ Number(account.cash_balance ?? 0).toFixed(2) }}</p>
|
||||
</div>
|
||||
<!-- 储值卡余额 -->
|
||||
<div class="bal-card blue">
|
||||
<p class="bal-label">储值卡余额</p>
|
||||
<p class="bal-val">¥ {{ Number(account.card_balance ?? 0).toFixed(2) }}</p>
|
||||
</div>
|
||||
<!-- 积分 -->
|
||||
<div class="bal-card amber">
|
||||
<p class="bal-label">积分</p>
|
||||
<p class="bal-val pts">{{ account.points || 0 }} <span>pts</span></p>
|
||||
</div>
|
||||
<van-button
|
||||
block round plain
|
||||
style="margin-top:12px; color:#f43f5e; border-color:#fecdd3; background:#fff8fc;"
|
||||
@click="router.push('/account/transactions')"
|
||||
>查看流水记录</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #fff8fc; min-height: 100vh; }
|
||||
.content { padding: 16px; }
|
||||
.center-load { display: flex; justify-content: center; padding: 60px 0; }
|
||||
.bal-card {
|
||||
border-radius: 22px; padding: 22px 20px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 14px rgba(0,0,0,0.06);
|
||||
}
|
||||
.bal-card.rose { background: linear-gradient(135deg, #f87171, #f43f5e); }
|
||||
.bal-card.blue { background: linear-gradient(135deg, #60a5fa, #3b82f6); }
|
||||
.bal-card.amber { background: linear-gradient(135deg, #fbbf24, #f59e0b); }
|
||||
.bal-label { font-size: 13px; color: rgba(255,255,255,0.85); margin: 0 0 8px; }
|
||||
.bal-val { font-size: 34px; font-weight: 700; color: white; margin: 0; }
|
||||
.bal-val.pts { font-size: 30px; }
|
||||
.bal-val span { font-size: 14px; font-weight: 500; margin-left: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import { complaintApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const list = ref([])
|
||||
const loading = ref(true)
|
||||
const showForm = ref(false)
|
||||
const submitting = ref(false)
|
||||
const form = ref({ type: '', content: '' })
|
||||
const typeOptions = [
|
||||
{ text: '服务质量', value: '服务质量' },
|
||||
{ text: '餐饮问题', value: '餐饮问题' },
|
||||
{ text: '环境卫生', value: '环境卫生' },
|
||||
{ text: '其他', value: '其他' },
|
||||
]
|
||||
|
||||
const statusLabel = { 0: '待处理', 1: '处理中', 2: '已解决', 3: '已关闭' }
|
||||
const statusColor = { 0: 'warning', 1: 'primary', 2: 'success', 3: 'default' }
|
||||
|
||||
const typeLabel = { 1: '服务质量', 2: '餐饮问题', 3: '环境卫生', 4: '其他' }
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await complaintApi.list()
|
||||
list.value = res.data?.data?.list || []
|
||||
} catch { } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.value.type || !form.value.content) {
|
||||
showToast('请填写投诉类型和内容')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
// 后端 complaints.type 是 tinyInteger,这里把中文选项映射为数字
|
||||
const typeMap = { '服务质量': 1, '餐饮问题': 2, '环境卫生': 3, '其他': 4 }
|
||||
const payload = {
|
||||
...form.value,
|
||||
type: typeMap[form.value.type] ?? form.value.type,
|
||||
}
|
||||
|
||||
await complaintApi.create(payload)
|
||||
showToast({ type: 'success', message: '提交成功' })
|
||||
showForm.value = false
|
||||
form.value = { type: '', content: '' }
|
||||
fetchList()
|
||||
} catch { showToast('提交失败') } finally { submitting.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<van-nav-bar title="投诉反馈" left-arrow @click-left="router.back()">
|
||||
<template #right>
|
||||
<van-icon name="plus" size="18" color="#e8718d" @click="showForm = true" />
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
|
||||
<div class="content">
|
||||
<van-loading v-if="loading" vertical color="#e8718d" class="center-load" />
|
||||
<van-empty v-else-if="!list.length" description="暂无投诉记录" image="comment-o" />
|
||||
<div v-else>
|
||||
<div v-for="item in list" :key="item.id" class="item-card">
|
||||
<div class="item-header">
|
||||
<span class="item-type">{{ typeLabel[item.type] || '其他' }}</span>
|
||||
<van-tag :type="statusColor[item.status] || 'default'" size="small">{{ statusLabel[item.status] || '待处理' }}</van-tag>
|
||||
</div>
|
||||
<p class="item-content">{{ item.content }}</p>
|
||||
<p class="item-time">{{ item.created_at?.slice(0, 16) }}</p>
|
||||
<div v-if="item.reply" class="item-reply">
|
||||
<span class="reply-label">回复:</span>{{ item.reply }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交表单 -->
|
||||
<van-popup v-model:show="showForm" round position="bottom" :style="{ padding: '24px 16px 40px' }">
|
||||
<h3 class="popup-title">提交投诉反馈</h3>
|
||||
<van-cell-group inset style="margin-top:8px">
|
||||
<van-field label="类型" v-model="form.type" placeholder="如:服务质量 / 餐饮问题" />
|
||||
<van-field label="内容" v-model="form.content" type="textarea" placeholder="请详细描述您的问题..." :rows="4" />
|
||||
</van-cell-group>
|
||||
<van-button block round type="primary" color="linear-gradient(135deg,#f5a0b0,#e8718d)" style="margin-top:16px" :loading="submitting" @click="submit">提交</van-button>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f8f0f5; min-height: 100vh; }
|
||||
.content { padding: 16px; }
|
||||
.center-load { display: flex; justify-content: center; padding: 60px 0; }
|
||||
.item-card { background: white; border-radius: 16px; padding: 16px; margin-bottom: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.04); }
|
||||
.item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.item-type { font-size: 14px; font-weight: 600; color: #333; }
|
||||
.item-content { font-size: 13px; color: #555; margin: 0 0 6px; line-height: 1.5; }
|
||||
.item-time { font-size: 11px; color: #999; margin: 0; }
|
||||
.item-reply { margin-top: 8px; padding: 8px; background: #f5f5f5; border-radius: 8px; font-size: 12px; color: #555; }
|
||||
.reply-label { color: #e8718d; font-weight: 600; }
|
||||
.popup-title { font-size: 16px; font-weight: 600; text-align: center; margin: 0 0 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { nannyApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const nannyInfo = ref(null)
|
||||
const loading = ref(true)
|
||||
const orderStatusLabel = { 1: '待确认', 2: '服务中', 3: '已完成', 4: '已取消' }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await nannyApi.myNanny()
|
||||
nannyInfo.value = res.data?.data
|
||||
} catch { } finally { loading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<van-nav-bar title="我的月嫂" left-arrow @click-left="router.back()" />
|
||||
<div class="content">
|
||||
<van-loading v-if="loading" vertical color="#e8718d" class="center-load" />
|
||||
<van-empty v-else-if="!nannyInfo" description="暂无月嫂服务信息" image="manager-o" />
|
||||
<div v-else>
|
||||
<!-- 月嫂卡 -->
|
||||
<div class="nanny-card">
|
||||
<div class="nanny-avatar">
|
||||
<van-icon name="user-circle-o" size="64" color="white" />
|
||||
</div>
|
||||
<div class="nanny-detail">
|
||||
<h2 class="nanny-name">{{ nannyInfo.nanny?.name || '- -' }}</h2>
|
||||
<p class="nanny-level">{{ nannyInfo.nanny?.level || '专业月嫂' }}</p>
|
||||
<van-tag type="success" size="medium" v-if="nannyInfo.order?.status === 2">服务中</van-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务信息 -->
|
||||
<van-cell-group inset title="服务信息" class="info-group">
|
||||
<van-cell title="服务状态" :value="orderStatusLabel[nannyInfo.order?.status] || '-'" />
|
||||
<van-cell title="服务开始" :value="nannyInfo.order?.start_date || '-'" />
|
||||
<van-cell title="服务结束" :value="nannyInfo.order?.end_date || '-'" />
|
||||
</van-cell-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f8f0f5; min-height: 100vh; }
|
||||
.content { padding: 16px; }
|
||||
.center-load { display: flex; justify-content: center; padding: 60px 0; }
|
||||
.nanny-card { background: linear-gradient(135deg, #f5a0b0, #e8718d); border-radius: 20px; padding: 24px 20px; display: flex; align-items: center; gap: 20px; margin-bottom: 16px; box-shadow: 0 8px 24px rgba(232,113,141,0.3); }
|
||||
.nanny-avatar { width: 80px; height: 80px; background: rgba(255,255,255,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; }
|
||||
.nanny-detail { color: white; }
|
||||
.nanny-name { font-size: 20px; font-weight: 700; margin: 0 0 4px; }
|
||||
.nanny-level { font-size: 13px; opacity: 0.85; margin: 0 0 8px; }
|
||||
.info-group { margin-top: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { serviceApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const orders = ref([])
|
||||
const loading = ref(true)
|
||||
const active = ref(0)
|
||||
|
||||
const tabs = ['全部', '进行中', '已完成']
|
||||
const tabStatus = [null, 2, 3]
|
||||
const statusLabel = { 1: '待确认', 2: '进行中', 3: '已完成', 4: '已取消' }
|
||||
const statusColor = { 1: 'warning', 2: 'primary', 3: 'success', 4: 'default' }
|
||||
|
||||
onMounted(fetchOrders)
|
||||
|
||||
async function fetchOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {}
|
||||
if (tabStatus[active.value] != null) params.status = tabStatus[active.value]
|
||||
const res = await serviceApi.orders(params)
|
||||
orders.value = res.data?.data?.list || []
|
||||
} catch { } finally { loading.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<van-nav-bar title="我的服务" left-arrow @click-left="router.back()" />
|
||||
<van-tabs v-model:active="active" @change="fetchOrders" color="#e8718d" sticky>
|
||||
<van-tab v-for="(t, i) in tabs" :key="i" :title="t" />
|
||||
</van-tabs>
|
||||
|
||||
<div class="content">
|
||||
<van-loading v-if="loading" vertical color="#e8718d" class="center-load" />
|
||||
<van-empty v-else-if="!orders.length" description="暂无服务订单" />
|
||||
<div v-else>
|
||||
<div v-for="o in orders" :key="o.id" class="order-card">
|
||||
<div class="order-header">
|
||||
<span class="order-name">{{ o.package?.name || o.item?.name || '服务项目' }}</span>
|
||||
<van-tag :type="statusColor[o.status] || 'default'" size="small">{{ statusLabel[o.status] || '-' }}</van-tag>
|
||||
</div>
|
||||
<div class="order-info">
|
||||
<span>下单:{{ o.created_at?.slice(0, 10) }}</span>
|
||||
<span v-if="o.total_sessions">共 {{ o.total_sessions }} 次</span>
|
||||
<span v-if="o.remaining_sessions != null">剩余 {{ o.remaining_sessions }} 次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f8f0f5; min-height: 100vh; }
|
||||
.content { padding: 16px; }
|
||||
.center-load { display: flex; justify-content: center; padding: 60px 0; }
|
||||
.order-card { background: white; border-radius: 16px; padding: 16px; margin-bottom: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.04); }
|
||||
.order-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.order-name { font-size: 15px; font-weight: 600; color: #1d1d1f; }
|
||||
.order-info { display: flex; gap: 12px; font-size: 12px; color: #999; }
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const list = ref([])
|
||||
const loading = ref(true)
|
||||
const finished = ref(false)
|
||||
const page = ref(1)
|
||||
const txTypeLabel = { 1: '充值', 2: '消费', 3: '退款', 4: '调整' }
|
||||
const txTypeColor = { 1: '#22c55e', 2: '#ef4444', 3: '#3b82f6', 4: '#f59e0b' }
|
||||
|
||||
async function onLoad() {
|
||||
try {
|
||||
const res = await accountApi.transactions({ page: page.value, per_page: 20 })
|
||||
const items = res.data?.data?.list || []
|
||||
list.value.push(...items)
|
||||
if (list.value.length >= (res.data?.data?.total || 0)) finished.value = true
|
||||
page.value++
|
||||
} catch { finished.value = true } finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(onLoad)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<van-nav-bar title="流水记录" left-arrow @click-left="router.back()" />
|
||||
<div class="content">
|
||||
<van-list :loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
|
||||
<div v-for="tx in list" :key="tx.id" class="tx-card">
|
||||
<div class="tx-left">
|
||||
<span class="tx-type">{{ txTypeLabel[tx.type] || '交易' }}</span>
|
||||
<span class="tx-time">{{ tx.created_at?.slice(0, 16) }}</span>
|
||||
</div>
|
||||
<span class="tx-amount" :style="{ color: [1,3].includes(tx.type) ? '#22c55e' : '#ef4444' }">
|
||||
{{ [1,3].includes(tx.type) ? '+' : '-' }}¥{{ Math.abs(tx.amount || 0).toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
</van-list>
|
||||
<van-empty v-if="!loading && !list.length" description="暂无流水记录" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f8f0f5; min-height: 100vh; }
|
||||
.content { padding: 16px; }
|
||||
.tx-card { background: white; border-radius: 14px; padding: 14px 16px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
|
||||
.tx-left { display: flex; flex-direction: column; gap: 2px; }
|
||||
.tx-type { font-size: 14px; font-weight: 600; color: #333; }
|
||||
.tx-time { font-size: 11px; color: #999; }
|
||||
.tx-amount { font-size: 18px; font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showConfirmDialog, showToast } from 'vant'
|
||||
import { useCustomerStore } from '@/stores/customer'
|
||||
import { authApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useCustomerStore()
|
||||
const customer = computed(() => store.info)
|
||||
|
||||
const menus = [
|
||||
{ icon: 'balance-list-o', label: '我的服务', desc: '服务订单与执行记录', color: '#3b82f6', bg: '#eff6ff', path: '/service/orders' },
|
||||
{ icon: 'gold-coin-o', label: '账户余额', desc: '充值与消费流水', color: '#f59e0b', bg: '#fffbeb', path: '/account' },
|
||||
{ icon: 'user-circle-o', label: '我的月嫂', desc: '月嫂信息与排班', color: '#8b5cf6', bg: '#f5f3ff', path: '/nanny' },
|
||||
{ icon: 'chat-o', label: '投诉反馈', desc: '问题与建议反馈', color: '#f43f5e', bg: '#fff0f4', path: '/complaints' },
|
||||
]
|
||||
|
||||
async function doLogout() {
|
||||
try {
|
||||
await showConfirmDialog({ title: '确定退出?', message: '退出后需重新登录' })
|
||||
await authApi.logout().catch(() => {})
|
||||
store.logout()
|
||||
router.replace('/login')
|
||||
} catch { }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<!-- 个人信息头部 -->
|
||||
<div class="profile-header">
|
||||
<div class="avatar-circle">
|
||||
<van-icon name="user-circle-o" size="44" color="white" />
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<p class="p-name">{{ customer?.name || '- -' }}</p>
|
||||
<p class="p-phone">{{ customer?.phone }}</p>
|
||||
</div>
|
||||
<div class="header-badge">
|
||||
<van-icon name="shield-o" size="16" color="rgba(255,255,255,0.8)" />
|
||||
<span>会员</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<!-- 状态统计卡 -->
|
||||
<div class="stat-card" v-if="customer">
|
||||
<div class="stat-item">
|
||||
<span class="stat-val">{{ customer.baby_count || 1 }}</span>
|
||||
<span class="stat-label">宝宝数</span>
|
||||
</div>
|
||||
<div class="stat-line" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-val">{{ customer.actual_date ? '在住' : '待入住' }}</span>
|
||||
<span class="stat-label">状态</span>
|
||||
</div>
|
||||
<div class="stat-line" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-val">{{ customer.baby_count ? customer.baby_count + '宝' : '--' }}</span>
|
||||
<span class="stat-label">宝宝信息</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 功能菜单 -->
|
||||
<div class="menu-list">
|
||||
<div
|
||||
v-for="m in menus"
|
||||
:key="m.path"
|
||||
class="menu-item"
|
||||
@click="router.push(m.path)"
|
||||
>
|
||||
<div class="menu-icon" :style="{ background: m.bg }">
|
||||
<van-icon :name="m.icon" :color="m.color" size="22" />
|
||||
</div>
|
||||
<div class="menu-text">
|
||||
<p class="menu-label">{{ m.label }}</p>
|
||||
<p class="menu-desc">{{ m.desc }}</p>
|
||||
</div>
|
||||
<van-icon name="arrow" color="#d1d5db" size="16" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 退出按钮 -->
|
||||
<div class="logout-wrap">
|
||||
<van-button
|
||||
round block plain
|
||||
style="color:#f43f5e; border-color:#fecdd3; background:#fff8fc;"
|
||||
@click="doLogout"
|
||||
>退出登录</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #fff8fc; min-height: 100vh; padding-bottom: 90px; }
|
||||
|
||||
/* Profile Header */
|
||||
.profile-header {
|
||||
background: linear-gradient(135deg, #f87171, #f43f5e);
|
||||
padding: 52px 22px 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
}
|
||||
.avatar-circle {
|
||||
width: 72px; height: 72px;
|
||||
background: rgba(255,255,255,0.22);
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: 2px solid rgba(255,255,255,0.4);
|
||||
}
|
||||
.profile-info { flex: 1; }
|
||||
.p-name { font-size: 20px; font-weight: 700; color: white; margin: 0 0 4px; }
|
||||
.p-phone { font-size: 13px; color: rgba(255,255,255,0.8); margin: 0; }
|
||||
.header-badge {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
background: rgba(255,255,255,0.18);
|
||||
padding: 5px 12px; border-radius: 20px;
|
||||
font-size: 12px; color: rgba(255,255,255,0.9);
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.body { padding: 16px; }
|
||||
|
||||
/* Stats */
|
||||
.stat-card {
|
||||
background: white; border-radius: 22px; padding: 18px 12px;
|
||||
display: flex; justify-content: space-around; align-items: center;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 14px rgba(0,0,0,0.06);
|
||||
}
|
||||
.stat-item { display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||||
.stat-val { font-size: 20px; font-weight: 700; color: #f43f5e; }
|
||||
.stat-label { font-size: 12px; color: #9ca3af; }
|
||||
.stat-line { width: 1px; height: 36px; background: #f5f5f5; }
|
||||
|
||||
/* Menu */
|
||||
.menu-list {
|
||||
background: white; border-radius: 22px; overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 14px rgba(0,0,0,0.06);
|
||||
}
|
||||
.menu-item {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 16px 16px;
|
||||
border-bottom: 1px solid #fafafa;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.menu-item:last-child { border: none; }
|
||||
.menu-item:active { background: #fafafa; }
|
||||
.menu-icon {
|
||||
width: 44px; height: 44px; border-radius: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.menu-text { flex: 1; }
|
||||
.menu-label { font-size: 15px; font-weight: 600; color: #1f2937; margin: 0 0 3px; }
|
||||
.menu-desc { font-size: 12px; color: #9ca3af; margin: 0; }
|
||||
|
||||
.logout-wrap { margin-top: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/wkb/',
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3001,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '../frontend/dist-client',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
@@ -1,26 +1,73 @@
|
||||
#!/bin/bash
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 月子 SaaS 一键部署
|
||||
# 用法:./deploy.sh [commit message]
|
||||
#
|
||||
# 流程:
|
||||
# 1. git commit + push(源码,不含 dist)
|
||||
# 2. 本地构建管理端前端
|
||||
# 3. 本地构建 H5 客户端(微客宝)
|
||||
# 4. rsync 两个 dist 到服务器
|
||||
# 5. SSH 触发后端 git pull + migrate + cache
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
set -e
|
||||
|
||||
SERVER="root@82.157.47.215"
|
||||
SERVER_PASS="Aiying88"
|
||||
COMMIT_MSG="${1:-deploy: update}"
|
||||
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
FRONTEND_DIR="$ROOT_DIR/frontend"
|
||||
CLIENT_DIR="$ROOT_DIR/client"
|
||||
REMOTE_FRONTEND="/var/www/saas/frontend"
|
||||
REMOTE_CLIENT="/var/www/saas/client-root/wkb"
|
||||
REMOTE_BACKEND_SCRIPT="/var/www/saas/deploy-backend.sh"
|
||||
|
||||
echo "▶ 构建前端..."
|
||||
cd "$ROOT_DIR/frontend"
|
||||
npm run build
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
step() { echo -e "${YELLOW}==> $1${NC}"; }
|
||||
ok() { echo -e "${GREEN}✓ $1${NC}"; }
|
||||
|
||||
echo "▶ 提交变更(含 dist)..."
|
||||
# ── 1. 提交并推送源码 ─────────────────────────────────────────
|
||||
step "提交源码到 git..."
|
||||
cd "$ROOT_DIR"
|
||||
git add -A
|
||||
git diff --cached --quiet && echo " 无变更,跳过提交" || git commit -m "$COMMIT_MSG"
|
||||
|
||||
echo "▶ 推送到远程..."
|
||||
if git diff --cached --quiet; then
|
||||
echo " 无源码变更,跳过提交"
|
||||
else
|
||||
git commit -m "$COMMIT_MSG"
|
||||
fi
|
||||
git push origin main
|
||||
ok "源码已推送"
|
||||
|
||||
echo "▶ 通知服务器更新..."
|
||||
ssh "$SERVER" '/var/www/saas/deploy.sh'
|
||||
# ── 2. 构建管理端前端 ─────────────────────────────────────────
|
||||
step "构建管理端前端..."
|
||||
cd "$FRONTEND_DIR"
|
||||
npm run build
|
||||
ok "管理端前端构建完成"
|
||||
|
||||
# ── 3. 构建 H5 客户端 ─────────────────────────────────────────
|
||||
step "构建 H5 客户端(微客宝)..."
|
||||
cd "$CLIENT_DIR"
|
||||
npm run build
|
||||
ok "H5 客户端构建完成"
|
||||
|
||||
# ── 4. 上传 dist 到服务器 ─────────────────────────────────────
|
||||
step "上传管理端 dist..."
|
||||
sshpass -p "$SERVER_PASS" rsync -az --delete \
|
||||
"$FRONTEND_DIR/dist/" \
|
||||
"$SERVER:$REMOTE_FRONTEND/"
|
||||
ok "管理端文件已同步"
|
||||
|
||||
step "上传 H5 客户端 dist..."
|
||||
sshpass -p "$SERVER_PASS" ssh "$SERVER" "mkdir -p $REMOTE_CLIENT"
|
||||
sshpass -p "$SERVER_PASS" rsync -az --delete \
|
||||
"$FRONTEND_DIR/dist-client/" \
|
||||
"$SERVER:$REMOTE_CLIENT/"
|
||||
ok "H5 客户端文件已同步"
|
||||
|
||||
# ── 5. 触发后端同步 ───────────────────────────────────────────
|
||||
step "同步后端(git pull + migrate + cache)..."
|
||||
sshpass -p "$SERVER_PASS" ssh "$SERVER" "bash $REMOTE_BACKEND_SCRIPT"
|
||||
ok "后端同步完成"
|
||||
|
||||
echo ""
|
||||
echo "✅ 部署完成!"
|
||||
echo -e "${GREEN}🎉 部署完成!${NC}"
|
||||
|
||||
@@ -10,6 +10,7 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
dist-client
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
@@ -26,7 +26,8 @@ export const customerApi = {
|
||||
getDetail: (id) => request.get(`/crm/customers/${id}`),
|
||||
create: (data) => request.post('/crm/customers', data),
|
||||
update: (id, data) => request.put(`/crm/customers/${id}`, data),
|
||||
delete: (id) => request.delete(`/crm/customers/${id}`)
|
||||
delete: (id) => request.delete(`/crm/customers/${id}`),
|
||||
setPassword: (id, data) => request.post(`/crm/customers/${id}/set-password`, data),
|
||||
}
|
||||
|
||||
// ─── Contracts ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
function normalizeParams(params) {
|
||||
if (!params || typeof params !== 'object') return params
|
||||
|
||||
const p = { ...params }
|
||||
|
||||
// Normalize pagination params (some endpoints use per_page, some use page_size)
|
||||
if (p.per_page !== undefined && p.per_page !== null && p.page_size === undefined) {
|
||||
p.page_size = p.per_page
|
||||
}
|
||||
if (p.page_size !== undefined && p.page_size !== null && p.per_page === undefined) {
|
||||
p.per_page = p.page_size
|
||||
}
|
||||
|
||||
// Remove empty filters to avoid backend `has('status')` treating '' as a real filter
|
||||
for (const key of Object.keys(p)) {
|
||||
const v = p[key]
|
||||
if (v === '' || v === null || v === undefined) {
|
||||
delete p[key]
|
||||
}
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 15000,
|
||||
@@ -17,6 +41,11 @@ request.interceptors.request.use(
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
if (config.params) {
|
||||
config.params = normalizeParams(config.params)
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
|
||||
@@ -12,6 +12,12 @@ const submitLoading = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const detailData = ref(null)
|
||||
|
||||
// 设置密码
|
||||
const pwdVisible = ref(false)
|
||||
const pwdForm = reactive({ password: '', confirm: '' })
|
||||
const pwdTarget = ref(null)
|
||||
const pwdLoading = ref(false)
|
||||
|
||||
const searchForm = reactive({ name: '', phone: '', status: '' })
|
||||
const pagination = reactive({ page: 1, per_page: 20 })
|
||||
|
||||
@@ -84,6 +90,27 @@ async function handleDetail(row) {
|
||||
function addFamily() { form.families.push({ name: '', phone: '', relation: '', is_emergency: false }) }
|
||||
function removeFamily(idx) { form.families.splice(idx, 1) }
|
||||
|
||||
function openSetPassword(row) {
|
||||
pwdTarget.value = row
|
||||
Object.assign(pwdForm, { password: '', confirm: '' })
|
||||
pwdVisible.value = true
|
||||
}
|
||||
|
||||
async function submitSetPassword() {
|
||||
if (!pwdForm.password || pwdForm.password.length < 6) {
|
||||
ElMessage.warning('密码不少于 6 位'); return
|
||||
}
|
||||
if (pwdForm.password !== pwdForm.confirm) {
|
||||
ElMessage.warning('两次密码不一致'); return
|
||||
}
|
||||
pwdLoading.value = true
|
||||
try {
|
||||
await customerApi.setPassword(pwdTarget.value.id, { password: pwdForm.password })
|
||||
ElMessage.success('微客宝登录密码已设置')
|
||||
pwdVisible.value = false
|
||||
} finally { pwdLoading.value = false }
|
||||
}
|
||||
|
||||
function handlePageChange(val) { pagination.page = val; fetchList() }
|
||||
function handleSizeChange(val) { pagination.per_page = val; pagination.page = 1; fetchList() }
|
||||
|
||||
@@ -124,10 +151,11 @@ onMounted(() => fetchList())
|
||||
<template #default="{ row }">{{ row.owner?.name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="160" />
|
||||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" text @click="handleDetail(row)">详情</el-button>
|
||||
<el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="warning" text @click="openSetPassword(row)">设置密码</el-button>
|
||||
<el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -195,5 +223,24 @@ onMounted(() => fetchList())
|
||||
</el-table>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 设置微客宝登录密码 -->
|
||||
<el-dialog v-model="pwdVisible" title="设置微客宝登录密码" width="400px" destroy-on-close>
|
||||
<div style="margin-bottom:12px; color:#606266; font-size:13px;">
|
||||
为客户 <strong>{{ pwdTarget?.name }}</strong>({{ pwdTarget?.phone }})设置手机号登录密码
|
||||
</div>
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="pwdForm.password" type="password" show-password placeholder="不少于 6 位" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码">
|
||||
<el-input v-model="pwdForm.confirm" type="password" show-password placeholder="再次输入密码" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="pwdVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="pwdLoading" @click="submitSetPassword">确认设置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -25,7 +25,7 @@ async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await financeCategoryApi.getList({ ...searchForm, ...pagination })
|
||||
tableData.value = res.data?.list || res.data?.data || []
|
||||
tableData.value = Array.isArray(res.data) ? res.data : (res.data?.list || res.data?.data || [])
|
||||
total.value = res.data?.total || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ import axios from 'axios'
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const packagesLoading = ref(false)
|
||||
const deptLoading = ref(false)
|
||||
const packages = ref([])
|
||||
const stores = ref([])
|
||||
const departments = ref([])
|
||||
|
||||
const form = reactive({
|
||||
store_id: '',
|
||||
registration_package_id: '',
|
||||
department_id: '',
|
||||
username: '',
|
||||
password: '',
|
||||
name: '',
|
||||
@@ -22,7 +25,7 @@ const form = reactive({
|
||||
const formRef = ref(null)
|
||||
const rules = {
|
||||
store_id: [{ required: true, message: '请选择门店', trigger: 'change' }],
|
||||
registration_package_id: [{ required: true, message: '请选择岗位套餐', trigger: 'change' }],
|
||||
registration_package_id: [{ required: true, message: '请选择申请岗位', trigger: 'change' }],
|
||||
username: [
|
||||
{ required: true, message: '请输入登录账号', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '2-50个字符', trigger: 'blur' },
|
||||
@@ -47,19 +50,29 @@ async function fetchStores() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPackages() {
|
||||
if (!form.store_id) return
|
||||
packagesLoading.value = true
|
||||
async function onStoreChange() {
|
||||
form.registration_package_id = ''
|
||||
form.department_id = ''
|
||||
packages.value = []
|
||||
departments.value = []
|
||||
if (!form.store_id) return
|
||||
|
||||
packagesLoading.value = true
|
||||
deptLoading.value = true
|
||||
|
||||
try {
|
||||
const res = await axios.get('/api/v1/auth/register/packages', {
|
||||
params: { store_id: form.store_id },
|
||||
})
|
||||
packages.value = res.data?.data || []
|
||||
const [pkgRes, deptRes] = await Promise.all([
|
||||
axios.get('/api/v1/auth/register/packages', { params: { store_id: form.store_id } }),
|
||||
axios.get('/api/v1/auth/register/departments', { params: { store_id: form.store_id } }),
|
||||
])
|
||||
packages.value = pkgRes.data?.data || []
|
||||
departments.value = deptRes.data?.data || []
|
||||
} catch {
|
||||
packages.value = []
|
||||
departments.value = []
|
||||
} finally {
|
||||
packagesLoading.value = false
|
||||
deptLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,22 +108,19 @@ onMounted(fetchStores)
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<!-- 门店 -->
|
||||
<el-form-item label="所属门店" prop="store_id">
|
||||
<el-select
|
||||
v-model="form.store_id"
|
||||
placeholder="请选择门店"
|
||||
style="width: 100%"
|
||||
@change="fetchPackages"
|
||||
@change="onStoreChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="s in stores"
|
||||
:key="s.id"
|
||||
:label="s.name"
|
||||
:value="s.id"
|
||||
/>
|
||||
<el-option v-for="s in stores" :key="s.id" :label="s.name" :value="s.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 申请岗位套餐 -->
|
||||
<el-form-item label="申请岗位" prop="registration_package_id">
|
||||
<el-select
|
||||
v-model="form.registration_package_id"
|
||||
@@ -129,7 +139,24 @@ onMounted(fetchStores)
|
||||
<span v-if="p.description" class="text-gray-400 text-xs ml-2">{{ p.description }}</span>
|
||||
</el-option>
|
||||
<template v-if="packages.length === 0 && form.store_id && !packagesLoading" #empty>
|
||||
<div class="text-center py-4 text-gray-400 text-sm">该门店暂无可用套餐</div>
|
||||
<div class="text-center py-4 text-gray-400 text-sm">该门店暂无可用套餐,请联系管理员</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 所属部门 -->
|
||||
<el-form-item label="所属部门" prop="department_id">
|
||||
<el-select
|
||||
v-model="form.department_id"
|
||||
placeholder="请先选择门店"
|
||||
style="width: 100%"
|
||||
:loading="deptLoading"
|
||||
:disabled="!form.store_id"
|
||||
clearable
|
||||
>
|
||||
<el-option v-for="d in departments" :key="d.id" :label="d.name" :value="d.id" />
|
||||
<template v-if="departments.length === 0 && form.store_id && !deptLoading" #empty>
|
||||
<div class="text-center py-4 text-gray-400 text-sm">该门店暂无部门数据</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
@@ -74,9 +74,15 @@ async function fetchTree() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await departmentApi.getList({ per_page: 500 })
|
||||
// Backend already returns a tree array directly in res.data
|
||||
if (Array.isArray(res.data)) {
|
||||
treeData.value = res.data
|
||||
flatDepts.value = flattenTree(res.data)
|
||||
} else {
|
||||
const flat = res.data?.list || res.data?.data || []
|
||||
flatDepts.value = flat.map(d => ({ id: d.id, name: d.name }))
|
||||
treeData.value = buildTree(flat)
|
||||
}
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
@@ -84,9 +90,9 @@ async function fetchTree() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildTree(nodes, parentId = null) {
|
||||
function buildTree(nodes, parentId = 0) {
|
||||
return nodes
|
||||
.filter(n => (n.parent_id ?? null) === parentId)
|
||||
.filter(n => (n.parent_id ?? 0) === parentId)
|
||||
.map(n => ({ ...n, children: buildTree(nodes, n.id) }))
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,8 @@ async function fetchTypeList() {
|
||||
keyword: typeSearch.keyword,
|
||||
...typePagination
|
||||
})
|
||||
typeList.value = res.data?.list || []
|
||||
const types = Array.isArray(res.data) ? res.data : (res.data?.list || res.data?.data || [])
|
||||
typeList.value = types
|
||||
typeTotal.value = res.data?.total || 0
|
||||
// Auto-select first type
|
||||
if (!selectedType.value && typeList.value.length > 0) {
|
||||
|
||||
@@ -64,8 +64,8 @@ async function fetchTree() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await menuApi.getList({ per_page: 500 })
|
||||
const flat = res.data?.list || res.data?.data || []
|
||||
treeData.value = buildTree(flat)
|
||||
const raw = Array.isArray(res.data) ? res.data : (res.data?.list || res.data?.data || [])
|
||||
treeData.value = buildTree(raw)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
|
||||
@@ -29,11 +29,11 @@ const form = reactive({
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
|
||||
code: [
|
||||
{ required: true, message: '请输入角色标识', trigger: 'blur' },
|
||||
{ pattern: /^[a-zA-Z][a-zA-Z0-9_:]*$/, message: '只允许字母、数字、下划线,以字母开头', trigger: 'blur' }
|
||||
]
|
||||
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
function genCode() {
|
||||
return 'role_' + Date.now().toString(36)
|
||||
}
|
||||
|
||||
// ─── Permission assignment ────────────────────────────────────────────────────
|
||||
@@ -68,17 +68,23 @@ async function fetchList() {
|
||||
|
||||
async function fetchPermTree() {
|
||||
try {
|
||||
const res = await permissionApi.getList({ per_page: 500 })
|
||||
const flat = res.data?.list || res.data?.data || []
|
||||
const res = await permissionApi.getList({ page_size: 500 })
|
||||
// API returns already-tree-structured array in res.data
|
||||
const data = res.data
|
||||
if (Array.isArray(data)) {
|
||||
permTree.value = data
|
||||
} else {
|
||||
const flat = data?.list || data?.data || []
|
||||
permTree.value = buildPermTree(flat)
|
||||
}
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
function buildPermTree(nodes, parentId = null) {
|
||||
function buildPermTree(nodes, parentId = 0) {
|
||||
return nodes
|
||||
.filter(n => (n.parent_id ?? null) === parentId)
|
||||
.filter(n => (n.parent_id ?? 0) === parentId)
|
||||
.map(n => ({ ...n, children: buildPermTree(nodes, n.id) }))
|
||||
}
|
||||
|
||||
@@ -97,7 +103,7 @@ function handleReset() {
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增角色'
|
||||
Object.assign(form, { id: null, name: '', code: '', description: '', status: 1 })
|
||||
Object.assign(form, { id: null, name: '', code: genCode(), description: '', status: 1 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -274,9 +280,9 @@ onMounted(() => {
|
||||
<el-form-item label="角色名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入角色名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色标识" prop="code">
|
||||
<el-input v-model="form.code" placeholder="如: admin, nurse_manager" :disabled="isEdit" />
|
||||
<div class="form-tip">标识用于程序内部权限判断,创建后不可修改</div>
|
||||
<el-form-item v-if="isEdit" label="角色标识">
|
||||
<el-input v-model="form.code" disabled />
|
||||
<div class="form-tip">角色标识由系统自动生成,不可修改</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { userApi, roleApi, departmentApi } from '@/api/system.js'
|
||||
import { userApi, roleApi, departmentApi, storeApi } from '@/api/system.js'
|
||||
import { useUserStore } from '@/stores/user.js'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const isSuper = computed(() => userStore.userInfo?.is_super)
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const tableData = ref([])
|
||||
const stores = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
@@ -42,7 +47,8 @@ const form = reactive({
|
||||
role_ids: [],
|
||||
status: 1,
|
||||
password: '',
|
||||
password_confirm: ''
|
||||
password_confirm: '',
|
||||
store_id: ''
|
||||
})
|
||||
|
||||
const isEdit = ref(false)
|
||||
@@ -99,6 +105,14 @@ const resetPwdRules = {
|
||||
|
||||
// ─── Methods ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function flattenDeptTree(nodes, result = []) {
|
||||
for (const n of nodes) {
|
||||
result.push({ id: n.id, name: n.name })
|
||||
if (n.children?.length) flattenDeptTree(n.children, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -124,7 +138,9 @@ async function fetchRoles() {
|
||||
async function fetchDepartments() {
|
||||
try {
|
||||
const res = await departmentApi.getList({ per_page: 500 })
|
||||
departments.value = res.data?.list || res.data?.data || []
|
||||
// API returns tree array directly in res.data, flatten it for selects
|
||||
const raw = Array.isArray(res.data) ? res.data : (res.data?.list || res.data?.data || [])
|
||||
departments.value = flattenDeptTree(raw)
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
@@ -146,7 +162,7 @@ function handleAdd() {
|
||||
Object.assign(form, {
|
||||
id: null, username: '', name: '', phone: '', email: '',
|
||||
department_id: '', position_id: '', role_ids: [], status: 1,
|
||||
password: '', password_confirm: ''
|
||||
password: '', password_confirm: '', store_id: ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
@@ -276,6 +292,11 @@ onMounted(() => {
|
||||
fetchList()
|
||||
fetchRoles()
|
||||
fetchDepartments()
|
||||
if (isSuper.value) {
|
||||
storeApi.getList({ page_size: 200 }).then(res => {
|
||||
stores.value = res.data?.list || []
|
||||
}).catch(() => {})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -411,6 +432,13 @@ onMounted(() => {
|
||||
label-position="right"
|
||||
>
|
||||
<el-row :gutter="16">
|
||||
<el-col v-if="isSuper && !isEdit" :span="24">
|
||||
<el-form-item label="所属门店" prop="store_id">
|
||||
<el-select v-model="form.store_id" placeholder="超管可指定门店(不选则为当前门店)" clearable style="width:100%">
|
||||
<el-option v-for="s in stores" :key="s.id" :label="s.name" :value="s.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="请输入用户名" :disabled="isEdit" />
|
||||
@@ -433,14 +461,9 @@ onMounted(() => {
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="部门" prop="department_id">
|
||||
<el-tree-select
|
||||
v-model="form.department_id"
|
||||
:data="departments"
|
||||
:props="{ label: 'name', value: 'id', children: 'children' }"
|
||||
placeholder="选择部门"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-select v-model="form.department_id" placeholder="选择部门" clearable style="width:100%">
|
||||
<el-option v-for="d in departments" :key="d.id" :label="d.name" :value="d.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
|
||||
Reference in New Issue
Block a user