Files

555 lines
36 KiB
PHP
Raw Permalink Normal View History

{{--
文件功能:后台职务管理页面
按部门分组展示所有职务,支持新增/编辑/删除职务
编辑时可通过多选框配置该职务可任命的目标职务列表(任命白名单)
@author ChatRoom Laravel
@version 1.0.0
--}}
@extends('admin.layouts.app')
@section('title', '职务管理')
@section('content')
<div x-data="{
showForm: false,
editing: null,
selectedIds: [],
selectedPermissions: [],
form: {
department_id: '',
name: '',
icon: '🎖️',
rank: 50,
level: 60,
max_persons: 1,
max_reward: '',
2026-03-01 11:09:29 +08:00
daily_reward_limit: '',
recipient_daily_limit: '',
2026-04-24 23:09:32 +08:00
red_packet_amount: 8888,
red_packet_count: 10,
sort_order: 0
},
openCreate() {
this.editing = null;
this.selectedIds = [];
this.selectedPermissions = [];
2026-04-24 23:09:32 +08:00
this.form = { department_id: '', name: '', icon: '🎖️', rank: 50, level: 60, max_persons: 1, max_reward: '', daily_reward_limit: '', recipient_daily_limit: '', red_packet_amount: 8888, red_packet_count: 10, sort_order: 0 };
this.showForm = true;
},
openEdit(pos, appointableIds, permissions) {
this.editing = pos;
this.selectedIds = appointableIds;
this.selectedPermissions = permissions;
this.form = {
department_id: pos.department_id,
name: pos.name,
icon: pos.icon || '',
rank: pos.rank,
level: pos.level,
max_persons: pos.max_persons ?? '',
max_reward: pos.max_reward !== null && pos.max_reward !== undefined ? pos.max_reward : '',
daily_reward_limit: pos.daily_reward_limit !== null && pos.daily_reward_limit !== undefined ? pos.daily_reward_limit : '',
recipient_daily_limit: pos.recipient_daily_limit !== null && pos.recipient_daily_limit !== undefined ? pos.recipient_daily_limit : '',
2026-04-24 23:09:32 +08:00
red_packet_amount: pos.red_packet_amount || 8888,
red_packet_count: pos.red_packet_count || 10,
sort_order: pos.sort_order,
};
this.showForm = true;
},
toggleId(id) {
if (this.selectedIds.includes(id)) {
this.selectedIds = this.selectedIds.filter(i => i !== id);
} else {
this.selectedIds.push(id);
}
},
isSelected(id) {
return this.selectedIds.includes(id);
},
togglePermission(code) {
if (this.selectedPermissions.includes(code)) {
this.selectedPermissions = this.selectedPermissions.filter(item => item !== code);
} else {
this.selectedPermissions.push(code);
}
},
isPermissionSelected(code) {
return this.selectedPermissions.includes(code);
}
}">
{{-- 头部 --}}
<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>
<div class="flex space-x-2">
<a href="{{ route('admin.departments.index') }}"
style="background-color:#e5e7eb;color:#374151;padding:0.5rem 1rem;border-radius:0.5rem;font-weight:700;font-size:0.875rem;display:inline-flex;align-items:center;text-decoration:none;"
onmouseover="this.style.backgroundColor='#d1d5db'" onmouseout="this.style.backgroundColor='#e5e7eb'">
部门管理
</a>
<a href="{{ route('admin.appointments.index') }}"
style="background-color:#f97316;color:#fff;padding:0.5rem 1rem;border-radius:0.5rem;font-weight:700;font-size:0.875rem;display:inline-flex;align-items:center;text-decoration:none;box-shadow:0 1px 2px rgba(0,0,0,.1);"
onmouseover="this.style.backgroundColor='#ea580c'" onmouseout="this.style.backgroundColor='#f97316'">
🎖️ 任命管理
</a>
@if (Auth::id() === 1)
<button @click="openCreate()"
style="background-color:#4f46e5;color:#fff;padding:0.5rem 1.25rem;border-radius:0.5rem;font-weight:700;border:none;cursor:pointer;box-shadow:0 1px 2px rgba(0,0,0,.1);"
onmouseover="this.style.backgroundColor='#4338ca'"
onmouseout="this.style.backgroundColor='#4f46e5'">
+ 新增职务
</button>
@endif
</div>
</div>
{{-- 全局奖励接收上限配置卡片(失焦/回车自动保存) --}}
<div class="mb-6 bg-amber-50 border border-amber-200 rounded-xl p-5" x-data="{
val: {{ $globalRecipientDailyMax }},
saving: false,
saved: false,
error: '',
async save() {
if (this.saving) return;
this.saving = true;
this.saved = false;
this.error = '';
try {
const res = await fetch('{{ route('admin.positions.reward_config') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]')?.content ||
'{{ csrf_token() }}',
'Accept': 'application/json',
},
body: JSON.stringify({ reward_recipient_daily_max: this.val }),
});
if (res.ok) {
this.saved = true;
setTimeout(() => this.saved = false, 3000);
} else {
const d = await res.json().catch(() => ({}));
this.error = d.message || '保存失败';
}
} catch {
this.error = '网络异常';
}
this.saving = false;
}
}">
<div class="flex items-center gap-6 flex-wrap">
<div class="flex-1 min-w-0">
<h3 class="text-sm font-bold text-amber-800 mb-1">💰 全局奖励接收上限</h3>
<p class="text-xs text-amber-700 leading-relaxed">
每位用户单日内可从<b>所有职务持有者</b>处累计接收奖励金币的最高次数。
设为 <code class="bg-amber-100 px-1 rounded">0</code> 表示不限制。
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<label class="text-xs text-amber-700 font-bold whitespace-nowrap">每日上限:</label>
<input type="number" x-model.number="val" min="0" max="9999" @blur="save()"
@keydown.enter.prevent="save()"
class="w-20 h-8 px-2 text-sm border border-amber-300 rounded-md bg-white text-amber-900
focus:outline-none focus:ring-2 focus:ring-amber-400 text-center">
<span class="text-xs text-amber-600 whitespace-nowrap">次(0=不限)</span>
{{-- 状态反馈 --}}
<span x-show="saving" class="text-xs text-amber-500 whitespace-nowrap">保存中…</span>
<span x-show="saved" class="text-xs text-green-600 font-bold whitespace-nowrap"> 已保存</span>
<span x-show="error" x-text="error" class="text-xs text-red-500 whitespace-nowrap"></span>
</div>
</div>
</div>
{{-- 按部门分组展示职务 --}}
@foreach ($departments as $dept)
<div class="mb-8">
<div class="flex items-center space-x-3 mb-3">
<div class="w-3 h-3 rounded-full" style="background-color: {{ $dept->color }}"></div>
<h3 class="font-bold text-base" style="color: {{ $dept->color }}">{{ $dept->name }}</h3>
<span class="text-xs text-gray-400">位阶 {{ $dept->rank }}</span>
</div>
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs">
<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-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>
<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>
2026-04-24 23:09:32 +08:00
<th class="px-4 py-3 text-center">礼包默认</th>
<th class="px-4 py-3 text-center">聊天室权限</th>
@php $superLvl = (int) \App\Models\Sysparam::getValue('superlevel', '100'); @endphp
@if (Auth::user()->user_level >= $superLvl)
<th class="px-4 py-3 text-right">操作</th>
@endif
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($dept->positions as $pos)
@php $appointableIds = $pos->appointablePositions->pluck('id')->toArray(); @endphp
<tr class="hover:bg-gray-50 transition">
<td class="px-4 py-3 text-xl">{{ $pos->icon }}</td>
<td class="px-4 py-3 font-bold">{{ $pos->name }}</td>
<td class="px-4 py-3 text-center">
<span
class="text-xs bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded font-mono">{{ $pos->rank }}</span>
</td>
<td class="px-4 py-3 text-center">
<span
class="text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded font-mono">Lv.{{ $pos->level }}</span>
</td>
{{-- 人数上限:内联编辑 --}}
<td class="px-2 py-3 text-center" x-data="inlinePatch({{ $pos->id }}, 'max_persons', {{ $pos->max_persons ?? 'null' }})">
<input type="number" x-model.number="val" min="1" max="9999"
@blur="save()" @keydown.enter.prevent="$el.blur()" placeholder="不限"
class="w-16 text-center text-xs border-0 border-b border-dashed border-gray-300
bg-transparent focus:outline-none focus:border-indigo-400
text-gray-600 py-0.5">
<span x-show="saved" class="block text-green-500 text-xs leading-none"></span>
<span x-show="error" x-text="error"
class="block text-red-400 text-xs leading-none"></span>
</td>
<td class="px-4 py-3 text-center">
<span
class="{{ $pos->active_user_positions_count >= ($pos->max_persons ?? 999) ? 'text-red-600 font-bold' : 'text-indigo-600' }}">
{{ $pos->active_user_positions_count }}&nbsp;
</span>
</td>
{{-- 单次奖励上限:内联编辑 --}}
<td class="px-2 py-3 text-center" x-data="inlinePatch({{ $pos->id }}, 'max_reward', {{ $pos->max_reward ?? 'null' }})">
<input type="number" x-model.number="val" min="0" max="999999"
@blur="save()" @keydown.enter.prevent="$el.blur()" placeholder="不限"
class="w-16 text-center text-xs border-0 border-b border-dashed border-gray-300
bg-transparent focus:outline-none focus:border-amber-400
text-amber-700 py-0.5">
<span x-show="saved" class="block text-green-500 text-xs leading-none"></span>
<span x-show="error" x-text="error"
class="block text-red-400 text-xs leading-none"></span>
</td>
{{-- 单日发放总上限:内联编辑 --}}
<td class="px-2 py-3 text-center" x-data="inlinePatch({{ $pos->id }}, 'daily_reward_limit', {{ $pos->daily_reward_limit ?? 'null' }})">
<input type="number" x-model.number="val" min="0" max="999999"
@blur="save()" @keydown.enter.prevent="$el.blur()" placeholder="不限"
class="w-16 text-center text-xs border-0 border-b border-dashed border-gray-300
bg-transparent focus:outline-none focus:border-indigo-400
text-indigo-700 py-0.5">
<span x-show="saved" class="block text-green-500 text-xs leading-none"></span>
<span x-show="error" x-text="error"
class="block text-red-400 text-xs leading-none"></span>
</td>
<td class="px-4 py-3 text-center">
@if (count($appointableIds) > 0)
<span
class="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">{{ count($appointableIds) }}
个职务</span>
@else
<span class="text-xs text-gray-400"></span>
@endif
</td>
2026-04-24 23:09:32 +08:00
<td class="px-4 py-3 text-center">
<div class="text-xs leading-5 text-red-700">
<div class="font-bold">{{ number_format((int) ($pos->red_packet_amount ?? 8888)) }}</div>
<div class="text-gray-400">{{ (int) ($pos->red_packet_count ?? 10) }} </div>
</div>
</td>
<td class="px-4 py-3">
@if (! empty($pos->permissions))
@php
$permissionSummaryLabels = collect($pos->permissions)
->map(fn ($permissionCode) => $permissionLabels[$permissionCode] ?? $permissionCode)
->values();
$permissionPreview = $permissionSummaryLabels->take(2)->implode('、');
$permissionTitle = $permissionSummaryLabels->implode(' / ');
@endphp
<div class="mx-auto max-w-[220px] rounded-xl border border-amber-100 bg-gradient-to-br from-amber-50 via-white to-orange-50 px-3 py-2 shadow-sm"
title="{{ $permissionTitle }}">
<div class="flex items-center justify-between gap-3">
<span class="text-[11px] font-semibold tracking-[0.08em] text-amber-700">已开通</span>
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-bold text-amber-700">
{{ $permissionSummaryLabels->count() }}
</span>
</div>
<div class="mt-1 text-xs leading-5 text-slate-600">
{{ $permissionPreview }}
@if ($permissionSummaryLabels->count() > 2)
<span class="text-amber-600"> {{ $permissionSummaryLabels->count() }} </span>
@endif
</div>
</div>
@else
<div class="text-center text-xs text-gray-400"></div>
@endif
</td>
<td class="px-4 py-3 text-right space-x-1">
@php $superLvl = (int) \App\Models\Sysparam::getValue('superlevel', '100'); @endphp
@if (Auth::user()->user_level >= $superLvl)
<button
@click="openEdit({
id: {{ $pos->id }},
department_id: {{ $pos->department_id }},
name: '{{ addslashes($pos->name) }}',
icon: '{{ $pos->icon }}',
rank: {{ $pos->rank }},
level: {{ $pos->level }},
max_persons: {{ $pos->max_persons ?? 'null' }},
max_reward: {{ $pos->max_reward ?? 'null' }},
2026-03-01 11:09:29 +08:00
daily_reward_limit: {{ $pos->daily_reward_limit ?? 'null' }},
recipient_daily_limit: {{ $pos->recipient_daily_limit ?? 'null' }},
2026-04-24 23:09:32 +08:00
red_packet_amount: {{ $pos->red_packet_amount ?? 8888 }},
red_packet_count: {{ $pos->red_packet_count ?? 10 }},
sort_order: {{ $pos->sort_order }},
requestUrl: '{{ route('admin.positions.update', $pos->id) }}'
}, {{ json_encode($appointableIds) }}, {{ json_encode($pos->permissions ?? []) }})"
class="text-xs bg-indigo-50 text-indigo-600 font-bold px-2 py-1 rounded hover:bg-indigo-600 hover:text-white transition">
编辑
</button>
@endif
@if (Auth::id() === 1)
<form action="{{ route('admin.positions.destroy', $pos->id) }}"
method="POST" class="inline"
onsubmit="return confirm('确定删除职务【{{ $pos->name }}】?')">
@csrf @method('DELETE')
<button type="submit"
class="text-xs bg-red-50 text-red-600 font-bold px-2 py-1 rounded hover:bg-red-600 hover:text-white transition">
删除
</button>
</form>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="{{ Auth::user()->user_level >= $superLvl ? 11 : 10 }}"
class="px-4 py-6 text-center text-gray-400">该部门暂无职务</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endforeach
{{-- 新增/编辑弹窗 --}}
<div x-show="showForm" style="display: none;"
class="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4">
<div @click.away="showForm = false"
class="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[92vh] overflow-y-auto" x-transition>
<div
class="bg-indigo-900 px-6 py-4 flex justify-between items-center rounded-t-xl text-white sticky top-0">
<h3 class="font-bold text-lg" x-text="editing ? '编辑职务:' + editing.name : '新增职务'"></h3>
<button @click="showForm = false" class="text-gray-400 hover:text-white text-xl">&times;</button>
</div>
<div class="p-6">
<form :action="editing ? editing.requestUrl : '{{ route('admin.positions.store') }}'" method="POST">
@csrf
<template x-if="editing"><input type="hidden" name="_method" value="PUT"></template>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="col-span-2">
<label class="block text-xs font-bold text-gray-600 mb-1">所属部门</label>
<select name="department_id" x-model="form.department_id" required
class="w-full border rounded-md p-2 text-sm">
<option value="">-- 请选择部门 --</option>
@foreach ($departments as $dept)
<option value="{{ $dept->id }}">{{ $dept->name }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">职务名称</label>
<input type="text" name="name" x-model="form.name" required maxlength="50"
class="w-full border rounded-md p-2 text-sm">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">图标(Emoji</label>
<input type="text" name="icon" x-model="form.icon" maxlength="10"
class="w-full border rounded-md p-2 text-sm">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">位阶(0~99,跨全局排序)</label>
<input type="number" name="rank" x-model="form.rank" required min="0"
max="99" class="w-full border rounded-md p-2 text-sm">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">等级(user_level1~100</label>
<input type="number" name="level" x-model="form.level" required min="1"
max="100" class="w-full border rounded-md p-2 text-sm">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">人数上限(空=不限)</label>
<input type="number" name="max_persons" x-model="form.max_persons" min="1"
class="w-full border rounded-md p-2 text-sm" placeholder="留空不限">
</div>
<div>
2026-03-01 11:09:29 +08:00
<label class="block text-xs font-bold text-gray-600 mb-1">单次奖励上限(空=不限)</label>
<input type="number" name="max_reward" x-model="form.max_reward" min="0"
2026-03-01 11:09:29 +08:00
class="w-full border rounded-md p-2 text-sm" placeholder="每次最多可发放金币数">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">单日发放总上限(空=不限)</label>
<input type="number" name="daily_reward_limit" x-model="form.daily_reward_limit"
min="0" class="w-full border rounded-md p-2 text-sm"
placeholder="操作人每日总计可发金币">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">同人每日次数上限(空=不限)</label>
<input type="number" name="recipient_daily_limit" x-model="form.recipient_daily_limit"
min="0" class="w-full border rounded-md p-2 text-sm"
placeholder="同一接收者每天最多收几次">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">排序</label>
<input type="number" name="sort_order" x-model="form.sort_order" required
min="0" class="w-full border rounded-md p-2 text-sm">
</div>
</div>
{{-- 任命白名单多选 --}}
<div class="border rounded-lg p-4 bg-gray-50">
<h4 class="text-xs font-bold text-gray-700 mb-2">
任命权限白名单
<span class="font-normal text-gray-400 ml-1">(勾选后此职务持有者可将用户任命到以下职务;不勾选则该职务无任命权)</span>
</h4>
<div class="grid grid-cols-2 gap-1 max-h-52 overflow-y-auto">
@foreach ($allPositions as $ap)
<label
class="flex items-center space-x-2 cursor-pointer hover:bg-white rounded p-1.5 text-sm"
:class="isSelected({{ $ap->id }}) ? 'bg-indigo-50 text-indigo-700 font-bold' :
'text-gray-700'">
<input type="checkbox" name="appointable_ids[]" value="{{ $ap->id }}"
:checked="isSelected({{ $ap->id }})"
@change="toggleId({{ $ap->id }})" class="rounded text-indigo-600">
<span>{{ $ap->department->name }}·{{ $ap->name }}</span>
</label>
@endforeach
</div>
</div>
{{-- 聊天室权限多选 --}}
<div class="border rounded-lg p-4 bg-amber-50 mt-4">
<h4 class="text-xs font-bold text-amber-800 mb-2">
权限管理
<span class="font-normal text-amber-700 ml-1">(控制聊天室输入框上方「管理」菜单中可见的功能按钮)</span>
</h4>
<div class="space-y-4">
@foreach ($positionPermissions as $groupName => $permissions)
<div>
<div class="text-xs font-bold text-amber-700 mb-2">{{ $groupName }}</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
@foreach ($permissions as $permissionCode => $permissionMeta)
<label
class="flex items-start gap-2 cursor-pointer rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm"
:class="isPermissionSelected('{{ $permissionCode }}') ? 'ring-2 ring-amber-300 border-amber-300' : ''">
<input type="checkbox" name="permissions[]" value="{{ $permissionCode }}"
:checked="isPermissionSelected('{{ $permissionCode }}')"
@change="togglePermission('{{ $permissionCode }}')"
class="mt-0.5 rounded text-amber-600">
<span class="min-w-0">
<span class="block font-bold text-gray-700">{{ $permissionMeta['label'] }}</span>
<span class="block text-xs text-gray-500">{{ $permissionMeta['description'] }}</span>
2026-04-24 23:09:32 +08:00
@if ($permissionCode === \App\Support\PositionPermissionRegistry::ROOM_RED_PACKET)
<span class="mt-3 grid grid-cols-2 gap-2 rounded-lg border border-red-100 bg-red-50/70 p-2"
@click.stop>
<span>
<span class="mb-1 block text-[11px] font-bold text-red-700">默认礼包总量</span>
<input type="number" name="red_packet_amount"
x-model="form.red_packet_amount" required min="1"
max="999999999"
class="w-full rounded-md border border-red-200 bg-white p-1.5 text-xs text-red-800"
placeholder="金币/经验共用">
</span>
<span>
<span class="mb-1 block text-[11px] font-bold text-red-700">默认礼包份数</span>
<input type="number" name="red_packet_count"
x-model="form.red_packet_count" required min="1"
max="100"
class="w-full rounded-md border border-red-200 bg-white p-1.5 text-xs text-red-800"
placeholder="拆成几份">
</span>
<span class="col-span-2 text-[11px] leading-4 text-red-600">
勾选后发金币/经验礼包都使用这组默认值;取消权限不会清空配置。
</span>
</span>
@endif
</span>
</label>
@endforeach
</div>
</div>
@endforeach
</div>
</div>
<div class="flex justify-end space-x-3 pt-4 mt-4 border-t">
<button type="button" @click="showForm = false"
class="px-4 py-2 border rounded font-medium text-gray-600 hover:bg-gray-50">取消</button>
<button type="submit"
class="px-4 py-2 bg-indigo-600 text-white rounded font-bold hover:bg-indigo-700 shadow-sm"
x-text="editing ? '保存修改' : '创建职务'"></button>
</div>
</form>
</div>
</div>
</div>
{{-- inlinePatch:职务列表内联编辑 Alpine 工厂函数(失焦/回车自动 PATCH 保存) --}}
<script>
function inlinePatch(positionId, field, initial) {
return {
val: initial, // null = 显示为空(placeholder "不限"
saving: false,
saved: false,
error: '',
async save() {
if (this.saving) return;
this.saving = true;
this.saved = false;
this.error = '';
try {
const body = {};
// 空字符串/null → 发 null=不限)
body[field] = (this.val === '' || this.val === null) ? null : Number(this.val);
const res = await fetch(`/admin/positions/${positionId}/patch`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]')?.content || '',
'Accept': 'application/json',
},
body: JSON.stringify(body),
});
if (res.ok) {
this.saved = true;
setTimeout(() => this.saved = false, 2000);
} else {
const d = await res.json().catch(() => ({}));
this.error = d.message || '保存失败';
setTimeout(() => this.error = '', 3000);
}
} catch {
this.error = '网络异常';
setTimeout(() => this.error = '', 3000);
}
this.saving = false;
}
};
}
</script>
</div>
@endsection