功能:后台新增商店管理页面(站长菜单)

- 路由:GET/POST/PUT/PATCH/DELETE /admin/shop
- 控制器:Admin/ShopItemController(index/store/update/toggle/destroy)
- 视图:admin/shop/index.blade.php
  - 表格展示所有商品(名称/类型色标/价格/有效期/排序/状态)
  - Alpine.js 弹窗新增/编辑(支持全字段)
  - 上下架一键切换(PATCH toggle)
  - 删除按键(含二次确认)
- 侧边栏:VIP 下方新增「🛒 商店管理」链接
- 权限:superlevel 可查看/编辑;id=1 可新增/删除
This commit is contained in:
2026-03-01 16:47:34 +08:00
parent 0ea6ea206c
commit 759fb6deae
4 changed files with 475 additions and 0 deletions

View File

@@ -0,0 +1,113 @@
<?php
/**
* 文件功能:后台商店商品管理控制器(站长功能)
*
* 提供商店商品的查看、编辑、切换上下架、删除等 CRUD 功能。
* superlevel 及以上可访问id=1 超级站长才能新增/删除。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ShopItem;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class ShopItemController extends Controller
{
/**
* 商品列表页(所有 superlevel 以上可查看)
*/
public function index(): View
{
$items = ShopItem::orderBy('sort_order')->orderBy('id')->get();
return view('admin.shop.index', compact('items'));
}
/**
* 新增商品(仅 id=1 超级站长)
*/
public function store(Request $request): RedirectResponse
{
abort_unless(Auth::id() === 1, 403);
$data = $this->validateItem($request);
ShopItem::create($data);
return redirect()->route('admin.shop.index')->with('success', '商品「'.$data['name'].'」创建成功!');
}
/**
* 更新商品信息
*
* @param ShopItem $shopItem 路由模型自动注入
*/
public function update(Request $request, ShopItem $shopItem): RedirectResponse
{
$data = $this->validateItem($request, $shopItem);
$shopItem->update($data);
return redirect()->route('admin.shop.index')->with('success', '商品「'.$shopItem->name.'」更新成功!');
}
/**
* 切换商品上下架状态
*
* @param ShopItem $shopItem 路由模型自动注入
*/
public function toggle(ShopItem $shopItem): RedirectResponse
{
$shopItem->update(['is_active' => ! $shopItem->is_active]);
$status = $shopItem->is_active ? '上架' : '下架';
return redirect()->route('admin.shop.index')->with('success', "{$shopItem->name}」已{$status}");
}
/**
* 删除商品(仅 id=1 超级站长)
*
* @param ShopItem $shopItem 路由模型自动注入
*/
public function destroy(ShopItem $shopItem): RedirectResponse
{
abort_unless(Auth::id() === 1, 403);
$name = $shopItem->name;
$shopItem->delete();
return redirect()->route('admin.shop.index')->with('success', "{$name}」已删除。");
}
/**
* 统一验证商品表单(新增/编辑共用)
*
* @return array<string, mixed>
*/
private function validateItem(Request $request, ?ShopItem $item = null): array
{
return $request->validate([
'name' => 'required|string|max:100',
'slug' => ['required', 'string', 'max:100',
\Illuminate\Validation\Rule::unique('shop_items', 'slug')->ignore($item?->id),
],
'icon' => 'required|string|max:20',
'description' => 'nullable|string|max:500',
'price' => 'required|integer|min:0',
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing',
'duration_days' => 'nullable|integer|min:0',
'duration_minutes' => 'nullable|integer|min:0',
'intimacy_bonus' => 'nullable|integer|min:0',
'charm_bonus' => 'nullable|integer|min:0',
'sort_order' => 'required|integer|min:0',
'is_active' => 'boolean',
]);
}
}

View File

@@ -67,6 +67,10 @@
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.vip.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
{!! '👑 VIP 会员等级' . $ro !!}
</a>
<a href="{{ route('admin.shop.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.shop.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
{!! '🛒 商店管理' . $ro !!}
</a>
<a href="{{ route('admin.marriages.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.marriages.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
{!! '💒 婚姻管理' . $ro !!}

View File

@@ -0,0 +1,351 @@
{{--
文件功能:后台商店商品管理页面(站长功能)
支持查看、新增、编辑、上下架切换、删除商品。
字段名称、Slug、图标、描述、价格、类型、有效期、排序、状态
@author ChatRoom Laravel
@version 1.0.0
--}}
@extends('admin.layouts.app')
@section('title', '🛒 商店商品管理')
@section('content')
@php
$typeLabels = [
'instant' => ['label' => '即时特效', 'color' => 'bg-blue-100 text-blue-700'],
'duration' => ['label' => '周卡/时效', 'color' => 'bg-purple-100 text-purple-700'],
'one_time' => ['label' => '一次性道具', 'color' => 'bg-yellow-100 text-yellow-700'],
'ring' => ['label' => '求婚戒指', 'color' => 'bg-rose-100 text-rose-700'],
'auto_fishing' => ['label' => '自动钓鱼卡', 'color' => 'bg-emerald-100 text-emerald-700'],
];
$isSuperAdmin = Auth::id() === 1;
@endphp
<div x-data="{
showForm: false,
editing: null,
form: {
name: '',
slug: '',
icon: '🎁',
description: '',
price: 100,
type: 'instant',
duration_days: 0,
duration_minutes: 0,
intimacy_bonus: 0,
charm_bonus: 0,
sort_order: 0,
is_active: true,
},
openCreate() {
this.editing = null;
this.form = {
name: '',
slug: '',
icon: '🎁',
description: '',
price: 100,
type: 'instant',
duration_days: 0,
duration_minutes: 0,
intimacy_bonus: 0,
charm_bonus: 0,
sort_order: 0,
is_active: true,
};
this.showForm = true;
this.$nextTick(() => this.$refs.nameInput?.focus());
},
openEdit(item) {
this.editing = item;
this.form = {
name: item.name,
slug: item.slug,
icon: item.icon,
description: item.description || '',
price: item.price,
type: item.type,
duration_days: item.duration_days || 0,
duration_minutes: item.duration_minutes || 0,
intimacy_bonus: item.intimacy_bonus || 0,
charm_bonus: item.charm_bonus || 0,
sort_order: item.sort_order,
is_active: item.is_active,
};
this.showForm = true;
this.$nextTick(() => this.$refs.nameInput?.focus());
},
closeForm() {
this.showForm = false;
this.editing = null;
}
}">
{{-- 头部操作栏 --}}
<div class="flex justify-between items-center mb-6">
<div>
<h2 class="text-lg font-bold text-gray-800">商店商品列表</h2>
<p class="text-sm text-gray-500">管理聊天室商店内所有可出售商品,支持上下架控制。</p>
</div>
@if ($isSuperAdmin)
<button @click="openCreate()"
class="bg-indigo-600 text-white px-5 py-2.5 rounded-lg font-bold hover:bg-indigo-700 transition shadow-sm">
+ 新增商品
</button>
@endif
</div>
{{-- 商品列表表格 --}}
<div class="bg-white rounded-xl shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500 uppercase text-xs tracking-wider">
<tr>
<th class="px-4 py-3 text-left">商品</th>
<th class="px-4 py-3 text-left">类型</th>
<th class="px-4 py-3 text-right">价格</th>
<th class="px-4 py-3 text-center">有效期</th>
<th class="px-4 py-3 text-center">排序</th>
<th class="px-4 py-3 text-center">状态</th>
<th class="px-4 py-3 text-center">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($items as $item)
@php $tl = $typeLabels[$item->type] ?? ['label' => $item->type, 'color' => 'bg-gray-100 text-gray-600']; @endphp
<tr class="hover:bg-gray-50 transition {{ $item->is_active ? '' : 'opacity-50' }}">
{{-- 商品信息 --}}
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<span class="text-2xl leading-none">{{ $item->icon }}</span>
<div>
<p class="font-semibold text-gray-800">{{ $item->name }}</p>
<p class="text-xs text-gray-400 font-mono">{{ $item->slug }}</p>
@if ($item->description)
<p class="text-xs text-gray-500 mt-0.5 max-w-xs truncate">
{{ $item->description }}</p>
@endif
</div>
</div>
</td>
{{-- 类型 --}}
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold {{ $tl['color'] }}">
{{ $tl['label'] }}
</span>
</td>
{{-- 价格 --}}
<td class="px-4 py-3 text-right font-mono font-bold text-amber-600">
{{ number_format($item->price) }}
</td>
{{-- 有效期 --}}
<td class="px-4 py-3 text-center text-gray-500 text-xs">
@if ($item->duration_minutes > 0)
{{ $item->duration_minutes >= 60 ? floor($item->duration_minutes / 60) . '小时' : $item->duration_minutes . '分钟' }}
@elseif ($item->duration_days > 0)
{{ $item->duration_days }}
@else
@endif
</td>
{{-- 排序 --}}
<td class="px-4 py-3 text-center text-gray-400 font-mono text-xs">{{ $item->sort_order }}</td>
{{-- 状态 --}}
<td class="px-4 py-3 text-center">
<form method="POST" action="{{ route('admin.shop.toggle', $item) }}" class="inline">
@csrf @method('PATCH')
<button type="submit"
class="px-2.5 py-1 rounded-full text-xs font-bold transition
{{ $item->is_active
? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200' }}">
{{ $item->is_active ? '上架中' : '已下架' }}
</button>
</form>
</td>
{{-- 操作 --}}
<td class="px-4 py-3">
<div class="flex items-center justify-center gap-2">
<button
@click="openEdit({{ json_encode([
'id' => $item->id,
'name' => $item->name,
'slug' => $item->slug,
'icon' => $item->icon,
'description' => $item->description,
'price' => $item->price,
'type' => $item->type,
'duration_days' => $item->duration_days,
'duration_minutes' => $item->duration_minutes,
'intimacy_bonus' => $item->intimacy_bonus,
'charm_bonus' => $item->charm_bonus,
'sort_order' => $item->sort_order,
'is_active' => (bool) $item->is_active,
]) }})"
class="text-indigo-600 hover:text-indigo-800 text-xs font-semibold px-2 py-1 rounded hover:bg-indigo-50 transition">
编辑
</button>
@if ($isSuperAdmin)
<form method="POST" action="{{ route('admin.shop.destroy', $item) }}"
onsubmit="return confirm('确定要删除「{{ $item->name }}」吗?此操作不可撤销!')">
@csrf @method('DELETE')
<button type="submit"
class="text-red-500 hover:text-red-700 text-xs font-semibold px-2 py-1 rounded hover:bg-red-50 transition">
删除
</button>
</form>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-12 text-center text-gray-400">暂无商品数据</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- 新增/编辑 抽屉弹窗 --}}
<div x-show="showForm" x-cloak class="fixed inset-0 z-50 flex items-center justify-center"
style="background: rgba(0,0,0,0.45);">
<div @click.stop class="bg-white rounded-2xl shadow-2xl w-full max-w-xl max-h-[90vh] overflow-y-auto"
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0">
{{-- 弹窗头部 --}}
<div class="flex items-center justify-between px-6 py-4 border-b">
<h3 class="font-bold text-gray-800 text-lg" x-text="editing ? '编辑商品:' + editing.name : '新增商品'"></h3>
<button @click="closeForm()"
class="text-gray-400 hover:text-gray-600 text-2xl leading-none">&times;</button>
</div>
{{-- 表单 --}}
<form method="POST"
:action="editing
?
'{{ url('admin/shop') }}/' + editing.id :
'{{ route('admin.shop.store') }}'"
class="px-6 py-5 space-y-4">
@csrf
<template x-if="editing"><input type="hidden" name="_method" value="PUT"></template>
{{-- 基本信息 --}}
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-600 mb-1">商品名称 <span
class="text-red-500">*</span></label>
<input x-ref="nameInput" type="text" name="name" x-model="form.name" required
maxlength="100"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 outline-none">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Slug唯一标识<span
class="text-red-500">*</span></label>
<input type="text" name="slug" x-model="form.slug" required maxlength="100"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 outline-none">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">图标Emoji<span
class="text-red-500">*</span></label>
<input type="text" name="icon" x-model="form.icon" required maxlength="20"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 outline-none">
</div>
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">商品描述</label>
<textarea name="description" x-model="form.description" rows="2" maxlength="500"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 outline-none resize-none"></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">价格(金币)<span
class="text-red-500">*</span></label>
<input type="number" name="price" x-model="form.price" required min="0"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 outline-none">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">商品类型 <span
class="text-red-500">*</span></label>
<select name="type" x-model="form.type" required
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 outline-none bg-white">
<option value="instant">instant 即时特效</option>
<option value="duration">duration 周卡/时效</option>
<option value="one_time">one_time 一次性道具</option>
<option value="ring">ring 求婚戒指</option>
<option value="auto_fishing">auto_fishing 自动钓鱼卡</option>
</select>
</div>
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">有效天数</label>
<input type="number" name="duration_days" x-model="form.duration_days" min="0"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 outline-none">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">有效分钟数</label>
<input type="number" name="duration_minutes" x-model="form.duration_minutes" min="0"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 outline-none">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">排序权重</label>
<input type="number" name="sort_order" x-model="form.sort_order" min="0"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 outline-none">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">亲密度加成(戒指)</label>
<input type="number" name="intimacy_bonus" x-model="form.intimacy_bonus" min="0"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 outline-none">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">魅力值加成(戒指)</label>
<input type="number" name="charm_bonus" x-model="form.charm_bonus" min="0"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 outline-none">
</div>
</div>
<div class="flex items-center gap-2">
<input type="hidden" name="is_active" :value="form.is_active ? 1 : 0">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" x-model="form.is_active" class="sr-only peer">
<div
class="w-10 h-5 bg-gray-200 peer-checked:bg-indigo-500 rounded-full transition peer-focus:ring-2 peer-focus:ring-indigo-300">
</div>
<div
class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full shadow transition peer-checked:translate-x-5">
</div>
</label>
<span class="text-sm text-gray-600" x-text="form.is_active ? '🟢 上架显示' : '⚫ 下架隐藏'"></span>
</div>
{{-- 弹窗底部按钮 --}}
<div class="flex justify-end gap-3 pt-2 border-t mt-4">
<button type="button" @click="closeForm()"
class="px-5 py-2 rounded-lg border border-gray-300 text-gray-600 hover:bg-gray-50 transition text-sm">
取消
</button>
<button type="submit"
class="px-6 py-2 bg-indigo-600 text-white rounded-lg font-bold hover:bg-indigo-700 transition text-sm shadow">
<span x-text="editing ? '保存修改' : '创建商品'"></span>
</button>
</div>
</form>
</div>
</div>
</div>
@endsection

View File

@@ -254,6 +254,13 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
Route::put('/vip/{vip}', [\App\Http\Controllers\Admin\VipController::class, 'update'])->name('vip.update');
Route::delete('/vip/{vip}', [\App\Http\Controllers\Admin\VipController::class, 'destroy'])->name('vip.destroy');
// 🛒 商店商品管理(查看/编辑所有 superlevel 可用,新增/删除仅 id=1
Route::get('/shop', [\App\Http\Controllers\Admin\ShopItemController::class, 'index'])->name('shop.index');
Route::post('/shop', [\App\Http\Controllers\Admin\ShopItemController::class, 'store'])->name('shop.store');
Route::put('/shop/{shopItem}', [\App\Http\Controllers\Admin\ShopItemController::class, 'update'])->name('shop.update');
Route::patch('/shop/{shopItem}/toggle', [\App\Http\Controllers\Admin\ShopItemController::class, 'toggle'])->name('shop.toggle');
Route::delete('/shop/{shopItem}', [\App\Http\Controllers\Admin\ShopItemController::class, 'destroy'])->name('shop.destroy');
// 💒 婚姻管理superlevel 及以上)
Route::prefix('marriages')->name('marriages.')->group(function () {
// 总览统计