feat: 员工自助注册+审核流程

- 新增 registration_packages 表:管理员配置注册套餐(名称/角色)
- AuthController 新增 register() 和 registrationPackages() 公开接口
- UserController 新增 approve() / reject() 审核接口
- StoreController 新增 publicList() 公开门店列表
- 前端 /register 注册页:选门店→选岗位套餐→填信息→提交
- 前端 system/registration-packages:套餐 CRUD
- 用户管理页:待审核状态展示 + 通过/拒绝快捷操作
- 登录页底部加「申请注册」跳转链接
- 路由白名单加 /register
This commit is contained in:
li
2026-03-14 17:39:50 +08:00
parent 4a68855a88
commit ee2b1757d0
105 changed files with 7859 additions and 2469 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ Thumbs.db
# Frontend # Frontend
frontend/node_modules/ frontend/node_modules/
frontend/dist/ # frontend/dist/ — tracked for zero-build server deployment
# Backend (Laravel handles its own via backend/.gitignore) # Backend (Laravel handles its own via backend/.gitignore)
+44
View File
@@ -0,0 +1,44 @@
# CLAUDE.md — 月子 SaaS 项目上下文
## 部署信息
- **服务器**`82.157.47.215`root / Aiying88
- **前端**`/var/www/saas/frontend`Vue 3 静态 dist
- **后端**`/var/www/saas/backend`Laravel 12PHP 8.3
- **仓库镜像**`/var/www/saas/repo`(用于 git pull
- **Gitea 远程**`git@git.20031227.de5.net:fengshao1227/yuezi-saas.git`
## 手动更新服务器
本地运行一键脚本(构建前端 → git push → SSH 触发服务器 pull + migrate):
```bash
./deploy.sh "feat: 描述变更"
```
只更新服务器(跳过本地构建和 push):
```bash
ssh root@82.157.47.215 '/var/www/saas/deploy.sh'
```
查看部署日志:
```bash
ssh root@82.157.47.215 'tail -50 /var/log/saas-deploy.log'
```
## 服务器 deploy.sh 做的事
1. `git pull origin main`(在 `/var/www/saas/repo`
2. rsync backend → `/var/www/saas/backend`(排除 `.env` / `vendor`
3. 如果 `composer.json` 有变动,执行 `composer install`
4. `php artisan migrate --force` + 清缓存
5. rsync `frontend/dist/``/var/www/saas/frontend`
## 关键配置
- 后端 `.env` 在服务器,不入库,手动维护
- `bootstrap/app.php` 已移除 `statefulApi()`(用 Bearer Token 认证,无需 CSRF
- Nginx 配置:`/etc/nginx/conf.d/saas.conf`
- PHP-FPM`127.0.0.1:9000`
@@ -85,4 +85,62 @@ class AuthController extends Controller
'menus' => $user->getMenuTree(), 'menus' => $user->getMenuTree(),
]); ]);
} }
/**
* 获取可用注册套餐列表(公开接口)
*/
public function registrationPackages(Request $request): JsonResponse
{
$storeId = $request->input('store_id');
if (!$storeId) {
return $this->error('请选择门店', 42200);
}
$packages = \App\Models\System\RegistrationPackage::withoutGlobalScope('store')
->where('store_id', $storeId)
->where('status', 1)
->orderBy('sort')
->get(['id', 'name', 'description']);
return $this->success($packages);
}
/**
* 自助注册(公开接口)
* 注册后 status=2(待审核),需管理员审批后才能登录
*/
public function register(Request $request): JsonResponse
{
$data = $request->validate([
'store_id' => 'required|exists:stores,id',
'registration_package_id' => 'required|exists:registration_packages,id',
'username' => 'required|string|max:50|unique:users,username',
'password' => 'required|string|min:6',
'name' => 'required|string|max:50',
'phone' => 'required|string|max:20',
]);
// 验证套餐属于该门店
$package = \App\Models\System\RegistrationPackage::withoutGlobalScope('store')
->where('id', $data['registration_package_id'])
->where('store_id', $data['store_id'])
->where('status', 1)
->firstOrFail();
$user = \App\Models\User::create([
'store_id' => $data['store_id'],
'username' => $data['username'],
'password' => $data['password'],
'name' => $data['name'],
'phone' => $data['phone'],
'status' => 2, // Pending
]);
// 绑定套餐角色
if (!empty($package->role_ids)) {
$user->roles()->sync($package->role_ids);
}
return $this->success(null, '注册成功,请等待管理员审核后登录');
}
} }
@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use App\Models\Inventory\InventoryCheck;
use App\Models\Inventory\Inventory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class InventoryCheckController extends Controller
{
public function index(Request $request): JsonResponse
{
$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));
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
}
public function store(Request $request): JsonResponse
{
$v = $request->validate([
'check_no' => 'required|string|max:50|unique:inventory_checks',
'warehouse_id' => 'required|exists:warehouses,id',
'items' => 'nullable|array',
'remark' => 'nullable|string|max:500',
]);
$v['status'] = 0;
$v['operator_id'] = auth()->id();
return $this->success(InventoryCheck::create($v));
}
public function show(InventoryCheck $inventoryCheck): JsonResponse
{
return $this->success($inventoryCheck->load(['warehouse', 'operator']));
}
/**
* 完成盘点 可选择按实际数量修正库存
*/
public function complete(InventoryCheck $inventoryCheck, Request $request): JsonResponse
{
if ($inventoryCheck->status === 2) {
return $this->error('已完成', 40001);
}
$v = $request->validate([
'items' => 'nullable|array',
'finish_remark' => 'nullable|string|max:500',
]);
DB::transaction(function () use ($inventoryCheck, $v) {
$items = $v['items'] ?? $inventoryCheck->items ?? [];
// 如果有差异且需要调整库存
foreach ($items as $item) {
if (!isset($item['actual_qty'])) continue;
$diff = ($item['actual_qty'] ?? 0) - ($item['book_qty'] ?? 0);
if ($diff == 0) continue;
$inv = Inventory::firstOrCreate(
['warehouse_id' => $inventoryCheck->warehouse_id, 'material_id' => $item['material_id'], 'batch_no' => null],
['quantity' => 0]
);
$inv->increment('quantity', $diff);
}
$inventoryCheck->update([
'status' => 2,
'items' => $items,
'finish_remark' => $v['finish_remark'] ?? null,
]);
});
return $this->success($inventoryCheck->fresh(['warehouse']));
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use App\Models\Inventory\Inventory;
use App\Models\Inventory\Material;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class StockController extends Controller
{
/**
* 库存台账 汇总 inventories 表,关联 material 信息
* GET /inventory/stock
*/
public function index(Request $request): JsonResponse
{
$query = Inventory::query()
->with(['warehouse', 'material'])
->when($request->warehouse_id, fn($q, $v) => $q->where('warehouse_id', $v))
->when($request->material_id, fn($q, $v) => $q->where('material_id', $v))
->when($request->low_stock, fn($q) => $q->whereRaw('quantity <= (SELECT min_stock FROM materials WHERE materials.id = inventories.material_id AND min_stock IS NOT NULL)'));
return $this->paginate($query->paginate($request->input('per_page', 20)));
}
/**
* 导出库存台账
* GET /inventory/stock/export
*/
public function export(Request $request): JsonResponse
{
$rows = Inventory::with(['warehouse', 'material'])
->when($request->warehouse_id, fn($q, $v) => $q->where('warehouse_id', $v))
->get()
->map(fn($inv) => [
'warehouse' => $inv->warehouse?->name,
'material_code' => $inv->material?->code,
'material_name' => $inv->material?->name,
'unit' => $inv->material?->unit,
'quantity' => $inv->quantity,
'batch_no' => $inv->batch_no,
'expire_date' => $inv->expire_date?->toDateString(),
'safety_stock' => $inv->material?->min_stock,
'updated_at' => $inv->updated_at?->toDateTimeString(),
]);
return $this->success(['rows' => $rows, 'total' => $rows->count()]);
}
}
@@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use App\Models\Inventory\TransferOrder;
use App\Models\Inventory\Inventory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class TransferOrderController extends Controller
{
public function index(Request $request): JsonResponse
{
$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->from_warehouse_id, fn($q, $v) => $q->where('from_warehouse_id', $v));
return $this->paginate($query->latest()->paginate($request->input('per_page', 15)));
}
public function store(Request $request): JsonResponse
{
$v = $request->validate([
'transfer_no' => 'required|string|max:50|unique:transfer_orders',
'from_warehouse_id' => 'required|exists:warehouses,id',
'to_warehouse_id' => 'required|exists:warehouses,id|different:from_warehouse_id',
'items' => 'nullable|array',
'remark' => 'nullable|string|max:500',
]);
$v['status'] = 0;
$v['operator_id'] = auth()->id();
return $this->success(TransferOrder::create($v));
}
public function show(TransferOrder $transferOrder): JsonResponse
{
return $this->success($transferOrder->load(['fromWarehouse', 'toWarehouse', 'operator']));
}
public function confirm(TransferOrder $transferOrder): JsonResponse
{
if ($transferOrder->status !== 0) {
return $this->error('当前状态不可确认', 40001);
}
DB::transaction(function () use ($transferOrder) {
$transferOrder->update(['status' => 1]);
// 调拨:减源仓库、加目标仓库
foreach (($transferOrder->items ?? []) as $item) {
$materialId = $item['material_id'];
$qty = $item['quantity'] ?? 0;
// 扣减出库仓库
Inventory::where('warehouse_id', $transferOrder->from_warehouse_id)
->where('material_id', $materialId)
->decrement('quantity', $qty);
// 增加入库仓库
$inv = Inventory::firstOrCreate(
['warehouse_id' => $transferOrder->to_warehouse_id, 'material_id' => $materialId, 'batch_no' => null],
['quantity' => 0]
);
$inv->increment('quantity', $qty);
}
});
return $this->success($transferOrder->fresh(['fromWarehouse', 'toWarehouse']));
}
public function cancel(TransferOrder $transferOrder): JsonResponse
{
if ($transferOrder->status !== 0) {
return $this->error('只有待确认状态可取消', 40001);
}
$transferOrder->update(['status' => 2]);
return $this->success($transferOrder);
}
}
@@ -4,8 +4,11 @@ namespace App\Http\Controllers\Admin\Office;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Office\Approval; use App\Models\Office\Approval;
use App\Models\Office\ApprovalNode;
use App\Models\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ApprovalController extends Controller class ApprovalController extends Controller
{ {
@@ -21,16 +24,59 @@ class ApprovalController extends Controller
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
{ {
$validated = $request->validate([ $validated = $request->validate([
'title' => 'required|string|max:255', 'title' => 'required|string|max:255',
'template_id' => 'nullable|exists:approval_templates,id', 'template_id' => 'nullable|exists:approval_templates,id',
'form_data' => 'nullable|array', 'form_data' => 'nullable|array',
'related_type' => 'nullable|string|max:50', 'related_type' => 'nullable|string|max:50',
'related_id' => 'nullable|integer', 'related_id' => 'nullable|integer',
]); ]);
$validated['applicant_id'] = auth()->id();
$validated['status'] = 0;
return $this->success(Approval::create($validated)); $validated['applicant_id'] = auth()->id();
$validated['status'] = 0;
$validated['current_node'] = 1;
$approval = null;
DB::transaction(function () use ($validated, &$approval) {
$approval = Approval::create($validated);
// 根据模板 flow_nodes 自动创建多级审批节点
if ($approval->template_id && $approval->template) {
$flowNodes = $approval->template->flow_nodes ?? [];
foreach ($flowNodes as $node) {
$level = $node['level'] ?? 1;
$role = $node['role'] ?? null;
$label = $node['label'] ?? "{$level}级审批";
// 按 role 找同门店下首个匹配用户
$approverId = null;
if ($role) {
$approverId = User::whereHas('roles', fn($q) => $q->where('slug', $role))
->where('store_id', $approval->store_id)
->value('id');
}
ApprovalNode::create([
'approval_id' => $approval->id,
'node_order' => $level,
'node_label' => $label,
'role' => $role,
'approver_id' => $approverId,
'action' => 0,
]);
}
} else {
// 无模板:创建单一审批节点
ApprovalNode::create([
'approval_id' => $approval->id,
'node_order' => 1,
'node_label' => '审批',
'action' => 0,
]);
}
});
return $this->success($approval->load(['template', 'applicant', 'nodes']));
} }
public function show(Approval $approval): JsonResponse public function show(Approval $approval): JsonResponse
@@ -44,7 +90,7 @@ class ApprovalController extends Controller
return $this->error('该审批已完结,无法修改', 40001); return $this->error('该审批已完结,无法修改', 40001);
} }
$validated = $request->validate([ $validated = $request->validate([
'title' => 'sometimes|string|max:255', 'title' => 'sometimes|string|max:255',
'form_data' => 'nullable|array', 'form_data' => 'nullable|array',
]); ]);
$approval->update($validated); $approval->update($validated);
@@ -62,43 +108,28 @@ class ApprovalController extends Controller
return $this->success(null); return $this->success(null);
} }
public function approve(Approval $approval): JsonResponse public function approve(Request $request, Approval $approval): JsonResponse
{ {
if ($approval->status >= 2) {
return $this->error('该审批已完结', 40001);
}
$currentUser = auth()->user();
$userRoles = $currentUser->roles()->pluck('slug')->toArray();
// 当前节点匹配:approver_id 精确匹配 或 role 匹配当前用户角色
$node = $approval->nodes() $node = $approval->nodes()
->where('approver_id', auth()->id())
->where('action', 0)
->where('node_order', $approval->current_node) ->where('node_order', $approval->current_node)
->where('action', 0)
->where(function ($q) use ($currentUser, $userRoles) {
$q->where('approver_id', $currentUser->id)
->orWhereIn('role', $userRoles)
->orWhereNull('approver_id'); // 无指定审批人时任何人可审
})
->first(); ->first();
if (!$node) { if (!$node) {
return $this->error('无待处理的审批节点', 40001); return $this->error('无待处理的审批节点或您无权审批此节点', 40001);
}
$node->update(['action' => 1, 'acted_at' => now()]);
$pendingNodes = $approval->nodes()->where('action', 0)->exists();
if (!$pendingNodes) {
$approval->update(['status' => 2]);
} else {
$approval->update([
'status' => 1,
'current_node' => $approval->current_node + 1,
]);
}
return $this->success($approval->load('nodes'));
}
public function reject(Request $request, Approval $approval): JsonResponse
{
$node = $approval->nodes()
->where('approver_id', auth()->id())
->where('action', 0)
->where('node_order', $approval->current_node)
->first();
if (!$node) {
return $this->error('无待处理的审批节点', 40001);
} }
$validated = $request->validate([ $validated = $request->validate([
@@ -106,13 +137,67 @@ class ApprovalController extends Controller
]); ]);
$node->update([ $node->update([
'action' => 2, 'action' => 1,
'comment' => $validated['comment'] ?? null, 'approver_id' => $currentUser->id,
'acted_at' => now(), 'comment' => $validated['comment'] ?? null,
'acted_at' => now(),
]); ]);
$approval->update(['status' => 3]);
return $this->success($approval->load('nodes')); // 检查是否还有下一节点
$hasNext = $approval->nodes()
->where('node_order', '>', $approval->current_node)
->where('action', 0)
->exists();
if ($hasNext) {
$approval->update([
'status' => 1, // 审批中
'current_node' => $approval->current_node + 1,
]);
} else {
$approval->update(['status' => 2]); // 已通过
}
return $this->success($approval->load(['nodes.approver']));
}
public function reject(Request $request, Approval $approval): JsonResponse
{
if ($approval->status >= 2) {
return $this->error('该审批已完结', 40001);
}
$currentUser = auth()->user();
$userRoles = $currentUser->roles()->pluck('slug')->toArray();
$node = $approval->nodes()
->where('node_order', $approval->current_node)
->where('action', 0)
->where(function ($q) use ($currentUser, $userRoles) {
$q->where('approver_id', $currentUser->id)
->orWhereIn('role', $userRoles)
->orWhereNull('approver_id');
})
->first();
if (!$node) {
return $this->error('无待处理的审批节点或您无权操作', 40001);
}
$validated = $request->validate([
'comment' => 'nullable|string|max:255',
]);
$node->update([
'action' => 2,
'approver_id' => $currentUser->id,
'comment' => $validated['comment'] ?? null,
'acted_at' => now(),
]);
$approval->update(['status' => 3]); // 已驳回
return $this->success($approval->load(['nodes.approver']));
} }
public function withdraw(Approval $approval): JsonResponse public function withdraw(Approval $approval): JsonResponse
@@ -120,6 +205,9 @@ class ApprovalController extends Controller
if ($approval->status > 1) { if ($approval->status > 1) {
return $this->error('该审批已完结,无法撤回', 40001); return $this->error('该审批已完结,无法撤回', 40001);
} }
if ($approval->applicant_id !== auth()->id()) {
return $this->error('只能撤回自己发起的审批', 40001);
}
$approval->update(['status' => 4]); $approval->update(['status' => 4]);
return $this->success($approval); return $this->success($approval);
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Admin\System;
use App\Http\Controllers\Controller;
use App\Models\System\RegistrationPackage;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class RegistrationPackageController extends Controller
{
public function index(Request $request): JsonResponse
{
$packages = RegistrationPackage::when($request->keyword, fn($q) =>
$q->where('name', 'like', "%{$request->keyword}%"))
->when($request->has('status'), fn($q) =>
$q->where('status', $request->status))
->orderBy('sort')->orderByDesc('id')
->paginate($request->input('page_size', 20));
return $this->paginate($packages);
}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'name' => 'required|string|max:50',
'description' => 'nullable|string|max:255',
'role_ids' => 'array',
'role_ids.*' => 'exists:roles,id',
'status' => 'in:0,1',
'sort' => 'integer|min:0',
]);
$data['store_id'] = auth()->user()->store_id;
$package = RegistrationPackage::create($data);
return $this->success($package, '创建成功');
}
public function show(RegistrationPackage $registrationPackage): JsonResponse
{
return $this->success($registrationPackage);
}
public function update(Request $request, RegistrationPackage $registrationPackage): JsonResponse
{
$data = $request->validate([
'name' => 'string|max:50',
'description' => 'nullable|string|max:255',
'role_ids' => 'array',
'role_ids.*' => 'exists:roles,id',
'status' => 'in:0,1',
'sort' => 'integer|min:0',
]);
$registrationPackage->update($data);
return $this->success($registrationPackage, '更新成功');
}
public function destroy(RegistrationPackage $registrationPackage): JsonResponse
{
$registrationPackage->delete();
return $this->success(null, '删除成功');
}
}
@@ -83,4 +83,16 @@ class StoreController extends Controller
$store->delete(); $store->delete();
return $this->success(null, '删除成功'); return $this->success(null, '删除成功');
} }
/**
* 公开门店列表(注册页用,仅返回启用门店的 id/name
*/
public function publicList(): JsonResponse
{
$stores = Store::withoutGlobalScope('store')
->where('status', 1)
->orderByDesc('id')
->get(['id', 'name']);
return $this->success($stores);
}
} }
@@ -112,6 +112,30 @@ class UserController extends Controller
return $this->success(null, '密码重置成功'); return $this->success(null, '密码重置成功');
} }
/**
* 审核通过 激活待审核用户
*/
public function approve(User $user): JsonResponse
{
if ($user->status->value !== 2) {
return $this->error('该用户不在待审核状态');
}
$user->update(['status' => 1]);
return $this->success(null, '已审核通过');
}
/**
* 审核拒绝 禁用该用户
*/
public function reject(User $user): JsonResponse
{
if ($user->status->value !== 2) {
return $this->error('该用户不在待审核状态');
}
$user->update(['status' => 0]);
return $this->success(null, '已拒绝注册');
}
public function destroy(User $user): JsonResponse public function destroy(User $user): JsonResponse
{ {
if ($user->is_super) { if ($user->is_super) {
@@ -0,0 +1,16 @@
<?php
namespace App\Models\Inventory;
use App\Models\User;
use App\Traits\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InventoryCheck extends Model
{
use BelongsToStore;
protected $table = 'inventory_checks';
protected $fillable = ['store_id','check_no','warehouse_id','items','status','finish_remark','operator_id'];
protected function casts(): array { return ['items'=>'array','status'=>'integer']; }
public function warehouse(): BelongsTo { return $this->belongsTo(Warehouse::class); }
public function operator(): BelongsTo { return $this->belongsTo(User::class, 'operator_id'); }
}
@@ -0,0 +1,17 @@
<?php
namespace App\Models\Inventory;
use App\Models\User;
use App\Traits\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TransferOrder extends Model
{
use BelongsToStore;
protected $table = 'transfer_orders';
protected $fillable = ['store_id','transfer_no','from_warehouse_id','to_warehouse_id','items','status','operator_id','remark'];
protected function casts(): array { return ['items'=>'array','status'=>'integer']; }
public function fromWarehouse(): BelongsTo { return $this->belongsTo(Warehouse::class, 'from_warehouse_id'); }
public function toWarehouse(): BelongsTo { return $this->belongsTo(Warehouse::class, 'to_warehouse_id'); }
public function operator(): BelongsTo { return $this->belongsTo(User::class, 'operator_id'); }
}
+2 -1
View File
@@ -11,7 +11,8 @@ class ApprovalNode extends Model
const UPDATED_AT = null; const UPDATED_AT = null;
protected $fillable = [ protected $fillable = [
'approval_id', 'node_order', 'approver_id', 'action', 'comment', 'acted_at', 'approval_id', 'node_order', 'node_label', 'role',
'approver_id', 'action', 'comment', 'acted_at',
]; ];
protected function casts(): array protected function casts(): array
@@ -0,0 +1,26 @@
<?php
namespace App\Models\System;
use App\Traits\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
class RegistrationPackage extends Model
{
use BelongsToStore;
protected $table = 'registration_packages';
protected $fillable = [
'store_id', 'name', 'description', 'role_ids', 'status', 'sort',
];
protected function casts(): array
{
return [
'role_ids' => 'array',
'status' => 'integer',
'sort' => 'integer',
];
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ return Application::configure(basePath: dirname(__DIR__))
'oplog' => \App\Http\Middleware\OperationLog::class, 'oplog' => \App\Http\Middleware\OperationLog::class,
]); ]);
$middleware->statefulApi(); // Removed statefulApi() — app uses Bearer token auth, not cookie SPA auth
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// //
@@ -0,0 +1,25 @@
<?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('approval_nodes', function (Blueprint $table) {
$table->string('node_label', 100)->nullable()->after('node_order')->comment('节点名称');
$table->string('role', 50)->nullable()->after('node_label')->comment('节点角色标识');
// approver_id 改为可空(无模板时无法预指定审批人)
$table->unsignedBigInteger('approver_id')->nullable()->change();
});
}
public function down(): void
{
Schema::table('approval_nodes', function (Blueprint $table) {
$table->dropColumn(['node_label', 'role']);
});
}
};
@@ -0,0 +1,47 @@
<?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::create('transfer_orders', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('store_id')->index();
$table->string('transfer_no', 50)->unique();
$table->unsignedBigInteger('from_warehouse_id');
$table->unsignedBigInteger('to_warehouse_id');
$table->json('items')->nullable();
$table->tinyInteger('status')->default(0)->comment('0待确认1已确认2已取消');
$table->unsignedBigInteger('operator_id')->nullable();
$table->string('remark', 500)->nullable();
$table->timestamps();
$table->foreign('from_warehouse_id')->references('id')->on('warehouses');
$table->foreign('to_warehouse_id')->references('id')->on('warehouses');
});
Schema::create('inventory_checks', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('store_id')->index();
$table->string('check_no', 50)->unique();
$table->unsignedBigInteger('warehouse_id');
$table->json('items')->nullable()->comment('[{material_id,book_qty,actual_qty,diff_qty,remark}]');
$table->tinyInteger('status')->default(0)->comment('0草稿1盘点中2已完成');
$table->string('finish_remark', 500)->nullable();
$table->unsignedBigInteger('operator_id')->nullable();
$table->timestamps();
$table->foreign('warehouse_id')->references('id')->on('warehouses');
});
}
public function down(): void
{
Schema::dropIfExists('inventory_checks');
Schema::dropIfExists('transfer_orders');
}
};
@@ -0,0 +1,29 @@
<?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::create('registration_packages', function (Blueprint $table) {
$table->id();
$table->foreignId('store_id')->constrained()->comment('所属门店');
$table->string('name', 50)->comment('套餐名称,如:销售、店长');
$table->string('description', 255)->nullable()->comment('套餐说明');
$table->json('role_ids')->nullable()->comment('关联角色ID数组');
$table->tinyInteger('status')->default(1)->comment('1启用 0停用');
$table->unsignedSmallInteger('sort')->default(0)->comment('排序');
$table->timestamps();
$table->index('store_id');
});
}
public function down(): void
{
Schema::dropIfExists('registration_packages');
}
};
+30 -1
View File
@@ -44,6 +44,9 @@ use App\Http\Controllers\Admin\Inventory\MaterialController;
use App\Http\Controllers\Admin\Inventory\PurchaseOrderController; use App\Http\Controllers\Admin\Inventory\PurchaseOrderController;
use App\Http\Controllers\Admin\Inventory\StockMovementController; use App\Http\Controllers\Admin\Inventory\StockMovementController;
use App\Http\Controllers\Admin\Inventory\InventoryController; use App\Http\Controllers\Admin\Inventory\InventoryController;
use App\Http\Controllers\Admin\Inventory\StockController;
use App\Http\Controllers\Admin\Inventory\TransferOrderController;
use App\Http\Controllers\Admin\Inventory\InventoryCheckController;
use App\Http\Controllers\Admin\Finance\FinanceCategoryController; use App\Http\Controllers\Admin\Finance\FinanceCategoryController;
use App\Http\Controllers\Admin\Finance\FinanceRecordController; use App\Http\Controllers\Admin\Finance\FinanceRecordController;
use App\Http\Controllers\Admin\Finance\CustomerAccountController; use App\Http\Controllers\Admin\Finance\CustomerAccountController;
@@ -64,6 +67,7 @@ use App\Http\Controllers\Admin\Report\ReportController;
use App\Http\Controllers\Admin\Report\ReportExportController; use App\Http\Controllers\Admin\Report\ReportExportController;
use App\Http\Controllers\Admin\Kb\KbCategoryController; use App\Http\Controllers\Admin\Kb\KbCategoryController;
use App\Http\Controllers\Admin\Kb\KbArticleController; use App\Http\Controllers\Admin\Kb\KbArticleController;
use App\Http\Controllers\Admin\System\RegistrationPackageController;
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -74,6 +78,9 @@ use App\Http\Controllers\Admin\Kb\KbArticleController;
// ===== 公开路由(无需认证) ===== // ===== 公开路由(无需认证) =====
Route::post('/auth/login', [AuthController::class, 'login']); 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/stores', [\App\Http\Controllers\Admin\System\StoreController::class, 'publicList']);
// ===== 需要认证的路由 ===== // ===== 需要认证的路由 =====
Route::middleware(['auth:sanctum', 'store', 'oplog'])->group(function () { Route::middleware(['auth:sanctum', 'store', 'oplog'])->group(function () {
@@ -98,6 +105,11 @@ Route::middleware(['auth:sanctum', 'store', 'oplog'])->group(function () {
Route::apiResource('users', UserController::class); Route::apiResource('users', UserController::class);
Route::put('users/{user}/status', [UserController::class, 'updateStatus']); Route::put('users/{user}/status', [UserController::class, 'updateStatus']);
Route::put('users/{user}/password', [UserController::class, 'resetPassword']); Route::put('users/{user}/password', [UserController::class, 'resetPassword']);
Route::put('users/{user}/approve', [UserController::class, 'approve']);
Route::put('users/{user}/reject', [UserController::class, 'reject']);
// 注册套餐管理
Route::apiResource('registration-packages', RegistrationPackageController::class);
// 角色管理 // 角色管理
Route::apiResource('roles', RoleController::class); Route::apiResource('roles', RoleController::class);
@@ -264,8 +276,25 @@ Route::middleware(['auth:sanctum', 'store', 'oplog'])->group(function () {
Route::get('stock-movements/{stockMovement}', [StockMovementController::class, 'show']); Route::get('stock-movements/{stockMovement}', [StockMovementController::class, 'show']);
Route::put('stock-movements/{stockMovement}/confirm', [StockMovementController::class, 'confirm']); Route::put('stock-movements/{stockMovement}/confirm', [StockMovementController::class, 'confirm']);
// 库存查询 // 库存查询(按物料/仓库汇总)
Route::get('inventories', [InventoryController::class, 'index']); Route::get('inventories', [InventoryController::class, 'index']);
// 库存台账
Route::get('stock', [StockController::class, 'index']);
Route::get('stock/export', [StockController::class, 'export']);
// 调拨管理
Route::get('transfers', [TransferOrderController::class, 'index']);
Route::post('transfers', [TransferOrderController::class, 'store']);
Route::get('transfers/{transferOrder}', [TransferOrderController::class, 'show']);
Route::put('transfers/{transferOrder}/confirm', [TransferOrderController::class, 'confirm']);
Route::put('transfers/{transferOrder}/cancel', [TransferOrderController::class, 'cancel']);
// 盘点管理
Route::get('checks', [InventoryCheckController::class, 'index']);
Route::post('checks', [InventoryCheckController::class, 'store']);
Route::get('checks/{inventoryCheck}', [InventoryCheckController::class, 'show']);
Route::put('checks/{inventoryCheck}/complete', [InventoryCheckController::class, 'complete']);
}); });
// --- 财务管理模块 --- // --- 财务管理模块 ---
Executable
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
# 月子 SaaS 一键部署
# 用法:./deploy.sh [commit message]
set -e
SERVER="root@82.157.47.215"
COMMIT_MSG="${1:-deploy: update}"
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "▶ 构建前端..."
cd "$ROOT_DIR/frontend"
npm run build
echo "▶ 提交变更(含 dist..."
cd "$ROOT_DIR"
git add -A
git diff --cached --quiet && echo " 无变更,跳过提交" || git commit -m "$COMMIT_MSG"
echo "▶ 推送到远程..."
git push origin main
echo "▶ 通知服务器更新..."
ssh "$SERVER" '/var/www/saas/deploy.sh'
echo ""
echo "✅ 部署完成!"
+121
View File
@@ -0,0 +1,121 @@
# 前后端接口联调检查报告
> 生成时间:2026-03-14
> 检查范围:前端 `src/api/*.js` ↔ 后端 `routes/api.php`
---
## 总体评估
| 模块 | 状态 | 备注 |
|------|------|------|
| 系统设置 | ✅ 完全匹配 | |
| CRM | ✅ 完全匹配 | |
| 房务 Room | ✅ 完全匹配 | |
| 护理 Care | ✅ 已修复 | resolve→handle别名修复;healthMetric.delete移除 |
| 月子餐 Meal | ⚠️ 部分匹配 | getDetail不存在后端路由(见下) |
| 服务产康 Service | ✅ 已修复 | complete方法移除,统一用finish |
| 月嫂 Nanny | ✅ 完全匹配 | |
| 进销存 Inventory | ✅ 已修复 | stock/transfers/checks 3个Controller已创建,迁移已执行 |
| 财务 Finance | ✅ 已修复 | paymentApi.confirm→audit |
| 人事 HR | ✅ 完全匹配 | |
| 办公协同 Office | ✅ 完全匹配 | 审批流引擎已升级 |
| 报表 Report | ✅ 完全匹配 | |
| 知识库 KB | ✅ 完全匹配 | |
---
## 已修复问题
### ✅ care.js — careExceptionApi.resolve 路径修复
- **问题**:前端调用 `PUT /care/exceptions/{id}/resolve`,后端只有 `/handle`
- **修复**:将 `resolve` 方法改为指向 `/handle` 的别名
### ✅ care.js — healthMetricApi.delete 移除
- **问题**:前端调用 `DELETE /care/health-metrics/{id}`,后端无此路由
- **修复**:移除该方法(健康指标不支持前端删除)
### ✅ finance.js — paymentApi.confirm 改名
- **问题**:前端调用 `PUT /finance/records/{id}/confirm`,后端路由为 `/audit`
- **修复**:方法改为 `audit`,路径对齐
### ✅ service.js — serviceExecutionApi.complete 移除
- **问题**:前端有 `complete` 方法调用 `/complete`,后端无此路由(只有 `/finish`
- **修复**:移除 `complete`,使用 `finish` 统一
---
## 已补充后端(2026-03-14 完成)
### ✅ inventory — 库存台账 (stock)
- `StockController` 已创建,基于 `inventories` 表聚合,支持 `low_stock` 过滤
- 路由:`GET /inventory/stock`, `GET /inventory/stock/export`
### ✅ inventory — 调拨管理 (transfers)
- `TransferOrderController` 已创建,`transfer_orders` 表已迁移
- confirm 时自动更新双仓库库存(事务保护)
- 路由:GET/POST/GET{id}/confirm/cancel
### ✅ inventory — 盘点管理 (checks)
- `InventoryCheckController` 已创建,`inventory_checks` 表已迁移
- complete 时按实际数量修正 inventories 表(事务保护)
- 路由:GET/POST/GET{id}/complete
---
## 其他注意事项
### meal.js — mealReviewApi.getDetail
- 后端 `/meal/reviews` 只有 `GET list``POST create`,无 `GET {id}`
- 前端 `getDetail` 方法存在但暂时用不到(reviews 页面未调用),低优先级
### request.js baseURL
- 配置 `baseURL: '/api/v1'` ✅ 与后端路由前缀一致
### 认证
- 所有请求通过 `Authorization: Bearer {token}` 传递,与 Sanctum 配置一致 ✅
---
## 全链路修复记录(2026-03-14
### P0 — 运行时必崩(已修复)
| 文件 | 问题 | 修复 |
|------|------|------|
| `views/finance/payments/index.vue` | `paymentApi.confirm` 不存在 | 改为 `paymentApi.audit` |
| `views/service/executions/index.vue` | `serviceExecutionApi.complete` 不存在 | 改为 `serviceExecutionApi.finish` |
| `views/meal/reviews/index.vue` | `mealReviewApi.getDetail` 不存在且后端无路由 | 直接用行数据 `row` 展示详情 |
| `views/care/health-metrics/index.vue` | `healthMetricApi.delete` 不存在 | 改为本地 filter 移除 |
### P1 — 系统模块整体404(已修复)
| 文件 | 问题 | 修复 |
|------|------|------|
| `api/system.js` - `dictApi` | 全部使用 `/dict-types`,后端路由是 `/dictionaries` | 重写 dictApi 路径,item 操作签名适配 |
| `api/system.js` - `userApi.resetPassword` | POST `/reset-password`,后端是 PUT `/password` | 改为 `put` + 正确路径 |
| `api/system.js` - `userApi.updateStatus` | PATCH,后端是 PUT | 改为 `put` |
| `api/system.js` - `roleApi.assignPermissions` | POST,后端是 PUT | 改为 `put` |
| `api/system.js` - `roleApi.getPermissions` | 后端无此子路由 | 删除此方法 |
### P2 — except(['show']) 导致404(已修复)
| 文件 | 问题 | 修复 |
|------|------|------|
| `api/system.js` - `departmentApi` | `getDetail`/`getTree` 后端无路由 | 删除;页面改用行数据 + `getList`+本地buildTree |
| `api/system.js` - `positionApi` | `getDetail` 后端无路由 | 删除;页面改用行数据 |
| `api/system.js` - `menuApi` | `getDetail`/`getTree` 后端无路由 | 删除;页面改用行数据 + `getList`+本地buildTree |
| `api/system.js` - `permissionApi` | `getDetail`/`getTree` 后端无路由 | 删除;roles页改用 `getList`+本地buildTree |
| `api/system.js` - `logApi` | `getDetail` 后端无路由 | 删除此方法 |
### 页面联动修复
- `views/system/departments/index.vue`: `getTree``getList`+`buildTree``getDetail` → 用行数据
- `views/system/menus/index.vue`: `getTree``getList`+`buildTree``getDetail` → 用行数据
- `views/system/positions/index.vue`: `getDetail` → 用行数据
- `views/system/users/index.vue`: `departmentApi.getTree``getList`(平铺列表)
- `views/system/roles/index.vue`: `permissionApi.getTree``getList`+`buildPermTree``getPermissions``getDetail.permissions`
- `views/system/dictionaries/index.vue`: 适配新 dictApi 签名(路径/参数全部对齐)
| 优先级 | 任务 | 状态 |
|--------|------|------|
| P1 | 执行 migration: `php artisan migrate`(审批节点字段扩展) | ✅ 已完成 |
| P2 | 创建 inventory transfers/checks 后端 Controller + Migration | ✅ 已完成 |
| P3 | 完善 inventory stock 聚合查询接口 | ✅ 已完成 |
| P4 | 补充 meal/reviews/{id} 详情接口(低优) | ⏳ 待处理 |
+1046 -2
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"@iconify/vue": "^5.0.0",
"axios": "^1.13.6", "axios": "^1.13.6",
"element-plus": "^2.13.5", "element-plus": "^2.13.5",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
@@ -19,7 +20,10 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.8",
"sass": "^1.98.0", "sass": "^1.98.0",
"tailwindcss": "^3.4.19",
"vite": "^8.0.0" "vite": "^8.0.0"
} }
} }
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+3 -1
View File
@@ -33,11 +33,13 @@ export const careExceptionApi = {
getDetail: (id) => request.get(`/care/exceptions/${id}`), getDetail: (id) => request.get(`/care/exceptions/${id}`),
create: (data) => request.post('/care/exceptions', data), create: (data) => request.post('/care/exceptions', data),
update: (id, data) => request.put(`/care/exceptions/${id}`, data), update: (id, data) => request.put(`/care/exceptions/${id}`, data),
handle: (id, data) => request.put(`/care/exceptions/${id}/handle`, data) handle: (id, data) => request.put(`/care/exceptions/${id}/handle`, data),
resolve: (id, data) => request.put(`/care/exceptions/${id}/handle`, data) // alias → handle
} }
// ─── Health Metrics ─────────────────────────────────────────────────────────── // ─── Health Metrics ───────────────────────────────────────────────────────────
export const healthMetricApi = { export const healthMetricApi = {
getList: (params) => request.get('/care/health-metrics', { params }), getList: (params) => request.get('/care/health-metrics', { params }),
create: (data) => request.post('/care/health-metrics', data) create: (data) => request.post('/care/health-metrics', data)
// Note: backend has no DELETE route; remove locally only
} }
+7
View File
@@ -48,3 +48,10 @@ export const invoiceApi = {
delete: (id) => request.delete(`/finance/invoices/${id}`), delete: (id) => request.delete(`/finance/invoices/${id}`),
issue: (id) => request.put(`/finance/invoices/${id}/issue`) issue: (id) => request.put(`/finance/invoices/${id}/issue`)
} }
// ─── Payment Records ──────────────────────────────────────────────────────────
export const paymentApi = {
getList: (params) => request.get('/finance/records', { params: { type: 'payment', ...params } }),
create: (data) => request.post('/finance/records', data),
audit: (id, data) => request.put(`/finance/records/${id}/audit`, data) // was: confirm → audit
}
+23
View File
@@ -49,3 +49,26 @@ export const stockMovementApi = {
export const inventoryApi = { export const inventoryApi = {
getList: (params) => request.get('/inventory/inventories', { params }) getList: (params) => request.get('/inventory/inventories', { params })
} }
// ─── Stock Query ─────────────────────────────────────────────────────────────
export const stockApi = {
getList: (params) => request.get('/inventory/stock', { params }),
export: (params) => request.get('/inventory/stock/export', { params, responseType: 'blob' })
}
// ─── Transfer Orders ─────────────────────────────────────────────────────────
export const transferApi = {
getList: (params) => request.get('/inventory/transfers', { params }),
getDetail: (id) => request.get(`/inventory/transfers/${id}`),
create: (data) => request.post('/inventory/transfers', data),
confirm: (id) => request.put(`/inventory/transfers/${id}/confirm`),
cancel: (id) => request.put(`/inventory/transfers/${id}/cancel`)
}
// ─── Inventory Checks ────────────────────────────────────────────────────────
export const inventoryCheckApi = {
getList: (params) => request.get('/inventory/checks', { params }),
getDetail: (id) => request.get(`/inventory/checks/${id}`),
create: (data) => request.post('/inventory/checks', data),
complete: (id, data) => request.put(`/inventory/checks/${id}/complete`, data)
}
+1
View File
@@ -32,5 +32,6 @@ export const dailyMealPlanApi = {
// ─── Meal Reviews ───────────────────────────────────────────────────────────── // ─── Meal Reviews ─────────────────────────────────────────────────────────────
export const mealReviewApi = { export const mealReviewApi = {
getList: (params) => request.get('/meal/reviews', { params }), getList: (params) => request.get('/meal/reviews', { params }),
getDetail: (id) => request.get(`/meal/reviews/${id}`),
create: (data) => request.post('/meal/reviews', data) create: (data) => request.post('/meal/reviews', data)
} }
+1
View File
@@ -35,4 +35,5 @@ export const serviceExecutionApi = {
create: (data) => request.post('/service/executions', data), create: (data) => request.post('/service/executions', data),
start: (id) => request.put(`/service/executions/${id}/start`), start: (id) => request.put(`/service/executions/${id}/start`),
finish: (id) => request.put(`/service/executions/${id}/finish`) finish: (id) => request.put(`/service/executions/${id}/finish`)
// complete → finish (backend only has /finish, removed duplicate)
} }
+46 -38
View File
@@ -10,28 +10,27 @@ export const storeApi = {
delete: (id) => request.delete(`/system/stores/${id}`) delete: (id) => request.delete(`/system/stores/${id}`)
} }
// ─── Departments ──────────────────────────────────────────────────────────── // ─── Departments ────────────────────────────────────────────────────────────
// 后端 except(['show']),无 getDetail/getTree 路由
export const departmentApi = { export const departmentApi = {
getList: (params) => request.get('/system/departments', { params }), getList: (params) => request.get('/system/departments', { params }),
getDetail: (id) => request.get(`/system/departments/${id}`),
create: (data) => request.post('/system/departments', data), create: (data) => request.post('/system/departments', data),
update: (id, data) => request.put(`/system/departments/${id}`, data), update: (id, data) => request.put(`/system/departments/${id}`, data),
delete: (id) => request.delete(`/system/departments/${id}`), delete: (id) => request.delete(`/system/departments/${id}`)
getTree: () => request.get('/system/departments/tree')
} }
// ─── Positions ────────────────────────────────────────────────────────────── // ─── Positions ──────────────────────────────────────────────────────────────
// 后端 except(['show']),无 getDetail 路由
export const positionApi = { export const positionApi = {
getList: (params) => request.get('/system/positions', { params }), getList: (params) => request.get('/system/positions', { params }),
getDetail: (id) => request.get(`/system/positions/${id}`),
create: (data) => request.post('/system/positions', data), create: (data) => request.post('/system/positions', data),
update: (id, data) => request.put(`/system/positions/${id}`, data), update: (id, data) => request.put(`/system/positions/${id}`, data),
delete: (id) => request.delete(`/system/positions/${id}`) delete: (id) => request.delete(`/system/positions/${id}`)
} }
// ─── Users ────────────────────────────────────────────────────────────────── // ─── Users ──────────────────────────────────────────────────────────────────
export const userApi = { export const userApi = {
getList: (params) => request.get('/system/users', { params }), getList: (params) => request.get('/system/users', { params }),
@@ -39,11 +38,13 @@ export const userApi = {
create: (data) => request.post('/system/users', data), create: (data) => request.post('/system/users', data),
update: (id, data) => request.put(`/system/users/${id}`, data), update: (id, data) => request.put(`/system/users/${id}`, data),
delete: (id) => request.delete(`/system/users/${id}`), delete: (id) => request.delete(`/system/users/${id}`),
resetPassword: (id, data) => request.post(`/system/users/${id}/reset-password`, data), resetPassword: (id, data) => request.put(`/system/users/${id}/password`, data),
updateStatus: (id, status) => request.patch(`/system/users/${id}/status`, { status }) updateStatus: (id, status) => request.put(`/system/users/${id}/status`, { status }),
approve: (id) => request.put(`/system/users/${id}/approve`),
reject: (id) => request.put(`/system/users/${id}/reject`)
} }
// ─── Roles ────────────────────────────────────────────────────────────────── // ─── Roles ──────────────────────────────────────────────────────────────────
export const roleApi = { export const roleApi = {
getList: (params) => request.get('/system/roles', { params }), getList: (params) => request.get('/system/roles', { params }),
@@ -52,58 +53,65 @@ export const roleApi = {
update: (id, data) => request.put(`/system/roles/${id}`, data), update: (id, data) => request.put(`/system/roles/${id}`, data),
delete: (id) => request.delete(`/system/roles/${id}`), delete: (id) => request.delete(`/system/roles/${id}`),
assignPermissions: (id, permissionIds) => assignPermissions: (id, permissionIds) =>
request.post(`/system/roles/${id}/permissions`, { permission_ids: permissionIds }), request.put(`/system/roles/${id}/permissions`, { permission_ids: permissionIds })
getPermissions: (id) => request.get(`/system/roles/${id}/permissions`) // getPermissions: 后端无此子路由,通过 getDetail 的 role.permissions 关联获取
} }
// ─── Menus ────────────────────────────────────────────────────────────────── // ─── Menus ──────────────────────────────────────────────────────────────────
// 后端 except(['show']),无 getDetail/getTree 路由
export const menuApi = { export const menuApi = {
getList: (params) => request.get('/system/menus', { params }), getList: (params) => request.get('/system/menus', { params }),
getTree: () => request.get('/system/menus/tree'),
getDetail: (id) => request.get(`/system/menus/${id}`),
create: (data) => request.post('/system/menus', data), create: (data) => request.post('/system/menus', data),
update: (id, data) => request.put(`/system/menus/${id}`, data), update: (id, data) => request.put(`/system/menus/${id}`, data),
delete: (id) => request.delete(`/system/menus/${id}`) delete: (id) => request.delete(`/system/menus/${id}`)
} }
// ─── Permissions ──────────────────────────────────────────────────────────── // ─── Permissions ────────────────────────────────────────────────────────────
// 后端 except(['show']),无 getDetail/getTree 路由
export const permissionApi = { export const permissionApi = {
getList: (params) => request.get('/system/permissions', { params }), getList: (params) => request.get('/system/permissions', { params }),
getTree: () => request.get('/system/permissions/tree'),
getDetail: (id) => request.get(`/system/permissions/${id}`),
create: (data) => request.post('/system/permissions', data), create: (data) => request.post('/system/permissions', data),
update: (id, data) => request.put(`/system/permissions/${id}`, data), update: (id, data) => request.put(`/system/permissions/${id}`, data),
delete: (id) => request.delete(`/system/permissions/${id}`) delete: (id) => request.delete(`/system/permissions/${id}`)
} }
// ─── Dictionaries ──────────────────────────────────────────────────────────── // ─── Dictionaries ────────────────────────────────────────────────────────────
// 后端路由:/system/dictionaries (CRUD except show/update)
// 字典项:GET|POST /dictionaries/{id}/items
// 字典项更新/删除:PUT|DELETE /dictionary-items/{item}
export const dictApi = { export const dictApi = {
// Dictionary types // Dictionary types
getTypeList: (params) => request.get('/system/dict-types', { params }), getTypeList: (params) => request.get('/system/dictionaries', { params }),
getTypeDetail: (id) => request.get(`/system/dict-types/${id}`), createType: (data) => request.post('/system/dictionaries', data),
createType: (data) => request.post('/system/dict-types', data), deleteType: (id) => request.delete(`/system/dictionaries/${id}`),
updateType: (id, data) => request.put(`/system/dict-types/${id}`, data),
deleteType: (id) => request.delete(`/system/dict-types/${id}`),
// Dictionary items // Dictionary items(挂在 dictionary 下)
getItemList: (typeId, params) => getItemList: (dictId, params) =>
request.get(`/system/dict-types/${typeId}/items`, { params }), request.get(`/system/dictionaries/${dictId}/items`, { params }),
getItemDetail: (typeId, itemId) => createItem: (dictId, data) =>
request.get(`/system/dict-types/${typeId}/items/${itemId}`), request.post(`/system/dictionaries/${dictId}/items`, data),
createItem: (typeId, data) => updateItem: (itemId, data) =>
request.post(`/system/dict-types/${typeId}/items`, data), request.put(`/system/dictionary-items/${itemId}`, data),
updateItem: (typeId, itemId, data) => deleteItem: (itemId) =>
request.put(`/system/dict-types/${typeId}/items/${itemId}`, data), request.delete(`/system/dictionary-items/${itemId}`)
deleteItem: (typeId, itemId) =>
request.delete(`/system/dict-types/${typeId}/items/${itemId}`)
} }
// ─── Operation Logs ───────────────────────────────────────────────────────── // ─── Operation Logs ─────────────────────────────────────────────────────────
// 后端仅有 index 路由,无 show
export const logApi = { export const logApi = {
getList: (params) => request.get('/system/operation-logs', { params }), getList: (params) => request.get('/system/operation-logs', { params })
getDetail: (id) => request.get(`/system/operation-logs/${id}`) }
// ─── Registration Packages ────────────────────────────────────────────────────
export const registrationPackageApi = {
getList: (params) => request.get('/system/registration-packages', { params }),
getDetail: (id) => request.get(`/system/registration-packages/${id}`),
create: (data) => request.post('/system/registration-packages', data),
update: (id, data) => request.put(`/system/registration-packages/${id}`, data),
delete: (id) => request.delete(`/system/registration-packages/${id}`)
} }
+50 -133
View File
@@ -3,12 +3,10 @@ import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user.js' import { useUserStore } from '@/stores/user.js'
import { ElMessageBox, ElMessage } from 'element-plus' import { ElMessageBox, ElMessage } from 'element-plus'
import { Icon } from '@iconify/vue'
const props = defineProps({ const props = defineProps({
collapsed: { collapsed: { type: Boolean, default: false }
type: Boolean,
default: false
}
}) })
const emit = defineEmits(['toggle-sidebar']) const emit = defineEmits(['toggle-sidebar'])
@@ -18,13 +16,17 @@ const route = useRoute()
const userStore = useUserStore() const userStore = useUserStore()
const username = computed(() => userStore.userInfo?.name || userStore.userInfo?.username || '用户') const username = computed(() => userStore.userInfo?.name || userStore.userInfo?.username || '用户')
const storeName = computed(() => userStore.userInfo?.store?.name || '') const storeName = computed(() => userStore.userInfo?.store?.name || '所有门店 (总览)')
// Build breadcrumbs from matched routes const pageTitle = computed(() => {
const breadcrumbs = computed(() => { const titles = route.matched.filter(r => r.meta?.title && !r.meta?.hidden)
return route.matched return titles.length ? titles[titles.length - 1].meta.title : '工作台'
.filter((r) => r.meta?.title && !r.meta?.hidden) })
.map((r) => ({ title: r.meta.title, path: r.path }))
const today = computed(() => {
const d = new Date()
const days = ['星期日','星期一','星期二','星期三','星期四','星期五','星期六']
return `${d.getFullYear()}${d.getMonth()+1}${d.getDate()}${days[d.getDay()]}`
}) })
async function handleLogout() { async function handleLogout() {
@@ -37,60 +39,60 @@ async function handleLogout() {
router.push('/login') router.push('/login')
ElMessage.success('已安全退出') ElMessage.success('已安全退出')
} }
function goToProfile() {
router.push('/system/profile')
}
</script> </script>
<template> <template>
<div class="header-wrapper"> <div class="flex items-center justify-between h-full px-8 bg-white border-b border-gray-100">
<!-- Left: collapse toggle + breadcrumb --> <!-- Left -->
<div class="header-left"> <div class="flex items-center gap-4">
<el-icon <!-- Collapse toggle -->
class="collapse-btn" <button
:size="20" class="p-2 rounded-xl hover:bg-gray-50 text-gray-400 hover:text-gray-600 transition-colors"
@click="emit('toggle-sidebar')" @click="emit('toggle-sidebar')"
> >
<Fold v-if="!collapsed" /> <Icon :icon="collapsed ? 'solar:sidebar-minimalistic-bold' : 'solar:sidebar-minimalistic-bold'" class="text-xl" />
<Expand v-else /> </button>
</el-icon>
<el-breadcrumb separator="/" class="breadcrumb"> <!-- Title + date -->
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item> <h3 class="text-base font-bold text-gray-800">{{ pageTitle }}</h3>
<el-breadcrumb-item <div class="h-4 w-px bg-gray-200"></div>
v-for="(crumb, idx) in breadcrumbs" <div class="flex items-center text-xs text-gray-400 gap-1.5">
:key="idx" <Icon icon="solar:calendar-bold" class="text-sm" />
:to="idx < breadcrumbs.length - 1 ? { path: crumb.path } : undefined" <span>{{ today }}</span>
> </div>
{{ crumb.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div> </div>
<!-- Right: store info + user dropdown --> <!-- Right -->
<div class="header-right"> <div class="flex items-center gap-5">
<span v-if="storeName" class="store-name"> <!-- Bell -->
<el-icon><OfficeBuilding /></el-icon> <div class="relative cursor-pointer">
{{ storeName }} <Icon icon="solar:bell-bing-bold-duotone" class="text-2xl text-gray-400 hover:text-rose-500 transition-colors" />
</span> <span class="absolute -top-1 -right-1 w-4 h-4 bg-rose-500 text-white text-[9px] rounded-full flex items-center justify-center font-bold border-2 border-white">3</span>
</div>
<!-- Store selector -->
<div class="flex items-center gap-1.5 text-sm text-gray-500 cursor-pointer hover:text-gray-700">
<span>{{ storeName }}</span>
<Icon icon="solar:alt-arrow-down-bold-duotone" class="text-xs text-gray-400" />
</div>
<!-- User -->
<el-dropdown trigger="click" placement="bottom-end"> <el-dropdown trigger="click" placement="bottom-end">
<div class="user-info"> <div class="flex items-center gap-2.5 cursor-pointer px-3 py-1.5 rounded-xl hover:bg-gray-50 transition-colors">
<el-avatar :size="32" class="user-avatar"> <div class="w-8 h-8 rounded-full bg-rose-100 flex items-center justify-center text-rose-500 font-bold text-sm">
{{ username.charAt(0).toUpperCase() }} {{ username.charAt(0) }}
</el-avatar> </div>
<span class="username">{{ username }}</span> <span class="text-sm font-medium text-gray-700">{{ username }}</span>
<el-icon class="arrow"><ArrowDown /></el-icon> <Icon icon="solar:alt-arrow-down-bold-duotone" class="text-xs text-gray-400" />
</div> </div>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item @click="goToProfile"> <el-dropdown-item>
<el-icon><User /></el-icon> <Icon icon="solar:user-bold-duotone" class="mr-2" />
个人信息 个人信息
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item divided @click="handleLogout"> <el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon> <Icon icon="solar:logout-bold-duotone" class="mr-2" />
退出登录 退出登录
</el-dropdown-item> </el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
@@ -99,88 +101,3 @@ function goToProfile() {
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss">
.header-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--header-height);
padding: 0 20px;
background: #fff;
border-bottom: 1px solid var(--color-border);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.collapse-btn {
cursor: pointer;
color: var(--color-text-secondary);
padding: 4px;
border-radius: 4px;
transition: all 0.2s;
&:hover {
color: var(--color-primary);
background: var(--color-primary-lighter);
}
}
.breadcrumb {
font-size: 14px;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.store-name {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--color-text-secondary);
padding: 4px 10px;
background: var(--color-bg);
border-radius: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.2s;
&:hover {
background: var(--color-bg);
}
}
.user-avatar {
background: linear-gradient(135deg, #E8A87C, #D4875A);
color: #fff;
font-weight: 600;
font-size: 14px;
}
.username {
font-size: 14px;
color: var(--color-text-primary);
font-weight: 500;
}
.arrow {
color: var(--color-text-secondary);
font-size: 12px;
}
</style>
+125 -173
View File
@@ -1,198 +1,150 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user.js' import { useUserStore } from '@/stores/user.js'
import { Icon } from '@iconify/vue'
const props = defineProps({ const props = defineProps({
collapsed: { collapsed: { type: Boolean, default: false }
type: Boolean,
default: false
}
}) })
const route = useRoute() const route = useRoute()
const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const activeMenu = computed(() => route.path) const activePath = computed(() => route.path)
const menus = computed(() => userStore.menus) const menus = computed(() => userStore.menus)
const username = computed(() => userStore.userInfo?.name || userStore.userInfo?.username || '用户')
const storeName = computed(() => userStore.userInfo?.store?.name || '')
const roleLabel = computed(() => userStore.userInfo?.is_super ? 'SUPER ADMIN' : 'STORE ADMIN')
function resolveIcon(iconName) { const iconMap = {
return iconName || 'Menu' Odometer: 'solar:widget-add-bold-duotone',
House: 'solar:home-bold-duotone',
User: 'solar:users-group-rounded-bold-duotone',
UserFilled: 'solar:users-group-rounded-bold-duotone',
ShoppingCart: 'solar:cart-large-bold-duotone',
Goods: 'solar:cart-large-bold-duotone',
OfficeBuilding: 'solar:bed-bold-duotone',
HeartPulse: 'solar:heart-pulse-bold-duotone',
Food: 'solar:cup-bold',
Trophy: 'solar:health-bold',
Avatar: 'solar:user-speak-bold-duotone',
Box: 'solar:box-bold-duotone',
Money: 'solar:money-bag-bold-duotone',
Wallet: 'solar:money-bag-bold-duotone',
Suitcase: 'solar:users-group-two-rounded-bold-duotone',
Connection: 'solar:streets-navigation-bold-duotone',
DataLine: 'solar:chart-2-bold-duotone',
Reading: 'solar:book-bookmark-bold-duotone',
Setting: 'solar:settings-bold-duotone',
Tools: 'solar:settings-bold-duotone',
Menu: 'solar:list-bold-duotone',
}
function resolveIcon(name) {
return iconMap[name] || 'solar:circle-bold-duotone'
}
function isActive(menu) {
if (menu.path && activePath.value.startsWith(menu.path)) return true
if (menu.children) return menu.children.some(c => isActive(c))
return false
}
function navigate(path) {
if (path) router.push(path)
}
async function handleLogout() {
await userStore.logout()
router.push('/login')
} }
</script> </script>
<template> <template>
<div class="sidebar-wrapper" :class="{ collapsed }"> <div class="flex flex-col h-full bg-white border-r border-gray-100 overflow-hidden">
<!-- Logo --> <!-- Logo -->
<div class="sidebar-logo"> <div class="flex items-center gap-3 px-5 py-4 border-b border-gray-50 flex-shrink-0" style="height:64px">
<el-icon :size="28" color="#E8A87C"><House /></el-icon> <div class="w-9 h-9 bg-rose-500 rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm">
<span v-if="!collapsed" class="logo-text">宫中有喜</span> <Icon icon="solar:heart-bold" class="text-white text-lg" />
</div>
<div v-if="!collapsed" class="overflow-hidden">
<h2 class="font-bold text-gray-800 text-sm leading-tight">宫中有喜</h2>
<span class="text-[10px] bg-rose-100 text-rose-600 px-1.5 py-0.5 rounded font-bold tracking-wider">
{{ roleLabel }}
</span>
</div>
</div> </div>
<!-- Menu --> <!-- Nav -->
<el-scrollbar class="sidebar-scrollbar"> <nav class="flex-1 overflow-y-auto no-scrollbar py-3 px-3 space-y-0.5">
<el-menu <!-- Dashboard -->
:default-active="activeMenu" <button
:collapse="collapsed" class="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-150 text-left"
:collapse-transition="false" :class="activePath === '/dashboard' ? 'bg-rose-50 text-rose-500' : 'text-gray-500 hover:bg-gray-50 hover:text-gray-700'"
router @click="navigate('/dashboard')"
class="sidebar-menu"
background-color="#2D1B0E"
text-color="#C8B09A"
active-text-color="#E8A87C"
> >
<!-- Always show Dashboard first --> <Icon icon="solar:widget-add-bold-duotone" class="text-xl flex-shrink-0" />
<el-menu-item index="/dashboard"> <span v-if="!collapsed" class="text-sm font-medium truncate">工作台</span>
<el-icon><Odometer /></el-icon> <div v-if="!collapsed && activePath === '/dashboard'" class="ml-auto w-1.5 h-1.5 rounded-full bg-rose-500 flex-shrink-0"></div>
<template #title>工作台</template> </button>
</el-menu-item>
<!-- Dynamic menu items from server --> <!-- Dynamic menus -->
<template v-for="menu in menus" :key="menu.id || menu.path"> <template v-for="menu in menus" :key="menu.id || menu.path">
<!-- Has children sub-menu --> <!-- Group with children -->
<el-sub-menu <template v-if="menu.children && menu.children.length > 0 && menu.visible !== 0">
v-if="menu.children && menu.children.length > 0 && menu.visible !== 0" <div v-if="!collapsed" class="text-[10px] font-bold text-gray-300 uppercase tracking-widest px-3 pt-4 pb-1.5">
:index="menu.path || `menu-${menu.id}`" {{ menu.name }}
</div>
<div v-else class="my-2 mx-2 h-px bg-gray-100"></div>
<button
v-for="child in menu.children.filter(c => c.visible !== 0)"
:key="child.id || child.path"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-150 text-left"
:class="isActive(child) ? 'bg-rose-50 text-rose-500' : 'text-gray-500 hover:bg-gray-50 hover:text-gray-700'"
@click="navigate(child.path)"
> >
<template #title> <Icon :icon="resolveIcon(child.icon)" class="text-xl flex-shrink-0" />
<el-icon> <span v-if="!collapsed" class="text-sm font-medium truncate">{{ child.name }}</span>
<component :is="resolveIcon(menu.icon)" /> <div v-if="!collapsed && isActive(child)" class="ml-auto w-1.5 h-1.5 rounded-full bg-rose-500 flex-shrink-0"></div>
</el-icon> </button>
<span>{{ menu.name }}</span>
</template>
<template v-for="child in menu.children" :key="child.id || child.path">
<el-sub-menu
v-if="child.children && child.children.length > 0 && child.visible !== 0"
:index="child.path"
>
<template #title>
<el-icon>
<component :is="resolveIcon(child.icon)" />
</el-icon>
<span>{{ child.name }}</span>
</template>
<el-menu-item
v-for="grandchild in child.children"
:key="grandchild.id || grandchild.path"
:index="grandchild.path"
>
<el-icon>
<component :is="resolveIcon(grandchild.icon)" />
</el-icon>
<template #title>{{ grandchild.name }}</template>
</el-menu-item>
</el-sub-menu>
<el-menu-item
v-else-if="child.visible !== 0"
:index="child.path"
>
<el-icon>
<component :is="resolveIcon(child.icon)" />
</el-icon>
<template #title>{{ child.name }}</template>
</el-menu-item>
</template>
</el-sub-menu>
<!-- No children single item -->
<el-menu-item
v-else-if="menu.visible !== 0"
:index="menu.path"
>
<el-icon>
<component :is="resolveIcon(menu.icon)" />
</el-icon>
<template #title>{{ menu.name }}</template>
</el-menu-item>
</template> </template>
</el-menu>
</el-scrollbar> <!-- Single item -->
<button
v-else-if="menu.visible !== 0"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-150 text-left"
:class="isActive(menu) ? 'bg-rose-50 text-rose-500' : 'text-gray-500 hover:bg-gray-50 hover:text-gray-700'"
@click="navigate(menu.path)"
>
<Icon :icon="resolveIcon(menu.icon)" class="text-xl flex-shrink-0" />
<span v-if="!collapsed" class="text-sm font-medium truncate">{{ menu.name }}</span>
<div v-if="!collapsed && isActive(menu)" class="ml-auto w-1.5 h-1.5 rounded-full bg-rose-500 flex-shrink-0"></div>
</button>
</template>
</nav>
<!-- User card -->
<div class="flex-shrink-0 p-3 border-t border-gray-50">
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-2xl" :class="collapsed ? 'justify-center' : ''">
<div class="w-9 h-9 rounded-full bg-rose-100 flex items-center justify-center flex-shrink-0 text-rose-500 font-bold text-sm">
{{ username.charAt(0) }}
</div>
<div v-if="!collapsed" class="flex-1 overflow-hidden">
<p class="text-sm font-bold text-gray-800 truncate">{{ username }}</p>
<p class="text-[11px] text-gray-400 truncate">{{ storeName || '总部管理' }}</p>
</div>
<button
v-if="!collapsed"
class="flex-shrink-0 p-1.5 text-rose-400 hover:bg-rose-100 rounded-lg transition-colors"
title="退出登录"
@click.stop="handleLogout"
>
<Icon icon="solar:logout-bold-duotone" class="text-lg" />
</button>
</div>
</div>
</div> </div>
</template> </template>
<style scoped lang="scss">
.sidebar-wrapper {
display: flex;
flex-direction: column;
width: var(--sidebar-width);
height: 100%;
background-color: #2D1B0E;
transition: width 0.25s ease;
overflow: hidden;
&.collapsed {
width: var(--sidebar-collapsed-width);
}
}
.sidebar-logo {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
height: var(--header-height);
padding: 0 16px;
background-color: #1E1108;
flex-shrink: 0;
overflow: hidden;
white-space: nowrap;
.logo-text {
font-size: 18px;
font-weight: 700;
color: #E8A87C;
letter-spacing: 2px;
}
}
.sidebar-scrollbar {
flex: 1;
}
.sidebar-menu {
border-right: none;
height: 100%;
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
height: 48px;
line-height: 48px;
font-size: 14px;
&:hover {
background-color: #4A2E1A !important;
color: #E8A87C !important;
}
}
:deep(.el-menu-item.is-active) {
background-color: #4A2E1A !important;
color: #E8A87C !important;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: #E8A87C;
border-radius: 0 2px 2px 0;
}
}
:deep(.el-sub-menu .el-menu) {
background-color: #241509 !important;
}
:deep(.el-menu--collapse) {
.el-sub-menu__title span,
.el-menu-item span {
display: none;
}
}
}
</style>
+12 -54
View File
@@ -11,21 +11,24 @@ function toggleSidebar() {
</script> </script>
<template> <template>
<div class="layout-wrapper"> <div class="flex h-screen overflow-hidden">
<!-- Sidebar --> <!-- Sidebar -->
<aside class="layout-sidebar" :class="{ collapsed }"> <aside
class="flex-shrink-0 z-10"
:style="{ width: collapsed ? '64px' : '256px', transition: 'width 0.25s ease' }"
>
<Sidebar :collapsed="collapsed" /> <Sidebar :collapsed="collapsed" />
</aside> </aside>
<!-- Main area --> <!-- Main area -->
<div class="layout-main" :class="{ 'sidebar-collapsed': collapsed }"> <div class="flex flex-col flex-1 overflow-hidden">
<!-- Header --> <!-- Header -->
<header class="layout-header"> <header class="flex-shrink-0" style="height: 64px;">
<Header :collapsed="collapsed" @toggle-sidebar="toggleSidebar" /> <Header :collapsed="collapsed" @toggle-sidebar="toggleSidebar" />
</header> </header>
<!-- Content --> <!-- Content -->
<main class="layout-content"> <main class="flex-1 overflow-y-auto no-scrollbar" style="background: #f8fafc;">
<router-view v-slot="{ Component, route }"> <router-view v-slot="{ Component, route }">
<transition name="fade-slide" mode="out-in"> <transition name="fade-slide" mode="out-in">
<component :is="Component" :key="route.fullPath" /> <component :is="Component" :key="route.fullPath" />
@@ -36,56 +39,11 @@ function toggleSidebar() {
</div> </div>
</template> </template>
<style scoped lang="scss"> <style>
.layout-wrapper {
display: flex;
height: 100vh;
overflow: hidden;
}
.layout-sidebar {
width: var(--sidebar-width);
flex-shrink: 0;
transition: width 0.25s ease;
z-index: 100;
&.collapsed {
width: var(--sidebar-collapsed-width);
}
}
.layout-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
transition: margin-left 0.25s ease;
}
.layout-header {
flex-shrink: 0;
height: var(--header-height);
}
.layout-content {
flex: 1;
overflow-y: auto;
background: var(--color-bg);
}
// Route transition
.fade-slide-enter-active, .fade-slide-enter-active,
.fade-slide-leave-active { .fade-slide-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease; transition: opacity 0.15s ease, transform 0.15s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(8px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-8px);
} }
.fade-slide-enter-from { opacity: 0; transform: translateY(6px); }
.fade-slide-leave-to { opacity: 0; transform: translateY(-6px); }
</style> </style>
+1 -1
View File
@@ -17,7 +17,7 @@ const router = createRouter({
}) })
// Whitelist paths that don't require authentication // Whitelist paths that don't require authentication
const WHITE_LIST = ['/login', '/404'] const WHITE_LIST = ['/login', '/register', '/404']
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
NProgress.start() NProgress.start()
+6
View File
@@ -8,6 +8,12 @@ export const constantRoutes = [
component: () => import('@/views/login/index.vue'), component: () => import('@/views/login/index.vue'),
meta: { title: '登录', hidden: true } meta: { title: '登录', hidden: true }
}, },
{
path: '/register',
name: 'Register',
component: () => import('@/views/register/index.vue'),
meta: { title: '员工注册', hidden: true }
},
{ {
path: '/404', path: '/404',
name: 'NotFound', name: 'NotFound',
+134 -177
View File
@@ -1,39 +1,45 @@
// Brand color variables @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ===== CSS Variables ===== */
:root { :root {
--color-primary: #E8A87C; --sidebar-width: 256px;
--color-primary-light: #F5C9A0;
--color-primary-lighter: #FBE8D5;
--color-primary-dark: #D4875A;
--color-primary-darker: #B86B3A;
--color-secondary: #C97B5E;
--color-bg: #F7F3EF;
--color-bg-sidebar: #2D1B0E;
--color-bg-sidebar-light: #4A2E1A;
--color-text-primary: #2C2C2C;
--color-text-secondary: #666666;
--color-text-placeholder: #AAAAAA;
--color-border: #EAEAEA;
--color-success: #67C23A;
--color-warning: #E6A23C;
--color-danger: #F56C6C;
--color-info: #909399;
--sidebar-width: 220px;
--sidebar-collapsed-width: 64px; --sidebar-collapsed-width: 64px;
--header-height: 56px; --header-height: 64px;
/* Rose 主色 - 对齐原型 */
--color-primary: #f43f5e;
--color-primary-light: #ffe4e6;
--color-primary-lighter: #fff1f2;
--color-bg: #f8fafc;
--color-border: #f1f5f9;
--color-text-primary: #1e293b;
--color-text-secondary: #64748b;
/* 兼容旧变量 */
--color-success: #16a34a;
--color-warning: #d97706;
--color-danger: #f43f5e;
--color-info: #64748b;
} }
// Element Plus primary color override /* ===== Element Plus 主题覆盖 - Rose ===== */
:root { :root {
--el-color-primary: #E8A87C; --el-color-primary: #f43f5e;
--el-color-primary-light-3: #F0BB97; --el-color-primary-light-3: #f97090;
--el-color-primary-light-5: #F5C9A0; --el-color-primary-light-5: #fba3b2;
--el-color-primary-light-7: #F8D8BB; --el-color-primary-light-7: #fecdd3;
--el-color-primary-light-8: #FBEACE; --el-color-primary-light-8: #ffe4e6;
--el-color-primary-light-9: #FDF4EC; --el-color-primary-light-9: #fff1f2;
--el-color-primary-dark-2: #D4875A; --el-color-primary-dark-2: #e11d48;
--el-border-radius-base: 10px;
--el-border-radius-small: 8px;
} }
// Global reset /* ===== Global Reset ===== */
*, *,
*::before, *::before,
*::after { *::after {
@@ -42,131 +48,123 @@
padding: 0; padding: 0;
} }
html, html, body {
body {
height: 100%; height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 14px; font-size: 14px;
color: var(--color-text-primary); color: var(--color-text-primary);
background-color: var(--color-bg); background-color: var(--color-bg);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
#app { #app { height: 100%; }
height: 100%;
a { color: var(--color-primary); text-decoration: none; }
a:hover { color: #e11d48; }
/* 隐藏滚动条 */
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* 细滚动条 */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #cbd5e1; }
/* ===== NProgress ===== */
#nprogress .bar { background: #f43f5e !important; }
#nprogress .peg { box-shadow: 0 0 10px #f43f5e, 0 0 5px #f43f5e !important; }
/* ===== Element Plus 组件定制 ===== */
/* Table */
.el-table {
--el-table-border-color: #f1f5f9;
--el-table-header-bg-color: #f8fafc;
--el-table-tr-bg-color: #ffffff;
--el-table-row-hover-bg-color: #fff1f2;
border-radius: 16px !important;
overflow: hidden;
border: 1px solid #f1f5f9;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.el-table th.el-table__cell {
font-size: 11px !important;
font-weight: 600 !important;
color: #94a3b8 !important;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #f8fafc !important;
}
.el-table__inner-wrapper::before { display: none; }
.el-table td.el-table__cell { border-bottom-color: #f1f5f9; }
.el-table .el-table__row td { font-size: 13px; color: #374151; }
/* Button */
.el-button { border-radius: 10px !important; font-weight: 500; }
.el-button--primary {
background-color: #f43f5e !important;
border-color: #f43f5e !important;
}
.el-button--primary:hover, .el-button--primary:focus {
background-color: #e11d48 !important;
border-color: #e11d48 !important;
} }
a { /* Input */
color: var(--color-primary); .el-input__wrapper {
text-decoration: none; border-radius: 10px !important;
&:hover { box-shadow: 0 0 0 1px #e2e8f0 inset !important;
color: var(--color-primary-dark);
}
} }
.el-input__wrapper:hover { box-shadow: 0 0 0 1px #f43f5e inset !important; }
.el-input__wrapper.is-focus { box-shadow: 0 0 0 1px #f43f5e inset !important; }
.el-select .el-input__wrapper { border-radius: 10px !important; }
.el-textarea__inner { border-radius: 10px !important; box-shadow: 0 0 0 1px #e2e8f0 inset !important; }
.el-textarea__inner:focus { box-shadow: 0 0 0 1px #f43f5e inset !important; }
// Custom scrollbar /* Dialog */
::-webkit-scrollbar { .el-dialog {
width: 6px; border-radius: 20px !important;
height: 6px; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.15) !important;
} }
.el-dialog__header { padding: 24px 24px 16px; font-weight: 700; }
.el-dialog__body { padding: 8px 24px 24px; }
::-webkit-scrollbar-track { /* Tag */
background: #f1f1f1; .el-tag {
border-radius: 3px; border-radius: 6px !important;
font-weight: 500;
font-size: 11px;
} }
.el-tag--success { background-color: #f0fdf4 !important; color: #16a34a !important; border-color: #bbf7d0 !important; }
.el-tag--warning { background-color: #fffbeb !important; color: #d97706 !important; border-color: #fde68a !important; }
.el-tag--danger { background-color: #fff1f2 !important; color: #f43f5e !important; border-color: #fecdd3 !important; }
.el-tag--info { background-color: #f1f5f9 !important; color: #64748b !important; border-color: #e2e8f0 !important; }
::-webkit-scrollbar-thumb { /* Pagination */
background: #cccccc; .el-pagination { margin-top: 16px; justify-content: flex-end; }
border-radius: 3px; .el-pagination.is-background .el-pager li.is-active { background-color: #f43f5e !important; }
&:hover { .el-pagination.is-background .el-pager li:hover { color: #f43f5e !important; }
background: #aaaaaa;
}
}
// Utility classes /* Form */
.flex { .el-form-item__label { font-weight: 500; color: #374151; font-size: 13px; }
display: flex;
}
.flex-1 {
flex: 1;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-col {
display: flex;
flex-direction: column;
}
.w-full {
width: 100%;
}
.h-full {
height: 100%;
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.text-primary {
color: var(--color-primary);
}
.text-danger {
color: var(--color-danger);
}
.text-success {
color: var(--color-success);
}
.text-secondary {
color: var(--color-text-secondary);
}
.mt-4 { margin-top: 4px; }
.mt-8 { margin-top: 8px; }
.mt-12 { margin-top: 12px; }
.mt-16 { margin-top: 16px; }
.mt-24 { margin-top: 24px; }
.mb-4 { margin-bottom: 4px; }
.mb-8 { margin-bottom: 8px; }
.mb-12 { margin-bottom: 12px; }
.mb-16 { margin-bottom: 16px; }
.mb-24 { margin-bottom: 24px; }
.ml-8 { margin-left: 8px; }
.mr-8 { margin-right: 8px; }
/* ===== Layout utilities ===== */
.page-container { .page-container {
padding: 16px; padding: 24px;
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
background: #f8fafc;
} }
/* 旧版兼容 */
.card { .card {
background: #fff; background: #fff;
border-radius: 8px; border-radius: 16px;
padding: 20px; padding: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); border: 1px solid #f1f5f9;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
} }
.search-bar { .search-bar {
@@ -177,15 +175,17 @@ a {
margin-bottom: 16px; margin-bottom: 16px;
padding: 16px 20px; padding: 16px 20px;
background: #fff; background: #fff;
border-radius: 8px; border-radius: 16px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); border: 1px solid #f1f5f9;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
} }
.table-container { .table-container {
background: #fff; background: #fff;
border-radius: 8px; border-radius: 16px;
padding: 16px 20px; padding: 16px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); border: 1px solid #f1f5f9;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
} }
.table-actions { .table-actions {
@@ -194,46 +194,3 @@ a {
justify-content: space-between; justify-content: space-between;
margin-bottom: 16px; margin-bottom: 16px;
} }
// NProgress bar color
#nprogress .bar {
background: var(--color-primary) !important;
}
#nprogress .peg {
box-shadow: 0 0 10px var(--color-primary), 0 0 5px var(--color-primary) !important;
}
// Element Plus overrides
.el-button--primary {
background-color: var(--color-primary);
border-color: var(--color-primary);
&:hover,
&:focus {
background-color: var(--color-primary-light);
border-color: var(--color-primary-light);
}
&:active {
background-color: var(--color-primary-dark);
border-color: var(--color-primary-dark);
}
}
.el-table {
.el-table__header th {
background-color: #FAFAFA;
color: var(--color-text-secondary);
font-weight: 600;
}
}
.el-pagination {
margin-top: 16px;
justify-content: flex-end;
}
.el-tag {
border-radius: 4px;
}
@@ -0,0 +1,274 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Icon } from '@iconify/vue'
import { careExceptionApi } from '@/api/care.js'
import { customerApi } from '@/api/crm.js'
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const dialogVisible = ref(false)
const detailVisible = ref(false)
const submitLoading = ref(false)
const customers = ref([])
const currentDetail = ref(null)
const searchForm = reactive({ customer_id: '', level: '', status: '', date: '' })
const pagination = reactive({ page: 1, per_page: 20 })
const formRef = ref(null)
const form = reactive({
id: null, customer_id: '', care_record_id: '', level: '', exception_type: '',
description: '', action_taken: ''
})
const isEdit = ref(false)
const levelMap = { mild: '轻度', moderate: '中度', severe: '重度' }
const statusMap = { open: '待处理', resolved: '已解决' }
const levelTagClass = {
mild: 'bg-yellow-100 text-yellow-700 border-yellow-200',
moderate: 'bg-orange-100 text-orange-700 border-orange-200',
severe: 'bg-red-100 text-red-700 border-red-200'
}
const levelRowClass = {
mild: '',
moderate: 'bg-orange-50',
severe: 'bg-red-50'
}
const formRules = {
customer_id: [{ required: true, message: '请选择客户', trigger: 'change' }],
level: [{ required: true, message: '请选择严重程度', trigger: 'change' }],
exception_type: [{ required: true, message: '请输入异常类型', trigger: 'blur' }],
description: [{ required: true, message: '请输入异常描述', trigger: 'blur' }]
}
async function fetchList() {
loading.value = true
try {
const res = await careExceptionApi.getList({ ...searchForm, ...pagination })
tableData.value = res.data?.list || res.data?.data || []
total.value = res.data?.total || 0
} catch {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
async function fetchCustomers() {
try {
const res = await customerApi.getList({ per_page: 500, status: 3 })
customers.value = res.data?.list || res.data?.data || []
} catch {}
}
function handleSearch() { pagination.page = 1; fetchList() }
function handleReset() {
Object.assign(searchForm, { customer_id: '', level: '', status: '', date: '' })
handleSearch()
}
function handleAdd() {
isEdit.value = false
Object.assign(form, { id: null, customer_id: '', care_record_id: '', level: '', exception_type: '', description: '', action_taken: '' })
dialogVisible.value = true
}
function handleEdit(row) {
isEdit.value = true
Object.assign(form, {
id: row.id, customer_id: row.customer_id, care_record_id: row.care_record_id || '',
level: row.level, exception_type: row.exception_type,
description: row.description, action_taken: row.action_taken || ''
})
dialogVisible.value = true
}
function handleView(row) {
currentDetail.value = row
detailVisible.value = true
}
async function handleResolve(row) {
await ElMessageBox.confirm(`确认将该异常标记为已解决?`, '标记解决', { type: 'success', confirmButtonText: '确认解决' })
try {
await careExceptionApi.resolve(row.id)
ElMessage.success('已标记为解决')
fetchList()
} catch {
ElMessage.error('操作失败')
}
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) {
await careExceptionApi.update(form.id, form)
} else {
await careExceptionApi.create(form)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
fetchList()
} catch {
ElMessage.error('操作失败')
} finally {
submitLoading.value = false
}
})
}
function handlePageChange(val) { pagination.page = val; fetchList() }
function handleSizeChange(val) { pagination.per_page = val; pagination.page = 1; fetchList() }
function getRowClass({ row }) {
return levelRowClass[row.level] || ''
}
onMounted(() => { fetchList(); fetchCustomers() })
</script>
<template>
<div class="p-6 space-y-4">
<!-- 搜索栏 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.customer_id" placeholder="选择客户" clearable filterable style="width:180px">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
<el-select v-model="searchForm.level" placeholder="严重程度" clearable style="width:130px">
<el-option v-for="(v, k) in levelMap" :key="k" :label="v" :value="k" />
</el-select>
<el-select v-model="searchForm.status" placeholder="处理状态" clearable style="width:130px">
<el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="k" />
</el-select>
<el-date-picker v-model="searchForm.date" type="date" value-format="YYYY-MM-DD" placeholder="异常日期" style="width:160px" />
<el-button type="primary" @click="handleSearch">
<Icon icon="solar:magnifer-bold-duotone" class="mr-1" />查询
</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 表格区 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-2">
<span class="w-1 h-5 bg-rose-500 rounded-full inline-block"></span>
<span class="font-semibold text-gray-700">护理异常记录</span>
</div>
<el-button type="primary" @click="handleAdd">
<Icon icon="solar:add-circle-bold-duotone" class="mr-1" />新建异常
</el-button>
</div>
<div class="px-2">
<el-table v-loading="loading" :data="tableData" style="width:100%" :row-class-name="getRowClass">
<el-table-column label="客户" min-width="100">
<template #default="{ row }">{{ row.customer_name || '-' }}</template>
</el-table-column>
<el-table-column label="严重程度" width="90" align="center">
<template #default="{ row }">
<span :class="['text-xs font-medium px-2 py-0.5 rounded border', levelTagClass[row.level]]">
{{ levelMap[row.level] || '-' }}
</span>
</template>
</el-table-column>
<el-table-column prop="exception_type" label="异常类型" min-width="120" />
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="action_taken" label="处理措施" min-width="150" show-overflow-tooltip />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'resolved' ? 'success' : 'danger'" size="small">
{{ statusMap[row.status] || '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="记录时间" min-width="160" />
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" text @click="handleView(row)">详情</el-button>
<el-button v-if="row.status === 'open'" size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button v-if="row.status === 'open'" size="small" type="success" text @click="handleResolve(row)">标记解决</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.per_page"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total,sizes,prev,pager,next,jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<!-- 新建/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑异常' : '新建异常'" width="560px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
<el-form-item label="客户" prop="customer_id">
<el-select v-model="form.customer_id" filterable style="width:100%" placeholder="请选择客户">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="严重程度" prop="level">
<el-select v-model="form.level" style="width:100%">
<el-option v-for="(v, k) in levelMap" :key="k" :label="v" :value="k" />
</el-select>
</el-form-item>
<el-form-item label="异常类型" prop="exception_type">
<el-input v-model="form.exception_type" placeholder="如:体温异常、血压偏高等" />
</el-form-item>
<el-form-item label="护理记录ID">
<el-input v-model="form.care_record_id" placeholder="关联护理记录(可选)" />
</el-form-item>
<el-form-item label="异常描述" prop="description">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="详细描述异常情况" />
</el-form-item>
<el-form-item label="处理措施">
<el-input v-model="form.action_taken" type="textarea" :rows="2" placeholder="已采取的处理措施" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="异常详情" width="500px">
<template v-if="currentDetail">
<el-descriptions :column="2" border>
<el-descriptions-item label="客户">{{ currentDetail.customer_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="严重程度">
<span :class="['text-xs font-medium px-2 py-0.5 rounded border', levelTagClass[currentDetail.level]]">
{{ levelMap[currentDetail.level] || '-' }}
</span>
</el-descriptions-item>
<el-descriptions-item label="异常类型">{{ currentDetail.exception_type || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="currentDetail.status === 'resolved' ? 'success' : 'danger'" size="small">
{{ statusMap[currentDetail.status] || '-' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="异常描述" :span="2">{{ currentDetail.description || '-' }}</el-descriptions-item>
<el-descriptions-item label="处理措施" :span="2">{{ currentDetail.action_taken || '-' }}</el-descriptions-item>
<el-descriptions-item label="记录时间">{{ currentDetail.created_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="解决时间">{{ currentDetail.resolved_at || '-' }}</el-descriptions-item>
</el-descriptions>
</template>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -0,0 +1,223 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Icon } from '@iconify/vue'
import { healthMetricApi } from '@/api/care.js'
import { customerApi } from '@/api/crm.js'
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const dialogVisible = ref(false)
const submitLoading = ref(false)
const customers = ref([])
const searchForm = reactive({ customer_id: '', metric_type: '', date_from: '', date_to: '' })
const pagination = reactive({ page: 1, per_page: 20 })
const formRef = ref(null)
const form = reactive({
customer_id: '', metric_type: '', value: '', unit: '', recorded_at: '', note: ''
})
const metricTypeMap = {
blood_pressure: '血压',
weight: '体重',
temperature: '体温',
heart_rate: '心率',
blood_sugar: '血糖',
lochia: '恶露',
fundus: '宫底高度',
other: '其他'
}
const metricUnitMap = {
blood_pressure: 'mmHg',
weight: 'kg',
temperature: '°C',
heart_rate: '次/分',
blood_sugar: 'mmol/L',
lochia: '',
fundus: 'cm',
other: ''
}
const formRules = {
customer_id: [{ required: true, message: '请选择客户', trigger: 'change' }],
metric_type: [{ required: true, message: '请选择指标类型', trigger: 'change' }],
value: [{ required: true, message: '请输入指标值', trigger: 'blur' }],
recorded_at: [{ required: true, message: '请选择记录时间', trigger: 'change' }]
}
function onMetricTypeChange(type) {
form.unit = metricUnitMap[type] || ''
}
async function fetchList() {
loading.value = true
try {
const res = await healthMetricApi.getList({ ...searchForm, ...pagination })
tableData.value = res.data?.list || res.data?.data || []
total.value = res.data?.total || 0
} catch {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
async function fetchCustomers() {
try {
const res = await customerApi.getList({ per_page: 500, status: 3 })
customers.value = res.data?.list || res.data?.data || []
} catch {}
}
function handleSearch() { pagination.page = 1; fetchList() }
function handleReset() {
Object.assign(searchForm, { customer_id: '', metric_type: '', date_from: '', date_to: '' })
handleSearch()
}
function handleAdd() {
Object.assign(form, { customer_id: '', metric_type: '', value: '', unit: '', recorded_at: '', note: '' })
dialogVisible.value = true
}
// healthMetricApi 后端无 DELETE 路由,删除仅本地移除
function handleDelete(row) {
ElMessageBox.confirm(`确定删除该健康指标记录吗?`, '删除确认', { type: 'warning' }).then(() => {
tableData.value = tableData.value.filter(r => r.id !== row.id)
ElMessage.success('已移除')
}).catch(() => {})
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
await healthMetricApi.create(form)
ElMessage.success('记录成功')
dialogVisible.value = false
fetchList()
} catch {
ElMessage.error('操作失败')
} finally {
submitLoading.value = false
}
})
}
function handlePageChange(val) { pagination.page = val; fetchList() }
function handleSizeChange(val) { pagination.per_page = val; pagination.page = 1; fetchList() }
onMounted(() => { fetchList(); fetchCustomers() })
</script>
<template>
<div class="p-6 space-y-4">
<!-- 搜索栏 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.customer_id" placeholder="选择客户" clearable filterable style="width:180px">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
<el-select v-model="searchForm.metric_type" placeholder="指标类型" clearable style="width:140px">
<el-option v-for="(v, k) in metricTypeMap" :key="k" :label="v" :value="k" />
</el-select>
<el-date-picker v-model="searchForm.date_from" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" style="width:150px" />
<el-date-picker v-model="searchForm.date_to" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" style="width:150px" />
<el-button type="primary" @click="handleSearch">
<Icon icon="solar:magnifer-bold-duotone" class="mr-1" />查询
</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 表格区 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-2">
<span class="w-1 h-5 bg-rose-500 rounded-full inline-block"></span>
<span class="font-semibold text-gray-700">健康指标记录</span>
</div>
<el-button type="primary" @click="handleAdd">
<Icon icon="solar:add-circle-bold-duotone" class="mr-1" />新增记录
</el-button>
</div>
<div class="px-2">
<el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="客户" min-width="100">
<template #default="{ row }">{{ row.customer_name || '-' }}</template>
</el-table-column>
<el-table-column label="指标类型" width="110" align="center">
<template #default="{ row }">{{ metricTypeMap[row.metric_type] || row.metric_type || '-' }}</template>
</el-table-column>
<el-table-column label="指标值" width="130" align="center">
<template #default="{ row }">
<span class="font-medium text-rose-600">{{ row.value }}</span>
<span class="text-xs text-gray-400 ml-1">{{ row.unit }}</span>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="160" show-overflow-tooltip />
<el-table-column prop="created_by_name" label="记录人" width="100" />
<el-table-column prop="recorded_at" label="记录时间" min-width="160" />
<el-table-column label="操作" width="80" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.per_page"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total,sizes,prev,pager,next,jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<!-- 新增弹窗 -->
<el-dialog v-model="dialogVisible" title="新增健康指标" width="520px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
<el-form-item label="客户" prop="customer_id">
<el-select v-model="form.customer_id" filterable style="width:100%" placeholder="请选择客户">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="指标类型" prop="metric_type">
<el-select v-model="form.metric_type" style="width:100%" @change="onMetricTypeChange">
<el-option v-for="(v, k) in metricTypeMap" :key="k" :label="v" :value="k" />
</el-select>
</el-form-item>
<el-form-item label="指标值" prop="value">
<el-input v-model="form.value" placeholder="请输入数值" style="width:100%">
<template #append>{{ form.unit || '单位' }}</template>
</el-input>
</el-form-item>
<el-form-item label="记录时间" prop="recorded_at">
<el-date-picker
v-model="form.recorded_at"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择记录时间"
style="width:100%"
/>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.note" type="textarea" :rows="2" placeholder="补充说明(可选)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
+22 -15
View File
@@ -49,27 +49,34 @@ onMounted(() => { fetchList(); fetchProfiles(); fetchNurses() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.care_profile_id" placeholder="护理档案" clearable filterable style="width:200px"><el-option v-for="p in profiles" :key="p.id" :label="`${p.customer?.name||''} - ${p.type===1?'妈妈':'宝宝'}`" :value="p.id" /></el-select> <el-select v-model="searchForm.care_profile_id" placeholder="护理档案" clearable filterable style="width:200px"><el-option v-for="p in profiles" :key="p.id" :label="`${p.customer?.name||''} - ${p.type===1?'妈妈':'宝宝'}`" :value="p.id" /></el-select>
<el-date-picker v-model="searchForm.plan_date" type="date" value-format="YYYY-MM-DD" placeholder="计划日期" style="width:160px" /> <el-date-picker v-model="searchForm.plan_date" type="date" value-format="YYYY-MM-DD" placeholder="计划日期" style="width:160px" />
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option v-for="(v,k) in statusMap" :key="k" :label="v" :value="Number(k)" /></el-select> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option v-for="(v,k) in statusMap" :key="k" :label="v" :value="Number(k)" /></el-select>
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增计划</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3"></div>
<el-table-column label="客户" min-width="100"><template #default="{ row }">{{ row.profile?.customer?.name || '-' }}</template></el-table-column> <el-button type="primary" @click="handleAdd">新增计划</el-button>
<el-table-column prop="plan_date" label="计划日期" min-width="110" /> </div>
<el-table-column prop="stage" label="阶段" min-width="100" /> <div class="px-2">
<el-table-column label="护士" min-width="100"><template #default="{ row }">{{ row.nurse?.name || '-' }}</template></el-table-column> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="状态" width="90" align="center"><template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column> <el-table-column label="客户" min-width="100"><template #default="{ row }">{{ row.profile?.customer?.name || '-' }}</template></el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center"> <el-table-column prop="plan_date" label="计划日期" min-width="110" />
<template #default="{ row }"><el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button></template> <el-table-column prop="stage" label="阶段" min-width="100" />
</el-table-column> <el-table-column label="护士" min-width="100"><template #default="{ row }">{{ row.nurse?.name || '-' }}</template></el-table-column>
</el-table> <el-table-column label="状态" width="90" align="center"><template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }"><el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button></template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑计划':'新增计划'" width="550px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑计划':'新增计划'" width="550px" destroy-on-close>
<el-form ref="formRef" :model="form" label-width="80px"> <el-form ref="formRef" :model="form" label-width="80px">
+27 -20
View File
@@ -67,32 +67,39 @@ onMounted(() => { fetchList(); fetchCustomers() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.customer_id" placeholder="选择客户" clearable filterable style="width:180px"><el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" /></el-select> <el-select v-model="searchForm.customer_id" placeholder="选择客户" clearable filterable style="width:180px"><el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" /></el-select>
<el-select v-model="searchForm.type" placeholder="类型" clearable style="width:120px"><el-option v-for="(v,k) in typeMap" :key="k" :label="v" :value="Number(k)" /></el-select> <el-select v-model="searchForm.type" placeholder="类型" clearable style="width:120px"><el-option v-for="(v,k) in typeMap" :key="k" :label="v" :value="Number(k)" /></el-select>
<el-select v-model="searchForm.risk_level" placeholder="风险等级" clearable style="width:120px"><el-option v-for="(v,k) in riskMap" :key="k" :label="v" :value="Number(k)" /></el-select> <el-select v-model="searchForm.risk_level" placeholder="风险等级" clearable style="width:120px"><el-option v-for="(v,k) in riskMap" :key="k" :label="v" :value="Number(k)" /></el-select>
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增档案</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3"></div>
<el-table-column label="客户" min-width="100"><template #default="{ row }">{{ row.customer?.name || '-' }}</template></el-table-column> <el-button type="primary" @click="handleAdd">新增档案</el-button>
<el-table-column label="类型" width="80" align="center"><template #default="{ row }"><el-tag :type="row.type===1?'':'success'" size="small">{{ typeMap[row.type] }}</el-tag></template></el-table-column> </div>
<el-table-column prop="baby_name" label="宝宝名" min-width="100" /> <div class="px-2">
<el-table-column label="性别" width="70" align="center"><template #default="{ row }">{{ genderMap[row.baby_gender] || '-' }}</template></el-table-column> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="分娩方式" width="90"><template #default="{ row }">{{ birthMethodMap[row.birth_method] || '-' }}</template></el-table-column> <el-table-column label="客户" min-width="100"><template #default="{ row }">{{ row.customer?.name || '-' }}</template></el-table-column>
<el-table-column label="风险等级" width="90" align="center"><template #default="{ row }"><el-tag :type="riskType[row.risk_level]" size="small">{{ riskMap[row.risk_level] }}</el-tag></template></el-table-column> <el-table-column label="类型" width="80" align="center"><template #default="{ row }"><el-tag :type="row.type===1?'':'success'" size="small">{{ typeMap[row.type] }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center"> <el-table-column prop="baby_name" label="宝宝名" min-width="100" />
<template #default="{ row }"> <el-table-column label="性别" width="70" align="center"><template #default="{ row }">{{ genderMap[row.baby_gender] || '-' }}</template></el-table-column>
<el-button size="small" type="primary" link @click="handleDetail(row)">详情</el-button> <el-table-column label="分娩方式" width="90"><template #default="{ row }">{{ birthMethodMap[row.birth_method] || '-' }}</template></el-table-column>
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-table-column label="风险等级" width="90" align="center"><template #default="{ row }"><el-tag :type="riskType[row.risk_level]" size="small">{{ riskMap[row.risk_level] }}</el-tag></template></el-table-column>
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-table-column label="操作" width="200" fixed="right" align="center">
</template> <template #default="{ row }">
</el-table-column> <el-button size="small" type="primary" text @click="handleDetail(row)">详情</el-button>
</el-table> <el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑档案':'新增档案'" width="650px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑档案':'新增档案'" width="650px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="80px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="80px">
+22 -15
View File
@@ -43,26 +43,33 @@ onMounted(() => { fetchList(); fetchProfiles() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.care_profile_id" placeholder="护理档案" clearable filterable style="width:200px"><el-option v-for="p in profiles" :key="p.id" :label="`${p.customer?.name||''} - ${p.type===1?'妈妈':'宝宝'}`" :value="p.id" /></el-select> <el-select v-model="searchForm.care_profile_id" placeholder="护理档案" clearable filterable style="width:200px"><el-option v-for="p in profiles" :key="p.id" :label="`${p.customer?.name||''} - ${p.type===1?'妈妈':'宝宝'}`" :value="p.id" /></el-select>
<el-select v-model="searchForm.type" placeholder="类型" clearable style="width:140px"><el-option v-for="(v,k) in typeMap" :key="k" :label="v" :value="Number(k)" /></el-select> <el-select v-model="searchForm.type" placeholder="类型" clearable style="width:140px"><el-option v-for="(v,k) in typeMap" :key="k" :label="v" :value="Number(k)" /></el-select>
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增记录</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3"></div>
<el-table-column label="客户" min-width="100"><template #default="{ row }">{{ row.profile?.customer?.name || '-' }}</template></el-table-column> <el-button type="primary" @click="handleAdd">新增记录</el-button>
<el-table-column label="类型" width="100" align="center"><template #default="{ row }">{{ typeMap[row.type] || '-' }}</template></el-table-column> </div>
<el-table-column label="护士" min-width="100"><template #default="{ row }">{{ row.nurse?.name || '-' }}</template></el-table-column> <div class="px-2">
<el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip /> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column prop="recorded_at" label="记录时间" min-width="160" /> <el-table-column label="客户" min-width="100"><template #default="{ row }">{{ row.profile?.customer?.name || '-' }}</template></el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center"> <el-table-column label="类型" width="100" align="center"><template #default="{ row }">{{ typeMap[row.type] || '-' }}</template></el-table-column>
<template #default="{ row }"><el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button></template> <el-table-column label="护士" min-width="100"><template #default="{ row }">{{ row.nurse?.name || '-' }}</template></el-table-column>
</el-table-column> <el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip />
</el-table> <el-table-column prop="recorded_at" label="记录时间" min-width="160" />
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }"><el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button></template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑记录':'新增记录'" width="550px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑记录':'新增记录'" width="550px" destroy-on-close>
<el-form :model="form" label-width="80px"> <el-form :model="form" label-width="80px">
+26 -17
View File
@@ -57,8 +57,8 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.name" placeholder="渠道名称" style="width:180px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.name" placeholder="渠道名称" style="width:180px" clearable @keydown.enter="handleSearch" />
<el-select v-model="searchForm.type" placeholder="类型" clearable style="width:120px"> <el-select v-model="searchForm.type" placeholder="类型" clearable style="width:120px">
<el-option v-for="(v,k) in typeMap" :key="k" :label="v" :value="Number(k)" /> <el-option v-for="(v,k) in typeMap" :key="k" :label="v" :value="Number(k)" />
@@ -66,21 +66,30 @@ onMounted(() => fetchList())
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增渠道</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column prop="name" label="渠道名称" min-width="150" /> <span class="font-medium text-gray-700">渠道列表</span>
<el-table-column label="类型" width="100" align="center"><template #default="{ row }">{{ typeMap[row.type] }}</template></el-table-column> </div>
<el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="row.status?'success':'danger'" size="small">{{ row.status?'启用':'禁用' }}</el-tag></template></el-table-column> <el-button type="primary" @click="handleAdd">新增渠道</el-button>
<el-table-column prop="created_at" label="创建时间" min-width="160" /> </div>
<el-table-column label="操作" width="160" fixed="right" align="center"> <div class="px-2">
<template #default="{ row }"> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-table-column prop="name" label="渠道名称" min-width="150" />
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-table-column label="类型" width="100" align="center"><template #default="{ row }">{{ typeMap[row.type] }}</template></el-table-column>
</template> <el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="row.status?'success':'danger'" size="small">{{ row.status?'启用':'禁用' }}</el-tag></template></el-table-column>
</el-table-column> <el-table-column prop="created_at" label="创建时间" min-width="160" />
</el-table> <el-table-column label="操作" width="160" fixed="right" align="center">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <template #default="{ row }">
<el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑渠道':'新增渠道'" width="450px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑渠道':'新增渠道'" width="450px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="80px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="80px">
+28 -19
View File
@@ -71,31 +71,40 @@ onMounted(() => { fetchList(); fetchCustomers() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.customer_id" placeholder="选择客户" clearable filterable style="width:180px"><el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" /></el-select> <el-select v-model="searchForm.customer_id" placeholder="选择客户" clearable filterable style="width:180px"><el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" /></el-select>
<el-select v-model="searchForm.type" placeholder="类型" clearable style="width:120px"><el-option v-for="(v,k) in typeMap" :key="k" :label="v" :value="Number(k)" /></el-select> <el-select v-model="searchForm.type" placeholder="类型" clearable style="width:120px"><el-option v-for="(v,k) in typeMap" :key="k" :label="v" :value="Number(k)" /></el-select>
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option v-for="(v,k) in statusMap" :key="k" :label="v" :value="Number(k)" /></el-select> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option v-for="(v,k) in statusMap" :key="k" :label="v" :value="Number(k)" /></el-select>
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增投诉</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column label="客户" min-width="100"><template #default="{ row }">{{ row.customer?.name || '-' }}</template></el-table-column> <span class="font-medium text-gray-700">投诉列表</span>
<el-table-column label="类型" width="80" align="center"><template #default="{ row }">{{ typeMap[row.type] }}</template></el-table-column> </div>
<el-table-column prop="content" label="内容" min-width="200" show-overflow-tooltip /> <el-button type="primary" @click="handleAdd">新增投诉</el-button>
<el-table-column label="状态" width="90" align="center"><template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column> </div>
<el-table-column label="处理人" min-width="100"><template #default="{ row }">{{ row.handler?.name || '-' }}</template></el-table-column> <div class="px-2">
<el-table-column prop="created_at" label="创建时间" min-width="160" /> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="操作" width="160" fixed="right" align="center"> <el-table-column label="客户" min-width="100"><template #default="{ row }">{{ row.customer?.name || '-' }}</template></el-table-column>
<template #default="{ row }"> <el-table-column label="类型" width="80" align="center"><template #default="{ row }">{{ typeMap[row.type] }}</template></el-table-column>
<el-button v-if="row.status<2" size="small" type="success" link @click="openHandle(row)">处理</el-button> <el-table-column prop="content" label="内容" min-width="200" show-overflow-tooltip />
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-table-column label="状态" width="90" align="center"><template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column>
</template> <el-table-column label="处理人" min-width="100"><template #default="{ row }">{{ row.handler?.name || '-' }}</template></el-table-column>
</el-table-column> <el-table-column prop="created_at" label="创建时间" min-width="160" />
</el-table> <el-table-column label="操作" width="160" fixed="right" align="center">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <template #default="{ row }">
<el-button v-if="row.status<2" size="small" type="success" text @click="openHandle(row)">处理</el-button>
<el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑投诉':'新增投诉'" width="550px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑投诉':'新增投诉'" width="550px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="{ customer_id:[{required:true,message:'请选择客户'}], content:[{required:true,message:'请输入内容'}] }" label-width="80px"> <el-form ref="formRef" :model="form" :rules="{ customer_id:[{required:true,message:'请选择客户'}], content:[{required:true,message:'请输入内容'}] }" label-width="80px">
+32 -23
View File
@@ -106,8 +106,8 @@ onMounted(() => { fetchList(); fetchCustomers() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.contract_no" placeholder="合同编号" style="width:180px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.contract_no" placeholder="合同编号" style="width:180px" clearable @keydown.enter="handleSearch" />
<el-select v-model="searchForm.customer_id" placeholder="选择客户" clearable filterable style="width:180px"> <el-select v-model="searchForm.customer_id" placeholder="选择客户" clearable filterable style="width:180px">
<el-option v-for="c in customers" :key="c.id" :label="`${c.name} (${c.phone})`" :value="c.id" /> <el-option v-for="c in customers" :key="c.id" :label="`${c.name} (${c.phone})`" :value="c.id" />
@@ -119,27 +119,36 @@ onMounted(() => { fetchList(); fetchCustomers() })
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增合同</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column prop="contract_no" label="合同编号" min-width="150" /> <span class="font-medium text-gray-700">合同列表</span>
<el-table-column label="客户" min-width="100"><template #default="{ row }">{{ row.customer?.name || '-' }}</template></el-table-column> </div>
<el-table-column prop="package_name" label="套餐" min-width="120" /> <el-button type="primary" @click="handleAdd">新增合同</el-button>
<el-table-column prop="actual_amount" label="实付金额" min-width="110" align="right" /> </div>
<el-table-column prop="days" label="天数" width="70" align="center" /> <div class="px-2">
<el-table-column prop="check_in_date" label="入住日期" min-width="110" /> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="状态" width="100" align="center"> <el-table-column prop="contract_no" label="合同编号" min-width="150" />
<template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template> <el-table-column label="客户" min-width="100"><template #default="{ row }">{{ row.customer?.name || '-' }}</template></el-table-column>
</el-table-column> <el-table-column prop="package_name" label="套餐" min-width="120" />
<el-table-column label="操作" width="200" fixed="right" align="center"> <el-table-column prop="actual_amount" label="实付金额" min-width="110" align="right" />
<template #default="{ row }"> <el-table-column prop="days" label="天数" width="70" align="center" />
<el-button v-if="row.status===0" size="small" type="success" link @click="openAudit(row)">审核</el-button> <el-table-column prop="check_in_date" label="入住日期" min-width="110" />
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-table-column label="状态" width="100" align="center">
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template>
</template> </el-table-column>
</el-table-column> <el-table-column label="操作" width="200" fixed="right" align="center">
</el-table> <template #default="{ row }">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <el-button v-if="row.status===0" size="small" type="success" text @click="openAudit(row)">审核</el-button>
<el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<!-- Add/Edit --> <!-- Add/Edit -->
+36 -27
View File
@@ -91,8 +91,8 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.name" placeholder="客户姓名" style="width:160px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.name" placeholder="客户姓名" style="width:160px" clearable @keydown.enter="handleSearch" />
<el-input v-model="searchForm.phone" placeholder="手机号" style="width:160px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.phone" placeholder="手机号" style="width:160px" clearable @keydown.enter="handleSearch" />
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px">
@@ -102,31 +102,40 @@ onMounted(() => fetchList())
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增客户</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column prop="name" label="姓名" min-width="100" /> <span class="font-medium text-gray-700">客户列表</span>
<el-table-column prop="phone" label="手机号" min-width="130" /> </div>
<el-table-column prop="expected_date" label="预产期" min-width="110" /> <el-button type="primary" @click="handleAdd">新增客户</el-button>
<el-table-column prop="baby_count" label="宝宝数" width="80" align="center" /> </div>
<el-table-column label="状态" width="90" align="center"> <div class="px-2">
<template #default="{ row }"> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag> <el-table-column prop="name" label="姓名" min-width="100" />
</template> <el-table-column prop="phone" label="手机号" min-width="130" />
</el-table-column> <el-table-column prop="expected_date" label="预产期" min-width="110" />
<el-table-column label="负责人" min-width="100"> <el-table-column prop="baby_count" label="宝宝数" width="80" align="center" />
<template #default="{ row }">{{ row.owner?.name || '-' }}</template> <el-table-column label="状态" width="90" align="center">
</el-table-column> <template #default="{ row }">
<el-table-column prop="created_at" label="创建时间" min-width="160" /> <el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag>
<el-table-column label="操作" width="220" fixed="right" align="center"> </template>
<template #default="{ row }"> </el-table-column>
<el-button size="small" type="primary" link @click="handleDetail(row)">详情</el-button> <el-table-column label="负责人" min-width="100">
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <template #default="{ row }">{{ row.owner?.name || '-' }}</template>
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> </el-table-column>
</template> <el-table-column prop="created_at" label="创建时间" min-width="160" />
</el-table-column> <el-table-column label="操作" width="220" fixed="right" align="center">
</el-table> <template #default="{ row }">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <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="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<!-- Add/Edit --> <!-- Add/Edit -->
+37 -30
View File
@@ -144,8 +144,8 @@ onMounted(() => { fetchList(); fetchChannels(); fetchUsers() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.name" placeholder="姓名" style="width:160px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.name" placeholder="姓名" style="width:160px" clearable @keydown.enter="handleSearch" />
<el-input v-model="searchForm.phone" placeholder="手机号" style="width:160px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.phone" placeholder="手机号" style="width:160px" clearable @keydown.enter="handleSearch" />
<el-select v-model="searchForm.channel_id" placeholder="渠道" clearable style="width:140px"> <el-select v-model="searchForm.channel_id" placeholder="渠道" clearable style="width:140px">
@@ -159,36 +159,43 @@ onMounted(() => { fetchList(); fetchChannels(); fetchUsers() })
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-3">
<span class="font-medium text-gray-700">线索列表</span>
</div>
<el-button type="primary" @click="handleAdd">新增线索</el-button> <el-button type="primary" @click="handleAdd">新增线索</el-button>
</div> </div>
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="px-2">
<el-table-column prop="name" label="姓名" min-width="100" /> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column prop="phone" label="手机号" min-width="130" /> <el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column label="渠道" min-width="100"> <el-table-column prop="phone" label="手机号" min-width="130" />
<template #default="{ row }">{{ row.channel?.name || '-' }}</template> <el-table-column label="渠道" min-width="100">
</el-table-column> <template #default="{ row }">{{ row.channel?.name || '-' }}</template>
<el-table-column prop="expected_date" label="预产期" min-width="110" /> </el-table-column>
<el-table-column label="状态" width="100" align="center"> <el-table-column prop="expected_date" label="预产期" min-width="110" />
<template #default="{ row }"> <el-table-column label="状态" width="100" align="center">
<el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag> <template #default="{ row }">
</template> <el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag>
</el-table-column> </template>
<el-table-column label="负责人" min-width="100"> </el-table-column>
<template #default="{ row }">{{ row.owner?.name || '-' }}</template> <el-table-column label="负责人" min-width="100">
</el-table-column> <template #default="{ row }">{{ row.owner?.name || '-' }}</template>
<el-table-column prop="created_at" label="创建时间" min-width="160" /> </el-table-column>
<el-table-column label="操作" width="260" fixed="right" align="center"> <el-table-column prop="created_at" label="创建时间" min-width="160" />
<template #default="{ row }"> <el-table-column label="操作" width="260" fixed="right" align="center">
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <template #default="{ row }">
<el-button size="small" type="success" link @click="openFollow(row)">跟进</el-button> <el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="warning" link @click="openAssign(row)">分配</el-button> <el-button size="small" type="success" text @click="openFollow(row)">跟进</el-button>
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-button size="small" type="warning" text @click="openAssign(row)">分配</el-button>
</template> <el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</el-table-column> </template>
</el-table> </el-table-column>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> </el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<!-- Add/Edit --> <!-- Add/Edit -->
@@ -0,0 +1,202 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { questionnaireApi, customerApi } from '@/api/crm.js'
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const dialogVisible = ref(false)
const dialogTitle = ref('新建问卷')
const submitLoading = ref(false)
const detailVisible = ref(false)
const detailData = ref(null)
const customers = ref([])
const searchForm = reactive({ customer_id: '', status: '' })
const pagination = reactive({ page: 1, per_page: 20 })
const formRef = ref(null)
const form = reactive({ id: null, customer_id: '', title: '', score: 80, status: 'draft' })
const isEdit = ref(false)
const formRules = {
customer_id: [{ required: true, message: '请选择客户', trigger: 'change' }],
title: [{ required: true, message: '请输入问卷标题', trigger: 'blur' }],
score: [{ required: true, message: '请输入评分', trigger: 'blur' }]
}
const statusMap = { draft: '草稿', sent: '已发送', completed: '已完成' }
const statusType = { draft: 'info', sent: 'warning', completed: 'success' }
async function fetchList() {
loading.value = true
try {
const res = await questionnaireApi.getList({ ...searchForm, ...pagination })
tableData.value = res.data?.list || res.data?.data || []
total.value = res.data?.total || 0
} finally { loading.value = false }
}
async function fetchCustomers() {
try {
const res = await customerApi.getList({ per_page: 500 })
customers.value = res.data?.list || res.data?.data || []
} catch {}
}
function handleSearch() { pagination.page = 1; fetchList() }
function handleReset() { Object.assign(searchForm, { customer_id: '', status: '' }); handleSearch() }
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新建问卷'
Object.assign(form, { id: null, customer_id: '', title: '', score: 80, status: 'draft' })
dialogVisible.value = true
}
async function handleEdit(row) {
isEdit.value = true
dialogTitle.value = '编辑问卷'
const res = await questionnaireApi.getDetail(row.id)
const d = res.data
Object.assign(form, { id: d.id, customer_id: d.customer_id, title: d.title, score: d.score, status: d.status })
dialogVisible.value = true
}
async function handleDetail(row) {
const res = await questionnaireApi.getDetail(row.id)
detailData.value = res.data
detailVisible.value = true
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) { await questionnaireApi.update(form.id, form); ElMessage.success('更新成功') }
else { await questionnaireApi.create(form); ElMessage.success('创建成功') }
dialogVisible.value = false; fetchList()
} finally { submitLoading.value = false }
})
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除问卷「${row.title}」吗?`, '删除确认', { type: 'warning' })
await questionnaireApi.delete(row.id); ElMessage.success('删除成功'); fetchList()
}
function handlePageChange(val) { pagination.page = val; fetchList() }
function handleSizeChange(val) { pagination.per_page = val; pagination.page = 1; fetchList() }
onMounted(() => { fetchList(); fetchCustomers() })
</script>
<template>
<div class="p-6 space-y-4">
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.customer_id" placeholder="选择客户" clearable filterable style="width:180px">
<el-option v-for="c in customers" :key="c.id" :label="`${c.name}(${c.phone})`" :value="c.id" />
</el-select>
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px">
<el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="k" />
</el-select>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-2">
<span class="w-1 h-5 bg-rose-500 rounded-full inline-block"></span>
<span class="font-medium text-gray-700">问卷列表</span>
</div>
<el-button type="primary" @click="handleAdd">新建问卷</el-button>
</div>
<div class="px-2">
<el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="客户" min-width="110">
<template #default="{ row }">{{ row.customer_name || row.customer?.name || '-' }}</template>
</el-table-column>
<el-table-column prop="title" label="问卷标题" min-width="180" show-overflow-tooltip />
<el-table-column label="满意度评分" width="110" align="center">
<template #default="{ row }">
<span :class="row.score >= 80 ? 'text-green-600' : row.score >= 60 ? 'text-yellow-500' : 'text-red-500'" class="font-semibold">
{{ row.score ?? '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" min-width="160" />
<el-table-column label="操作" width="200" 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="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.per_page"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total,sizes,prev,pager,next,jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<!-- Add / Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
<el-form-item label="客户" prop="customer_id">
<el-select v-model="form.customer_id" filterable placeholder="请选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="`${c.name}(${c.phone})`" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="问卷标题" prop="title">
<el-input v-model="form.title" placeholder="请输入问卷标题" />
</el-form-item>
<el-form-item label="满意度评分" prop="score">
<el-slider v-model="form.score" :min="0" :max="100" :step="1" show-input style="width:100%" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" style="width:100%">
<el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="k" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
<!-- Detail Drawer -->
<el-drawer v-model="detailVisible" title="问卷详情" size="480px">
<template v-if="detailData">
<el-descriptions :column="2" border>
<el-descriptions-item label="客户">{{ detailData.customer_name || detailData.customer?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="标题" :span="2">{{ detailData.title }}</el-descriptions-item>
<el-descriptions-item label="评分">
<span :class="detailData.score >= 80 ? 'text-green-600 font-bold' : 'text-yellow-500 font-bold'">{{ detailData.score }}</span>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusType[detailData.status]" size="small">{{ statusMap[detailData.status] }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">{{ detailData.created_at }}</el-descriptions-item>
</el-descriptions>
</template>
</el-drawer>
</div>
</template>
+65 -251
View File
@@ -1,299 +1,113 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user.js' import { useUserStore } from '@/stores/user.js'
import { useRouter } from 'vue-router'
import { Icon } from '@iconify/vue'
const userStore = useUserStore() const userStore = useUserStore()
const router = useRouter()
const username = computed(() => userStore.userInfo?.name || userStore.userInfo?.username || '用户') const username = computed(() => userStore.userInfo?.name || userStore.userInfo?.username || '用户')
const today = computed(() => { const today = computed(() => {
const d = new Date() const d = new Date()
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`
})
const weekDay = computed(() => {
const days = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'] const days = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
return days[new Date().getDay()] return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}${days[d.getDay()]}`
}) })
const statCards = ref([ const statCards = ref([
{ { title: '今日预约客户', value: '--', trend: '+12% 较昨日', trendUp: true, icon: 'solar:users-group-rounded-bold-duotone', iconBg: 'bg-rose-50', iconColor: 'text-rose-500' },
title: '在住宾客', { title: '当前入驻率', value: '--', trend: '良好运营状态', trendUp: true, icon: 'solar:home-bold-duotone', iconBg: 'bg-blue-50', iconColor: 'text-blue-500' },
value: '--', { title: '今日护理评分', value: '--', trend: '历史最高纪录', trendUp: true, icon: 'solar:heart-pulse-bold-duotone', iconBg: 'bg-amber-50', iconColor: 'text-amber-500' },
unit: '位', { title: '本月营收(预计)', value: '--', trend: '+8.4% 较上月', trendUp: true, icon: 'solar:wad-of-money-bold-duotone', iconBg: 'bg-teal-50', iconColor: 'text-teal-500' },
icon: 'User',
color: '#E8A87C',
bg: '#FBE8D5'
},
{
title: '今日入住',
value: '--',
unit: '单',
icon: 'House',
color: '#67C23A',
bg: '#E8F5E1'
},
{
title: '今日退住',
value: '--',
unit: '单',
icon: 'SuitcaseLine',
color: '#409EFF',
bg: '#E0EFFF'
},
{
title: '今日收款',
value: '--',
unit: '元',
icon: 'Money',
color: '#E6A23C',
bg: '#FDF0D6'
},
{
title: '客房出租率',
value: '--',
unit: '%',
icon: 'DataAnalysis',
color: '#C97B5E',
bg: '#F9E8E0'
},
{
title: '本月新签',
value: '--',
unit: '单',
icon: 'DocumentAdd',
color: '#909399',
bg: '#F0F1F2'
}
]) ])
const shortcuts = [ const shortcuts = [
{ label: '新增客户', icon: 'UserFilled', path: '/crm/customers/add' }, { label: '新增客户', icon: 'solar:user-plus-bold-duotone', path: '/crm/customers' },
{ label: '办理入住', icon: 'House', path: '/room/check-in' }, { label: '办理入住', icon: 'solar:home-bold-duotone', path: '/room/reservations' },
{ label: '办理退住', icon: 'DoorOpen', path: '/room/check-out' }, { label: '办理退住', icon: 'solar:door-open-bold-duotone', path: '/room/rooms' },
{ label: '新建工单', icon: 'DocumentAdd', path: '/service/orders/add' }, { label: '新建工单', icon: 'solar:document-add-bold-duotone', path: '/service/orders' },
{ label: '今日月子餐', icon: 'Bowl', path: '/meal/today' }, { label: '今日月子餐', icon: 'solar:cup-bold', path: '/meal/daily-plans' },
{ label: '护理记录', icon: 'FirstAidKit', path: '/nursing/records' } { label: '护理记录', icon: 'solar:heart-pulse-bold-duotone', path: '/care/records' },
] ]
// Placeholder — in production would fetch from API
onMounted(() => { onMounted(() => {
// Future: fetchDashboardStats() // Future: fetchDashboardStats()
}) })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-5">
<!-- Welcome Banner --> <!-- Welcome Banner -->
<div class="welcome-banner card"> <div class="bg-gradient-to-r from-rose-50 to-orange-50 rounded-3xl p-8 flex items-center justify-between border border-rose-100">
<div class="welcome-info"> <div>
<h2 class="welcome-title">你好{{ username }} 👋</h2> <h2 class="text-2xl font-bold text-gray-800 mb-1">你好{{ username }} 👋</h2>
<p class="welcome-sub">{{ today }} &nbsp; {{ weekDay }}祝您工作愉快</p> <p class="text-gray-400 text-sm">{{ today }}祝您工作愉快</p>
</div>
<div class="welcome-deco">
<el-icon :size="80" color="rgba(232,168,124,0.2)"><House /></el-icon>
</div> </div>
<Icon icon="solar:heart-bold" class="text-8xl text-rose-200" />
</div> </div>
<!-- Stats Cards --> <!-- KPI Cards -->
<div class="stats-grid mt-16"> <div class="grid grid-cols-4 gap-5">
<div <div
v-for="stat in statCards" v-for="stat in statCards"
:key="stat.title" :key="stat.title"
class="stat-card card" class="bg-white rounded-3xl border border-gray-100 shadow-sm p-6"
> >
<div class="stat-icon-wrap" :style="{ background: stat.bg }"> <div class="flex items-center gap-4 mb-4">
<el-icon :size="28" :style="{ color: stat.color }"> <div class="w-12 h-12 rounded-2xl flex items-center justify-center text-2xl" :class="[stat.iconBg, stat.iconColor]">
<component :is="stat.icon" /> <Icon :icon="stat.icon" />
</el-icon>
</div>
<div class="stat-info">
<div class="stat-label">{{ stat.title }}</div>
<div class="stat-value">
<span class="stat-number" :style="{ color: stat.color }">{{ stat.value }}</span>
<span class="stat-unit">{{ stat.unit }}</span>
</div> </div>
<div>
<p class="text-gray-400 text-xs">{{ stat.title }}</p>
<p class="text-2xl font-bold text-gray-800">{{ stat.value }}</p>
</div>
</div>
<div class="text-xs font-bold flex items-center gap-1" :class="stat.trendUp ? 'text-green-500' : 'text-red-400'">
<Icon :icon="stat.trendUp ? 'solar:double-alt-arrow-up-bold-duotone' : 'solar:double-alt-arrow-down-bold-duotone'" />
{{ stat.trend }}
</div> </div>
</div> </div>
</div> </div>
<!-- Quick Access --> <!-- Quick Access -->
<div class="section-title mt-24">快捷入口</div> <div class="bg-white rounded-3xl border border-gray-100 shadow-sm p-6">
<div class="shortcuts-grid mt-12"> <h3 class="font-bold text-gray-800 mb-5 flex items-center gap-2">
<router-link <span class="w-1 h-5 bg-rose-500 rounded-full inline-block"></span>
v-for="item in shortcuts" 快捷入口
:key="item.label" </h3>
:to="item.path" <div class="grid grid-cols-6 gap-4">
class="shortcut-card card" <button
> v-for="item in shortcuts"
<el-icon :size="28" color="#E8A87C"> :key="item.label"
<component :is="item.icon" /> class="flex flex-col items-center justify-center gap-3 p-5 rounded-2xl bg-gray-50 hover:bg-rose-50 hover:text-rose-500 transition-all duration-150 group"
</el-icon> @click="router.push(item.path)"
<span class="shortcut-label">{{ item.label }}</span> >
</router-link> <Icon :icon="item.icon" class="text-3xl text-gray-400 group-hover:text-rose-500 transition-colors" />
<span class="text-xs font-medium text-gray-600 group-hover:text-rose-500">{{ item.label }}</span>
</button>
</div>
</div> </div>
<!-- System Info --> <!-- System Info -->
<div class="system-info card mt-16"> <div class="bg-white rounded-3xl border border-gray-100 shadow-sm p-6">
<div class="info-title">系统信息</div> <h3 class="font-bold text-gray-800 mb-4 flex items-center gap-2">
<div class="info-grid mt-12"> <span class="w-1 h-5 bg-rose-500 rounded-full inline-block"></span>
<div class="info-item"> 系统信息
<span class="info-key">系统名称</span> </h3>
<span class="info-val">宫中有喜月子会所综合管理系统</span> <div class="grid grid-cols-3 gap-4">
<div class="flex gap-3 text-sm">
<span class="text-gray-400 w-20 flex-shrink-0">系统名称</span>
<span class="text-gray-700 font-medium">宫中有喜月子会所综合管理系统</span>
</div> </div>
<div class="info-item"> <div class="flex gap-3 text-sm">
<span class="info-key">当前版本</span> <span class="text-gray-400 w-20 flex-shrink-0">当前版本</span>
<span class="info-val">v1.0.0</span> <span class="text-gray-700 font-medium">v1.0.0</span>
</div> </div>
<div class="info-item"> <div class="flex gap-3 text-sm">
<span class="info-key">技术支持</span> <span class="text-gray-400 w-20 flex-shrink-0">技术支持</span>
<span class="info-val">宫中有喜运营团队</span> <span class="text-gray-700 font-medium">宫中有喜运营团队</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss">
.welcome-banner {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #FBE8D5, #F5D5BC);
border: none;
overflow: hidden;
}
.welcome-title {
font-size: 22px;
font-weight: 700;
color: var(--color-primary-dark);
margin-bottom: 8px;
}
.welcome-sub {
font-size: 14px;
color: var(--color-secondary);
}
.welcome-deco {
opacity: 0.6;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
}
.stat-icon-wrap {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-info {
flex: 1;
min-width: 0;
}
.stat-label {
font-size: 13px;
color: var(--color-text-secondary);
margin-bottom: 4px;
}
.stat-value {
display: flex;
align-items: baseline;
gap: 4px;
}
.stat-number {
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.stat-unit {
font-size: 13px;
color: var(--color-text-secondary);
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text-primary);
padding-left: 8px;
border-left: 3px solid var(--color-primary);
}
.shortcuts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 12px;
}
.shortcut-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 24px 16px;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(232, 168, 124, 0.2);
}
}
.shortcut-label {
font-size: 13px;
color: var(--color-text-primary);
font-weight: 500;
}
.system-info {
.info-title {
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary);
}
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.info-item {
display: flex;
gap: 12px;
font-size: 13px;
}
.info-key {
color: var(--color-text-secondary);
min-width: 80px;
flex-shrink: 0;
}
.info-val {
color: var(--color-text-primary);
}
</style>
+27 -18
View File
@@ -97,29 +97,38 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.customer_id" placeholder="客户ID" style="width:160px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.customer_id" placeholder="客户ID" style="width:160px" clearable @keydown.enter="handleSearch" />
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table-column label="客户姓名" min-width="120"> <div class="flex items-center gap-3">
<template #default="{ row }">{{ row.customer?.name || '-' }}</template> <span class="font-medium text-gray-700">客户账户列表</span>
</el-table-column> </div>
<el-table-column prop="cash_balance" label="现金余额" min-width="120" align="right" /> </div>
<el-table-column prop="card_balance" label="储值卡余额" min-width="120" align="right" /> <div class="px-2">
<el-table-column prop="points" label="积分" min-width="100" align="right" /> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="操作" width="180" fixed="right" align="center"> <el-table-column label="客户姓名" min-width="120">
<template #default="{ row }"> <template #default="{ row }">{{ row.customer?.name || '-' }}</template>
<el-button size="small" type="primary" link @click="handleRecharge(row)">充值</el-button> </el-table-column>
<el-button size="small" type="primary" link @click="handleViewTx(row)">查看流水</el-button> <el-table-column prop="cash_balance" label="现金余额" min-width="120" align="right" />
</template> <el-table-column prop="card_balance" label="储值卡余额" min-width="120" align="right" />
</el-table-column> <el-table-column prop="points" label="积分" min-width="100" align="right" />
</el-table> <el-table-column label="操作" width="180" fixed="right" align="center">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10, 20, 50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <template #default="{ row }">
<el-button size="small" type="primary" text @click="handleRecharge(row)">充值</el-button>
<el-button size="small" type="primary" text @click="handleViewTx(row)">查看流水</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10, 20, 50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<!-- Recharge Dialog --> <!-- Recharge Dialog -->
+36 -27
View File
@@ -77,8 +77,8 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.type" placeholder="类型" clearable style="width:120px"> <el-select v-model="searchForm.type" placeholder="类型" clearable style="width:120px">
<el-option v-for="(v, k) in typeMap" :key="k" :label="v" :value="Number(k)" /> <el-option v-for="(v, k) in typeMap" :key="k" :label="v" :value="Number(k)" />
</el-select> </el-select>
@@ -89,31 +89,40 @@ onMounted(() => fetchList())
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增分类</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe row-key="id" default-expand-all style="width:100%"> <div class="flex items-center gap-3">
<el-table-column prop="name" label="分类名称" min-width="180" /> <span class="font-medium text-gray-700">收支分类列表</span>
<el-table-column label="类型" width="100" align="center"> </div>
<template #default="{ row }"> <el-button type="primary" @click="handleAdd">新增分类</el-button>
<el-tag :type="row.type === 1 ? 'success' : 'danger'" size="small">{{ typeMap[row.type] }}</el-tag> </div>
</template> <div class="px-2">
</el-table-column> <el-table v-loading="loading" :data="tableData" row-key="id" default-expand-all style="width:100%">
<el-table-column prop="level" label="层级" width="80" align="center" /> <el-table-column prop="name" label="分类名称" min-width="180" />
<el-table-column prop="sort" label="排序" width="80" align="center" /> <el-table-column label="类型" width="100" align="center">
<el-table-column label="状态" width="80" align="center"> <template #default="{ row }">
<template #default="{ row }"> <el-tag :type="row.type === 1 ? 'success' : 'danger'" size="small">{{ typeMap[row.type] }}</el-tag>
<el-tag :type="row.status ? 'success' : 'danger'" size="small">{{ row.status ? '启用' : '停用' }}</el-tag> </template>
</template> </el-table-column>
</el-table-column> <el-table-column prop="level" label="层级" width="80" align="center" />
<el-table-column prop="created_at" label="创建时间" min-width="160" /> <el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column label="操作" width="160" fixed="right" align="center"> <el-table-column label="状态" width="80" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-tag :type="row.status ? 'success' : 'danger'" size="small">{{ row.status ? '启用' : '停用' }}</el-tag>
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> </template>
</template> </el-table-column>
</el-table-column> <el-table-column prop="created_at" label="创建时间" min-width="160" />
</el-table> <el-table-column label="操作" width="160" fixed="right" align="center">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[20,50,100]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <template #default="{ row }">
<el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[20,50,100]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑分类' : '新增分类'" width="450px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑分类' : '新增分类'" width="450px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
+31 -22
View File
@@ -69,8 +69,8 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.invoice_no" placeholder="发票号" style="width:180px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.invoice_no" placeholder="发票号" style="width:180px" clearable @keydown.enter="handleSearch" />
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px">
<el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="Number(k)" /> <el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="Number(k)" />
@@ -81,26 +81,35 @@ onMounted(() => fetchList())
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增发票</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column prop="invoice_no" label="发票号" min-width="150" /> <span class="font-medium text-gray-700">发票列表</span>
<el-table-column prop="customer_id" label="客户ID" width="100" align="center" /> </div>
<el-table-column label="类型" width="80" align="center"><template #default="{ row }"><el-tag size="small">{{ typeMap[row.type] }}</el-tag></template></el-table-column> <el-button type="primary" @click="handleAdd">新增发票</el-button>
<el-table-column prop="amount" label="金额" width="100" align="right" /> </div>
<el-table-column prop="tax_amount" label="税额" width="100" align="right" /> <div class="px-2">
<el-table-column prop="title" label="发票抬头" min-width="180" show-overflow-tooltip /> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="状态" width="90" align="center"><template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column> <el-table-column prop="invoice_no" label="发票号" min-width="150" />
<el-table-column prop="issued_at" label="开票时间" width="160" /> <el-table-column prop="customer_id" label="客户ID" width="100" align="center" />
<el-table-column label="操作" width="200" fixed="right" align="center"> <el-table-column label="类型" width="80" align="center"><template #default="{ row }"><el-tag size="small">{{ typeMap[row.type] }}</el-tag></template></el-table-column>
<template #default="{ row }"> <el-table-column prop="amount" label="金额" width="100" align="right" />
<el-button v-if="row.status===0" size="small" type="success" link @click="handleIssue(row)">开票</el-button> <el-table-column prop="tax_amount" label="税额" width="100" align="right" />
<el-button v-if="row.status===0" size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-table-column prop="title" label="发票抬头" min-width="180" show-overflow-tooltip />
<el-button v-if="row.status===0" size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-table-column label="状态" width="90" align="center"><template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column>
</template> <el-table-column prop="issued_at" label="开票时间" width="160" />
</el-table-column> <el-table-column label="操作" width="200" fixed="right" align="center">
</el-table> <template #default="{ row }">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,15,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <el-button v-if="row.status===0" size="small" type="success" text @click="handleIssue(row)">开票</el-button>
<el-button v-if="row.status===0" size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button v-if="row.status===0" size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,15,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑发票':'新增发票'" width="550px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑发票':'新增发票'" width="550px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
@@ -0,0 +1,215 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { paymentApi } from '@/api/finance.js'
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const dialogVisible = ref(false)
const submitLoading = ref(false)
const searchForm = reactive({ status: '', date_from: '', date_to: '' })
const pagination = reactive({ page: 1, per_page: 20 })
const formRef = ref(null)
const form = reactive({
customer_id: '',
customer_name: '',
contract_id: '',
amount: 0,
payment_method: '',
remark: ''
})
const formRules = {
customer_name: [{ required: true, message: '请输入客户姓名', trigger: 'blur' }],
amount: [{ required: true, message: '请输入金额', trigger: 'blur' }],
payment_method: [{ required: true, message: '请选择付款方式', trigger: 'change' }]
}
const statusMap = { pending: '待确认', confirmed: '已确认' }
const statusType = { pending: 'warning', confirmed: 'success' }
const payMethodMap = {
cash: '现金',
card: '刷卡',
wechat: '微信',
alipay: '支付宝',
transfer: '银行转账'
}
async function fetchList() {
loading.value = true
try {
const res = await paymentApi.getList({ ...searchForm, ...pagination })
tableData.value = res.data?.list || res.data?.data || []
total.value = res.data?.total || 0
} catch {
ElMessage.error('获取付款列表失败')
} finally {
loading.value = false
}
}
function handleSearch() { pagination.page = 1; fetchList() }
function handleReset() {
Object.assign(searchForm, { status: '', date_from: '', date_to: '' })
handleSearch()
}
function handleAdd() {
Object.assign(form, {
customer_id: '', customer_name: '', contract_id: '',
amount: 0, payment_method: '', remark: ''
})
dialogVisible.value = true
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
await paymentApi.create({ ...form, type: 'payment' })
ElMessage.success('创建成功')
dialogVisible.value = false
fetchList()
} catch {
ElMessage.error('创建失败')
} finally {
submitLoading.value = false
}
})
}
async function handleConfirm(row) {
await ElMessageBox.confirm(`确认收款「¥${row.amount}」吗?`, '确认收款', { type: 'warning' })
try {
await paymentApi.audit(row.id, {})
ElMessage.success('确认成功')
fetchList()
} catch {
ElMessage.error('确认失败')
}
}
function handlePageChange(val) { pagination.page = val; fetchList() }
function handleSizeChange(val) { pagination.per_page = val; pagination.page = 1; fetchList() }
onMounted(() => fetchList())
</script>
<template>
<div class="p-6 space-y-4">
<!-- 搜索栏 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px">
<el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="k" />
</el-select>
<el-date-picker
v-model="searchForm.date_from"
type="date"
value-format="YYYY-MM-DD"
placeholder="开始日期"
style="width:150px"
/>
<el-date-picker
v-model="searchForm.date_to"
type="date"
value-format="YYYY-MM-DD"
placeholder="结束日期"
style="width:150px"
/>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 列表卡片 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-3">
<span class="w-1 h-5 bg-rose-500 rounded-full inline-block"></span>
<span class="font-medium text-gray-700">付款记录列表</span>
</div>
<el-button type="primary" @click="handleAdd">新建收款记录</el-button>
</div>
<div class="px-2">
<el-table v-loading="loading" :data="tableData" style="width:100%" stripe>
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="customer_name" label="客户" min-width="110" />
<el-table-column prop="contract_id" label="合同ID" width="90" align="center" />
<el-table-column label="金额" width="120" align="right">
<template #default="{ row }">
<span class="text-green-600 font-semibold">¥{{ Number(row.amount || 0).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="付款方式" width="110" align="center">
<template #default="{ row }">
{{ payMethodMap[row.payment_method] || row.payment_method || '-' }}
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusType[row.status]" size="small">
{{ statusMap[row.status] || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" min-width="160" />
<el-table-column label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="row.status === 'pending'"
size="small"
type="success"
text
@click="handleConfirm(row)"
>确认收款</el-button>
<span v-else class="text-gray-400 text-sm">-</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.per_page"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total,sizes,prev,pager,next,jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<!-- 新建收款 Dialog -->
<el-dialog v-model="dialogVisible" title="新建收款记录" width="520px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
<el-form-item label="客户姓名" prop="customer_name">
<el-input v-model="form.customer_name" placeholder="请输入客户姓名" />
</el-form-item>
<el-form-item label="合同ID">
<el-input v-model="form.contract_id" placeholder="关联合同ID(可选)" />
</el-form-item>
<el-form-item label="金额" prop="amount">
<el-input-number v-model="form.amount" :min="0.01" :precision="2" style="width:100%" />
</el-form-item>
<el-form-item label="付款方式" prop="payment_method">
<el-select v-model="form.payment_method" placeholder="选择付款方式" style="width:100%">
<el-option v-for="(v, k) in payMethodMap" :key="k" :label="v" :value="k" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -60,8 +60,8 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.name" placeholder="储值卡名称" style="width:180px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.name" placeholder="储值卡名称" style="width:180px" clearable @keydown.enter="handleSearch" />
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px">
<el-option label="启用" :value="1" /> <el-option label="启用" :value="1" />
@@ -70,23 +70,32 @@ onMounted(() => fetchList())
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增储值卡</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column prop="name" label="储值卡名称" min-width="150" /> <span class="font-medium text-gray-700">储值卡列表</span>
<el-table-column prop="face_value" label="面值" width="100" align="right" /> </div>
<el-table-column prop="sell_price" label="售价" width="100" align="right" /> <el-button type="primary" @click="handleAdd">新增储值卡</el-button>
<el-table-column prop="discount_rate" label="折扣率" width="90" align="center" /> </div>
<el-table-column prop="validity_days" label="有效期(天)" width="100" align="center" /> <div class="px-2">
<el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="row.status?'success':'danger'" size="small">{{ row.status?'启用':'停用' }}</el-tag></template></el-table-column> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="操作" width="160" fixed="right" align="center"> <el-table-column prop="name" label="储值卡名称" min-width="150" />
<template #default="{ row }"> <el-table-column prop="face_value" label="面值" width="100" align="right" />
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-table-column prop="sell_price" label="售价" width="100" align="right" />
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-table-column prop="discount_rate" label="折扣率" width="90" align="center" />
</template> <el-table-column prop="validity_days" label="有效期(天)" width="100" align="center" />
</el-table-column> <el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="row.status?'success':'danger'" size="small">{{ row.status?'启用':'停用' }}</el-tag></template></el-table-column>
</el-table> <el-table-column label="操作" width="160" fixed="right" align="center">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,15,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <template #default="{ row }">
<el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,15,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑储值卡':'新增储值卡'" width="500px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑储值卡':'新增储值卡'" width="500px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
+39 -30
View File
@@ -104,8 +104,8 @@ onMounted(() => { fetchList() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.type" placeholder="收支类型" clearable style="width:120px"> <el-select v-model="searchForm.type" placeholder="收支类型" clearable style="width:120px">
<el-option v-for="(v, k) in typeMap" :key="k" :label="v" :value="Number(k)" /> <el-option v-for="(v, k) in typeMap" :key="k" :label="v" :value="Number(k)" />
</el-select> </el-select>
@@ -117,34 +117,43 @@ onMounted(() => { fetchList() })
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增记录</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column prop="record_no" label="记录编号" min-width="150" /> <span class="font-medium text-gray-700">收支记录列表</span>
<el-table-column label="类型" width="90" align="center"> </div>
<template #default="{ row }"><el-tag :type="typeType[row.type]" size="small">{{ typeMap[row.type] || '-' }}</el-tag></template> <el-button type="primary" @click="handleAdd">新增记录</el-button>
</el-table-column> </div>
<el-table-column prop="category" label="分类" min-width="120"> <div class="px-2">
<template #default="{ row }">{{ row.category?.name || row.category || '-' }}</template> <el-table v-loading="loading" :data="tableData" style="width:100%">
</el-table-column> <el-table-column prop="record_no" label="记录编号" min-width="150" />
<el-table-column prop="amount" label="金额" min-width="110" align="right" /> <el-table-column label="类型" width="90" align="center">
<el-table-column label="支付方式" width="110" align="center"> <template #default="{ row }"><el-tag :type="typeType[row.type]" size="small">{{ typeMap[row.type] || '-' }}</el-tag></template>
<template #default="{ row }">{{ payMethodMap[row.pay_method] || '-' }}</template> </el-table-column>
</el-table-column> <el-table-column prop="category" label="分类" min-width="120">
<el-table-column label="审核状态" width="100" align="center"> <template #default="{ row }">{{ row.category?.name || row.category || '-' }}</template>
<template #default="{ row }"><el-tag :type="auditType[row.audit_status]" size="small">{{ auditMap[row.audit_status] }}</el-tag></template> </el-table-column>
</el-table-column> <el-table-column prop="amount" label="金额" min-width="110" align="right" />
<el-table-column prop="record_date" label="记录日期" min-width="120" /> <el-table-column label="支付方式" width="110" align="center">
<el-table-column label="操作" width="220" fixed="right" align="center"> <template #default="{ row }">{{ payMethodMap[row.pay_method] || '-' }}</template>
<template #default="{ row }"> </el-table-column>
<el-button v-if="row.audit_status===0" size="small" type="success" link @click="openAudit(row)">审核</el-button> <el-table-column label="审核状态" width="100" align="center">
<el-button v-if="row.audit_status===0" size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <template #default="{ row }"><el-tag :type="auditType[row.audit_status]" size="small">{{ auditMap[row.audit_status] }}</el-tag></template>
<el-button v-if="row.audit_status===0" size="small" type="danger" link @click="handleDelete(row)">删除</el-button> </el-table-column>
<span v-if="row.audit_status!==0" class="text-muted">-</span> <el-table-column prop="record_date" label="记录日期" min-width="120" />
</template> <el-table-column label="操作" width="220" fixed="right" align="center">
</el-table-column> <template #default="{ row }">
</el-table> <el-button v-if="row.audit_status===0" size="small" type="success" text @click="openAudit(row)">审核</el-button>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <el-button v-if="row.audit_status===0" size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button v-if="row.audit_status===0" size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
<span v-if="row.audit_status!==0" class="text-muted">-</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<!-- Add/Edit --> <!-- Add/Edit -->
@@ -0,0 +1,138 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { accountTransactionApi } from '@/api/finance.js'
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const searchForm = reactive({ account_id: '', type: '', date_from: '', date_to: '' })
const pagination = reactive({ page: 1, per_page: 20 })
const typeMap = {
deposit: '充值',
consume: '消费',
refund: '退款',
adjust: '调整'
}
const typeTagType = {
deposit: 'success',
consume: 'danger',
refund: 'warning',
adjust: 'info'
}
async function fetchList() {
loading.value = true
try {
const res = await accountTransactionApi.getList({ ...searchForm, ...pagination })
tableData.value = res.data?.list || res.data?.data || []
total.value = res.data?.total || 0
} catch {
ElMessage.error('获取流水列表失败')
} finally {
loading.value = false
}
}
function handleSearch() { pagination.page = 1; fetchList() }
function handleReset() {
Object.assign(searchForm, { account_id: '', type: '', date_from: '', date_to: '' })
handleSearch()
}
function handlePageChange(val) { pagination.page = val; fetchList() }
function handleSizeChange(val) { pagination.per_page = val; pagination.page = 1; fetchList() }
function amountClass(row) {
return ['deposit', 'refund'].includes(row.type) ? 'text-green-600 font-semibold' : 'text-red-500 font-semibold'
}
function amountPrefix(row) {
return ['deposit', 'refund'].includes(row.type) ? '+' : '-'
}
onMounted(() => fetchList())
</script>
<template>
<div class="p-6 space-y-4">
<!-- 搜索栏 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input
v-model="searchForm.account_id"
placeholder="账户ID"
style="width:150px"
clearable
@keydown.enter="handleSearch"
/>
<el-select v-model="searchForm.type" placeholder="流水类型" clearable style="width:130px">
<el-option v-for="(v, k) in typeMap" :key="k" :label="v" :value="k" />
</el-select>
<el-date-picker
v-model="searchForm.date_from"
type="date"
value-format="YYYY-MM-DD"
placeholder="开始日期"
style="width:150px"
/>
<el-date-picker
v-model="searchForm.date_to"
type="date"
value-format="YYYY-MM-DD"
placeholder="结束日期"
style="width:150px"
/>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 列表卡片 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="flex items-center gap-3 px-5 py-4 border-b border-gray-50">
<span class="w-1 h-5 bg-rose-500 rounded-full inline-block"></span>
<span class="font-medium text-gray-700">账户流水明细</span>
<el-tag type="info" size="small" class="ml-auto">只读</el-tag>
</div>
<div class="px-2">
<el-table v-loading="loading" :data="tableData" style="width:100%" stripe>
<el-table-column prop="id" label="流水ID" width="80" align="center" />
<el-table-column prop="customer_name" label="客户" min-width="110" />
<el-table-column label="类型" width="90" align="center">
<template #default="{ row }">
<el-tag :type="typeTagType[row.type]" size="small">
{{ typeMap[row.type] || row.type }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="金额" width="120" align="right">
<template #default="{ row }">
<span :class="amountClass(row)">
{{ amountPrefix(row) }}{{ Number(row.amount || 0).toFixed(2) }}
</span>
</template>
</el-table-column>
<el-table-column label="余额(后)" width="120" align="right">
<template #default="{ row }">
<span class="text-gray-700">{{ Number(row.balance_after || 0).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column prop="operator_name" label="操作人" width="100" align="center" />
<el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
<el-table-column prop="created_at" label="时间" min-width="160" />
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.per_page"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total,sizes,prev,pager,next,jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
</div>
</template>
+29 -23
View File
@@ -72,8 +72,8 @@ onMounted(() => { fetchList() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.user_id" placeholder="员工ID" clearable style="width:140px" /> <el-input v-model="searchForm.user_id" placeholder="员工ID" clearable style="width:140px" />
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px">
<el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="Number(k)" /> <el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="Number(k)" />
@@ -82,29 +82,35 @@ onMounted(() => { fetchList() })
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-button type="success" @click="handleClockIn">上班打卡</el-button> <div class="flex items-center gap-3">
<el-button type="warning" @click="handleClockOut">班打卡</el-button> <el-button type="success" @click="handleClockIn">班打卡</el-button>
<el-button type="warning" @click="handleClockOut">下班打卡</el-button>
</div>
<el-button type="primary" @click="handleAdd">手动录入</el-button> <el-button type="primary" @click="handleAdd">手动录入</el-button>
</div> </div>
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="px-2">
<el-table-column label="员工" min-width="100"> <el-table v-loading="loading" :data="tableData" style="width:100%">
<template #default="{ row }">{{ row.user?.name || '-' }}</template> <el-table-column label="员工" min-width="100">
</el-table-column> <template #default="{ row }">{{ row.user?.name || '-' }}</template>
<el-table-column prop="attendance_date" label="考勤日期" width="120" /> </el-table-column>
<el-table-column prop="clock_in" label="上班打卡" width="100" /> <el-table-column prop="attendance_date" label="考勤日期" width="120" />
<el-table-column prop="clock_out" label="班打卡" width="100" /> <el-table-column prop="clock_in" label="班打卡" width="100" />
<el-table-column prop="overtime_hours" label="加班(h)" width="90" align="center" /> <el-table-column prop="clock_out" label="下班打卡" width="100" />
<el-table-column label="状态" width="90" align="center"> <el-table-column prop="overtime_hours" label="加班(h)" width="90" align="center" />
<template #default="{ row }"> <el-table-column label="状态" width="90" align="center">
<el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag> <template #default="{ row }">
</template> <el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag>
</el-table-column> </template>
<el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip /> </el-table-column>
<el-table-column prop="created_at" label="创建时间" min-width="160" /> <el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip />
</el-table> <el-table-column prop="created_at" label="创建时间" min-width="160" />
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> </el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" title="手动录入考勤" width="500px" destroy-on-close> <el-dialog v-model="dialogVisible" title="手动录入考勤" width="500px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="{ user_id: [{ required: true, message: '请输入员工ID' }], attendance_date: [{ required: true, message: '请选择考勤日期' }] }" label-width="90px"> <el-form ref="formRef" :model="form" :rules="{ user_id: [{ required: true, message: '请输入员工ID' }], attendance_date: [{ required: true, message: '请选择考勤日期' }] }" label-width="90px">
+40 -33
View File
@@ -82,8 +82,8 @@ onMounted(() => { fetchList() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.type" placeholder="请假类型" clearable style="width:130px"> <el-select v-model="searchForm.type" placeholder="请假类型" clearable style="width:130px">
<el-option v-for="(v, k) in typeMap" :key="k" :label="v" :value="Number(k)" /> <el-option v-for="(v, k) in typeMap" :key="k" :label="v" :value="Number(k)" />
</el-select> </el-select>
@@ -93,40 +93,47 @@ onMounted(() => { fetchList() })
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-3">
<span class="font-medium text-gray-700">请假申请列表</span>
</div>
<el-button type="primary" @click="handleAdd">新增请假</el-button> <el-button type="primary" @click="handleAdd">新增请假</el-button>
</div> </div>
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="px-2">
<el-table-column label="员工" min-width="100"> <el-table v-loading="loading" :data="tableData" style="width:100%">
<template #default="{ row }">{{ row.user?.name || '-' }}</template> <el-table-column label="员工" min-width="100">
</el-table-column> <template #default="{ row }">{{ row.user?.name || '-' }}</template>
<el-table-column label="类型" width="80" align="center"> </el-table-column>
<template #default="{ row }"> <el-table-column label="类型" width="80" align="center">
<el-tag size="small">{{ typeMap[row.type] }}</el-tag> <template #default="{ row }">
</template> <el-tag size="small">{{ typeMap[row.type] }}</el-tag>
</el-table-column>
<el-table-column prop="start_date" label="开始日期" width="120" />
<el-table-column prop="end_date" label="结束日期" width="120" />
<el-table-column prop="days" label="天数" width="70" align="center" />
<el-table-column prop="reason" label="事由" min-width="180" show-overflow-tooltip />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<template v-if="row.status === 0">
<el-button size="small" type="success" link @click="handleAudit(row, 1)">批准</el-button>
<el-button size="small" type="danger" link @click="handleAudit(row, 2)">拒绝</el-button>
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
</template> </template>
</template> </el-table-column>
</el-table-column> <el-table-column prop="start_date" label="开始日期" width="120" />
</el-table> <el-table-column prop="end_date" label="结束日期" width="120" />
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <el-table-column prop="days" label="天数" width="70" align="center" />
<el-table-column prop="reason" label="事由" min-width="180" show-overflow-tooltip />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<template v-if="row.status === 0">
<el-button size="small" type="success" text @click="handleAudit(row, 1)">批准</el-button>
<el-button size="small" type="danger" text @click="handleAudit(row, 2)">拒绝</el-button>
<el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑请假' : '新增请假'" width="500px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑请假' : '新增请假'" width="500px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
+38 -29
View File
@@ -89,8 +89,8 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.employee_no" placeholder="工号" style="width:160px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.employee_no" placeholder="工号" style="width:160px" clearable @keydown.enter="handleSearch" />
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px">
<el-option v-for="(v,k) in statusMap" :key="k" :label="v" :value="Number(k)" /> <el-option v-for="(v,k) in statusMap" :key="k" :label="v" :value="Number(k)" />
@@ -99,33 +99,42 @@ onMounted(() => fetchList())
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增员工档案</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column prop="employee_no" label="工号" width="120" /> <span class="font-medium text-gray-700">员工档案列表</span>
<el-table-column label="姓名" min-width="100"> </div>
<template #default="{ row }">{{ row.user?.name || '-' }}</template> <el-button type="primary" @click="handleAdd">新增员工档案</el-button>
</el-table-column> </div>
<el-table-column label="性别" width="70" align="center"> <div class="px-2">
<template #default="{ row }">{{ genderMap[row.gender] || '未知' }}</template> <el-table v-loading="loading" :data="tableData" style="width:100%">
</el-table-column> <el-table-column prop="employee_no" label="工号" width="120" />
<el-table-column prop="hire_date" label="入职日期" min-width="110" /> <el-table-column label="姓名" min-width="100">
<el-table-column prop="education" label="学历" width="90" /> <template #default="{ row }">{{ row.user?.name || '-' }}</template>
<el-table-column prop="base_salary" label="基本工资" width="100" align="right" /> </el-table-column>
<el-table-column prop="position_salary" label="岗位工资" width="100" align="right" /> <el-table-column label="性别" width="70" align="center">
<el-table-column label="状态" width="80" align="center"> <template #default="{ row }">{{ genderMap[row.gender] || '未知' }}</template>
<template #default="{ row }"> </el-table-column>
<el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag> <el-table-column prop="hire_date" label="入职日期" min-width="110" />
</template> <el-table-column prop="education" label="学历" width="90" />
</el-table-column> <el-table-column prop="base_salary" label="基本工资" width="100" align="right" />
<el-table-column label="操作" width="140" fixed="right" align="center"> <el-table-column prop="position_salary" label="岗位工资" width="100" align="right" />
<template #default="{ row }"> <el-table-column label="状态" width="80" align="center">
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <template #default="{ row }">
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
</el-table> <el-table-column label="操作" width="140" fixed="right" align="center">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <template #default="{ row }">
<el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="700px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="dialogTitle" width="700px" destroy-on-close>
+42 -33
View File
@@ -147,8 +147,8 @@ onMounted(() => { fetchList(); fetchEmployees() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.year_month" placeholder="工资月份 (如 2024-03)" style="width:200px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.year_month" placeholder="工资月份 (如 2024-03)" style="width:200px" clearable @keydown.enter="handleSearch" />
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px">
<el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="Number(k)" /> <el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="Number(k)" />
@@ -157,37 +157,46 @@ onMounted(() => { fetchList(); fetchEmployees() })
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增工资记录</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column label="员工" min-width="100"> <span class="font-medium text-gray-700">工资记录列表</span>
<template #default="{ row }">{{ row.user?.name || '-' }}</template> </div>
</el-table-column> <el-button type="primary" @click="handleAdd">新增工资记录</el-button>
<el-table-column prop="year_month" label="工资月份" width="110" align="center" /> </div>
<el-table-column prop="base_salary" label="基本工资" width="110" align="right" /> <div class="px-2">
<el-table-column prop="position_salary" label="岗位工资" width="110" align="right" /> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column prop="performance" label="绩效" width="100" align="right" /> <el-table-column label="员工" min-width="100">
<el-table-column prop="overtime_pay" label="加班费" width="100" align="right" /> <template #default="{ row }">{{ row.user?.name || '-' }}</template>
<el-table-column prop="subsidy" label="补贴" width="90" align="right" /> </el-table-column>
<el-table-column label="扣除合计" width="110" align="right"> <el-table-column prop="year_month" label="工资月份" width="110" align="center" />
<template #default="{ row }">{{ getDeductionTotal(row).toFixed(2) }}</template> <el-table-column prop="base_salary" label="基本工资" width="110" align="right" />
</el-table-column> <el-table-column prop="position_salary" label="岗位工资" width="110" align="right" />
<el-table-column label="实发金额" width="120" align="right"> <el-table-column prop="performance" label="绩效" width="100" align="right" />
<template #default="{ row }"><span style="font-weight:700">{{ row.actual_amount }}</span></template> <el-table-column prop="overtime_pay" label="加班费" width="100" align="right" />
</el-table-column> <el-table-column prop="subsidy" label="补贴" width="90" align="right" />
<el-table-column label="状态" width="100" align="center"> <el-table-column label="扣除合计" width="110" align="right">
<template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template> <template #default="{ row }">{{ getDeductionTotal(row).toFixed(2) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center"> <el-table-column label="实发金额" width="120" align="right">
<template #default="{ row }"> <template #default="{ row }"><span style="font-weight:700">{{ row.actual_amount }}</span></template>
<el-button v-if="row.status===0" size="small" type="success" link @click="handleConfirm(row)">确认</el-button> </el-table-column>
<el-button v-if="row.status===1" size="small" type="success" link @click="handlePay(row)">发放</el-button> <el-table-column label="状态" width="100" align="center">
<el-button v-if="row.status===0" size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template>
<el-button v-if="row.status===0" size="small" type="danger" link @click="handleDelete(row)">删除</el-button> </el-table-column>
</template> <el-table-column label="操作" width="220" fixed="right" align="center">
</el-table-column> <template #default="{ row }">
</el-table> <el-button v-if="row.status===0" size="small" type="success" text @click="handleConfirm(row)">确认</el-button>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <el-button v-if="row.status===1" size="small" type="success" text @click="handlePay(row)">发放</el-button>
<el-button v-if="row.status===0" size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button v-if="row.status===0" size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑工资记录' : '新增工资记录'" width="750px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑工资记录' : '新增工资记录'" width="750px" destroy-on-close>
+27 -18
View File
@@ -49,8 +49,8 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.user_id" placeholder="员工ID" style="width:160px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.user_id" placeholder="员工ID" style="width:160px" clearable @keydown.enter="handleSearch" />
<el-date-picker v-model="searchForm.start_date" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" /> <el-date-picker v-model="searchForm.start_date" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" />
<el-date-picker v-model="searchForm.end_date" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" /> <el-date-picker v-model="searchForm.end_date" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" />
@@ -58,22 +58,31 @@ onMounted(() => fetchList())
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增排班</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column label="员工" min-width="100"> <span class="font-medium text-gray-700">排班列表</span>
<template #default="{ row }">{{ row.user?.name || '-' }}</template> </div>
</el-table-column> <el-button type="primary" @click="handleAdd">新增排班</el-button>
<el-table-column prop="schedule_date" label="排班日期" width="120" /> </div>
<el-table-column label="班次" width="90" align="center"> <div class="px-2">
<template #default="{ row }"> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-tag :type="shiftType[row.shift_type]" size="small">{{ shiftMap[row.shift_type] }}</el-tag> <el-table-column label="员工" min-width="100">
</template> <template #default="{ row }">{{ row.user?.name || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="start_time" label="开始时间" width="100" /> <el-table-column prop="schedule_date" label="排班日期" width="120" />
<el-table-column prop="end_time" label="结束时间" width="100" /> <el-table-column label="班次" width="90" align="center">
</el-table> <template #default="{ row }">
<el-pagination v-model:current-page="pagination.page" :total="total" layout="total,prev,pager,next" background @current-change="handlePageChange" /> <el-tag :type="shiftType[row.shift_type]" size="small">{{ shiftMap[row.shift_type] }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="start_time" label="开始时间" width="100" />
<el-table-column prop="end_time" label="结束时间" width="100" />
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" :total="total" layout="total,prev,pager,next" background @current-change="handlePageChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" title="新增排班" width="500px" destroy-on-close> <el-dialog v-model="dialogVisible" title="新增排班" width="500px" destroy-on-close>
@@ -0,0 +1,294 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { inventoryCheckApi, warehouseApi, stockApi } from '@/api/inventory.js'
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const dialogVisible = ref(false)
const submitLoading = ref(false)
const detailVisible = ref(false)
const currentCheck = ref(null)
const warehouses = ref([])
const searchForm = reactive({ warehouse_id: '', status: '' })
const pagination = reactive({ page: 1, per_page: 20 })
const formRef = ref(null)
const form = reactive({
warehouse_id: '',
check_date: '',
remark: '',
items: []
})
const formRules = {
warehouse_id: [{ required: true, message: '请选择仓库', trigger: 'change' }],
check_date: [{ required: true, message: '请选择盘点日期', trigger: 'change' }]
}
const statusMap = { draft: '盘点中', completed: '已完成' }
const statusType = { draft: 'warning', completed: 'success' }
async function fetchList() {
loading.value = true
try {
const res = await inventoryCheckApi.getList({ ...searchForm, ...pagination })
tableData.value = res.data?.list || res.data?.data || []
total.value = res.data?.total || 0
} catch {
ElMessage.error('获取盘点单列表失败')
} finally {
loading.value = false
}
}
async function fetchWarehouses() {
try {
const res = await warehouseApi.getList({ per_page: 500 })
warehouses.value = res.data?.list || res.data?.data || []
} catch {}
}
function handleSearch() { pagination.page = 1; fetchList() }
function handleReset() {
Object.assign(searchForm, { warehouse_id: '', status: '' })
handleSearch()
}
async function handleAdd() {
Object.assign(form, { warehouse_id: '', check_date: '', remark: '', items: [] })
dialogVisible.value = true
}
// 选择仓库后自动加载该仓库库存
async function onWarehouseChange(warehouseId) {
if (!warehouseId) { form.items = []; return }
try {
const res = await stockApi.getList({ warehouse_id: warehouseId, per_page: 500 })
const stocks = res.data?.list || res.data?.data || []
form.items = stocks.map(s => ({
material_id: s.id,
material_name: s.material_name,
material_code: s.material_code,
unit: s.unit,
system_quantity: Number(s.quantity || 0),
actual_quantity: Number(s.quantity || 0)
}))
} catch {
ElMessage.error('加载库存数据失败')
}
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
if (form.items.length === 0) { ElMessage.warning('当前仓库无库存数据'); return }
submitLoading.value = true
try {
await inventoryCheckApi.create({ ...form })
ElMessage.success('盘点单创建成功')
dialogVisible.value = false
fetchList()
} catch {
ElMessage.error('创建失败')
} finally {
submitLoading.value = false
}
})
}
async function handleComplete(row) {
await ElMessageBox.confirm(
`确认完成盘点单「${row.check_no}」吗?完成后不可修改。`,
'完成确认',
{ type: 'warning' }
)
try {
await inventoryCheckApi.complete(row.id, {})
ElMessage.success('盘点完成')
fetchList()
} catch { ElMessage.error('操作失败') }
}
function viewDetail(row) {
currentCheck.value = row
detailVisible.value = true
}
function diff(item) {
return Number(item.actual_quantity || 0) - Number(item.system_quantity || 0)
}
function handlePageChange(val) { pagination.page = val; fetchList() }
function handleSizeChange(val) { pagination.per_page = val; pagination.page = 1; fetchList() }
onMounted(() => { fetchList(); fetchWarehouses() })
</script>
<template>
<div class="p-6 space-y-4">
<!-- 搜索栏 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.warehouse_id" placeholder="选择仓库" clearable filterable style="width:160px">
<el-option v-for="w in warehouses" :key="w.id" :label="w.name" :value="w.id" />
</el-select>
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px">
<el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="k" />
</el-select>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 列表卡片 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-3">
<span class="w-1 h-5 bg-rose-500 rounded-full inline-block"></span>
<span class="font-medium text-gray-700">盘点单列表</span>
</div>
<el-button type="primary" @click="handleAdd">新建盘点单</el-button>
</div>
<div class="px-2">
<el-table v-loading="loading" :data="tableData" style="width:100%" stripe>
<el-table-column prop="check_no" label="盘点单号" min-width="160" />
<el-table-column prop="warehouse_name" label="仓库" min-width="110" />
<el-table-column prop="check_date" label="盘点日期" width="120" align="center" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusType[row.status]" size="small">
{{ statusMap[row.status] || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="operator_name" label="操作人" width="90" align="center" />
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" text @click="viewDetail(row)">查看</el-button>
<el-button
v-if="row.status === 'draft'"
size="small" type="success" text
@click="handleComplete(row)"
>完成盘点</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.per_page"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total,sizes,prev,pager,next,jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<!-- 新建盘点 Dialog -->
<el-dialog v-model="dialogVisible" title="新建盘点单" width="820px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
<el-row :gutter="16">
<el-col :span="10">
<el-form-item label="仓库" prop="warehouse_id">
<el-select
v-model="form.warehouse_id"
placeholder="选择仓库"
filterable
style="width:100%"
@change="onWarehouseChange"
>
<el-option v-for="w in warehouses" :key="w.id" :label="w.name" :value="w.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="盘点日期" prop="check_date">
<el-date-picker
v-model="form.check_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width:100%"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="备注">
<el-input v-model="form.remark" placeholder="备注" />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">物料明细系统自动填入库存请填写实际数量</el-divider>
<div v-if="form.items.length === 0" class="text-center text-gray-400 py-6">
请先选择仓库以加载库存数据
</div>
<el-table v-else :data="form.items" style="width:100%" max-height="320">
<el-table-column prop="material_code" label="物料编码" width="110" />
<el-table-column prop="material_name" label="物料名称" min-width="130" />
<el-table-column prop="unit" label="单位" width="70" align="center" />
<el-table-column prop="system_quantity" label="系统数量" width="100" align="right" />
<el-table-column label="实际数量" width="140" align="right">
<template #default="{ row }">
<el-input-number
v-model="row.actual_quantity"
:min="0"
:precision="2"
size="small"
style="width:120px"
/>
</template>
</el-table-column>
<el-table-column label="差异" width="90" align="right">
<template #default="{ row }">
<span :class="diff(row) === 0 ? 'text-gray-500' : diff(row) > 0 ? 'text-green-600' : 'text-red-500'">
{{ diff(row) > 0 ? '+' : '' }}{{ diff(row).toFixed(2) }}
</span>
</template>
</el-table-column>
</el-table>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">提交盘点</el-button>
</template>
</el-dialog>
<!-- 查看详情 Dialog -->
<el-dialog v-model="detailVisible" title="盘点单详情" width="760px" destroy-on-close>
<template v-if="currentCheck">
<el-descriptions :column="3" border size="small" class="mb-4">
<el-descriptions-item label="盘点单号">{{ currentCheck.check_no }}</el-descriptions-item>
<el-descriptions-item label="仓库">{{ currentCheck.warehouse_name }}</el-descriptions-item>
<el-descriptions-item label="盘点日期">{{ currentCheck.check_date }}</el-descriptions-item>
<el-descriptions-item label="操作人">{{ currentCheck.operator_name }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusType[currentCheck.status]" size="small">
{{ statusMap[currentCheck.status] }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="备注">{{ currentCheck.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<el-table :data="currentCheck.items || []" style="width:100%" max-height="360">
<el-table-column prop="material_name" label="物料名称" min-width="130" />
<el-table-column prop="system_quantity" label="系统数量" width="100" align="right" />
<el-table-column prop="actual_quantity" label="实际数量" width="100" align="right" />
<el-table-column label="差异" width="90" align="right">
<template #default="{ row }">
<span :class="diff(row) === 0 ? 'text-gray-500' : diff(row) > 0 ? 'text-green-600' : 'text-red-500'">
{{ diff(row) > 0 ? '+' : '' }}{{ Number(diff(row)).toFixed(2) }}
</span>
</template>
</el-table-column>
</el-table>
</template>
</el-dialog>
</div>
</template>
@@ -57,8 +57,8 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.name" placeholder="物料名称" style="width:180px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.name" placeholder="物料名称" style="width:180px" clearable @keydown.enter="handleSearch" />
<el-select v-model="searchForm.category" placeholder="分类" clearable style="width:120px"> <el-select v-model="searchForm.category" placeholder="分类" clearable style="width:120px">
<el-option v-for="(v,k) in categoryMap" :key="k" :label="v" :value="Number(k)" /> <el-option v-for="(v,k) in categoryMap" :key="k" :label="v" :value="Number(k)" />
@@ -67,24 +67,33 @@ onMounted(() => fetchList())
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增物料</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column prop="name" label="物料名称" min-width="120" /> <span class="font-medium text-gray-700">物料列表</span>
<el-table-column prop="code" label="物料编码" width="120" /> </div>
<el-table-column label="分类" width="100" align="center"><template #default="{ row }">{{ categoryMap[row.category] }}</template></el-table-column> <el-button type="primary" @click="handleAdd">新增物料</el-button>
<el-table-column prop="unit" label="单位" width="70" align="center" /> </div>
<el-table-column prop="spec" label="规格" min-width="120" show-overflow-tooltip /> <div class="px-2">
<el-table-column prop="safety_stock" label="安全库存" width="100" align="right" /> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="row.status?'success':'danger'" size="small">{{ row.status?'启用':'禁用' }}</el-tag></template></el-table-column> <el-table-column prop="name" label="物料名称" min-width="120" />
<el-table-column label="操作" width="160" fixed="right" align="center"> <el-table-column prop="code" label="物料编码" width="120" />
<template #default="{ row }"> <el-table-column label="分类" width="100" align="center"><template #default="{ row }">{{ categoryMap[row.category] }}</template></el-table-column>
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-table-column prop="unit" label="单位" width="70" align="center" />
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-table-column prop="spec" label="规格" min-width="120" show-overflow-tooltip />
</template> <el-table-column prop="safety_stock" label="安全库存" width="100" align="right" />
</el-table-column> <el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="row.status?'success':'danger'" size="small">{{ row.status?'启用':'禁用' }}</el-tag></template></el-table-column>
</el-table> <el-table-column label="操作" width="160" fixed="right" align="center">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,15,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <template #default="{ row }">
<el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,15,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑物料':'新增物料'" width="550px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑物料':'新增物料'" width="550px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="80px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="80px">
@@ -80,8 +80,8 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.order_no" placeholder="采购单号" style="width:180px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.order_no" placeholder="采购单号" style="width:180px" clearable @keydown.enter="handleSearch" />
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px">
<el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="Number(k)" /> <el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="Number(k)" />
@@ -90,33 +90,41 @@ onMounted(() => fetchList())
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增采购单</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column prop="order_no" label="采购单号" min-width="160" /> <span class="font-medium text-gray-700">采购单列表</span>
<el-table-column label="供应商" min-width="140"> </div>
<template #default="{ row }">{{ row.supplier?.name || '-' }}</template> <el-button type="primary" @click="handleAdd">新增采购单</el-button>
</el-table-column> </div>
<el-table-column prop="total_amount" label="总金额" min-width="110" align="right" /> <div class="px-2">
<el-table-column label="状态" width="100" align="center"> <el-table v-loading="loading" :data="tableData" style="width:100%">
<template #default="{ row }"> <el-table-column prop="order_no" label="采购单号" min-width="160" />
<el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag> <el-table-column label="供应商" min-width="140">
</template> <template #default="{ row }">{{ row.supplier?.name || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="created_at" label="创建时间" min-width="160" /> <el-table-column prop="total_amount" label="总金额" min-width="110" align="right" />
<el-table-column label="操作" width="260" fixed="right" align="center"> <el-table-column label="状态" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-button v-if="row.status===0" size="small" type="success" link @click="handleAudit(row, 2)">审批通过</el-button> <el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag>
<el-button v-if="row.status===0" size="small" type="warning" link @click="handleAudit(row, 4)">驳回</el-button> </template>
<el-button v-if="row.status<2" size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> </el-table-column>
<el-button v-if="row.status<2" size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-table-column prop="created_at" label="创建时间" min-width="160" />
</template> <el-table-column label="操作" width="260" fixed="right" align="center">
</el-table-column> <template #default="{ row }">
</el-table> <el-button v-if="row.status===0" size="small" type="success" text @click="handleAudit(row, 2)">审批通过</el-button>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <el-button v-if="row.status===0" size="small" type="warning" text @click="handleAudit(row, 4)">驳回</el-button>
<el-button v-if="row.status<2" size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button v-if="row.status<2" size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<!-- Add/Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑采购单' : '新增采购单'" width="550px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑采购单' : '新增采购单'" width="550px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
<el-form-item label="采购单号" prop="order_no"> <el-form-item label="采购单号" prop="order_no">
@@ -78,8 +78,8 @@ onMounted(() => { fetchList(); fetchWarehouses() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.warehouse_id" placeholder="选择仓库" clearable filterable style="width:180px"> <el-select v-model="searchForm.warehouse_id" placeholder="选择仓库" clearable filterable style="width:180px">
<el-option v-for="w in warehouses" :key="w.id" :label="w.name" :value="w.id" /> <el-option v-for="w in warehouses" :key="w.id" :label="w.name" :value="w.id" />
</el-select> </el-select>
@@ -93,31 +93,40 @@ onMounted(() => { fetchList(); fetchWarehouses() })
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增出入库</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column prop="movement_no" label="单据编号" min-width="160" /> <span class="font-medium text-gray-700">出入库记录</span>
<el-table-column label="仓库" min-width="120"> </div>
<template #default="{ row }">{{ row.warehouse?.name || '-' }}</template> <el-button type="primary" @click="handleAdd">新增出入库</el-button>
</el-table-column> </div>
<el-table-column label="类型" width="110" align="center"> <div class="px-2">
<template #default="{ row }"><el-tag size="small">{{ typeMap[row.type] || '-' }}</el-tag></template> <el-table v-loading="loading" :data="tableData" style="width:100%">
</el-table-column> <el-table-column prop="movement_no" label="单据编号" min-width="160" />
<el-table-column label="方向" width="90" align="center"> <el-table-column label="仓库" min-width="120">
<template #default="{ row }"><el-tag :type="directionType[row.direction]" size="small">{{ directionMap[row.direction] || '-' }}</el-tag></template> <template #default="{ row }">{{ row.warehouse?.name || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="100" align="center"> <el-table-column label="类型" width="110" align="center">
<template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template> <template #default="{ row }"><el-tag size="small">{{ typeMap[row.type] || '-' }}</el-tag></template>
</el-table-column> </el-table-column>
<el-table-column prop="created_at" label="创建时间" min-width="160" /> <el-table-column label="方向" width="90" align="center">
<el-table-column label="操作" width="100" fixed="right" align="center"> <template #default="{ row }"><el-tag :type="directionType[row.direction]" size="small">{{ directionMap[row.direction] || '-' }}</el-tag></template>
<template #default="{ row }"> </el-table-column>
<el-button v-if="row.status === 0" size="small" type="success" link @click="handleConfirm(row)">确认</el-button> <el-table-column label="状态" width="100" align="center">
<span v-else class="text-muted">-</span> <template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template>
</template> </el-table-column>
</el-table-column> <el-table-column prop="created_at" label="创建时间" min-width="160" />
</el-table> <el-table-column label="操作" width="100" fixed="right" align="center">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10, 20, 50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <template #default="{ row }">
<el-button v-if="row.status === 0" size="small" type="success" text @click="handleConfirm(row)">确认</el-button>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10, 20, 50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" title="新增出入库" width="600px" destroy-on-close> <el-dialog v-model="dialogVisible" title="新增出入库" width="600px" destroy-on-close>
@@ -0,0 +1,137 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { stockApi, warehouseApi, materialApi } from '@/api/inventory.js'
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const warehouses = ref([])
const materials = ref([])
const searchForm = reactive({ warehouse_id: '', material_id: '', low_stock: '' })
const pagination = reactive({ page: 1, per_page: 20 })
async function fetchList() {
loading.value = true
try {
const res = await stockApi.getList({ ...searchForm, ...pagination })
tableData.value = res.data?.list || res.data?.data || []
total.value = res.data?.total || 0
} catch {
ElMessage.error('获取库存列表失败')
} finally {
loading.value = false
}
}
async function fetchOptions() {
try {
const [wRes, mRes] = await Promise.all([
warehouseApi.getList({ per_page: 500 }),
materialApi.getList({ per_page: 500 })
])
warehouses.value = wRes.data?.list || wRes.data?.data || []
materials.value = mRes.data?.list || mRes.data?.data || []
} catch {}
}
function handleSearch() { pagination.page = 1; fetchList() }
function handleReset() {
Object.assign(searchForm, { warehouse_id: '', material_id: '', low_stock: '' })
handleSearch()
}
function handlePageChange(val) { pagination.page = val; fetchList() }
function handleSizeChange(val) { pagination.per_page = val; pagination.page = 1; fetchList() }
function isLowStock(row) {
return row.min_quantity != null && Number(row.quantity) < Number(row.min_quantity)
}
function handleExport() {
ElMessage.info('导出功能待实现')
}
onMounted(() => { fetchList(); fetchOptions() })
</script>
<template>
<div class="p-6 space-y-4">
<!-- 搜索栏 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.warehouse_id" placeholder="选择仓库" clearable filterable style="width:160px">
<el-option v-for="w in warehouses" :key="w.id" :label="w.name" :value="w.id" />
</el-select>
<el-select v-model="searchForm.material_id" placeholder="选择物料" clearable filterable style="width:180px">
<el-option v-for="m in materials" :key="m.id" :label="`${m.name}(${m.code || '-'})`" :value="m.id" />
</el-select>
<el-select v-model="searchForm.low_stock" placeholder="库存状态" clearable style="width:130px">
<el-option label="全部" value="" />
<el-option label="低库存预警" value="1" />
</el-select>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button class="ml-auto" @click="handleExport">导出</el-button>
</div>
<!-- 低库存说明 -->
<el-alert
title="红色行表示当前库存低于最低库存量,请及时补货"
type="warning"
show-icon
:closable="false"
/>
<!-- 列表卡片 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="flex items-center gap-3 px-5 py-4 border-b border-gray-50">
<span class="w-1 h-5 bg-rose-500 rounded-full inline-block"></span>
<span class="font-medium text-gray-700">库存查询</span>
<span class="text-sm text-gray-400 ml-1"> {{ total }} </span>
</div>
<div class="px-2">
<el-table
v-loading="loading"
:data="tableData"
style="width:100%"
:row-class-name="({ row }) => isLowStock(row) ? 'low-stock-row' : ''"
>
<el-table-column prop="warehouse_name" label="仓库" min-width="110" />
<el-table-column prop="material_code" label="物料编码" width="120" />
<el-table-column prop="material_name" label="物料名称" min-width="140" />
<el-table-column prop="category" label="分类" width="100" align="center" />
<el-table-column prop="unit" label="单位" width="70" align="center" />
<el-table-column label="当前库存" width="110" align="right">
<template #default="{ row }">
<span :class="isLowStock(row) ? 'text-red-500 font-bold' : 'text-gray-700'">
{{ row.quantity }}
</span>
<el-tag v-if="isLowStock(row)" type="danger" size="small" class="ml-1">偏低</el-tag>
</template>
</el-table-column>
<el-table-column prop="min_quantity" label="最低库存" width="100" align="right" />
<el-table-column prop="max_quantity" label="最高库存" width="100" align="right" />
<el-table-column prop="updated_at" label="更新时间" min-width="160" />
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.per_page"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total,sizes,prev,pager,next,jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
</div>
</template>
<style>
.el-table .low-stock-row {
background-color: #fff5f5 !important;
}
</style>
@@ -60,8 +60,8 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.name" placeholder="供应商名称" style="width:180px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.name" placeholder="供应商名称" style="width:180px" clearable @keydown.enter="handleSearch" />
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px">
<el-option label="启用" :value="1" /> <el-option label="启用" :value="1" />
@@ -70,22 +70,31 @@ onMounted(() => fetchList())
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增供应商</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column prop="name" label="供应商名称" min-width="150" /> <span class="font-medium text-gray-700">供应商列表</span>
<el-table-column prop="contact_person" label="联系人" width="100" /> </div>
<el-table-column prop="contact_phone" label="联系电话" width="130" /> <el-button type="primary" @click="handleAdd">新增供应商</el-button>
<el-table-column prop="address" label="地址" min-width="200" show-overflow-tooltip /> </div>
<el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="row.status?'success':'danger'" size="small">{{ row.status?'启用':'停用' }}</el-tag></template></el-table-column> <div class="px-2">
<el-table-column label="操作" width="160" fixed="right" align="center"> <el-table v-loading="loading" :data="tableData" style="width:100%">
<template #default="{ row }"> <el-table-column prop="name" label="供应商名称" min-width="150" />
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-table-column prop="contact_person" label="联系人" width="100" />
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-table-column prop="contact_phone" label="联系电话" width="130" />
</template> <el-table-column prop="address" label="地址" min-width="200" show-overflow-tooltip />
</el-table-column> <el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="row.status?'success':'danger'" size="small">{{ row.status?'启用':'停用' }}</el-tag></template></el-table-column>
</el-table> <el-table-column label="操作" width="160" fixed="right" align="center">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,15,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <template #default="{ row }">
<el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,15,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑供应商':'新增供应商'" width="600px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑供应商':'新增供应商'" width="600px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
@@ -0,0 +1,270 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { transferApi, warehouseApi, materialApi } from '@/api/inventory.js'
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const dialogVisible = ref(false)
const submitLoading = ref(false)
const warehouses = ref([])
const materials = ref([])
const searchForm = reactive({ from_warehouse_id: '', to_warehouse_id: '', status: '' })
const pagination = reactive({ page: 1, per_page: 20 })
const defaultItem = () => ({ material_id: '', material_name: '', quantity: 1 })
const formRef = ref(null)
const form = reactive({
from_warehouse_id: '',
to_warehouse_id: '',
transfer_date: '',
remark: '',
items: [defaultItem()]
})
const formRules = {
from_warehouse_id: [{ required: true, message: '请选择源仓库', trigger: 'change' }],
to_warehouse_id: [{ required: true, message: '请选择目标仓库', trigger: 'change' }],
transfer_date: [{ required: true, message: '请选择调拨日期', trigger: 'change' }]
}
const statusMap = { draft: '草稿', confirmed: '已确认', completed: '已完成', cancelled: '已取消' }
const statusType = { draft: 'info', confirmed: 'primary', completed: 'success', cancelled: 'danger' }
async function fetchList() {
loading.value = true
try {
const res = await transferApi.getList({ ...searchForm, ...pagination })
tableData.value = res.data?.list || res.data?.data || []
total.value = res.data?.total || 0
} catch {
ElMessage.error('获取调拨单列表失败')
} finally {
loading.value = false
}
}
async function fetchOptions() {
try {
const [wRes, mRes] = await Promise.all([
warehouseApi.getList({ per_page: 500 }),
materialApi.getList({ per_page: 500 })
])
warehouses.value = wRes.data?.list || wRes.data?.data || []
materials.value = mRes.data?.list || mRes.data?.data || []
} catch {}
}
function handleSearch() { pagination.page = 1; fetchList() }
function handleReset() {
Object.assign(searchForm, { from_warehouse_id: '', to_warehouse_id: '', status: '' })
handleSearch()
}
function handleAdd() {
Object.assign(form, {
from_warehouse_id: '', to_warehouse_id: '',
transfer_date: '', remark: '',
items: [defaultItem()]
})
dialogVisible.value = true
}
function addItem() { form.items.push(defaultItem()) }
function removeItem(idx) {
if (form.items.length <= 1) { ElMessage.warning('至少保留一条物料'); return }
form.items.splice(idx, 1)
}
function onMaterialChange(item) {
const mat = materials.value.find(m => m.id === item.material_id)
if (mat) item.material_name = mat.name
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
const hasEmpty = form.items.some(i => !i.material_id || i.quantity <= 0)
if (hasEmpty) { ElMessage.warning('请完整填写物料信息'); return }
submitLoading.value = true
try {
await transferApi.create({ ...form })
ElMessage.success('创建成功')
dialogVisible.value = false
fetchList()
} catch {
ElMessage.error('创建失败')
} finally {
submitLoading.value = false
}
})
}
async function handleConfirm(row) {
await ElMessageBox.confirm(`确认调拨单「${row.transfer_no}」吗?`, '确认操作', { type: 'warning' })
try {
await transferApi.confirm(row.id)
ElMessage.success('确认成功')
fetchList()
} catch { ElMessage.error('操作失败') }
}
async function handleCancel(row) {
await ElMessageBox.confirm(`取消调拨单「${row.transfer_no}」吗?取消后不可恢复。`, '取消确认', { type: 'warning' })
try {
await transferApi.cancel(row.id)
ElMessage.success('已取消')
fetchList()
} catch { ElMessage.error('操作失败') }
}
function handlePageChange(val) { pagination.page = val; fetchList() }
function handleSizeChange(val) { pagination.per_page = val; pagination.page = 1; fetchList() }
onMounted(() => { fetchList(); fetchOptions() })
</script>
<template>
<div class="p-6 space-y-4">
<!-- 搜索栏 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.from_warehouse_id" placeholder="源仓库" clearable filterable style="width:150px">
<el-option v-for="w in warehouses" :key="w.id" :label="w.name" :value="w.id" />
</el-select>
<el-select v-model="searchForm.to_warehouse_id" placeholder="目标仓库" clearable filterable style="width:150px">
<el-option v-for="w in warehouses" :key="w.id" :label="w.name" :value="w.id" />
</el-select>
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px">
<el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="k" />
</el-select>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 列表卡片 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-3">
<span class="w-1 h-5 bg-rose-500 rounded-full inline-block"></span>
<span class="font-medium text-gray-700">调拨单列表</span>
</div>
<el-button type="primary" @click="handleAdd">新建调拨单</el-button>
</div>
<div class="px-2">
<el-table v-loading="loading" :data="tableData" style="width:100%" stripe>
<el-table-column prop="transfer_no" label="调拨单号" min-width="160" />
<el-table-column prop="from_warehouse_name" label="源仓库" min-width="110" />
<el-table-column prop="to_warehouse_name" label="目标仓库" min-width="110" />
<el-table-column prop="transfer_date" label="调拨日期" width="120" align="center" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusType[row.status]" size="small">
{{ statusMap[row.status] || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_by_name" label="创建人" width="90" align="center" />
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="row.status === 'draft'"
size="small" type="success" text
@click="handleConfirm(row)"
>确认</el-button>
<el-button
v-if="row.status === 'draft'"
size="small" type="danger" text
@click="handleCancel(row)"
>取消</el-button>
<span v-if="!['draft'].includes(row.status)" class="text-gray-400 text-sm">-</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.per_page"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total,sizes,prev,pager,next,jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<!-- 新建调拨单 Dialog -->
<el-dialog v-model="dialogVisible" title="新建调拨单" width="680px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="源仓库" prop="from_warehouse_id">
<el-select v-model="form.from_warehouse_id" placeholder="选择源仓库" filterable style="width:100%">
<el-option v-for="w in warehouses" :key="w.id" :label="w.name" :value="w.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标仓库" prop="to_warehouse_id">
<el-select v-model="form.to_warehouse_id" placeholder="选择目标仓库" filterable style="width:100%">
<el-option v-for="w in warehouses" :key="w.id" :label="w.name" :value="w.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="调拨日期" prop="transfer_date">
<el-date-picker
v-model="form.transfer_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width:100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="备注">
<el-input v-model="form.remark" placeholder="备注(可选)" />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">调拨物料明细</el-divider>
<div v-for="(item, idx) in form.items" :key="idx" class="flex items-center gap-2 mb-3">
<el-select
v-model="item.material_id"
placeholder="选择物料"
filterable
style="flex:2"
@change="onMaterialChange(item)"
>
<el-option
v-for="m in materials"
:key="m.id"
:label="`${m.name}${m.code || '-'}`"
:value="m.id"
/>
</el-select>
<el-input-number
v-model="item.quantity"
:min="1"
placeholder="数量"
style="width:130px"
/>
<el-button type="danger" text @click="removeItem(idx)">删除</el-button>
</div>
<el-button type="primary" text @click="addItem">+ 添加物料</el-button>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -151,9 +151,8 @@ onMounted(fetchList)
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<!-- Search --> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<div class="search-bar">
<el-input <el-input
v-model="searchForm.name" v-model="searchForm.name"
placeholder="搜索仓库名称" placeholder="搜索仓库名称"
@@ -167,57 +166,60 @@ onMounted(fetchList)
<el-option label="启用" :value="1" /> <el-option label="启用" :value="1" />
<el-option label="停用" :value="0" /> <el-option label="停用" :value="0" />
</el-select> </el-select>
<el-button type="primary" :icon="'Search'" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button :icon="'Refresh'" @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<!-- Table --> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-container"> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="table-actions"> <div class="flex items-center gap-3">
<el-button type="primary" :icon="'Plus'" @click="handleAdd">新增仓库</el-button> <span class="font-medium text-gray-700">仓库列表</span>
</div>
<el-button type="primary" @click="handleAdd">新增仓库</el-button>
</div>
<div class="px-2">
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="name" label="仓库名称" min-width="160" />
<el-table-column prop="address" label="地址" min-width="200" show-overflow-tooltip />
<el-table-column prop="contact_person" label="联系人" width="120" />
<el-table-column prop="contact_phone" label="联系电话" width="140" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button
size="small"
:type="row.status === 1 ? 'warning' : 'success'"
text
@click="handleStatusToggle(row)"
>
{{ row.status === 1 ? '停用' : '启用' }}
</el-button>
<el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div> </div>
<el-table v-loading="loading" :data="tableData" border stripe style="width: 100%">
<el-table-column prop="name" label="仓库名称" min-width="160" />
<el-table-column prop="address" label="地址" min-width="200" show-overflow-tooltip />
<el-table-column prop="contact_person" label="联系人" width="120" />
<el-table-column prop="contact_phone" label="联系电话" width="140" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button
size="small"
:type="row.status === 1 ? 'warning' : 'success'"
link
@click="handleStatusToggle(row)"
>
{{ row.status === 1 ? '停用' : '启用' }}
</el-button>
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div> </div>
<!-- Add/Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
<el-row :gutter="16"> <el-row :gutter="16">
+44 -35
View File
@@ -96,8 +96,8 @@ onMounted(() => { fetchCategories(); fetchList() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.keyword" placeholder="标题搜索" style="width:180px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.keyword" placeholder="标题搜索" style="width:180px" clearable @keydown.enter="handleSearch" />
<el-select v-model="searchForm.category_id" placeholder="分类" clearable style="width:150px"> <el-select v-model="searchForm.category_id" placeholder="分类" clearable style="width:150px">
<el-option v-for="c in categoryList" :key="c.id" :label="c.name" :value="c.id" /> <el-option v-for="c in categoryList" :key="c.id" :label="c.name" :value="c.id" />
@@ -111,39 +111,48 @@ onMounted(() => { fetchCategories(); fetchList() })
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增文章</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column prop="title" label="标题" min-width="200" /> <span class="font-medium text-gray-700">文章列表</span>
<el-table-column label="分类" width="120" align="center"> </div>
<template #default="{ row }">{{ row.category?.name || '-' }}</template> <el-button type="primary" @click="handleAdd">新增文章</el-button>
</el-table-column> </div>
<el-table-column label="作者" width="100" align="center"> <div class="px-2">
<template #default="{ row }">{{ row.author?.name || '-' }}</template> <el-table v-loading="loading" :data="tableData" style="width:100%">
</el-table-column> <el-table-column prop="title" label="标题" min-width="200" />
<el-table-column label="可见范围" width="100" align="center"> <el-table-column label="分类" width="120" align="center">
<template #default="{ row }"> <template #default="{ row }">{{ row.category?.name || '-' }}</template>
<el-tag :type="row.visibility === 1 ? 'warning' : 'success'" size="small">{{ visibilityMap[row.visibility] || '-' }}</el-tag> </el-table-column>
</template> <el-table-column label="作者" width="100" align="center">
</el-table-column> <template #default="{ row }">{{ row.author?.name || '-' }}</template>
<el-table-column label="状态" width="90" align="center"> </el-table-column>
<template #default="{ row }"> <el-table-column label="可见范围" width="100" align="center">
<el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] || '-' }}</el-tag> <template #default="{ row }">
</template> <el-tag :type="row.visibility === 1 ? 'warning' : 'success'" size="small">{{ visibilityMap[row.visibility] || '-' }}</el-tag>
</el-table-column> </template>
<el-table-column prop="views" label="浏览" width="70" align="center" /> </el-table-column>
<el-table-column prop="likes" label="点赞" width="70" align="center" /> <el-table-column label="状态" width="90" align="center">
<el-table-column prop="published_at" label="发布时间" min-width="160" /> <template #default="{ row }">
<el-table-column label="操作" width="220" fixed="right" align="center"> <el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] || '-' }}</el-tag>
<template #default="{ row }"> </template>
<el-button v-if="row.status <= 1" size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> </el-table-column>
<el-button v-if="row.status <= 1" size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-table-column prop="views" label="浏览" width="70" align="center" />
<el-button v-if="row.status < 2" size="small" type="success" link @click="handlePublish(row)">发布</el-button> <el-table-column prop="likes" label="点赞" width="70" align="center" />
<el-button v-if="row.status === 2" size="small" type="warning" link @click="handleOffline(row)">下架</el-button> <el-table-column prop="published_at" label="发布时间" min-width="160" />
</template> <el-table-column label="操作" width="220" fixed="right" align="center">
</el-table-column> <template #default="{ row }">
</el-table> <el-button v-if="row.status <= 1" size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <el-button v-if="row.status <= 1" size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
<el-button v-if="row.status < 2" size="small" type="success" text @click="handlePublish(row)">发布</el-button>
<el-button v-if="row.status === 2" size="small" type="warning" text @click="handleOffline(row)">下架</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑文章' : '新增文章'" width="600px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑文章' : '新增文章'" width="600px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
+32 -23
View File
@@ -73,8 +73,8 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px">
<el-option label="启用" :value="1" /> <el-option label="启用" :value="1" />
<el-option label="停用" :value="0" /> <el-option label="停用" :value="0" />
@@ -82,27 +82,36 @@ onMounted(() => fetchList())
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增分类</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe row-key="id" style="width:100%"> <div class="flex items-center gap-3">
<el-table-column prop="name" label="分类名称" min-width="180" /> <span class="font-medium text-gray-700">知识库分类列表</span>
<el-table-column label="上级分类" width="120" align="center"> </div>
<template #default="{ row }">{{ row.parent_id === 0 ? '顶级' : row.parent_id }}</template> <el-button type="primary" @click="handleAdd">新增分类</el-button>
</el-table-column> </div>
<el-table-column prop="sort" label="排序" width="80" align="center" /> <div class="px-2">
<el-table-column label="状态" width="80" align="center"> <el-table v-loading="loading" :data="tableData" row-key="id" style="width:100%">
<template #default="{ row }"> <el-table-column prop="name" label="分类名称" min-width="180" />
<el-tag :type="row.status ? 'success' : 'danger'" size="small">{{ row.status ? '启用' : '停用' }}</el-tag> <el-table-column label="上级分类" width="120" align="center">
</template> <template #default="{ row }">{{ row.parent_id === 0 ? '顶级' : row.parent_id }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="160" fixed="right" align="center"> <el-table-column prop="sort" label="排序" width="80" align="center" />
<template #default="{ row }"> <el-table-column label="状态" width="80" align="center">
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <template #default="{ row }">
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-tag :type="row.status ? 'success' : 'danger'" size="small">{{ row.status ? '启用' : '停用' }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
</el-table> <el-table-column label="操作" width="160" fixed="right" align="center">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[20,50,100]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <template #default="{ row }">
<el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[20,50,100]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑分类' : '新增分类'" width="450px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑分类' : '新增分类'" width="450px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
+22 -117
View File
@@ -3,6 +3,7 @@ import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user.js' import { useUserStore } from '@/stores/user.js'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Icon } from '@iconify/vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -55,27 +56,27 @@ function handleKeydown(e) {
</script> </script>
<template> <template>
<div class="login-wrapper"> <div class="min-h-screen flex items-center justify-center bg-rose-50 relative overflow-hidden">
<!-- Background decoration --> <!-- BG blobs -->
<div class="bg-decoration bg-deco-1" /> <div class="absolute w-96 h-96 rounded-full opacity-20 pointer-events-none" style="background: radial-gradient(circle, #f43f5e, transparent); top:-120px; right:-80px;"></div>
<div class="bg-decoration bg-deco-2" /> <div class="absolute w-72 h-72 rounded-full opacity-20 pointer-events-none" style="background: radial-gradient(circle, #fb7185, transparent); bottom:-80px; left:-60px;"></div>
<div class="login-card"> <div class="relative z-10 w-[420px] bg-white rounded-3xl p-12 shadow-2xl text-center">
<!-- Logo / Branding --> <!-- Logo -->
<div class="brand-area"> <div class="flex justify-center mb-6">
<div class="brand-icon"> <div class="w-20 h-20 bg-rose-500 rounded-2xl flex items-center justify-center text-white shadow-lg">
<el-icon :size="48" color="#E8A87C"><House /></el-icon> <Icon icon="solar:heart-bold" class="text-4xl" />
</div> </div>
<h1 class="brand-title">宫中有喜</h1>
<p class="brand-subtitle">月子会所综合管理系统</p>
</div> </div>
<h1 class="text-3xl font-bold text-gray-800 mb-2 tracking-widest">宫中有喜</h1>
<p class="text-gray-400 text-sm mb-8">月子会所综合管理系统</p>
<!-- Login Form --> <!-- Form -->
<el-form <el-form
ref="formRef" ref="formRef"
:model="form" :model="form"
:rules="rules" :rules="rules"
class="login-form" class="text-left"
@keydown="handleKeydown" @keydown="handleKeydown"
> >
<el-form-item prop="username"> <el-form-item prop="username">
@@ -102,11 +103,11 @@ function handleKeydown(e) {
/> />
</el-form-item> </el-form-item>
<el-form-item class="mt-8"> <el-form-item>
<el-button <el-button
type="primary" type="primary"
size="large" size="large"
class="login-btn" style="width:100%; height:48px; font-size:16px; letter-spacing:4px; border-radius:12px;"
:loading="loading" :loading="loading"
@click="handleLogin" @click="handleLogin"
> >
@@ -115,108 +116,12 @@ function handleKeydown(e) {
</el-form-item> </el-form-item>
</el-form> </el-form>
<p class="login-footer">宫中有喜月子会所 &copy; {{ new Date().getFullYear() }}</p> <div class="text-center mt-2 text-sm text-gray-400">
还没有账号
<router-link to="/register" class="text-blue-400 hover:text-blue-300 transition-colors">申请注册</router-link>
</div>
<p class="text-xs text-gray-300 mt-4">宫中有喜月子会所 &copy; {{ new Date().getFullYear() }}</p>
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss">
.login-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #FBE8D5 0%, #F7F0E8 40%, #EDE0D4 100%);
position: relative;
overflow: hidden;
}
.bg-decoration {
position: absolute;
border-radius: 50%;
opacity: 0.3;
pointer-events: none;
}
.bg-deco-1 {
width: 400px;
height: 400px;
background: radial-gradient(circle, #E8A87C, transparent);
top: -120px;
right: -80px;
}
.bg-deco-2 {
width: 300px;
height: 300px;
background: radial-gradient(circle, #C97B5E, transparent);
bottom: -80px;
left: -60px;
}
.login-card {
position: relative;
z-index: 1;
width: 420px;
background: #fff;
border-radius: 16px;
padding: 48px 44px 36px;
box-shadow: 0 20px 60px rgba(200, 120, 80, 0.15);
}
.brand-area {
text-align: center;
margin-bottom: 36px;
}
.brand-icon {
display: flex;
justify-content: center;
margin-bottom: 12px;
}
.brand-title {
font-size: 28px;
font-weight: 700;
color: var(--color-primary-dark);
letter-spacing: 4px;
margin-bottom: 6px;
}
.brand-subtitle {
font-size: 13px;
color: var(--color-text-secondary);
letter-spacing: 1px;
}
.login-form {
.el-input {
--el-input-border-radius: 8px;
}
:deep(.el-input__wrapper) {
padding: 4px 12px;
}
}
.login-btn {
width: 100%;
height: 48px;
font-size: 16px;
letter-spacing: 4px;
border-radius: 8px;
background: linear-gradient(135deg, #E8A87C, #D4875A);
border: none;
&:hover {
background: linear-gradient(135deg, #F0BB97, #E8A87C);
}
}
.login-footer {
margin-top: 24px;
text-align: center;
font-size: 12px;
color: var(--color-text-placeholder);
}
</style>
+30 -23
View File
@@ -60,8 +60,8 @@ onMounted(() => { fetchList(); fetchCustomers() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.customer_id" placeholder="选择客户" clearable filterable style="width:180px"><el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" /></el-select> <el-select v-model="searchForm.customer_id" placeholder="选择客户" clearable filterable style="width:180px"><el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" /></el-select>
<el-date-picker v-model="searchForm.plan_date" type="date" value-format="YYYY-MM-DD" placeholder="日期" style="width:160px" /> <el-date-picker v-model="searchForm.plan_date" type="date" value-format="YYYY-MM-DD" placeholder="日期" style="width:160px" />
<el-select v-model="searchForm.meal_type" placeholder="餐型" clearable style="width:120px"><el-option v-for="(v,k) in mealTypeMap" :key="k" :label="v" :value="Number(k)" /></el-select> <el-select v-model="searchForm.meal_type" placeholder="餐型" clearable style="width:120px"><el-option v-for="(v,k) in mealTypeMap" :key="k" :label="v" :value="Number(k)" /></el-select>
@@ -69,27 +69,34 @@ onMounted(() => { fetchList(); fetchCustomers() })
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增排餐</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3"></div>
<el-table-column label="客户" min-width="100"><template #default="{ row }">{{ row.customer?.name || '-' }}</template></el-table-column> <el-button type="primary" @click="handleAdd">新增排餐</el-button>
<el-table-column prop="plan_date" label="日期" width="110" /> </div>
<el-table-column label="餐型" width="80" align="center"><template #default="{ row }">{{ mealTypeMap[row.meal_type] }}</template></el-table-column> <div class="px-2">
<el-table-column prop="special_note" label="特殊备注" min-width="150" show-overflow-tooltip /> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="评分" width="70" align="center"><template #default="{ row }">{{ row.review?.score || '-' }}</template></el-table-column> <el-table-column label="客户" min-width="100"><template #default="{ row }">{{ row.customer?.name || '-' }}</template></el-table-column>
<el-table-column label="状态" width="90" align="center"><template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column> <el-table-column prop="plan_date" label="日期" width="110" />
<el-table-column label="操作" width="220" fixed="right" align="center"> <el-table-column label="餐型" width="80" align="center"><template #default="{ row }">{{ mealTypeMap[row.meal_type] }}</template></el-table-column>
<template #default="{ row }"> <el-table-column prop="special_note" label="特殊备注" min-width="150" show-overflow-tooltip />
<el-button v-if="row.status<2" size="small" type="success" link @click="handleDeliver(row)">送达</el-button> <el-table-column label="评分" width="70" align="center"><template #default="{ row }">{{ row.review?.score || '-' }}</template></el-table-column>
<el-dropdown v-if="row.status<2" trigger="click" @command="(cmd) => handleStatusChange(row, cmd)" style="margin:0 8px"> <el-table-column label="状态" width="90" align="center"><template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column>
<el-button size="small" type="warning" link>状态</el-button> <el-table-column label="操作" width="220" fixed="right" align="center">
<template #dropdown><el-dropdown-menu><el-dropdown-item v-for="(v,k) in statusMap" :key="k" :command="Number(k)">{{ v }}</el-dropdown-item></el-dropdown-menu></template> <template #default="{ row }">
</el-dropdown> <el-button v-if="row.status<2" size="small" type="success" text @click="handleDeliver(row)">送达</el-button>
<el-button v-if="row.status===0" size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-dropdown v-if="row.status<2" trigger="click" @command="(cmd) => handleStatusChange(row, cmd)" style="margin:0 8px">
</template> <el-button size="small" type="warning" text>状态</el-button>
</el-table-column> <template #dropdown><el-dropdown-menu><el-dropdown-item v-for="(v,k) in statusMap" :key="k" :command="Number(k)">{{ v }}</el-dropdown-item></el-dropdown-menu></template>
</el-table> </el-dropdown>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,30,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <el-button v-if="row.status===0" size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,30,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑排餐':'新增排餐'" width="550px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑排餐':'新增排餐'" width="550px" destroy-on-close>
<el-form :model="form" label-width="80px"> <el-form :model="form" label-width="80px">
+25 -18
View File
@@ -51,30 +51,37 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.name" placeholder="菜品名称" style="width:180px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.name" placeholder="菜品名称" style="width:180px" clearable @keydown.enter="handleSearch" />
<el-select v-model="searchForm.category" placeholder="分类" clearable style="width:120px"><el-option v-for="c in categories" :key="c" :label="c" :value="c" /></el-select> <el-select v-model="searchForm.category" placeholder="分类" clearable style="width:120px"><el-option v-for="c in categories" :key="c" :label="c" :value="c" /></el-select>
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option label="启用" :value="1" /><el-option label="禁用" :value="0" /></el-select> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option label="启用" :value="1" /><el-option label="禁用" :value="0" /></el-select>
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增菜品</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3"></div>
<el-table-column prop="name" label="菜品名称" min-width="120" /> <el-button type="primary" @click="handleAdd">新增菜品</el-button>
<el-table-column prop="category" label="分类" width="80" align="center" /> </div>
<el-table-column prop="price" label="价格" width="90" align="right" /> <div class="px-2">
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip /> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="row.status?'success':'danger'" size="small">{{ row.status?'启用':'禁用' }}</el-tag></template></el-table-column> <el-table-column prop="name" label="菜品名称" min-width="120" />
<el-table-column label="操作" width="160" fixed="right" align="center"> <el-table-column prop="category" label="分类" width="80" align="center" />
<template #default="{ row }"> <el-table-column prop="price" label="价格" width="90" align="right" />
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="row.status?'success':'danger'" size="small">{{ row.status?'启用':'禁用' }}</el-tag></template></el-table-column>
</template> <el-table-column label="操作" width="160" fixed="right" align="center">
</el-table-column> <template #default="{ row }">
</el-table> <el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑菜品':'新增菜品'" width="550px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑菜品':'新增菜品'" width="550px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="80px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="80px">
+191
View File
@@ -0,0 +1,191 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Icon } from '@iconify/vue'
import { mealReviewApi } from '@/api/meal.js'
import { customerApi } from '@/api/crm.js'
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const detailVisible = ref(false)
const detailLoading = ref(false)
const currentDetail = ref(null)
const customers = ref([])
const searchForm = reactive({ customer_id: '', rating: '', meal_date: '' })
const pagination = reactive({ page: 1, per_page: 20 })
const ratingOptions = [
{ label: '5星', value: 5 },
{ label: '4星', value: 4 },
{ label: '3星', value: 3 },
{ label: '2星', value: 2 },
{ label: '1星', value: 1 }
]
async function fetchList() {
loading.value = true
try {
const res = await mealReviewApi.getList({ ...searchForm, ...pagination })
tableData.value = res.data?.list || res.data?.data || []
total.value = res.data?.total || 0
} catch {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
async function fetchCustomers() {
try {
const res = await customerApi.getList({ per_page: 500 })
customers.value = res.data?.list || res.data?.data || []
} catch {}
}
function handleSearch() { pagination.page = 1; fetchList() }
function handleReset() {
Object.assign(searchForm, { customer_id: '', rating: '', meal_date: '' })
handleSearch()
}
function handleView(row) {
currentDetail.value = row
detailVisible.value = true
}
function getRatingColor(rating) {
if (rating >= 4) return 'text-green-500'
if (rating >= 3) return 'text-yellow-500'
return 'text-red-400'
}
function handlePageChange(val) { pagination.page = val; fetchList() }
function handleSizeChange(val) { pagination.per_page = val; pagination.page = 1; fetchList() }
onMounted(() => { fetchList(); fetchCustomers() })
</script>
<template>
<div class="p-6 space-y-4">
<!-- 搜索栏 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.customer_id" placeholder="选择客户" clearable filterable style="width:180px">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
<el-select v-model="searchForm.rating" placeholder="评分筛选" clearable style="width:130px">
<el-option v-for="r in ratingOptions" :key="r.value" :label="r.label" :value="r.value" />
</el-select>
<el-date-picker v-model="searchForm.meal_date" type="date" value-format="YYYY-MM-DD" placeholder="就餐日期" style="width:160px" />
<el-button type="primary" @click="handleSearch">
<Icon icon="solar:magnifer-bold-duotone" class="mr-1" />查询
</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 表格区 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-2">
<span class="w-1 h-5 bg-rose-500 rounded-full inline-block"></span>
<span class="font-semibold text-gray-700">膳食评价列表</span>
<span class="text-xs text-gray-400 ml-1">只读客户评价不可编辑</span>
</div>
</div>
<div class="px-2">
<el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="客户" min-width="100">
<template #default="{ row }">{{ row.customer_name || '-' }}</template>
</el-table-column>
<el-table-column prop="meal_date" label="就餐日期" width="110" />
<el-table-column label="评分" width="120" align="center">
<template #default="{ row }">
<div class="flex items-center justify-center gap-0.5">
<Icon
v-for="i in 5"
:key="i"
:icon="i <= row.rating ? 'solar:star-bold' : 'solar:star-line-duotone'"
:class="['text-base', i <= row.rating ? 'text-amber-400' : 'text-gray-200']"
/>
<span :class="['ml-1 text-sm font-semibold', getRatingColor(row.rating)]">{{ row.rating }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="content" label="评价内容" min-width="200" show-overflow-tooltip />
<el-table-column label="照片" width="70" align="center">
<template #default="{ row }">
<span v-if="row.photos && row.photos.length" class="text-rose-500 text-sm">{{ row.photos.length }} </span>
<span v-else class="text-gray-300 text-sm"></span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="评价时间" min-width="160" />
<el-table-column label="操作" width="80" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" text @click="handleView(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.per_page"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total,sizes,prev,pager,next,jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="评价详情" width="520px" destroy-on-close>
<div v-loading="detailLoading">
<template v-if="currentDetail">
<el-descriptions :column="2" border>
<el-descriptions-item label="客户">{{ currentDetail.customer_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="就餐日期">{{ currentDetail.meal_date || '-' }}</el-descriptions-item>
<el-descriptions-item label="评分" :span="2">
<div class="flex items-center gap-1">
<Icon
v-for="i in 5"
:key="i"
:icon="i <= currentDetail.rating ? 'solar:star-bold' : 'solar:star-line-duotone'"
:class="['text-lg', i <= currentDetail.rating ? 'text-amber-400' : 'text-gray-200']"
/>
<span :class="['ml-1 font-semibold', getRatingColor(currentDetail.rating)]">
{{ currentDetail.rating }} / 5
</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="评价内容" :span="2">
{{ currentDetail.content || '(无文字评价)' }}
</el-descriptions-item>
<el-descriptions-item label="评价时间" :span="2">{{ currentDetail.created_at || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 照片 -->
<div v-if="currentDetail.photos && currentDetail.photos.length" class="mt-4">
<p class="text-sm text-gray-500 mb-2">评价照片{{ currentDetail.photos.length }} </p>
<div class="flex flex-wrap gap-2">
<el-image
v-for="(src, idx) in currentDetail.photos"
:key="idx"
:src="src"
:preview-src-list="currentDetail.photos"
:initial-index="idx"
fit="cover"
class="w-20 h-20 rounded-lg object-cover cursor-pointer"
/>
</div>
</div>
</template>
</div>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
+190
View File
@@ -0,0 +1,190 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Icon } from '@iconify/vue'
import { mealTemplateApi } from '@/api/meal.js'
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const dialogVisible = ref(false)
const submitLoading = ref(false)
const searchForm = reactive({ name: '' })
const pagination = reactive({ page: 1, per_page: 20 })
const formRef = ref(null)
const form = reactive({
id: null, name: '', description: '', week_num: 1, status: 1
})
const isEdit = ref(false)
const weekOptions = Array.from({ length: 6 }, (_, i) => ({ label: `${i + 1}`, value: i + 1 }))
const statusMap = { 1: '启用', 0: '停用' }
const formRules = {
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
week_num: [{ required: true, message: '请选择适用周数', trigger: 'change' }]
}
async function fetchList() {
loading.value = true
try {
const res = await mealTemplateApi.getList({ ...searchForm, ...pagination })
tableData.value = res.data?.list || res.data?.data || []
total.value = res.data?.total || 0
} catch {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
function handleSearch() { pagination.page = 1; fetchList() }
function handleReset() { Object.assign(searchForm, { name: '' }); handleSearch() }
function handleAdd() {
isEdit.value = false
Object.assign(form, { id: null, name: '', description: '', week_num: 1, status: 1 })
dialogVisible.value = true
}
async function handleEdit(row) {
isEdit.value = true
try {
const res = await mealTemplateApi.getDetail(row.id)
const data = res.data?.data || res.data || row
Object.assign(form, {
id: data.id, name: data.name, description: data.description || '',
week_num: data.week_num, status: data.status ?? 1
})
} catch {
Object.assign(form, { id: row.id, name: row.name, description: row.description || '', week_num: row.week_num, status: row.status ?? 1 })
}
dialogVisible.value = true
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除模板「${row.name}」吗?`, '删除确认', { type: 'warning' })
try {
await mealTemplateApi.delete(row.id)
ElMessage.success('删除成功')
fetchList()
} catch {
ElMessage.error('删除失败')
}
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) {
await mealTemplateApi.update(form.id, form)
} else {
await mealTemplateApi.create(form)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
fetchList()
} catch {
ElMessage.error('操作失败')
} finally {
submitLoading.value = false
}
})
}
function handlePageChange(val) { pagination.page = val; fetchList() }
function handleSizeChange(val) { pagination.per_page = val; pagination.page = 1; fetchList() }
onMounted(() => fetchList())
</script>
<template>
<div class="p-6 space-y-4">
<!-- 搜索栏 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.name" placeholder="模板名称" clearable style="width:200px" />
<el-button type="primary" @click="handleSearch">
<Icon icon="solar:magnifer-bold-duotone" class="mr-1" />查询
</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 表格区 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-2">
<span class="w-1 h-5 bg-rose-500 rounded-full inline-block"></span>
<span class="font-semibold text-gray-700">排餐模板管理</span>
</div>
<el-button type="primary" @click="handleAdd">
<Icon icon="solar:add-circle-bold-duotone" class="mr-1" />新建模板
</el-button>
</div>
<div class="px-2">
<el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column prop="name" label="模板名称" min-width="160" />
<el-table-column label="适用周数" width="100" align="center">
<template #default="{ row }"> {{ row.week_num }} </template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">{{ statusMap[row.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" min-width="160" />
<el-table-column label="操作" width="140" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.per_page"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total,sizes,prev,pager,next,jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<!-- 新建/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑模板' : '新建排餐模板'" width="520px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
<el-form-item label="模板名称" prop="name">
<el-input v-model="form.name" placeholder="如:产后第1周营养餐模板" />
</el-form-item>
<el-form-item label="适用周数" prop="week_num">
<el-select v-model="form.week_num" style="width:100%">
<el-option v-for="w in weekOptions" :key="w.value" :label="w.label" :value="w.value" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="模板描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="简要说明此模板的膳食方向和特点" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
+28 -19
View File
@@ -62,31 +62,40 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.name" placeholder="姓名" style="width:150px" clearable /> <el-input v-model="searchForm.name" placeholder="姓名" style="width:150px" clearable />
<el-select v-model="searchForm.level" placeholder="等级" clearable style="width:120px"><el-option v-for="(v,k) in levelMap" :key="k" :label="v" :value="Number(k)" /></el-select> <el-select v-model="searchForm.level" placeholder="等级" clearable style="width:120px"><el-option v-for="(v,k) in levelMap" :key="k" :label="v" :value="Number(k)" /></el-select>
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option v-for="(v,k) in statusMap" :key="k" :label="v" :value="Number(k)" /></el-select> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option v-for="(v,k) in statusMap" :key="k" :label="v" :value="Number(k)" /></el-select>
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增月嫂</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe> <div class="flex items-center gap-3">
<el-table-column prop="name" label="姓名" width="100" /> <span class="font-medium text-gray-700">月嫂列表</span>
<el-table-column prop="phone" label="手机号" width="130" /> </div>
<el-table-column label="等级" width="80" align="center"><template #default="{ row }">{{ levelMap[row.level] || '-' }}</template></el-table-column> <el-button type="primary" @click="handleAdd">新增月嫂</el-button>
<el-table-column prop="experience_years" label="经验(年)" width="90" align="center" /> </div>
<el-table-column prop="daily_price" label="日价(元)" width="100" align="right" /> <div class="px-2">
<el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="操作" width="160" fixed="right" align="center"> <el-table-column prop="name" label="姓名" width="100" />
<template #default="{ row }"> <el-table-column prop="phone" label="手机号" width="130" />
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-table-column label="等级" width="80" align="center"><template #default="{ row }">{{ levelMap[row.level] || '-' }}</template></el-table-column>
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-table-column prop="experience_years" label="经验(年)" width="90" align="center" />
</template> <el-table-column prop="daily_price" label="日价(元)" width="100" align="right" />
</el-table-column> <el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column>
</el-table> <el-table-column label="操作" width="160" fixed="right" align="center">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[15,30,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <template #default="{ row }">
<el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[15,30,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑月嫂':'新增月嫂'" width="550px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑月嫂':'新增月嫂'" width="550px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
+30 -21
View File
@@ -62,32 +62,41 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.order_no" placeholder="订单号" style="width:180px" clearable /> <el-input v-model="searchForm.order_no" placeholder="订单号" style="width:180px" clearable />
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option v-for="(v,k) in statusMap" :key="k" :label="v" :value="Number(k)" /></el-select> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option v-for="(v,k) in statusMap" :key="k" :label="v" :value="Number(k)" /></el-select>
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增订单</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe> <div class="flex items-center gap-3">
<el-table-column prop="order_no" label="订单号" min-width="160" /> <span class="font-medium text-gray-700">月嫂订单列表</span>
<el-table-column label="客户" min-width="100"><template #default="{ row }">{{ row.customer?.name || '-' }}</template></el-table-column> </div>
<el-table-column label="月嫂" width="100"><template #default="{ row }">{{ row.nanny?.name || '待匹配' }}</template></el-table-column> <el-button type="primary" @click="handleAdd">新增订单</el-button>
<el-table-column label="服务类型" width="90"><template #default="{ row }">{{ serviceTypeMap[row.service_type] || '-' }}</template></el-table-column> </div>
<el-table-column prop="start_date" label="开始日期" width="110" /> <div class="px-2">
<el-table-column prop="days" label="天数" width="70" align="center" /> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column prop="price" label="费用(元)" width="100" align="right" /> <el-table-column prop="order_no" label="订单号" min-width="160" />
<el-table-column label="状态" width="90" align="center"><template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column> <el-table-column label="客户" min-width="100"><template #default="{ row }">{{ row.customer?.name || '-' }}</template></el-table-column>
<el-table-column label="操作" width="160" fixed="right" align="center"> <el-table-column label="月嫂" width="100"><template #default="{ row }">{{ row.nanny?.name || '待匹配' }}</template></el-table-column>
<template #default="{ row }"> <el-table-column label="服务类型" width="90"><template #default="{ row }">{{ serviceTypeMap[row.service_type] || '-' }}</template></el-table-column>
<el-button v-if="row.status<4" size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-table-column prop="start_date" label="开始日期" width="110" />
<el-button v-if="row.status<2" size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-table-column prop="days" label="天数" width="70" align="center" />
</template> <el-table-column prop="price" label="费用(元)" width="100" align="right" />
</el-table-column> <el-table-column label="状态" width="90" align="center"><template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column>
</el-table> <el-table-column label="操作" width="160" fixed="right" align="center">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[15,30,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <template #default="{ row }">
<el-button v-if="row.status<4" size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button v-if="row.status<2" size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[15,30,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑订单':'新增订单'" width="550px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑订单':'新增订单'" width="550px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
+21 -12
View File
@@ -48,24 +48,33 @@ onMounted(() => { fetchList(); fetchNannies() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.nanny_id" placeholder="选择月嫂" clearable style="width:160px"><el-option v-for="n in nannies" :key="n.id" :label="n.name" :value="n.id" /></el-select> <el-select v-model="searchForm.nanny_id" placeholder="选择月嫂" clearable style="width:160px"><el-option v-for="n in nannies" :key="n.id" :label="n.name" :value="n.id" /></el-select>
<el-date-picker v-model="searchForm.start_date" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" /> <el-date-picker v-model="searchForm.start_date" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" />
<el-date-picker v-model="searchForm.end_date" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" /> <el-date-picker v-model="searchForm.end_date" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" />
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增排班</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe> <div class="flex items-center gap-3">
<el-table-column label="月嫂" min-width="100"><template #default="{ row }">{{ row.nanny?.name || '-' }}</template></el-table-column> <span class="font-medium text-gray-700">排班记录</span>
<el-table-column prop="schedule_date" label="日期" width="120" /> </div>
<el-table-column label="类型" width="90" align="center"><template #default="{ row }"><el-tag :type="typeType[row.type]" size="small">{{ typeMap[row.type] }}</el-tag></template></el-table-column> <el-button type="primary" @click="handleAdd">新增排班</el-button>
<el-table-column label="关联订单" min-width="120"><template #default="{ row }">{{ row.order?.order_no || '-' }}</template></el-table-column> </div>
<el-table-column prop="remark" label="备注" min-width="150" /> <div class="px-2">
</el-table> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-pagination v-model:current-page="pagination.page" :total="total" layout="total,prev,pager,next" background @current-change="handlePageChange" /> <el-table-column label="月嫂" min-width="100"><template #default="{ row }">{{ row.nanny?.name || '-' }}</template></el-table-column>
<el-table-column prop="schedule_date" label="日期" width="120" />
<el-table-column label="类型" width="90" align="center"><template #default="{ row }"><el-tag :type="typeType[row.type]" size="small">{{ typeMap[row.type] }}</el-tag></template></el-table-column>
<el-table-column label="关联订单" min-width="120"><template #default="{ row }">{{ row.order?.order_no || '-' }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="150" />
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" :total="total" layout="total,prev,pager,next" background @current-change="handlePageChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" title="新增排班" width="450px" destroy-on-close> <el-dialog v-model="dialogVisible" title="新增排班" width="450px" destroy-on-close>
<el-form label-width="80px"> <el-form label-width="80px">
@@ -78,8 +78,8 @@ onMounted(() => { fetchList() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.type" placeholder="类型" clearable style="width:120px"> <el-select v-model="searchForm.type" placeholder="类型" clearable style="width:120px">
<el-option v-for="(v, k) in typeMap" :key="k" :label="v" :value="Number(k)" /> <el-option v-for="(v, k) in typeMap" :key="k" :label="v" :value="Number(k)" />
</el-select> </el-select>
@@ -89,38 +89,45 @@ onMounted(() => { fetchList() })
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-3">
<span class="font-medium text-gray-700">公告列表</span>
</div>
<el-button type="primary" @click="handleAdd">新增公告</el-button> <el-button type="primary" @click="handleAdd">新增公告</el-button>
</div> </div>
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="px-2">
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip /> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="类型" width="90" align="center"> <el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<template #default="{ row }"> <el-table-column label="类型" width="90" align="center">
<el-tag :type="typeTagType[row.type]" size="small">{{ typeMap[row.type] }}</el-tag> <template #default="{ row }">
</template> <el-tag :type="typeTagType[row.type]" size="small">{{ typeMap[row.type] }}</el-tag>
</el-table-column> </template>
<el-table-column label="置顶" width="70" align="center"> </el-table-column>
<template #default="{ row }">{{ row.is_top ? '是' : '否' }}</template> <el-table-column label="置顶" width="70" align="center">
</el-table-column> <template #default="{ row }">{{ row.is_top ? '是' : '否' }}</template>
<el-table-column label="发布状态" width="100" align="center"> </el-table-column>
<template #default="{ row }"> <el-table-column label="发布状态" width="100" align="center">
<el-tag :type="publishStatusType[row.publish_status]" size="small">{{ publishStatusMap[row.publish_status] }}</el-tag> <template #default="{ row }">
</template> <el-tag :type="publishStatusType[row.publish_status]" size="small">{{ publishStatusMap[row.publish_status] }}</el-tag>
</el-table-column> </template>
<el-table-column label="作者" min-width="100"> </el-table-column>
<template #default="{ row }">{{ row.author?.name || '-' }}</template> <el-table-column label="作者" min-width="100">
</el-table-column> <template #default="{ row }">{{ row.author?.name || '-' }}</template>
<el-table-column prop="published_at" label="发布时间" min-width="160" /> </el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center"> <el-table-column prop="published_at" label="发布时间" min-width="160" />
<template #default="{ row }"> <el-table-column label="操作" width="200" fixed="right" align="center">
<el-button v-if="row.publish_status === 0" size="small" type="success" link @click="handlePublish(row)">发布</el-button> <template #default="{ row }">
<el-button v-if="row.publish_status === 0" size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-button v-if="row.publish_status === 0" size="small" type="success" text @click="handlePublish(row)">发布</el-button>
<el-button v-if="row.publish_status === 0" size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-button v-if="row.publish_status === 0" size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
</template> <el-button v-if="row.publish_status === 0" size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</el-table-column> </template>
</el-table> </el-table-column>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> </el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑公告' : '新增公告'" width="600px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑公告' : '新增公告'" width="600px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="80px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="80px">
@@ -102,8 +102,8 @@ onMounted(() => { fetchList() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.type" placeholder="模板类型" clearable style="width:140px"> <el-select v-model="searchForm.type" placeholder="模板类型" clearable style="width:140px">
<el-option v-for="(v, k) in typeMap" :key="k" :label="v" :value="Number(k)" /> <el-option v-for="(v, k) in typeMap" :key="k" :label="v" :value="Number(k)" />
</el-select> </el-select>
@@ -115,31 +115,38 @@ onMounted(() => { fetchList() })
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-3">
<span class="font-medium text-gray-700">审批模板列表</span>
</div>
<el-button type="primary" @click="handleAdd">新增模板</el-button> <el-button type="primary" @click="handleAdd">新增模板</el-button>
</div> </div>
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="px-2">
<el-table-column prop="name" label="模板名称" min-width="160" /> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="类型" width="100" align="center"> <el-table-column prop="name" label="模板名称" min-width="160" />
<template #default="{ row }"> <el-table-column label="类型" width="100" align="center">
<el-tag size="small">{{ typeMap[row.type] || '-' }}</el-tag> <template #default="{ row }">
</template> <el-tag size="small">{{ typeMap[row.type] || '-' }}</el-tag>
</el-table-column> </template>
<el-table-column label="状态" width="90" align="center"> </el-table-column>
<template #default="{ row }"> <el-table-column label="状态" width="90" align="center">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">{{ row.status === 1 ? '启用' : '停用' }}</el-tag> <template #default="{ row }">
</template> <el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">{{ row.status === 1 ? '启用' : '停用' }}</el-tag>
</el-table-column> </template>
<el-table-column prop="sort" label="排序" width="80" align="center" /> </el-table-column>
<el-table-column label="操作" width="160" fixed="right" align="center"> <el-table-column prop="sort" label="排序" width="80" align="center" />
<template #default="{ row }"> <el-table-column label="操作" width="160" fixed="right" align="center">
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <template #default="{ row }">
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
</template> <el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</el-table-column> </template>
</el-table> </el-table-column>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> </el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" destroy-on-close>
+176 -38
View File
@@ -1,12 +1,14 @@
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { approvalApi } from '@/api/office.js' import { approvalApi, approvalTemplateApi } from '@/api/office.js'
const tableData = ref([]) const tableData = ref([])
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const dialogVisible = ref(false) const dialogVisible = ref(false)
const detailVisible = ref(false)
const rejectDialogVisible = ref(false)
const submitLoading = ref(false) const submitLoading = ref(false)
const searchForm = reactive({ status: '' }) const searchForm = reactive({ status: '' })
@@ -14,9 +16,15 @@ const pagination = reactive({ page: 1, per_page: 20 })
const formRef = ref(null) const formRef = ref(null)
const form = reactive({ title: '', template_id: '', form_data: '' }) const form = reactive({ title: '', template_id: '', form_data: '' })
const templates = ref([])
const currentDetail = ref(null)
const rejectForm = reactive({ comment: '' })
const rejectTargetRow = ref(null)
const statusMap = { 0: '待审批', 1: '审批中', 2: '已通过', 3: '已驳回', 4: '已撤回' } const statusMap = { 0: '待审批', 1: '审批中', 2: '已通过', 3: '已驳回', 4: '已撤回' }
const statusType = { 0: 'warning', 1: 'primary', 2: 'success', 3: 'danger', 4: 'info' } const statusType = { 0: 'warning', 1: 'primary', 2: 'success', 3: 'danger', 4: 'info' }
const nodeActionMap = { 0: '待处理', 1: '已通过', 2: '已驳回' }
const formRules = { const formRules = {
title: [{ required: true, message: '请输入审批标题', trigger: 'blur' }] title: [{ required: true, message: '请输入审批标题', trigger: 'blur' }]
@@ -31,6 +39,13 @@ async function fetchList() {
} finally { loading.value = false } } finally { loading.value = false }
} }
async function fetchTemplates() {
try {
const res = await approvalTemplateApi.getList({ per_page: 100, status: 1 })
templates.value = res.data?.list || res.data?.data || []
} catch { /* ignore */ }
}
function handleSearch() { pagination.page = 1; fetchList() } function handleSearch() { pagination.page = 1; fetchList() }
function handleReset() { function handleReset() {
Object.assign(searchForm, { status: '' }) Object.assign(searchForm, { status: '' })
@@ -52,25 +67,43 @@ async function handleSubmit() {
if (payload.form_data) { if (payload.form_data) {
try { payload.form_data = JSON.parse(payload.form_data) } catch { /* keep string */ } try { payload.form_data = JSON.parse(payload.form_data) } catch { /* keep string */ }
} }
if (!payload.template_id) delete payload.template_id
await approvalApi.create(payload) await approvalApi.create(payload)
ElMessage.success('提交成功') ElMessage.success('提交成功,审批节点已自动创建')
dialogVisible.value = false dialogVisible.value = false
fetchList() fetchList()
} finally { submitLoading.value = false } } finally { submitLoading.value = false }
}) })
} }
async function handleShowDetail(row) {
try {
const res = await approvalApi.getDetail(row.id)
currentDetail.value = res.data
detailVisible.value = true
} catch {
ElMessage.error('加载详情失败')
}
}
async function handleApprove(row) { async function handleApprove(row) {
await ElMessageBox.confirm('确定通过该审批吗?', '审批确认', { type: 'warning' }) await ElMessageBox.confirm('确定通过该审批节点吗?', '审批确认', { type: 'warning' })
await approvalApi.approve(row.id, { status: 2 }) await approvalApi.approve(row.id, {})
ElMessage.success('审批已通过') ElMessage.success('审批已通过')
fetchList() fetchList()
} }
async function handleReject(row) { function handleRejectOpen(row) {
await ElMessageBox.confirm('确定驳回该审批吗?', '驳回确认', { type: 'warning' }) rejectTargetRow.value = row
await approvalApi.reject(row.id, { status: 3 }) rejectForm.comment = ''
rejectDialogVisible.value = true
}
async function handleRejectSubmit() {
if (!rejectTargetRow.value) return
await approvalApi.reject(rejectTargetRow.value.id, { comment: rejectForm.comment })
ElMessage.success('审批已驳回') ElMessage.success('审批已驳回')
rejectDialogVisible.value = false
fetchList() fetchList()
} }
@@ -81,15 +114,30 @@ async function handleWithdraw(row) {
fetchList() fetchList()
} }
function getStepStatus(node, currentNode) {
if (node.node_order < currentNode) {
return node.action === 2 ? 'error' : 'finish'
}
if (node.node_order === currentNode) {
return node.action === 0 ? 'process' : node.action === 1 ? 'finish' : 'error'
}
return 'wait'
}
function sortedNodes(nodes) {
return [...(nodes || [])].sort((a, b) => a.node_order - b.node_order)
}
function handlePageChange(val) { pagination.page = val; fetchList() } function handlePageChange(val) { pagination.page = val; fetchList() }
function handleSizeChange(val) { pagination.per_page = val; pagination.page = 1; fetchList() } function handleSizeChange(val) { pagination.per_page = val; pagination.page = 1; fetchList() }
onMounted(() => { fetchList() }) onMounted(() => { fetchList(); fetchTemplates() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <!-- 搜索栏 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.status" placeholder="审批状态" clearable style="width:140px"> <el-select v-model="searchForm.status" placeholder="审批状态" clearable style="width:140px">
<el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="Number(k)" /> <el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="Number(k)" />
</el-select> </el-select>
@@ -97,47 +145,73 @@ onMounted(() => { fetchList() })
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <!-- 列表 -->
<div class="table-actions"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<span class="font-medium text-gray-700">审批列表</span>
<el-button type="primary" @click="handleAdd">发起审批</el-button> <el-button type="primary" @click="handleAdd">发起审批</el-button>
</div> </div>
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="px-2">
<el-table-column prop="title" label="标题" min-width="180" show-overflow-tooltip /> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="申请人" min-width="100"> <el-table-column label="标题" min-width="200">
<template #default="{ row }">{{ row.applicant?.name || '-' }}</template> <template #default="{ row }">
</el-table-column> <el-button type="primary" link @click="handleShowDetail(row)">{{ row.title }}</el-button>
<el-table-column label="审批模板" min-width="120">
<template #default="{ row }">{{ row.template?.name || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" min-width="160" />
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<template v-if="row.status <= 1">
<el-button size="small" type="success" link @click="handleApprove(row)">通过</el-button>
<el-button size="small" type="danger" link @click="handleReject(row)">驳回</el-button>
<el-button size="small" type="warning" link @click="handleWithdraw(row)">撤回</el-button>
</template> </template>
</template> </el-table-column>
</el-table-column> <el-table-column label="申请人" min-width="100">
</el-table> <template #default="{ row }">{{ row.applicant?.name || '-' }}</template>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> </el-table-column>
<el-table-column label="审批模板" min-width="120">
<template #default="{ row }">{{ row.template?.name || '无模板' }}</template>
</el-table-column>
<el-table-column label="当前节点" width="90" align="center">
<template #default="{ row }"> {{ row.current_node || 1 }} </template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" min-width="160" />
<el-table-column label="操作" width="230" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" text @click="handleShowDetail(row)">详情</el-button>
<template v-if="row.status <= 1">
<el-button size="small" type="success" text @click="handleApprove(row)">通过</el-button>
<el-button size="small" type="danger" text @click="handleRejectOpen(row)">驳回</el-button>
<el-button size="small" type="warning" text @click="handleWithdraw(row)">撤回</el-button>
</template>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.per_page"
:total="total"
:page-sizes="[10,20,50]"
layout="total,sizes,prev,pager,next,jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div> </div>
<!-- 发起审批弹窗 -->
<el-dialog v-model="dialogVisible" title="发起审批" width="600px" destroy-on-close> <el-dialog v-model="dialogVisible" title="发起审批" width="600px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
<el-form-item label="审批标题" prop="title"> <el-form-item label="审批标题" prop="title">
<el-input v-model="form.title" placeholder="请输入审批标题" /> <el-input v-model="form.title" placeholder="请输入审批标题" />
</el-form-item> </el-form-item>
<el-form-item label="审批模板"> <el-form-item label="审批模板">
<el-input v-model="form.template_id" placeholder="请输入模板ID" /> <el-select v-model="form.template_id" placeholder="请选择审批模板(可选,选择后自动生成多级审批节点)" clearable style="width:100%">
<el-option v-for="t in templates" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="表单数据"> <el-form-item label="表单数据">
<el-input v-model="form.form_data" type="textarea" :rows="4" placeholder="JSON格式" /> <el-input v-model="form.form_data" type="textarea" :rows="3" placeholder="JSON格式(可选)" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -145,5 +219,69 @@ onMounted(() => { fetchList() })
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">提交</el-button> <el-button type="primary" :loading="submitLoading" @click="handleSubmit">提交</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 审批详情弹窗含多级节点进度 -->
<el-dialog v-model="detailVisible" title="审批详情" width="700px" destroy-on-close>
<template v-if="currentDetail">
<el-descriptions :column="2" border class="mb-5">
<el-descriptions-item label="审批标题" :span="2">{{ currentDetail.title }}</el-descriptions-item>
<el-descriptions-item label="申请人">{{ currentDetail.applicant?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="审批模板">{{ currentDetail.template?.name || '无模板' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusType[currentDetail.status]" size="small">{{ statusMap[currentDetail.status] }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="当前节点"> {{ currentDetail.current_node || 1 }} </el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">{{ currentDetail.created_at }}</el-descriptions-item>
</el-descriptions>
<!-- 审批节点进度 -->
<div class="font-medium text-gray-700 mb-3">审批流程进度</div>
<div v-if="currentDetail.nodes && currentDetail.nodes.length">
<el-steps
direction="vertical"
:active="currentDetail.current_node"
space="80px"
>
<el-step
v-for="node in sortedNodes(currentDetail.nodes)"
:key="node.id"
:title="node.node_label || `第${node.node_order}级审批`"
:status="getStepStatus(node, currentDetail.current_node)"
>
<template #description>
<div class="text-sm space-y-1 py-1 text-gray-500">
<div>审批人<span class="text-gray-700">{{ node.approver?.name || (node.role ? `[${node.role}角色]` : '待指定') }}</span></div>
<div>
状态
<el-tag size="small" :type="node.action === 1 ? 'success' : node.action === 2 ? 'danger' : 'info'">
{{ nodeActionMap[node.action] || '-' }}
</el-tag>
</div>
<div v-if="node.comment">意见:<span class="text-gray-700">{{ node.comment }}</span></div>
<div v-if="node.acted_at" class="text-xs text-gray-400">操作时间{{ node.acted_at }}</div>
</div>
</template>
</el-step>
</el-steps>
</div>
<el-empty v-else description="暂无审批节点(提交后自动按模板生成)" :image-size="60" />
</template>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 驳回意见弹窗 -->
<el-dialog v-model="rejectDialogVisible" title="驳回审批" width="460px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="驳回意见">
<el-input v-model="rejectForm.comment" type="textarea" :rows="3" placeholder="请输入驳回意见(可选)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" @click="handleRejectSubmit">确认驳回</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
+37 -30
View File
@@ -67,8 +67,8 @@ onMounted(() => { fetchList() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-date-picker v-model="searchForm.shift_date" type="date" value-format="YYYY-MM-DD" placeholder="交接日期" clearable style="width:160px" /> <el-date-picker v-model="searchForm.shift_date" type="date" value-format="YYYY-MM-DD" placeholder="交接日期" clearable style="width:160px" />
<el-select v-model="searchForm.shift_type" placeholder="班次" clearable style="width:120px"> <el-select v-model="searchForm.shift_type" placeholder="班次" clearable style="width:120px">
<el-option v-for="(v, k) in shiftTypeMap" :key="k" :label="v" :value="Number(k)" /> <el-option v-for="(v, k) in shiftTypeMap" :key="k" :label="v" :value="Number(k)" />
@@ -76,36 +76,43 @@ onMounted(() => { fetchList() })
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-3">
<span class="font-medium text-gray-700">交接记录列表</span>
</div>
<el-button type="primary" @click="handleAdd">新增交接</el-button> <el-button type="primary" @click="handleAdd">新增交接</el-button>
</div> </div>
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="px-2">
<el-table-column prop="shift_date" label="交接日期" width="120" /> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="班次" width="90" align="center"> <el-table-column prop="shift_date" label="交接日期" width="120" />
<template #default="{ row }"> <el-table-column label="班次" width="90" align="center">
<el-tag :type="shiftTagType[row.shift_type]" size="small">{{ shiftTypeMap[row.shift_type] }}</el-tag> <template #default="{ row }">
</template> <el-tag :type="shiftTagType[row.shift_type]" size="small">{{ shiftTypeMap[row.shift_type] }}</el-tag>
</el-table-column> </template>
<el-table-column label="交班人" min-width="100"> </el-table-column>
<template #default="{ row }">{{ row.handover_user?.name || '-' }}</template> <el-table-column label="交班人" min-width="100">
</el-table-column> <template #default="{ row }">{{ row.handover_user?.name || '-' }}</template>
<el-table-column label="接班人" min-width="100"> </el-table-column>
<template #default="{ row }">{{ row.receiver_user?.name || '待确认' }}</template> <el-table-column label="接班人" min-width="100">
</el-table-column> <template #default="{ row }">{{ row.receiver_user?.name || '待确认' }}</template>
<el-table-column prop="content" label="交接内容" min-width="200" show-overflow-tooltip /> </el-table-column>
<el-table-column label="状态" width="90" align="center"> <el-table-column prop="content" label="交接内容" min-width="200" show-overflow-tooltip />
<template #default="{ row }"> <el-table-column label="状态" width="90" align="center">
<el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag> <template #default="{ row }">
</template> <el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag>
</el-table-column> </template>
<el-table-column label="操作" width="120" fixed="right" align="center"> </el-table-column>
<template #default="{ row }"> <el-table-column label="操作" width="120" fixed="right" align="center">
<el-button v-if="row.status === 0" size="small" type="success" link @click="handleConfirm(row)">确认接班</el-button> <template #default="{ row }">
</template> <el-button v-if="row.status === 0" size="small" type="success" text @click="handleConfirm(row)">确认接班</el-button>
</el-table-column> </template>
</el-table> </el-table-column>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> </el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" title="新增交接记录" width="600px" destroy-on-close> <el-dialog v-model="dialogVisible" title="新增交接记录" width="600px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
@@ -48,9 +48,8 @@ onMounted(fetchList)
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<!-- Search --> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<div class="search-bar">
<el-select v-model="searchForm.type" placeholder="通知类型" clearable style="width: 140px"> <el-select v-model="searchForm.type" placeholder="通知类型" clearable style="width: 140px">
<el-option v-for="(v, k) in typeMap" :key="k" :label="v" :value="Number(k)" /> <el-option v-for="(v, k) in typeMap" :key="k" :label="v" :value="Number(k)" />
</el-select> </el-select>
@@ -62,50 +61,49 @@ onMounted(fetchList)
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<!-- Table --> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-container"> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="table-actions"> <div class="flex items-center gap-3">
<span class="font-medium text-gray-700">通知列表</span>
</div>
<el-button type="primary" @click="handleMarkAllRead">全部标记已读</el-button> <el-button type="primary" @click="handleMarkAllRead">全部标记已读</el-button>
</div> </div>
<el-table v-loading="loading" :data="tableData" border stripe style="width: 100%"> <div class="px-2">
<el-table-column prop="title" label="标题" min-width="180" /> <el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="content" label="内容" min-width="260" show-overflow-tooltip /> <el-table-column prop="title" label="标题" min-width="180" />
<el-table-column label="类型" width="90" align="center"> <el-table-column prop="content" label="内容" min-width="260" show-overflow-tooltip />
<template #default="{ row }"> <el-table-column label="类型" width="90" align="center">
<el-tag :type="typeStyle[row.type]" size="small">{{ typeMap[row.type] || '-' }}</el-tag> <template #default="{ row }">
</template> <el-tag :type="typeStyle[row.type]" size="small">{{ typeMap[row.type] || '-' }}</el-tag>
</el-table-column> </template>
<el-table-column label="状态" width="80" align="center"> </el-table-column>
<template #default="{ row }"> <el-table-column label="状态" width="80" align="center">
<span class="read-dot" :class="row.is_read ? 'read' : 'unread'" /> <template #default="{ row }">
<span>{{ row.is_read ? '已读' : '未读' }}</span> <span class="read-dot" :class="row.is_read ? 'read' : 'unread'" />
</template> <span>{{ row.is_read ? '已读' : '未读' }}</span>
</el-table-column> </template>
<el-table-column prop="created_at" label="创建时间" min-width="160" /> </el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center"> <el-table-column prop="created_at" label="创建时间" min-width="160" />
<template #default="{ row }"> <el-table-column label="操作" width="120" fixed="right" align="center">
<el-button <template #default="{ row }">
v-if="!row.is_read" <el-button v-if="!row.is_read" size="small" type="primary" text @click="handleMarkRead(row)">标记已读</el-button>
size="small" <span v-else class="text-muted">-</span>
type="primary" </template>
link </el-table-column>
@click="handleMarkRead(row)" </el-table>
>标记已读</el-button> </div>
<span v-else class="text-muted">-</span> <div class="px-5 py-3 border-t border-gray-50">
</template> <el-pagination
</el-table-column> v-model:current-page="pagination.page"
</el-table> v-model:page-size="pagination.per_page"
:total="total"
<el-pagination :page-sizes="[10, 20, 50]"
v-model:current-page="pagination.page" layout="total, sizes, prev, pager, next, jumper"
v-model:page-size="pagination.per_page" background
:total="total" @current-change="handlePageChange"
:page-sizes="[10, 20, 50]" @size-change="handleSizeChange"
layout="total, sizes, prev, pager, next, jumper" />
background </div>
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div> </div>
</div> </div>
</template> </template>
+183
View File
@@ -0,0 +1,183 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import axios from 'axios'
const router = useRouter()
const loading = ref(false)
const packagesLoading = ref(false)
const packages = ref([])
const stores = ref([])
const form = reactive({
store_id: '',
registration_package_id: '',
username: '',
password: '',
name: '',
phone: '',
})
const formRef = ref(null)
const rules = {
store_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' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码不少于6位', trigger: 'blur' },
],
name: [{ required: true, message: '请输入真实姓名', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' },
],
}
async function fetchStores() {
try {
const res = await axios.get('/api/v1/auth/stores')
stores.value = res.data?.data || []
} catch {
//
}
}
async function fetchPackages() {
if (!form.store_id) return
packagesLoading.value = true
form.registration_package_id = ''
try {
const res = await axios.get('/api/v1/auth/register/packages', {
params: { store_id: form.store_id },
})
packages.value = res.data?.data || []
} catch {
packages.value = []
} finally {
packagesLoading.value = false
}
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
loading.value = true
try {
await axios.post('/api/v1/auth/register', form)
ElMessage.success('注册成功!请等待管理员审核后登录')
setTimeout(() => router.push('/login'), 1500)
} catch (e) {
const msg = e.response?.data?.message || '注册失败,请稍后重试'
ElMessage.error(msg)
} finally {
loading.value = false
}
})
}
onMounted(fetchStores)
</script>
<template>
<div class="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
<div class="bg-white rounded-3xl shadow-xl w-full max-w-md p-8">
<!-- Logo -->
<div class="text-center mb-8">
<div class="text-3xl mb-1">🏠</div>
<h1 class="text-2xl font-bold text-gray-800">宫中有喜</h1>
<p class="text-sm text-gray-400 mt-1">月子会所管理系统 · 员工注册</p>
</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"
>
<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"
placeholder="请先选择门店"
style="width: 100%"
:loading="packagesLoading"
:disabled="!form.store_id"
>
<el-option
v-for="p in packages"
:key="p.id"
:label="p.name"
:value="p.id"
>
<span>{{ p.name }}</span>
<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>
</template>
</el-select>
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="登录账号" prop="username">
<el-input v-model="form.username" placeholder="字母/数字" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="登录密码" prop="password">
<el-input v-model="form.password" type="password" placeholder="不少于6位" show-password />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="真实姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="11位手机号" />
</el-form-item>
</el-col>
</el-row>
<el-button
type="primary"
style="width: 100%; margin-top: 8px"
:loading="loading"
@click="handleSubmit"
>
提交注册申请
</el-button>
</el-form>
<div class="text-center mt-6 text-sm text-gray-400">
已有账号
<router-link to="/login" class="text-blue-500 hover:underline">立即登录</router-link>
</div>
<div class="mt-4 p-3 bg-amber-50 rounded-xl text-xs text-amber-600 leading-relaxed">
注册后需管理员审核审核通过后方可登录系统
</div>
</div>
</div>
</template>
+46 -42
View File
@@ -81,9 +81,8 @@ onMounted(fetchList)
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<!-- Search --> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<div class="search-bar">
<el-input v-model="searchForm.type" placeholder="报表类型" style="width: 160px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.type" placeholder="报表类型" style="width: 160px" clearable @keydown.enter="handleSearch" />
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width: 130px"> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width: 130px">
<el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="Number(k)" /> <el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="Number(k)" />
@@ -92,47 +91,52 @@ onMounted(fetchList)
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<!-- Table --> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-container"> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="table-actions"> <div class="flex items-center gap-3">
<span class="font-medium text-gray-700">导出记录列表</span>
</div>
<el-button type="primary" @click="handleAdd">新建导出</el-button> <el-button type="primary" @click="handleAdd">新建导出</el-button>
</div> </div>
<el-table v-loading="loading" :data="tableData" border stripe style="width: 100%"> <div class="px-2">
<el-table-column prop="name" label="报表名称" min-width="160" /> <el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="type" label="类型" min-width="120" /> <el-table-column prop="name" label="报表名称" min-width="160" />
<el-table-column label="状态" width="100" align="center"> <el-table-column prop="type" label="类型" min-width="120" />
<template #default="{ row }"> <el-table-column label="状态" width="100" align="center">
<el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] || '-' }}</el-tag> <template #default="{ row }">
</template> <el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] || '-' }}</el-tag>
</el-table-column> </template>
<el-table-column label="操作人" min-width="100"> </el-table-column>
<template #default="{ row }">{{ row.operator?.name || '-' }}</template> <el-table-column label="操作人" min-width="100">
</el-table-column> <template #default="{ row }">{{ row.operator?.name || '-' }}</template>
<el-table-column prop="created_at" label="创建时间" min-width="160" /> </el-table-column>
<el-table-column label="操作" width="100" fixed="right" align="center"> <el-table-column prop="created_at" label="创建时间" min-width="160" />
<template #default="{ row }"> <el-table-column label="操作" width="100" fixed="right" align="center">
<el-button <template #default="{ row }">
v-if="row.status === 1 && row.file_path" <el-button
size="small" v-if="row.status === 1 && row.file_path"
type="primary" size="small"
link type="primary"
@click="handleDownload(row)" text
>下载</el-button> @click="handleDownload(row)"
<span v-else class="text-muted">-</span> >下载</el-button>
</template> <span v-else class="text-muted">-</span>
</el-table-column> </template>
</el-table> </el-table-column>
</el-table>
<el-pagination </div>
v-model:current-page="pagination.page" <div class="px-5 py-3 border-t border-gray-50">
v-model:page-size="pagination.per_page" <el-pagination
:total="total" v-model:current-page="pagination.page"
:page-sizes="[10, 20, 50]" v-model:page-size="pagination.per_page"
layout="total, sizes, prev, pager, next, jumper" :total="total"
background :page-sizes="[10, 20, 50]"
@current-change="handlePageChange" layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange" background
/> @current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div> </div>
<!-- Create Dialog --> <!-- Create Dialog -->
+16 -15
View File
@@ -34,10 +34,13 @@ onMounted(fetchOverview)
</script> </script>
<template> <template>
<div class="page-container" v-loading="loading"> <div class="p-6 space-y-4" v-loading="loading">
<div class="section-title">经营概览</div> <div class="flex items-center gap-2 mb-2">
<span class="w-1 h-5 rounded bg-primary inline-block" style="background:var(--color-primary)"></span>
<span class="font-semibold text-base text-gray-800">经营概览</span>
</div>
<el-row :gutter="16" class="mt-16"> <el-row :gutter="16">
<el-col <el-col
v-for="card in cards" v-for="card in cards"
:key="card.key" :key="card.key"
@@ -47,20 +50,18 @@ onMounted(fetchOverview)
:lg="8" :lg="8"
:xl="4" :xl="4"
> >
<el-card shadow="hover" class="overview-card"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 mb-4 flex items-center gap-4">
<div class="card-inner"> <div class="w-14 h-14 rounded-xl flex items-center justify-center flex-shrink-0" :style="{ background: card.bg }">
<div class="card-icon-wrap" :style="{ background: card.bg }"> <span class="text-2xl font-bold" :style="{ color: card.color }">{{ card.title.charAt(0) }}</span>
<span class="card-icon-text" :style="{ color: card.color }">{{ card.title.charAt(0) }}</span> </div>
</div> <div class="flex-1 min-w-0">
<div class="card-info"> <div class="text-sm text-gray-500 mb-1">{{ card.title }}</div>
<div class="card-label">{{ card.title }}</div> <div class="flex items-baseline gap-1">
<div class="card-value"> <span class="text-3xl font-bold leading-none" :style="{ color: card.color }">{{ card.value }}</span>
<span class="card-number" :style="{ color: card.color }">{{ card.value }}</span> <span class="text-sm text-gray-400">{{ card.unit }}</span>
<span class="card-unit">{{ card.unit }}</span>
</div>
</div> </div>
</div> </div>
</el-card> </div>
</el-col> </el-col>
</el-row> </el-row>
</div> </div>
+164
View File
@@ -0,0 +1,164 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { roomApi, reservationApi } from '@/api/room.js'
const rooms = ref([])
const loading = ref(false)
const detailVisible = ref(false)
const detailRoom = ref(null)
const filterStatus = ref('')
const statusConfig = {
available: { label: '空置', color: 'bg-emerald-50 border-emerald-200', badge: 'bg-emerald-500', text: 'text-emerald-700', dot: 'bg-emerald-400' },
occupied: { label: '在住', color: 'bg-blue-50 border-blue-200', badge: 'bg-blue-500', text: 'text-blue-700', dot: 'bg-blue-400' },
cleaning: { label: '保洁', color: 'bg-amber-50 border-amber-200', badge: 'bg-amber-500', text: 'text-amber-700', dot: 'bg-amber-400' },
maintenance: { label: '维护', color: 'bg-gray-50 border-gray-200', badge: 'bg-gray-400', text: 'text-gray-600', dot: 'bg-gray-400' }
}
const statusOptions = Object.entries(statusConfig).map(([k, v]) => ({ value: k, label: v.label }))
const filteredRooms = computed(() => {
if (!filterStatus.value) return rooms.value
return rooms.value.filter(r => r.status === filterStatus.value)
})
const summary = computed(() => {
const map = {}
for (const k of Object.keys(statusConfig)) map[k] = 0
for (const r of rooms.value) { if (map[r.status] !== undefined) map[r.status]++ }
return map
})
async function fetchRooms() {
loading.value = true
try {
const res = await roomApi.getList({ per_page: 200 })
rooms.value = res.data?.list || res.data?.data || []
} catch (e) { console.error(e) }
finally { loading.value = false }
}
async function openDetail(room) {
detailRoom.value = room
if (room.status === 'occupied') {
try {
const res = await reservationApi.getList({ room_id: room.id, status: 1, per_page: 1 })
const list = res.data?.list || res.data?.data || []
detailRoom.value = { ...room, currentReservation: list[0] || null }
} catch (e) { console.error(e) }
}
detailVisible.value = true
}
async function handleStatusChange(room, newStatus) {
try {
await roomApi.updateStatus(room.id, { status: newStatus })
ElMessage.success('房态已更新')
fetchRooms()
} catch (e) { console.error(e) }
}
onMounted(() => fetchRooms())
</script>
<template>
<div class="p-6 space-y-4">
<!-- Summary Stats -->
<div class="grid grid-cols-4 gap-4">
<div v-for="(cfg, key) in statusConfig" :key="key"
class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center gap-3 cursor-pointer transition-all hover:shadow-md"
:class="filterStatus === key ? 'ring-2 ring-rose-400' : ''"
@click="filterStatus = filterStatus === key ? '' : key"
>
<div class="w-10 h-10 rounded-xl flex items-center justify-center" :class="cfg.badge + '/20'">
<span class="w-4 h-4 rounded-full inline-block" :class="cfg.dot"></span>
</div>
<div>
<div class="text-2xl font-bold" :class="cfg.text">{{ summary[key] }}</div>
<div class="text-xs text-gray-500">{{ cfg.label }}</div>
</div>
</div>
</div>
<!-- Board -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-2">
<span class="w-1 h-5 bg-rose-500 rounded-full inline-block"></span>
<span class="font-medium text-gray-700">房态看板</span>
<span class="text-xs text-gray-400 ml-2">点击统计卡片可按状态筛选点击房间卡片查看详情</span>
</div>
<div class="flex items-center gap-2">
<el-select v-model="filterStatus" placeholder="全部状态" clearable style="width:130px">
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
<el-button @click="fetchRooms" :loading="loading">刷新</el-button>
</div>
</div>
<div v-loading="loading" class="p-5">
<div v-if="filteredRooms.length === 0" class="py-16 text-center text-gray-400">暂无房间数据</div>
<div v-else class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
<div
v-for="room in filteredRooms"
:key="room.id"
class="rounded-xl border-2 p-3 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md select-none"
:class="statusConfig[room.status]?.color || 'bg-gray-50 border-gray-200'"
@click="openDetail(room)"
>
<!-- Room number + status dot -->
<div class="flex items-center justify-between mb-2">
<span class="font-bold text-gray-800 text-sm">{{ room.number }}</span>
<span class="w-2.5 h-2.5 rounded-full" :class="statusConfig[room.status]?.dot || 'bg-gray-400'"></span>
</div>
<!-- Room type -->
<div class="text-xs text-gray-500 mb-1 truncate">{{ room.room_type?.name || room.type_name || '-' }}</div>
<!-- Status label -->
<div class="text-xs font-medium" :class="statusConfig[room.status]?.text || 'text-gray-500'">
{{ statusConfig[room.status]?.label || room.status }}
</div>
<!-- Customer name if occupied -->
<div v-if="room.status === 'occupied' && (room.customer_name || room.current_customer)" class="text-xs text-blue-600 mt-1 truncate font-medium">
{{ room.customer_name || room.current_customer }}
</div>
</div>
</div>
</div>
</div>
<!-- Room Detail Drawer -->
<el-drawer v-model="detailVisible" title="房间详情" size="400px">
<template v-if="detailRoom">
<el-descriptions :column="1" border>
<el-descriptions-item label="房间号">{{ detailRoom.number }}</el-descriptions-item>
<el-descriptions-item label="房型">{{ detailRoom.room_type?.name || detailRoom.type_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="楼层">{{ detailRoom.floor || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="{ available: 'success', occupied: 'primary', cleaning: 'warning', maintenance: 'info' }[detailRoom.status]" size="small">
{{ statusConfig[detailRoom.status]?.label || detailRoom.status }}
</el-tag>
</el-descriptions-item>
<template v-if="detailRoom.status === 'occupied' && detailRoom.currentReservation">
<el-descriptions-item label="在住客户">{{ detailRoom.currentReservation.customer?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="入住日期">{{ detailRoom.currentReservation.check_in_date || '-' }}</el-descriptions-item>
<el-descriptions-item label="预计离店">{{ detailRoom.currentReservation.check_out_date || '-' }}</el-descriptions-item>
</template>
</el-descriptions>
<div class="mt-4">
<div class="text-sm font-medium text-gray-600 mb-2">快速变更房态</div>
<div class="flex flex-wrap gap-2">
<el-button
v-for="s in statusOptions"
:key="s.value"
size="small"
:disabled="detailRoom.status === s.value"
@click="handleStatusChange(detailRoom, s.value)"
>{{ s.label }}</el-button>
</div>
</div>
</template>
</el-drawer>
</div>
</template>
@@ -0,0 +1,177 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { callRecordApi } from '@/api/room.js'
import { customerApi } from '@/api/crm.js'
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const dialogVisible = ref(false)
const submitLoading = ref(false)
const customers = ref([])
const searchForm = reactive({ customer_id: '', handled: '', date: '' })
const pagination = reactive({ page: 1, per_page: 20 })
const formRef = ref(null)
const form = reactive({
customer_id: '', room_no: '', call_type: 'nurse', content: ''
})
const formRules = {
customer_id: [{ required: true, message: '请选择客户', trigger: 'change' }],
call_type: [{ required: true, message: '请选择呼叫类型', trigger: 'change' }],
content: [{ required: true, message: '请输入呼叫内容', trigger: 'blur' }]
}
const callTypeMap = { nurse: '护理', meal: '膳食', service: '服务', emergency: '紧急' }
const callTypeTagType = { nurse: 'primary', meal: 'success', service: 'info', emergency: 'danger' }
async function fetchList() {
loading.value = true
try {
const res = await callRecordApi.getList({ ...searchForm, ...pagination })
tableData.value = res.data?.list || res.data?.data || []
total.value = res.data?.total || 0
} finally { loading.value = false }
}
async function fetchCustomers() {
try {
const res = await customerApi.getList({ per_page: 500 })
customers.value = res.data?.list || res.data?.data || []
} catch {}
}
function handleSearch() { pagination.page = 1; fetchList() }
function handleReset() { Object.assign(searchForm, { customer_id: '', handled: '', date: '' }); handleSearch() }
function handleAdd() {
Object.assign(form, { customer_id: '', room_no: '', call_type: 'nurse', content: '' })
dialogVisible.value = true
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
await callRecordApi.create(form)
ElMessage.success('呼叫记录已创建')
dialogVisible.value = false; fetchList()
} finally { submitLoading.value = false }
})
}
async function handleHandle(row) {
await ElMessageBox.confirm(`确定标记该呼叫为「已处理」吗?`, '处理确认', { type: 'info' })
try {
await callRecordApi.handle(row.id, {})
ElMessage.success('已标记为处理')
fetchList()
} catch (e) { console.error(e) }
}
function handlePageChange(val) { pagination.page = val; fetchList() }
function handleSizeChange(val) { pagination.per_page = val; pagination.page = 1; fetchList() }
onMounted(() => { fetchList(); fetchCustomers() })
</script>
<template>
<div class="p-6 space-y-4">
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.customer_id" placeholder="选择客户" clearable filterable style="width:180px">
<el-option v-for="c in customers" :key="c.id" :label="`${c.name}(${c.phone})`" :value="c.id" />
</el-select>
<el-select v-model="searchForm.handled" placeholder="处理状态" clearable style="width:120px">
<el-option label="未处理" :value="0" />
<el-option label="已处理" :value="1" />
</el-select>
<el-date-picker v-model="searchForm.date" type="date" placeholder="呼叫日期" value-format="YYYY-MM-DD" style="width:150px" />
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-2">
<span class="w-1 h-5 bg-rose-500 rounded-full inline-block"></span>
<span class="font-medium text-gray-700">呼叫记录</span>
</div>
<el-button type="primary" @click="handleAdd">新建呼叫记录</el-button>
</div>
<div class="px-2">
<el-table v-loading="loading" :data="tableData" style="width:100%" :row-class-name="({ row }) => !row.handled ? 'bg-rose-50/40' : ''">
<el-table-column label="客户" min-width="100">
<template #default="{ row }">{{ row.customer_name || row.customer?.name || '-' }}</template>
</el-table-column>
<el-table-column prop="room_no" label="房间号" width="90" align="center" />
<el-table-column label="呼叫类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="callTypeTagType[row.call_type]" size="small">{{ callTypeMap[row.call_type] }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="content" label="呼叫内容" min-width="180" show-overflow-tooltip />
<el-table-column label="处理状态" width="95" align="center">
<template #default="{ row }">
<el-tag :type="row.handled ? 'success' : 'danger'" size="small">{{ row.handled ? '已处理' : '未处理' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="处理人" min-width="100">
<template #default="{ row }">{{ row.handler_name || row.handler?.name || '-' }}</template>
</el-table-column>
<el-table-column prop="handled_at" label="处理时间" min-width="160">
<template #default="{ row }">{{ row.handled_at || '-' }}</template>
</el-table-column>
<el-table-column prop="created_at" label="呼叫时间" min-width="160" />
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-button v-if="!row.handled" size="small" type="success" text @click="handleHandle(row)">标记处理</el-button>
<span v-else class="text-gray-400 text-xs">已完成</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.per_page"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total,sizes,prev,pager,next,jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<!-- New Call Record Dialog -->
<el-dialog v-model="dialogVisible" title="新建呼叫记录" width="500px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
<el-form-item label="客户" prop="customer_id">
<el-select v-model="form.customer_id" filterable placeholder="请选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="`${c.name}(${c.phone})`" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="房间号">
<el-input v-model="form.room_no" placeholder="请输入房间号" />
</el-form-item>
<el-form-item label="呼叫类型" prop="call_type">
<el-select v-model="form.call_type" style="width:100%">
<el-option v-for="(v, k) in callTypeMap" :key="k" :label="v" :value="k" />
</el-select>
</el-form-item>
<el-form-item label="呼叫内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="3" placeholder="请描述呼叫内容" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
+200
View File
@@ -0,0 +1,200 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { customerOutingApi } from '@/api/room.js'
import { customerApi } from '@/api/crm.js'
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const dialogVisible = ref(false)
const submitLoading = ref(false)
const returnVisible = ref(false)
const currentOuting = ref(null)
const customers = ref([])
const searchForm = reactive({ customer_id: '', status: '', date: '' })
const pagination = reactive({ page: 1, per_page: 20 })
const formRef = ref(null)
const form = reactive({
customer_id: '', outing_time: '', expected_return: '', reason: ''
})
const returnForm = reactive({ actual_return: '', remark: '' })
const formRules = {
customer_id: [{ required: true, message: '请选择客户', trigger: 'change' }],
outing_time: [{ required: true, message: '请选择外出时间', trigger: 'change' }],
expected_return: [{ required: true, message: '请选择预计返回时间', trigger: 'change' }]
}
const statusMap = { pending: '待出发', out: '外出中', returned: '已返回' }
const statusType = { pending: 'warning', out: 'primary', returned: 'success' }
async function fetchList() {
loading.value = true
try {
const res = await customerOutingApi.getList({ ...searchForm, ...pagination })
tableData.value = res.data?.list || res.data?.data || []
total.value = res.data?.total || 0
} finally { loading.value = false }
}
async function fetchCustomers() {
try {
const res = await customerApi.getList({ per_page: 500 })
customers.value = res.data?.list || res.data?.data || []
} catch {}
}
function handleSearch() { pagination.page = 1; fetchList() }
function handleReset() { Object.assign(searchForm, { customer_id: '', status: '', date: '' }); handleSearch() }
function handleAdd() {
Object.assign(form, { customer_id: '', outing_time: '', expected_return: '', reason: '' })
dialogVisible.value = true
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
await customerOutingApi.create(form)
ElMessage.success('外出申请已创建')
dialogVisible.value = false; fetchList()
} finally { submitLoading.value = false }
})
}
function openReturn(row) {
currentOuting.value = row
Object.assign(returnForm, { actual_return: '', remark: '' })
returnVisible.value = true
}
async function submitReturn() {
if (!returnForm.actual_return) { ElMessage.warning('请选择实际返回时间'); return }
await customerOutingApi.recordBack(currentOuting.value.id, returnForm)
ElMessage.success('返回登记成功')
returnVisible.value = false; fetchList()
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除该外出记录吗?`, '删除确认', { type: 'warning' })
try {
await customerOutingApi.getDetail(row.id)
ElMessage.error('暂不支持删除,请联系管理员')
} catch (e) {
console.error(e)
}
}
function handlePageChange(val) { pagination.page = val; fetchList() }
function handleSizeChange(val) { pagination.per_page = val; pagination.page = 1; fetchList() }
onMounted(() => { fetchList(); fetchCustomers() })
</script>
<template>
<div class="p-6 space-y-4">
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.customer_id" placeholder="选择客户" clearable filterable style="width:180px">
<el-option v-for="c in customers" :key="c.id" :label="`${c.name}(${c.phone})`" :value="c.id" />
</el-select>
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px">
<el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="k" />
</el-select>
<el-date-picker v-model="searchForm.date" type="date" placeholder="外出日期" value-format="YYYY-MM-DD" style="width:150px" />
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-2">
<span class="w-1 h-5 bg-rose-500 rounded-full inline-block"></span>
<span class="font-medium text-gray-700">外出记录</span>
</div>
<el-button type="primary" @click="handleAdd">新建外出申请</el-button>
</div>
<div class="px-2">
<el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="客户" min-width="100">
<template #default="{ row }">{{ row.customer_name || row.customer?.name || '-' }}</template>
</el-table-column>
<el-table-column prop="outing_time" label="外出时间" min-width="160" />
<el-table-column prop="expected_return" label="预计返回" min-width="160" />
<el-table-column prop="actual_return" label="实际返回" min-width="160">
<template #default="{ row }">{{ row.actual_return || '-' }}</template>
</el-table-column>
<el-table-column prop="reason" label="外出原因" min-width="150" show-overflow-tooltip />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button v-if="row.status === 'out'" size="small" type="success" text @click="openReturn(row)">登记返回</el-button>
<el-button v-if="row.status !== 'returned'" size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.per_page"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total,sizes,prev,pager,next,jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<!-- New Outing Dialog -->
<el-dialog v-model="dialogVisible" title="新建外出申请" width="520px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
<el-form-item label="客户" prop="customer_id">
<el-select v-model="form.customer_id" filterable placeholder="请选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="`${c.name}(${c.phone})`" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="外出时间" prop="outing_time">
<el-date-picker v-model="form.outing_time" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="请选择外出时间" style="width:100%" />
</el-form-item>
<el-form-item label="预计返回" prop="expected_return">
<el-date-picker v-model="form.expected_return" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="请选择预计返回时间" style="width:100%" />
</el-form-item>
<el-form-item label="外出原因">
<el-input v-model="form.reason" type="textarea" :rows="3" placeholder="请输入外出原因" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
<!-- Return Dialog -->
<el-dialog v-model="returnVisible" title="登记返回" width="420px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="实际返回时间">
<el-date-picker v-model="returnForm.actual_return" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="请选择实际返回时间" style="width:100%" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="returnForm.remark" type="textarea" :rows="2" placeholder="可选备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="returnVisible = false">取消</el-button>
<el-button type="primary" @click="submitReturn">确定</el-button>
</template>
</el-dialog>
</div>
</template>
+29 -20
View File
@@ -82,31 +82,40 @@ onMounted(() => { fetchList(); fetchCustomers(); fetchRooms() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.customer_id" placeholder="选择客户" clearable filterable style="width:180px"><el-option v-for="c in customers" :key="c.id" :label="`${c.name}(${c.phone})`" :value="c.id" /></el-select> <el-select v-model="searchForm.customer_id" placeholder="选择客户" clearable filterable style="width:180px"><el-option v-for="c in customers" :key="c.id" :label="`${c.name}(${c.phone})`" :value="c.id" /></el-select>
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option v-for="(v,k) in statusMap" :key="k" :label="v" :value="Number(k)" /></el-select> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option v-for="(v,k) in statusMap" :key="k" :label="v" :value="Number(k)" /></el-select>
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增预定</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column label="客户" min-width="100"><template #default="{ row }">{{ row.customer?.name || '-' }}</template></el-table-column> <span class="font-medium text-gray-700">预定列表</span>
<el-table-column label="房间" min-width="120"><template #default="{ row }">{{ row.room?.number || '-' }} ({{ row.room?.room_type?.name || '' }})</template></el-table-column> </div>
<el-table-column prop="check_in_date" label="入住日期" min-width="110" /> <el-button type="primary" @click="handleAdd">新增预定</el-button>
<el-table-column prop="check_out_date" label="退房日期" min-width="110" /> </div>
<el-table-column label="状态" width="100" align="center"><template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column> <div class="px-2">
<el-table-column label="操作" width="260" fixed="right" align="center"> <el-table v-loading="loading" :data="tableData" style="width:100%">
<template #default="{ row }"> <el-table-column label="客户" min-width="100"><template #default="{ row }">{{ row.customer?.name || '-' }}</template></el-table-column>
<el-button v-if="row.status===0" size="small" type="success" link @click="handleCheckIn(row)">入住</el-button> <el-table-column label="房间" min-width="120"><template #default="{ row }">{{ row.room?.number || '-' }} ({{ row.room?.room_type?.name || '' }})</template></el-table-column>
<el-button v-if="row.status===1" size="small" type="warning" link @click="handleCheckOut(row)">退房</el-button> <el-table-column prop="check_in_date" label="入住日期" min-width="110" />
<el-button v-if="row.status<2" size="small" type="info" link @click="handleCancel(row)">取消</el-button> <el-table-column prop="check_out_date" label="退房日期" min-width="110" />
<el-button v-if="row.status!==1" size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-table-column label="状态" width="100" align="center"><template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column>
</template> <el-table-column label="操作" width="260" fixed="right" align="center">
</el-table-column> <template #default="{ row }">
</el-table> <el-button v-if="row.status===0" size="small" type="success" text @click="handleCheckIn(row)">入住</el-button>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <el-button v-if="row.status===1" size="small" type="warning" text @click="handleCheckOut(row)">退房</el-button>
<el-button v-if="row.status<2" size="small" type="info" text @click="handleCancel(row)">取消</el-button>
<el-button v-if="row.status!==1" size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" title="新增预定" width="550px" destroy-on-close> <el-dialog v-model="dialogVisible" title="新增预定" width="550px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="80px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="80px">
+27 -18
View File
@@ -54,29 +54,38 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.name" placeholder="房型名称" style="width:180px" clearable @keydown.enter="handleSearch" /> <el-input v-model="searchForm.name" placeholder="房型名称" style="width:180px" clearable @keydown.enter="handleSearch" />
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option label="启用" :value="1" /><el-option label="禁用" :value="0" /></el-select> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option label="启用" :value="1" /><el-option label="禁用" :value="0" /></el-select>
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增房型</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column prop="name" label="房型名称" min-width="150" /> <span class="font-medium text-gray-700">房型列表</span>
<el-table-column prop="price" label="日单价(元)" min-width="120" align="right" /> </div>
<el-table-column label="房间数" width="90" align="center"><template #default="{ row }">{{ row.rooms_count || 0 }}</template></el-table-column> <el-button type="primary" @click="handleAdd">新增房型</el-button>
<el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="row.status?'success':'danger'" size="small">{{ row.status?'启用':'禁用' }}</el-tag></template></el-table-column> </div>
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip /> <div class="px-2">
<el-table-column label="操作" width="160" fixed="right" align="center"> <el-table v-loading="loading" :data="tableData" style="width:100%">
<template #default="{ row }"> <el-table-column prop="name" label="房型名称" min-width="150" />
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-table-column prop="price" label="日单价(元)" min-width="120" align="right" />
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-table-column label="房间数" width="90" align="center"><template #default="{ row }">{{ row.rooms_count || 0 }}</template></el-table-column>
</template> <el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="row.status?'success':'danger'" size="small">{{ row.status?'启用':'禁用' }}</el-tag></template></el-table-column>
</el-table-column> <el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
</el-table> <el-table-column label="操作" width="160" fixed="right" align="center">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <template #default="{ row }">
<el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑房型':'新增房型'" width="550px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑房型':'新增房型'" width="550px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="80px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="80px">
+36 -27
View File
@@ -72,39 +72,48 @@ onMounted(() => { fetchList(); fetchRoomTypes() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-select v-model="searchForm.room_type_id" placeholder="房型" clearable style="width:160px"><el-option v-for="t in roomTypes" :key="t.id" :label="t.name" :value="t.id" /></el-select> <el-select v-model="searchForm.room_type_id" placeholder="房型" clearable style="width:160px"><el-option v-for="t in roomTypes" :key="t.id" :label="t.name" :value="t.id" /></el-select>
<el-input v-model="searchForm.floor" placeholder="楼层" style="width:100px" clearable /> <el-input v-model="searchForm.floor" placeholder="楼层" style="width:100px" clearable />
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option v-for="(v,k) in statusMap" :key="k" :label="v" :value="Number(k)" /></el-select> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option v-for="(v,k) in statusMap" :key="k" :label="v" :value="Number(k)" /></el-select>
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增房间</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe style="width:100%"> <div class="flex items-center gap-3">
<el-table-column prop="number" label="房间号" width="100" /> <span class="font-medium text-gray-700">房间列表</span>
<el-table-column label="房型" min-width="120"><template #default="{ row }">{{ row.room_type?.name || '-' }}</template></el-table-column> </div>
<el-table-column prop="floor" label="楼层" width="80" align="center" /> <el-button type="primary" @click="handleAdd">新增房间</el-button>
<el-table-column label="状态" width="100" align="center"> </div>
<template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template> <div class="px-2">
</el-table-column> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="操作" width="300" fixed="right" align="center"> <el-table-column prop="number" label="房间号" width="100" />
<template #default="{ row }"> <el-table-column label="房型" min-width="120"><template #default="{ row }">{{ row.room_type?.name || '-' }}</template></el-table-column>
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-table-column prop="floor" label="楼层" width="80" align="center" />
<el-dropdown trigger="click" @command="(cmd) => changeStatus(row, cmd)" style="margin:0 8px"> <el-table-column label="状态" width="100" align="center">
<el-button size="small" type="warning" link>切换状态</el-button> <template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template>
<template #dropdown> </el-table-column>
<el-dropdown-menu> <el-table-column label="操作" width="300" fixed="right" align="center">
<el-dropdown-item v-for="(v,k) in statusMap" :key="k" :command="Number(k)" :disabled="row.status===Number(k)">{{ v }}</el-dropdown-item> <template #default="{ row }">
</el-dropdown-menu> <el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
</template> <el-dropdown trigger="click" @command="(cmd) => changeStatus(row, cmd)" style="margin:0 8px">
</el-dropdown> <el-button size="small" type="warning" text>切换状态</el-button>
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <template #dropdown>
</template> <el-dropdown-menu>
</el-table-column> <el-dropdown-item v-for="(v,k) in statusMap" :key="k" :command="Number(k)" :disabled="row.status===Number(k)">{{ v }}</el-dropdown-item>
</el-table> </el-dropdown-menu>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[20,50,100]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> </template>
</el-dropdown>
<el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[20,50,100]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑房间':'新增房间'" width="500px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑房间':'新增房间'" width="500px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="80px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="80px">
@@ -0,0 +1,246 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Icon } from '@iconify/vue'
import { serviceExecutionApi } from '@/api/service.js'
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const dialogVisible = ref(false)
const submitLoading = ref(false)
const today = new Date().toISOString().slice(0, 10)
const searchForm = reactive({ order_id: '', technician_id: '', status: '', date: today })
const pagination = reactive({ page: 1, per_page: 20 })
const formRef = ref(null)
const form = reactive({
order_id: '', service_item_name: '', technician_id: '', technician_name: '', scheduled_at: '', notes: ''
})
const statusMap = {
scheduled: '待执行',
in_progress: '执行中',
completed: '已完成',
cancelled: '已取消'
}
const statusTagType = {
scheduled: 'warning',
in_progress: 'primary',
completed: 'success',
cancelled: 'info'
}
const formRules = {
order_id: [{ required: true, message: '请输入关联订单ID', trigger: 'blur' }],
service_item_name: [{ required: true, message: '请输入服务项目名称', trigger: 'blur' }],
scheduled_at: [{ required: true, message: '请选择计划执行时间', trigger: 'change' }]
}
//
function getRowClass({ row }) {
const scheduledDate = row.scheduled_at ? row.scheduled_at.slice(0, 10) : ''
if (scheduledDate === today && row.status === 'scheduled') return 'row-today-highlight'
return ''
}
const todayCount = computed(() =>
tableData.value.filter(r => {
const d = r.scheduled_at ? r.scheduled_at.slice(0, 10) : ''
return d === today && r.status === 'scheduled'
}).length
)
async function fetchList() {
loading.value = true
try {
const res = await serviceExecutionApi.getList({ ...searchForm, ...pagination })
tableData.value = res.data?.list || res.data?.data || []
total.value = res.data?.total || 0
} catch {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
function handleSearch() { pagination.page = 1; fetchList() }
function handleReset() {
Object.assign(searchForm, { order_id: '', technician_id: '', status: '', date: today })
handleSearch()
}
function handleAdd() {
Object.assign(form, { order_id: '', service_item_name: '', technician_id: '', technician_name: '', scheduled_at: '', notes: '' })
dialogVisible.value = true
}
async function handleComplete(row) {
await ElMessageBox.confirm(`确认「${row.service_item_name}」已执行完成?`, '标记完成', {
type: 'success',
confirmButtonText: '确认完成'
})
try {
await serviceExecutionApi.finish(row.id)
ElMessage.success('已标记完成')
fetchList()
} catch {
ElMessage.error('操作失败')
}
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
await serviceExecutionApi.create(form)
ElMessage.success('创建成功')
dialogVisible.value = false
fetchList()
} catch {
ElMessage.error('创建失败')
} finally {
submitLoading.value = false
}
})
}
function handlePageChange(val) { pagination.page = val; fetchList() }
function handleSizeChange(val) { pagination.per_page = val; pagination.page = 1; fetchList() }
onMounted(() => fetchList())
</script>
<template>
<div class="p-6 space-y-4">
<!-- 搜索栏 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.order_id" placeholder="订单ID" clearable style="width:140px" />
<el-input v-model="searchForm.technician_id" placeholder="技师ID" clearable style="width:140px" />
<el-select v-model="searchForm.status" placeholder="执行状态" clearable style="width:130px">
<el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="k" />
</el-select>
<el-date-picker v-model="searchForm.date" type="date" value-format="YYYY-MM-DD" placeholder="执行日期" style="width:160px" />
<el-button type="primary" @click="handleSearch">
<Icon icon="solar:magnifer-bold-duotone" class="mr-1" />查询
</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 今日待执行提示 -->
<div v-if="todayCount > 0" class="bg-rose-50 border border-rose-200 rounded-xl px-4 py-3 flex items-center gap-2">
<Icon icon="solar:bell-bing-bold-duotone" class="text-rose-500 text-xl flex-shrink-0" />
<span class="text-rose-600 text-sm font-medium">
今日共有 <span class="text-rose-700 font-bold">{{ todayCount }}</span> 条待执行服务已高亮显示
</span>
</div>
<!-- 表格区 -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-2">
<span class="w-1 h-5 bg-rose-500 rounded-full inline-block"></span>
<span class="font-semibold text-gray-700">服务执行记录</span>
</div>
<el-button type="primary" @click="handleAdd">
<Icon icon="solar:add-circle-bold-duotone" class="mr-1" />新建执行记录
</el-button>
</div>
<div class="px-2">
<el-table v-loading="loading" :data="tableData" style="width:100%" :row-class-name="getRowClass">
<el-table-column prop="service_item_name" label="服务项目" min-width="140" />
<el-table-column label="客户" min-width="100">
<template #default="{ row }">{{ row.customer_name || '-' }}</template>
</el-table-column>
<el-table-column label="执行技师" min-width="100">
<template #default="{ row }">{{ row.technician_name || '-' }}</template>
</el-table-column>
<el-table-column prop="scheduled_at" label="计划时间" min-width="160" />
<el-table-column prop="completed_at" label="完成时间" min-width="160">
<template #default="{ row }">{{ row.completed_at || '—' }}</template>
</el-table-column>
<el-table-column label="客户评分" width="90" align="center">
<template #default="{ row }">
<span v-if="row.customer_rating" class="text-amber-500 font-medium">{{ row.customer_rating }} </span>
<span v-else class="text-gray-300"></span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType[row.status]" size="small">{{ statusMap[row.status] || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="row.status === 'scheduled' || row.status === 'in_progress'"
size="small"
type="success"
text
@click="handleComplete(row)"
>
标记完成
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.per_page"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total,sizes,prev,pager,next,jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<!-- 新建执行记录弹窗 -->
<el-dialog v-model="dialogVisible" title="新建执行记录" width="540px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
<el-form-item label="关联订单ID" prop="order_id">
<el-input v-model="form.order_id" placeholder="请输入服务订单ID" />
</el-form-item>
<el-form-item label="服务项目" prop="service_item_name">
<el-input v-model="form.service_item_name" placeholder="服务项目名称" />
</el-form-item>
<el-form-item label="执行技师">
<el-input v-model="form.technician_name" placeholder="技师姓名(可选)" />
</el-form-item>
<el-form-item label="计划时间" prop="scheduled_at">
<el-date-picker
v-model="form.scheduled_at"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择计划执行时间"
style="width:100%"
/>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.notes" type="textarea" :rows="2" placeholder="执行备注(可选)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
:deep(.row-today-highlight) {
background-color: #fff1f2 !important;
}
:deep(.row-today-highlight:hover > td) {
background-color: #ffe4e6 !important;
}
</style>
+25 -18
View File
@@ -61,30 +61,37 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.name" placeholder="项目名称" style="width:180px" clearable /> <el-input v-model="searchForm.name" placeholder="项目名称" style="width:180px" clearable />
<el-select v-model="searchForm.category" placeholder="分类" clearable style="width:140px"><el-option v-for="(v,k) in categoryMap" :key="k" :label="v" :value="Number(k)" /></el-select> <el-select v-model="searchForm.category" placeholder="分类" clearable style="width:140px"><el-option v-for="(v,k) in categoryMap" :key="k" :label="v" :value="Number(k)" /></el-select>
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option label="启用" :value="1" /><el-option label="停用" :value="0" /></el-select> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option label="启用" :value="1" /><el-option label="停用" :value="0" /></el-select>
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增项目</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe> <div class="flex items-center gap-3"></div>
<el-table-column prop="name" label="项目名称" min-width="150" /> <el-button type="primary" @click="handleAdd">新增项目</el-button>
<el-table-column label="分类" width="120"><template #default="{ row }">{{ categoryMap[row.category] || '-' }}</template></el-table-column> </div>
<el-table-column prop="price" label="价格(元)" width="100" align="right" /> <div class="px-2">
<el-table-column prop="duration" label="时长(分)" width="100" align="center" /> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="row.status===1?'success':'info'" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column> <el-table-column prop="name" label="项目名称" min-width="150" />
<el-table-column label="操作" width="160" fixed="right" align="center"> <el-table-column label="分类" width="120"><template #default="{ row }">{{ categoryMap[row.category] || '-' }}</template></el-table-column>
<template #default="{ row }"> <el-table-column prop="price" label="价格(元)" width="100" align="right" />
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-table-column prop="duration" label="时长(分)" width="100" align="center" />
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="row.status===1?'success':'info'" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column>
</template> <el-table-column label="操作" width="160" fixed="right" align="center">
</el-table-column> <template #default="{ row }">
</el-table> <el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[15,30,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[15,30,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑项目':'新增项目'" width="500px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑项目':'新增项目'" width="500px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
+27 -20
View File
@@ -73,31 +73,38 @@ onMounted(() => fetchList())
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.order_no" placeholder="订单号" style="width:180px" clearable /> <el-input v-model="searchForm.order_no" placeholder="订单号" style="width:180px" clearable />
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option v-for="(v,k) in statusMap" :key="k" :label="v" :value="Number(k)" /></el-select> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option v-for="(v,k) in statusMap" :key="k" :label="v" :value="Number(k)" /></el-select>
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增订单</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe> <div class="flex items-center gap-3"></div>
<el-table-column prop="order_no" label="订单号" min-width="160" /> <el-button type="primary" @click="handleAdd">新增订单</el-button>
<el-table-column label="客户" min-width="120"><template #default="{ row }">{{ row.customer?.name || '-' }}</template></el-table-column> </div>
<el-table-column label="类型" width="100"><template #default="{ row }">{{ typeMap[row.type] || '-' }}</template></el-table-column> <div class="px-2">
<el-table-column prop="actual_amount" label="实付(元)" width="100" align="right" /> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="状态" width="90" align="center"><template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column> <el-table-column prop="order_no" label="订单号" min-width="160" />
<el-table-column label="操作" width="260" fixed="right" align="center"> <el-table-column label="客户" min-width="120"><template #default="{ row }">{{ row.customer?.name || '-' }}</template></el-table-column>
<template #default="{ row }"> <el-table-column label="类型" width="100"><template #default="{ row }">{{ typeMap[row.type] || '-' }}</template></el-table-column>
<el-button v-if="row.status===0" size="small" type="success" link @click="handlePay(row)">付款</el-button> <el-table-column prop="actual_amount" label="实付(元)" width="100" align="right" />
<el-button v-if="row.status===1" size="small" type="primary" link @click="handleComplete(row)">完成</el-button> <el-table-column label="状态" width="90" align="center"><template #default="{ row }"><el-tag :type="statusType[row.status]" size="small">{{ statusMap[row.status] }}</el-tag></template></el-table-column>
<el-button v-if="row.status<2" size="small" type="warning" link @click="handleEdit(row)">编辑</el-button> <el-table-column label="操作" width="260" fixed="right" align="center">
<el-button v-if="row.status===0" size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <template #default="{ row }">
</template> <el-button v-if="row.status===0" size="small" type="success" text @click="handlePay(row)">付款</el-button>
</el-table-column> <el-button v-if="row.status===1" size="small" type="primary" text @click="handleComplete(row)">完成</el-button>
</el-table> <el-button v-if="row.status<2" size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[15,30,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <el-button v-if="row.status===0" size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[15,30,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑订单':'新增订单'" width="550px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑订单':'新增订单'" width="550px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
+25 -18
View File
@@ -61,29 +61,36 @@ onMounted(() => { fetchList(); fetchItems() })
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input v-model="searchForm.name" placeholder="套餐名称" style="width:180px" clearable /> <el-input v-model="searchForm.name" placeholder="套餐名称" style="width:180px" clearable />
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option label="启用" :value="1" /><el-option label="停用" :value="0" /></el-select> <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:120px"><el-option label="启用" :value="1" /><el-option label="停用" :value="0" /></el-select>
<el-button type="primary" @click="handleSearch">查询</el-button> <el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</div> </div>
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"><el-button type="primary" @click="handleAdd">新增套餐</el-button></div> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<el-table v-loading="loading" :data="tableData" border stripe> <div class="flex items-center gap-3"></div>
<el-table-column prop="name" label="套餐名称" min-width="180" /> <el-button type="primary" @click="handleAdd">新增套餐</el-button>
<el-table-column prop="original_price" label="原价(元)" width="100" align="right" /> </div>
<el-table-column prop="price" label="套餐价(元)" width="110" align="right" /> <div class="px-2">
<el-table-column label="包含项目" min-width="200"><template #default="{ row }">{{ Array.isArray(row.items) ? row.items.length + '个项目' : '-' }}</template></el-table-column> <el-table v-loading="loading" :data="tableData" style="width:100%">
<el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="row.status===1?'success':'info'" size="small">{{ row.status===1?'启用':'停用' }}</el-tag></template></el-table-column> <el-table-column prop="name" label="套餐名称" min-width="180" />
<el-table-column label="操作" width="160" fixed="right" align="center"> <el-table-column prop="original_price" label="原价(元)" width="100" align="right" />
<template #default="{ row }"> <el-table-column prop="price" label="套餐价(元)" width="110" align="right" />
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-table-column label="包含项目" min-width="200"><template #default="{ row }">{{ Array.isArray(row.items) ? row.items.length + '个项目' : '-' }}</template></el-table-column>
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-table-column label="状态" width="80" align="center"><template #default="{ row }"><el-tag :type="row.status===1?'success':'info'" size="small">{{ row.status===1?'启用':'停用' }}</el-tag></template></el-table-column>
</template> <el-table-column label="操作" width="160" fixed="right" align="center">
</el-table-column> <template #default="{ row }">
</el-table> <el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[15,30,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" /> <el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="px-5 py-3 border-t border-gray-50">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page" :total="total" :page-sizes="[15,30,50]" layout="total,sizes,prev,pager,next,jumper" background @current-change="handlePageChange" @size-change="handleSizeChange" />
</div>
</div> </div>
<el-dialog v-model="dialogVisible" :title="isEdit?'编辑套餐':'新增套餐'" width="600px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit?'编辑套餐':'新增套餐'" width="600px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
+56 -49
View File
@@ -73,9 +73,10 @@ function buildParentOptions(excludeId) {
async function fetchTree() { async function fetchTree() {
loading.value = true loading.value = true
try { try {
const res = await departmentApi.getTree() const res = await departmentApi.getList({ per_page: 500 })
treeData.value = res.data || [] const flat = res.data?.list || res.data?.data || []
flatDepts.value = flattenTree(treeData.value) flatDepts.value = flat.map(d => ({ id: d.id, name: d.name }))
treeData.value = buildTree(flat)
} catch { } catch {
// //
} finally { } finally {
@@ -83,6 +84,12 @@ async function fetchTree() {
} }
} }
function buildTree(nodes, parentId = null) {
return nodes
.filter(n => (n.parent_id ?? null) === parentId)
.map(n => ({ ...n, children: buildTree(nodes, n.id) }))
}
function handleAdd(parentRow) { function handleAdd(parentRow) {
isEdit.value = false isEdit.value = false
dialogTitle.value = parentRow ? `在「${parentRow.name}」下新增部门` : '新增顶级部门' dialogTitle.value = parentRow ? `在「${parentRow.name}」下新增部门` : '新增顶级部门'
@@ -99,18 +106,16 @@ function handleAdd(parentRow) {
async function handleEdit(row) { async function handleEdit(row) {
isEdit.value = true isEdit.value = true
dialogTitle.value = `编辑部门 - ${row.name}` dialogTitle.value = `编辑部门 - ${row.name}`
const res = await departmentApi.getDetail(row.id)
const dept = res.data
Object.assign(form, { Object.assign(form, {
id: dept.id, id: row.id,
parent_id: dept.parent_id, parent_id: row.parent_id,
name: dept.name, name: row.name,
code: dept.code || '', code: row.code || '',
leader: dept.leader || '', leader: row.leader || '',
phone: dept.phone || '', phone: row.phone || '',
sort: dept.sort || 0, sort: row.sort || 0,
status: dept.status, status: row.status,
description: dept.description || '' description: row.description || ''
}) })
buildParentOptions(row.id) buildParentOptions(row.id)
dialogVisible.value = true dialogVisible.value = true
@@ -163,43 +168,45 @@ onMounted(fetchTree)
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-3">
<el-button :icon="'Refresh'" @click="fetchTree">刷新</el-button>
</div>
<el-button type="primary" :icon="'Plus'" @click="handleAdd(null)">新增顶级部门</el-button> <el-button type="primary" :icon="'Plus'" @click="handleAdd(null)">新增顶级部门</el-button>
<el-button :icon="'Refresh'" @click="fetchTree">刷新</el-button>
</div> </div>
<div class="px-2">
<el-table <el-table
v-loading="loading" v-loading="loading"
:data="treeData" :data="treeData"
border row-key="id"
row-key="id" :tree-props="{ children: 'children', hasChildren: 'has_children' }"
:tree-props="{ children: 'children', hasChildren: 'has_children' }" default-expand-all
default-expand-all style="width: 100%"
style="width: 100%" >
> <el-table-column prop="name" label="部门名称" min-width="200" />
<el-table-column prop="name" label="部门名称" min-width="200" /> <el-table-column prop="code" label="部门编码" width="120" />
<el-table-column prop="code" label="部门编码" width="120" /> <el-table-column prop="leader" label="负责人" width="110" />
<el-table-column prop="leader" label="负责人" width="110" /> <el-table-column prop="phone" label="联系电话" width="140" />
<el-table-column prop="phone" label="联系电话" width="140" /> <el-table-column prop="sort" label="排序" width="70" align="center" />
<el-table-column prop="sort" label="排序" width="70" align="center" /> <el-table-column label="状态" width="90" align="center">
<el-table-column label="状态" width="90" align="center"> <template #default="{ row }">
<template #default="{ row }"> <el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small"> {{ row.status === 1 ? '正常' : '停用' }}
{{ row.status === 1 ? '正常' : '停用' }} </el-tag>
</el-tag> </template>
</template> </el-table-column>
</el-table-column> <el-table-column prop="created_at" label="创建时间" min-width="160" />
<el-table-column prop="created_at" label="创建时间" min-width="160" /> <el-table-column label="操作" width="180" fixed="right" align="center">
<el-table-column label="操作" width="180" fixed="right" align="center"> <template #default="{ row }">
<template #default="{ row }"> <el-button size="small" type="success" text @click="handleAdd(row)">新增子部门</el-button>
<el-button size="small" type="success" link @click="handleAdd(row)">新增子部门</el-button> <el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> </template>
</template> </el-table-column>
</el-table-column> </el-table>
</el-table> </div>
</div> </div>
<!-- Dialog --> <!-- Dialog -->
@@ -107,11 +107,9 @@ function handleTypeAdd() {
async function handleTypeEdit(type) { async function handleTypeEdit(type) {
typeIsEdit.value = true typeIsEdit.value = true
typeDialogTitle.value = `编辑字典类型 - ${type.name}` typeDialogTitle.value = `编辑字典类型 - ${type.name}`
const res = await dictApi.getTypeDetail(type.id)
const t = res.data
Object.assign(typeForm, { Object.assign(typeForm, {
id: t.id, name: t.name, code: t.code, id: type.id, name: type.name, code: type.code,
description: t.description || '', status: t.status description: type.description || '', status: type.status
}) })
typeDialogVisible.value = true typeDialogVisible.value = true
} }
@@ -123,7 +121,9 @@ async function handleTypeSubmit() {
typeSubmitLoading.value = true typeSubmitLoading.value = true
try { try {
if (typeIsEdit.value) { if (typeIsEdit.value) {
await dictApi.updateType(typeForm.id, typeForm) // update items
await dictApi.deleteType(typeForm.id)
await dictApi.createType({ name: typeForm.name, code: typeForm.code, description: typeForm.description, status: typeForm.status })
ElMessage.success('字典类型更新成功') ElMessage.success('字典类型更新成功')
// Update selected type name // Update selected type name
if (selectedType.value?.id === typeForm.id) { if (selectedType.value?.id === typeForm.id) {
@@ -193,12 +193,10 @@ function handleItemAdd() {
async function handleItemEdit(item) { async function handleItemEdit(item) {
itemIsEdit.value = true itemIsEdit.value = true
itemDialogTitle.value = `编辑字典项 - ${item.label}` itemDialogTitle.value = `编辑字典项 - ${item.label}`
const res = await dictApi.getItemDetail(selectedType.value.id, item.id)
const i = res.data
Object.assign(itemForm, { Object.assign(itemForm, {
id: i.id, label: i.label, value: i.value, id: item.id, label: item.label, value: item.value,
sort: i.sort || 0, color: i.color || '', sort: item.sort || 0, color: item.color || '',
description: i.description || '', status: i.status description: item.description || '', status: item.status
}) })
itemDialogVisible.value = true itemDialogVisible.value = true
} }
@@ -210,7 +208,7 @@ async function handleItemSubmit() {
itemSubmitLoading.value = true itemSubmitLoading.value = true
try { try {
if (itemIsEdit.value) { if (itemIsEdit.value) {
await dictApi.updateItem(selectedType.value.id, itemForm.id, itemForm) await dictApi.updateItem(itemForm.id, itemForm)
ElMessage.success('字典项更新成功') ElMessage.success('字典项更新成功')
} else { } else {
await dictApi.createItem(selectedType.value.id, itemForm) await dictApi.createItem(selectedType.value.id, itemForm)
@@ -233,7 +231,7 @@ async function handleItemDelete(item) {
type: 'warning', type: 'warning',
confirmButtonClass: 'el-button--danger' confirmButtonClass: 'el-button--danger'
}) })
await dictApi.deleteItem(selectedType.value.id, item.id) await dictApi.deleteItem(item.id)
ElMessage.success('删除成功') ElMessage.success('删除成功')
fetchItemList() fetchItemList()
} }
@@ -247,15 +245,15 @@ onMounted(fetchTypeList)
</script> </script>
<template> <template>
<div class="page-container dict-page"> <div class="p-6 space-y-4">
<div class="dict-layout"> <div class="flex gap-4" style="min-height: 0">
<!-- Left: Type List --> <!-- Left: Type List -->
<div class="dict-left card"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm flex flex-col gap-3 p-4" style="width: 280px; flex-shrink: 0; height: fit-content; max-height: calc(100vh - 140px); overflow: hidden">
<div class="panel-header"> <div class="flex items-center justify-between">
<span class="panel-title">字典类型</span> <span class="text-[15px] font-semibold text-gray-800">字典类型</span>
<el-button size="small" type="primary" :icon="'Plus'" @click="handleTypeAdd" /> <el-button size="small" type="primary" :icon="'Plus'" @click="handleTypeAdd" />
</div> </div>
<div class="type-search"> <div>
<el-input <el-input
v-model="typeSearch.keyword" v-model="typeSearch.keyword"
placeholder="搜索类型" placeholder="搜索类型"
@@ -268,21 +266,21 @@ onMounted(fetchTypeList)
</el-input> </el-input>
</div> </div>
<div v-loading="typeLoading" class="type-list"> <div v-loading="typeLoading" class="flex-1 overflow-y-auto" style="min-height: 100px">
<div <div
v-for="type in typeList" v-for="type in typeList"
:key="type.id" :key="type.id"
class="type-item" class="flex items-center justify-between px-3 py-2.5 rounded-md cursor-pointer transition-colors duration-150 hover:bg-blue-50 group"
:class="{ active: selectedType?.id === type.id }" :class="{ 'bg-blue-50': selectedType?.id === type.id }"
@click="selectType(type)" @click="selectType(type)"
> >
<div class="type-item-info"> <div class="flex-1 min-w-0">
<div class="type-name">{{ type.name }}</div> <div class="text-sm truncate" :class="selectedType?.id === type.id ? 'text-blue-700 font-semibold' : 'text-gray-800'">{{ type.name }}</div>
<div class="type-code">{{ type.code }}</div> <div class="text-[11px] text-gray-400 mt-0.5">{{ type.code }}</div>
</div> </div>
<div class="type-item-actions" @click.stop> <div class="flex gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity duration-150" @click.stop>
<el-icon class="action-icon" @click="handleTypeEdit(type)"><Edit /></el-icon> <el-icon class="cursor-pointer text-gray-400 hover:text-blue-500 text-[14px]" @click="handleTypeEdit(type)"><Edit /></el-icon>
<el-icon class="action-icon danger" @click="handleTypeDelete(type)"><Delete /></el-icon> <el-icon class="cursor-pointer text-gray-400 hover:text-red-500 text-[14px]" @click="handleTypeDelete(type)"><Delete /></el-icon>
</div> </div>
</div> </div>
<el-empty v-if="!typeLoading && typeList.length === 0" description="暂无字典类型" :image-size="60" /> <el-empty v-if="!typeLoading && typeList.length === 0" description="暂无字典类型" :image-size="60" />
@@ -300,9 +298,9 @@ onMounted(fetchTypeList)
</div> </div>
<!-- Right: Item List --> <!-- Right: Item List -->
<div class="dict-right card"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm flex-1 min-w-0">
<div class="panel-header"> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<span class="panel-title"> <span class="text-[15px] font-semibold text-gray-800">
{{ selectedType ? `字典项 — ${selectedType.name}${selectedType.code}` : '字典项' }} {{ selectedType ? `字典项 — ${selectedType.name}${selectedType.code}` : '字典项' }}
</span> </span>
<el-button size="small" type="primary" :icon="'Plus'" :disabled="!selectedType" @click="handleItemAdd"> <el-button size="small" type="primary" :icon="'Plus'" :disabled="!selectedType" @click="handleItemAdd">
@@ -310,43 +308,43 @@ onMounted(fetchTypeList)
</el-button> </el-button>
</div> </div>
<el-table <div class="px-2">
v-loading="itemLoading" <el-table
:data="itemList" v-loading="itemLoading"
border :data="itemList"
stripe style="width: 100%"
style="width: 100%" >
> <el-table-column prop="label" label="字典标签" min-width="140">
<el-table-column prop="label" label="字典标签" min-width="140"> <template #default="{ row }">
<template #default="{ row }"> <el-tag
<el-tag v-if="row.color"
v-if="row.color" :color="row.color"
:color="row.color" style="color: #fff; border-color: transparent"
style="color: #fff; border-color: transparent" size="small"
size="small" >
> {{ row.label }}
{{ row.label }} </el-tag>
</el-tag> <span v-else>{{ row.label }}</span>
<span v-else>{{ row.label }}</span> </template>
</template> </el-table-column>
</el-table-column> <el-table-column prop="value" label="字典值" min-width="120" />
<el-table-column prop="value" label="字典值" min-width="120" /> <el-table-column prop="sort" label="排序" width="70" align="center" />
<el-table-column prop="sort" label="排序" width="70" align="center" /> <el-table-column label="状态" width="80" align="center">
<el-table-column label="状态" width="80" align="center"> <template #default="{ row }">
<template #default="{ row }"> <el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small"> {{ row.status === 1 ? '启用' : '禁用' }}
{{ row.status === 1 ? '启用' : '禁用' }} </el-tag>
</el-tag> </template>
</template> </el-table-column>
</el-table-column> <el-table-column prop="description" label="备注" min-width="160" show-overflow-tooltip />
<el-table-column prop="description" label="备注" min-width="160" show-overflow-tooltip /> <el-table-column label="操作" width="120" fixed="right" align="center">
<el-table-column label="操作" width="120" fixed="right" align="center"> <template #default="{ row }">
<template #default="{ row }"> <el-button size="small" type="primary" text @click="handleItemEdit(row)">编辑</el-button>
<el-button size="small" type="primary" link @click="handleItemEdit(row)">编辑</el-button> <el-button size="small" type="danger" text @click="handleItemDelete(row)">删除</el-button>
<el-button size="small" type="danger" link @click="handleItemDelete(row)">删除</el-button> </template>
</template> </el-table-column>
</el-table-column> </el-table>
</el-table> </div>
<el-empty <el-empty
v-if="!itemLoading && !selectedType" v-if="!itemLoading && !selectedType"
@@ -354,16 +352,16 @@ onMounted(fetchTypeList)
style="margin-top: 40px" style="margin-top: 40px"
/> />
<el-pagination <div v-if="itemTotal > 0" class="px-5 py-3 border-t border-gray-50">
v-if="itemTotal > 0" <el-pagination
v-model:current-page="itemPagination.page" v-model:current-page="itemPagination.page"
:page-size="itemPagination.page_size" :page-size="itemPagination.page_size"
:total="itemTotal" :total="itemTotal"
layout="total, prev, pager, next" layout="total, prev, pager, next"
background background
style="margin-top: 16px" @current-change="handleItemPageChange"
@current-change="handleItemPageChange" />
/> </div>
</div> </div>
</div> </div>
+88 -80
View File
@@ -63,8 +63,9 @@ const commonIcons = [
async function fetchTree() { async function fetchTree() {
loading.value = true loading.value = true
try { try {
const res = await menuApi.getTree() const res = await menuApi.getList({ per_page: 500 })
treeData.value = res.data || [] const flat = res.data?.list || res.data?.data || []
treeData.value = buildTree(flat)
} catch { } catch {
// //
} finally { } finally {
@@ -72,6 +73,13 @@ async function fetchTree() {
} }
} }
function buildTree(nodes, parentId = null) {
return nodes
.filter(n => (n.parent_id ?? null) === parentId)
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
.map(n => ({ ...n, children: buildTree(nodes, n.id) }))
}
function menuTypeTag(type) { function menuTypeTag(type) {
const map = { 1: '', 2: 'success', 3: 'info' } const map = { 1: '', 2: 'success', 3: 'info' }
return map[type] || '' return map[type] || ''
@@ -98,21 +106,19 @@ function handleAdd(parentRow) {
async function handleEdit(row) { async function handleEdit(row) {
isEdit.value = true isEdit.value = true
dialogTitle.value = `编辑菜单 - ${row.title}` dialogTitle.value = `编辑菜单 - ${row.title}`
const res = await menuApi.getDetail(row.id)
const menu = res.data
Object.assign(form, { Object.assign(form, {
id: menu.id, id: row.id,
parent_id: menu.parent_id, parent_id: row.parent_id,
title: menu.title, title: row.title,
name: menu.name || '', name: row.name || '',
icon: menu.icon || '', icon: row.icon || '',
path: menu.path || '', path: row.path || '',
component: menu.component || '', component: row.component || '',
type: menu.type, type: row.type,
sort: menu.sort || 0, sort: row.sort || 0,
visible: menu.visible, visible: row.visible,
status: menu.status, status: row.status,
redirect: menu.redirect || '' redirect: row.redirect || ''
}) })
dialogVisible.value = true dialogVisible.value = true
} }
@@ -162,72 +168,74 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-3">
<el-button :icon="'Refresh'" @click="fetchTree">刷新</el-button>
</div>
<el-button type="primary" :icon="'Plus'" @click="handleAdd(null)">新增顶级菜单</el-button> <el-button type="primary" :icon="'Plus'" @click="handleAdd(null)">新增顶级菜单</el-button>
<el-button :icon="'Refresh'" @click="fetchTree">刷新</el-button>
</div> </div>
<div class="px-2">
<el-table <el-table
v-loading="loading" v-loading="loading"
:data="treeData" :data="treeData"
border row-key="id"
row-key="id" :tree-props="{ children: 'children', hasChildren: 'has_children' }"
:tree-props="{ children: 'children', hasChildren: 'has_children' }" default-expand-all
default-expand-all style="width: 100%"
style="width: 100%" >
> <el-table-column prop="title" label="菜单名称" min-width="180">
<el-table-column prop="title" label="菜单名称" min-width="180"> <template #default="{ row }">
<template #default="{ row }"> <span style="display: flex; align-items: center; gap: 6px">
<span style="display: flex; align-items: center; gap: 6px"> <el-icon v-if="row.icon" size="14">
<el-icon v-if="row.icon" size="14"> <component :is="row.icon" />
<component :is="row.icon" /> </el-icon>
</el-icon> {{ row.title }}
{{ row.title }} </span>
</span> </template>
</template> </el-table-column>
</el-table-column> <el-table-column label="类型" width="80" align="center">
<el-table-column label="类型" width="80" align="center"> <template #default="{ row }">
<template #default="{ row }"> <el-tag :type="menuTypeTag(row.type)" size="small">
<el-tag :type="menuTypeTag(row.type)" size="small"> {{ menuTypeLabel(row.type) }}
{{ menuTypeLabel(row.type) }} </el-tag>
</el-tag> </template>
</template> </el-table-column>
</el-table-column> <el-table-column prop="path" label="路由路径" min-width="180" show-overflow-tooltip />
<el-table-column prop="path" label="路由路径" min-width="180" show-overflow-tooltip /> <el-table-column prop="component" label="组件路径" min-width="200" show-overflow-tooltip />
<el-table-column prop="component" label="组件路径" min-width="200" show-overflow-tooltip /> <el-table-column prop="sort" label="排序" width="70" align="center" />
<el-table-column prop="sort" label="排序" width="70" align="center" /> <el-table-column label="显示" width="80" align="center">
<el-table-column label="显示" width="80" align="center"> <template #default="{ row }">
<template #default="{ row }"> <el-tag :type="row.visible === 1 ? 'success' : 'info'" size="small">
<el-tag :type="row.visible === 1 ? 'success' : 'info'" size="small"> {{ row.visible === 1 ? '显示' : '隐藏' }}
{{ row.visible === 1 ? '显示' : '隐藏' }} </el-tag>
</el-tag> </template>
</template> </el-table-column>
</el-table-column> <el-table-column label="状态" width="80" align="center">
<el-table-column label="状态" width="80" align="center"> <template #default="{ row }">
<template #default="{ row }"> <el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small"> {{ row.status === 1 ? '正常' : '停用' }}
{{ row.status === 1 ? '正常' : '停用' }} </el-tag>
</el-tag> </template>
</template> </el-table-column>
</el-table-column> <el-table-column label="操作" width="200" fixed="right" align="center">
<el-table-column label="操作" width="200" fixed="right" align="center"> <template #default="{ row }">
<template #default="{ row }"> <el-button
<el-button v-if="row.type !== 3"
v-if="row.type !== 3" size="small"
size="small" type="success"
type="success" text
link @click="handleAdd(row)"
@click="handleAdd(row)" >
> 新增子项
新增子项 </el-button>
</el-button> <el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> </template>
</template> </el-table-column>
</el-table-column> </el-table>
</el-table> </div>
</div> </div>
<!-- Dialog --> <!-- Dialog -->
+53 -52
View File
@@ -69,15 +69,13 @@ function handleAdd() {
async function handleEdit(row) { async function handleEdit(row) {
isEdit.value = true isEdit.value = true
dialogTitle.value = `编辑职务 - ${row.name}` dialogTitle.value = `编辑职务 - ${row.name}`
const res = await positionApi.getDetail(row.id)
const position = res.data
Object.assign(form, { Object.assign(form, {
id: position.id, id: row.id,
name: position.name, name: row.name,
code: position.code || '', code: row.code || '',
sort: position.sort ?? 0, sort: row.sort ?? 0,
status: position.status, status: row.status,
description: position.description || '' description: row.description || ''
}) })
dialogVisible.value = true dialogVisible.value = true
} }
@@ -148,9 +146,9 @@ onMounted(fetchList)
</script> </script>
<template> <template>
<div class="page-container"> <div class="p-6 space-y-4">
<!-- Search --> <!-- Search -->
<div class="search-bar"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-center flex-wrap gap-3">
<el-input <el-input
v-model="searchForm.keyword" v-model="searchForm.keyword"
placeholder="搜索职务名称/编码" placeholder="搜索职务名称/编码"
@@ -169,50 +167,53 @@ onMounted(fetchList)
</div> </div>
<!-- Table --> <!-- Table -->
<div class="table-container"> <div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="table-actions"> <div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-3"></div>
<el-button type="primary" :icon="'Plus'" @click="handleAdd">新增职务</el-button> <el-button type="primary" :icon="'Plus'" @click="handleAdd">新增职务</el-button>
</div> </div>
<div class="px-2">
<el-table v-loading="loading" :data="tableData" border stripe style="width: 100%"> <el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column type="index" label="#" width="60" align="center" /> <el-table-column type="index" label="#" width="60" align="center" />
<el-table-column prop="name" label="职务名称" min-width="160" /> <el-table-column prop="name" label="职务名称" min-width="160" />
<el-table-column prop="code" label="职务编码" width="140" /> <el-table-column prop="code" label="职务编码" width="140" />
<el-table-column prop="sort" label="排序" width="80" align="center" /> <el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column label="状态" width="90" align="center"> <el-table-column label="状态" width="90" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small"> <el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '启用' : '停用' }} {{ row.status === 1 ? '启用' : '停用' }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="created_at" label="创建时间" min-width="160" /> <el-table-column prop="created_at" label="创建时间" min-width="160" />
<el-table-column label="操作" width="180" fixed="right" align="center"> <el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-button size="small" type="primary" text @click="handleEdit(row)">编辑</el-button>
<el-button <el-button
size="small" size="small"
:type="row.status === 1 ? 'warning' : 'success'" :type="row.status === 1 ? 'warning' : 'success'"
link text
@click="handleStatusToggle(row)" @click="handleStatusToggle(row)"
> >
{{ row.status === 1 ? '停用' : '启用' }} {{ row.status === 1 ? '停用' : '启用' }}
</el-button> </el-button>
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> <el-button size="small" type="danger" text @click="handleDelete(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
<el-pagination <div class="px-5 py-3 border-t border-gray-50">
v-model:current-page="pagination.page" <el-pagination
v-model:page-size="pagination.page_size" v-model:current-page="pagination.page"
:total="total" v-model:page-size="pagination.page_size"
:page-sizes="[10, 20, 50]" :total="total"
layout="total, sizes, prev, pager, next, jumper" :page-sizes="[10, 20, 50]"
background layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange" background
@size-change="handleSizeChange" @current-change="handlePageChange"
/> @size-change="handleSizeChange"
/>
</div>
</div> </div>
<!-- Add/Edit Dialog --> <!-- Add/Edit Dialog -->

Some files were not shown because too many files have changed in this diff Show More