Compare commits

..
36 Commits
Author SHA1 Message Date
li c778639f05 fix: 修复列表筛选空参与树数据展示
将后端 status/audit_status 筛选从 has() 改为 filled(),避免空字符串导致列表被误筛空;修复预定 status=0 筛选失效;前端兼容财务分类树形返回与菜单/字典数组返回,解决新增成功但列表空。
2026-03-15 00:06:11 +08:00
li a29faa3b47 fix: client service/complaints 500 修复 2026-03-14 23:21:15 +08:00
li a163ac1766 fix: 健康指标接口字段名修复 2026-03-14 23:14:03 +08:00
li d104286a37 fix: H5 账户余额与投诉提交修复 2026-03-14 23:10:35 +08:00
li f97fe4d9d3 fix: tab bar 首页/我的 icon 静态化防 Vite tree-shake 2026-03-14 21:41:35 +08:00
li 893191880f feat: 首页功能图标改用静态van-icon(6格3x2),修复Vite tree-shake导致点击消失问题 2026-03-14 21:38:14 +08:00
li d3f3076db8 revert: 回滚首页图标改动,恢复4格emoji版本 2026-03-14 21:33:18 +08:00
li 5474ebe96c Revert "feat: 首页功能图标换用van-icon,从4格扩展到6格"
This reverts commit 862705847e.
2026-03-14 21:32:59 +08:00
li 21d70c40c6 Revert "fix: 替换不存在的food-o图标为fire-o"
This reverts commit 9aa23638a9.
2026-03-14 21:32:59 +08:00
li 2327209e95 Revert "fix: 首页功能图标改用原生CSS class渲染,规避van-icon组件渲染问题"
This reverts commit 740fe251e3.
2026-03-14 21:32:59 +08:00
li 1141486129 Revert "fix: 首页功能图标改回emoji,规避Vite动态class tree-shake问题"
This reverts commit 1080f6bd2c.
2026-03-14 21:32:59 +08:00
li 1080f6bd2c fix: 首页功能图标改回emoji,规避Vite动态class tree-shake问题 2026-03-14 21:32:03 +08:00
li 740fe251e3 fix: 首页功能图标改用原生CSS class渲染,规避van-icon组件渲染问题 2026-03-14 21:27:09 +08:00
li 9aa23638a9 fix: 替换不存在的food-o图标为fire-o 2026-03-14 21:23:03 +08:00
li 862705847e feat: 首页功能图标换用van-icon,从4格扩展到6格 2026-03-14 21:18:59 +08:00
li 4cdfeb034c fix: HomeController contracts字段名修正(start_date→check_in_date) 2026-03-14 21:14:38 +08:00
li 1c1e44ade7 feat: H5首页对齐原型(暗色监控卡/圆形图标/服务排期) + demo演示数据 2026-03-14 21:07:47 +08:00
li 29d5b9e7db fix: H5客户端接口全链路修复 — 登录状态/首页key/账户字段/膳食类型 2026-03-14 20:56:10 +08:00
li 66ea2f558d feat: H5微客宝UI全面升级 - 对齐原型图设计 2026-03-14 20:38:01 +08:00
li 6bf79d6e65 feat: CRM客户管理-设置微客宝登录密码 2026-03-14 19:55:09 +08:00
li c3011d0636 fix: H5 vite base=/wkb/ + router history base,修复子路径部署资源加载 2026-03-14 19:51:23 +08:00
li 78bebe134e fix: 修复 deploy.sh 上传路径为 client-root/wkb 2026-03-14 19:48:41 +08:00
li b5c493333c chore: 从 git 移除 client/node_modules,添加 .gitignore 2026-03-14 19:45:24 +08:00
li 0e974da09c feat: 微客宝H5客户端 - 基础架构+核心页面 2026-03-14 19:44:03 +08:00
li 54d2f617bb chore: 前端dist移出git,改为本地build直传服务器 2026-03-14 19:20:48 +08:00
li 94b46611d3 feat: 注册页加部门选择/用户管理修部门下拉/超管可跨店建用户/公开部门接口 2026-03-14 18:28:35 +08:00
li f7adb5f609 fix: rebuild frontend — departments fetchTree fix 2026-03-14 18:19:05 +08:00
li 444065d496 fix: 部门列表空/职务无法启用 — fetchTree适配树形API响应, positions表加status/code/description字段 2026-03-14 18:15:36 +08:00
li f12cb4a2e0 fix: track dist in git — remove dist from frontend/.gitignore for server deployment 2026-03-14 18:08:30 +08:00
li 12a1be5571 fix: role code auto-gen; fix permission tree API response parsing; fix buildPermTree parent_id=0 2026-03-14 18:05:58 +08:00
li b590fc88a4 fix: null->0 for parent_id/sort NOT NULL columns; prevent DB constraint violation 2026-03-14 18:01:47 +08:00
li 39234a0f4d fix: custom Authenticate middleware returns null redirectTo — prevents Route[login] not defined 500 2026-03-14 17:58:26 +08:00
li 0f721bcc09 fix: return JSON 401 for unauthenticated API requests instead of redirecting to login route 2026-03-14 17:57:27 +08:00
li f7eaedd022 fix: deduplicate apiResource route names across modules (orders/profiles/records/categories) 2026-03-14 17:50:29 +08:00
li 7d2b906e77 fix: nullable|integer — system controllers accept null/empty for optional integer fields 2026-03-14 17:46:18 +08:00
li 5cc8df4d68 chore: add frontend dist for server deployment 2026-03-14 17:40:50 +08:00
94 changed files with 5197 additions and 148 deletions
+1
View File
@@ -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;
}
}
+11 -6
View File
@@ -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',
];
}
+1 -1
View File
@@ -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'];
}
+32 -4
View File
@@ -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;
}
}
+6 -1
View File
@@ -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
View File
@@ -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']);
});
});
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
dist-ssr
*.local
.DS_Store
+14
View File
@@ -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>
+1901
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
}
}
+16
View File
@@ -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>
+44
View File
@@ -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),
}
+29
View File
@@ -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)
}
)
+13
View File
@@ -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')
+99
View File
@@ -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
+37
View File
@@ -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 }
})
+33
View File
@@ -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;
}
+56
View File
@@ -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>
+57
View File
@@ -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>
+162
View File
@@ -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>
+345
View File
@@ -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-icon3×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>
+113
View File
@@ -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>
+151
View File
@@ -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>
+172
View File
@@ -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>
+67
View File
@@ -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>
+108
View File
@@ -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>
+59
View File
@@ -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>
+64
View File
@@ -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>
+55
View File
@@ -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>
+165
View File
@@ -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>
+26
View File
@@ -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,
},
})
+57 -10
View File
@@ -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}"
+1
View File
@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
dist-client
*.local
# Editor directories and files
+2 -1
View File
@@ -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 ────────────────────────────────────────────────────────────────
+29
View File
@@ -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) => {
+48 -1
View File
@@ -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 }
}
+43 -16
View File
@@ -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) {
+2 -2
View File
@@ -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 {
+19 -13
View File
@@ -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
+36 -13
View File
@@ -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">