Files
chatroom/resources/views/admin/positions/index.blade.php
lkddi 040dbdef3c 优化:全站金币图标由 🪙(银灰色)统一替换为 💰(金黄色)
🪙 在多数平台/字体上渲染为银灰色,与「金币」语义不符;
💰 各平台均渲染为金黄色,更直观传达金币概念。

涉及文件(43处):
- app/Jobs:百家乐、赛马结算广播
- app/Http/Controllers:管理员命令、红包、老虎机、神秘箱子
- app/Listeners
- resources/views:聊天室各游戏面板、商店、toolbar、后台页面等
2026-03-04 15:00:02 +08:00

453 lines
28 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{--
文件功能:后台职务管理页面
按部门分组展示所有职务,支持新增/编辑/删除职务
编辑时可通过多选框配置该职务可任命的目标职务列表(任命白名单)
@author ChatRoom Laravel
@version 1.0.0
--}}
@extends('admin.layouts.app')
@section('title', '职务管理')
@section('content')
<div x-data="{
showForm: false,
editing: null,
selectedIds: [],
form: {
department_id: '',
name: '',
icon: '🎖️',
rank: 50,
level: 60,
max_persons: 1,
max_reward: '',
daily_reward_limit: '',
recipient_daily_limit: '',
sort_order: 0
},
openCreate() {
this.editing = null;
this.selectedIds = [];
this.form = { department_id: '', name: '', icon: '🎖️', rank: 50, level: 60, max_persons: 1, max_reward: '', daily_reward_limit: '', recipient_daily_limit: '', sort_order: 0 };
this.showForm = true;
},
openEdit(pos, appointableIds) {
this.editing = pos;
this.selectedIds = appointableIds;
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 : '',
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);
}
}">
{{-- 头部 --}}
<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>
@if (session('success'))
<div class="mb-4 px-4 py-3 bg-green-50 border border-green-200 text-green-700 rounded-lg text-sm">
{{ session('success') }}</div>
@endif
@if (session('error'))
<div class="mb-4 px-4 py-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{{ session('error') }}</div>
@endif
{{-- 全局奖励接收上限配置卡片(失焦/回车自动保存) --}}
<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>
@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>
<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' }},
daily_reward_limit: {{ $pos->daily_reward_limit ?? 'null' }},
recipient_daily_limit: {{ $pos->recipient_daily_limit ?? 'null' }},
sort_order: {{ $pos->sort_order }},
requestUrl: '{{ route('admin.positions.update', $pos->id) }}'
}, {{ json_encode($appointableIds) }})"
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="9" 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>
<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"
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="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