功能:职务列表内联编辑 + 全局奖励配置自动保存
职务列表三列内联编辑(失焦/回车自动保存,无需打开编辑弹窗):
- 人数上限:PATCH max_persons
- 单次上限:PATCH max_reward
- 单日上限:PATCH daily_reward_limit
保存成功显示短暂绿色 ✓,失败显示红色错误提示
全局奖励接收次数配置改为 AJAX 自动保存,失焦/回车触发,
无需保存按钮(原表单已移除)
新增接口:
- PATCH /admin/positions/{position}/patch(quickPatch)
- POST /admin/positions/reward-config(saveRewardConfig,兼容 JSON + 重定向)
This commit is contained in:
@@ -72,13 +72,35 @@ class PositionController extends Controller
|
|||||||
return redirect()->route('admin.positions.index')->with('success', "职务【{$data['name']}】创建成功!");
|
return redirect()->route('admin.positions.index')->with('success', "职务【{$data['name']}】创建成功!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快速补丁:仅更新职务的数值限额字段(内联编辑专用)
|
||||||
|
*
|
||||||
|
* 允许修改的字段:max_persons / max_reward / daily_reward_limit。
|
||||||
|
* 只接受 JSON AJAX 请求,只更新提交的字段,其余字段保持不变。
|
||||||
|
*
|
||||||
|
* @param Position $position 目标职务
|
||||||
|
*/
|
||||||
|
public function quickPatch(Request $request, Position $position): \Illuminate\Http\JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'max_persons' => 'sometimes|nullable|integer|min:1|max:9999',
|
||||||
|
'max_reward' => 'sometimes|nullable|integer|min:0|max:999999',
|
||||||
|
'daily_reward_limit' => 'sometimes|nullable|integer|min:0|max:999999',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 用 fill+save 确保 null 值(不限)也能正确写入
|
||||||
|
$position->fill($data)->save();
|
||||||
|
|
||||||
|
return response()->json(['status' => 'success']);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存全局奖励金币接收次数上限
|
* 保存全局奖励金币接收次数上限
|
||||||
*
|
*
|
||||||
* 控制每位用户单日内可从所有职务持有者处累计接收奖励的最高次数。
|
* 控制每位用户单日内可从所有职务持有者处累计接收奖励的最高次数。
|
||||||
* 0 表示不限制,保存到 sysparam 表中(key: reward_recipient_daily_max)。
|
* 0 表示不限制,保存到 sysparam 表中(key: reward_recipient_daily_max)。
|
||||||
*/
|
*/
|
||||||
public function saveRewardConfig(Request $request): RedirectResponse
|
public function saveRewardConfig(Request $request): \Illuminate\Http\JsonResponse|RedirectResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'reward_recipient_daily_max' => 'required|integer|min:0|max:9999',
|
'reward_recipient_daily_max' => 'required|integer|min:0|max:9999',
|
||||||
@@ -98,6 +120,11 @@ class PositionController extends Controller
|
|||||||
|
|
||||||
$label = $value === '0' ? '不限' : "{$value} 次";
|
$label = $value === '0' ? '不限' : "{$value} 次";
|
||||||
|
|
||||||
|
// AJAX 请求返回 JSON,普通表单提交返回重定向
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json(['status' => 'success', 'message' => "全局接收次数上限已更新为:{$label}"]);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->route('admin.positions.index')
|
return redirect()->route('admin.positions.index')
|
||||||
->with('success', "全局接收次数上限已更新为:{$label}");
|
->with('success', "全局接收次数上限已更新为:{$label}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,30 +101,61 @@
|
|||||||
{{ session('error') }}</div>
|
{{ session('error') }}</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- 全局奖励接收上限配置卡片 --}}
|
{{-- 全局奖励接收上限配置卡片(失焦/回车自动保存) --}}
|
||||||
<div class="mb-6 bg-amber-50 border border-amber-200 rounded-xl p-5">
|
<div class="mb-6 bg-amber-50 border border-amber-200 rounded-xl p-5" x-data="{
|
||||||
<div class="flex items-start justify-between gap-4 flex-wrap">
|
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">
|
<div class="flex-1 min-w-0">
|
||||||
<h3 class="text-sm font-bold text-amber-800 mb-1">🪙 全局奖励接收上限</h3>
|
<h3 class="text-sm font-bold text-amber-800 mb-1">🪙 全局奖励接收上限</h3>
|
||||||
<p class="text-xs text-amber-700 leading-relaxed">
|
<p class="text-xs text-amber-700 leading-relaxed">
|
||||||
每位用户单日内可从<b>所有职务持有者</b>处累计接收奖励金币的最高次数。
|
每位用户单日内可从<b>所有职务持有者</b>处累计接收奖励金币的最高次数。
|
||||||
设为 <code class="bg-amber-100 px-1 rounded">0</code> 表示不限制。
|
设为 <code class="bg-amber-100 px-1 rounded">0</code> 表示不限制。
|
||||||
当前配置:<b>{{ $globalRecipientDailyMax > 0 ? $globalRecipientDailyMax . ' 次' : '不限' }}</b>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form action="{{ route('admin.positions.reward_config') }}" method="POST"
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
class="flex items-center gap-2 shrink-0">
|
|
||||||
@csrf
|
|
||||||
<label class="text-xs text-amber-700 font-bold whitespace-nowrap">每日上限:</label>
|
<label class="text-xs text-amber-700 font-bold whitespace-nowrap">每日上限:</label>
|
||||||
<input type="number" name="reward_recipient_daily_max" value="{{ $globalRecipientDailyMax }}"
|
<input type="number" x-model.number="val" min="0" max="9999" @blur="save()"
|
||||||
min="0" max="9999"
|
@keydown.enter.prevent="save()"
|
||||||
class="w-24 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">
|
class="w-20 h-8 px-2 text-sm border border-amber-300 rounded-md bg-white text-amber-900
|
||||||
<span class="text-xs text-amber-600">次(0=不限)</span>
|
focus:outline-none focus:ring-2 focus:ring-amber-400 text-center">
|
||||||
<button type="submit"
|
<span class="text-xs text-amber-600 whitespace-nowrap">次(0=不限)</span>
|
||||||
class="h-8 px-3 bg-amber-500 text-white text-xs font-bold rounded-md hover:bg-amber-600 transition">
|
{{-- 状态反馈 --}}
|
||||||
保存
|
<span x-show="saving" class="text-xs text-amber-500 whitespace-nowrap">保存中…</span>
|
||||||
</button>
|
<span x-show="saved" class="text-xs text-green-600 font-bold whitespace-nowrap">✓ 已保存</span>
|
||||||
</form>
|
<span x-show="error" x-text="error" class="text-xs text-red-500 whitespace-nowrap"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -170,32 +201,44 @@
|
|||||||
<span
|
<span
|
||||||
class="text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded font-mono">Lv.{{ $pos->level }}</span>
|
class="text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded font-mono">Lv.{{ $pos->level }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-center text-gray-600">{{ $pos->max_persons ?? '不限' }}</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">
|
<td class="px-4 py-3 text-center">
|
||||||
<span
|
<span
|
||||||
class="{{ $pos->active_user_positions_count >= ($pos->max_persons ?? 999) ? 'text-red-600 font-bold' : 'text-indigo-600' }}">
|
class="{{ $pos->active_user_positions_count >= ($pos->max_persons ?? 999) ? 'text-red-600 font-bold' : 'text-indigo-600' }}">
|
||||||
{{ $pos->active_user_positions_count }} 人
|
{{ $pos->active_user_positions_count }} 人
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-center text-gray-600">
|
{{-- 单次奖励上限:内联编辑 --}}
|
||||||
@if ($pos->max_reward === null)
|
<td class="px-2 py-3 text-center" x-data="inlinePatch({{ $pos->id }}, 'max_reward', {{ $pos->max_reward ?? 'null' }})">
|
||||||
<span class="text-gray-400 text-xs">不限</span>
|
<input type="number" x-model.number="val" min="0" max="999999"
|
||||||
@elseif ($pos->max_reward === 0)
|
@blur="save()" @keydown.enter.prevent="$el.blur()" placeholder="不限"
|
||||||
<span class="text-xs text-red-500 font-bold">禁止</span>
|
class="w-16 text-center text-xs border-0 border-b border-dashed border-gray-300
|
||||||
@else
|
bg-transparent focus:outline-none focus:border-amber-400
|
||||||
<span
|
text-amber-700 py-0.5">
|
||||||
class="text-xs font-mono text-amber-700">{{ number_format($pos->max_reward) }}</span>
|
<span x-show="saved" class="block text-green-500 text-xs leading-none">✓</span>
|
||||||
@endif
|
<span x-show="error" x-text="error"
|
||||||
|
class="block text-red-400 text-xs leading-none"></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-center text-gray-600">
|
{{-- 单日发放总上限:内联编辑 --}}
|
||||||
@if ($pos->daily_reward_limit === null)
|
<td class="px-2 py-3 text-center" x-data="inlinePatch({{ $pos->id }}, 'daily_reward_limit', {{ $pos->daily_reward_limit ?? 'null' }})">
|
||||||
<span class="text-gray-400 text-xs">不限</span>
|
<input type="number" x-model.number="val" min="0" max="999999"
|
||||||
@elseif ($pos->daily_reward_limit === 0)
|
@blur="save()" @keydown.enter.prevent="$el.blur()" placeholder="不限"
|
||||||
<span class="text-xs text-red-500 font-bold">禁止</span>
|
class="w-16 text-center text-xs border-0 border-b border-dashed border-gray-300
|
||||||
@else
|
bg-transparent focus:outline-none focus:border-indigo-400
|
||||||
<span
|
text-indigo-700 py-0.5">
|
||||||
class="text-xs font-mono text-indigo-700">{{ number_format($pos->daily_reward_limit) }}</span>
|
<span x-show="saved" class="block text-green-500 text-xs leading-none">✓</span>
|
||||||
@endif
|
<span x-show="error" x-text="error"
|
||||||
|
class="block text-red-400 text-xs leading-none"></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-center">
|
<td class="px-4 py-3 text-center">
|
||||||
@if (count($appointableIds) > 0)
|
@if (count($appointableIds) > 0)
|
||||||
@@ -229,8 +272,9 @@
|
|||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
@if (Auth::id() === 1)
|
@if (Auth::id() === 1)
|
||||||
<form action="{{ route('admin.positions.destroy', $pos->id) }}" method="POST"
|
<form action="{{ route('admin.positions.destroy', $pos->id) }}"
|
||||||
class="inline" onsubmit="return confirm('确定删除职务【{{ $pos->name }}】?')">
|
method="POST" class="inline"
|
||||||
|
onsubmit="return confirm('确定删除职务【{{ $pos->name }}】?')">
|
||||||
@csrf @method('DELETE')
|
@csrf @method('DELETE')
|
||||||
<button type="submit"
|
<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">
|
class="text-xs bg-red-50 text-red-600 font-bold px-2 py-1 rounded hover:bg-red-600 hover:text-white transition">
|
||||||
@@ -256,7 +300,8 @@
|
|||||||
class="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4">
|
class="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4">
|
||||||
<div @click.away="showForm = false"
|
<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>
|
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">
|
<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>
|
<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">×</button>
|
<button @click="showForm = false" class="text-gray-400 hover:text-white text-xl">×</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,3 +404,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* inlinePatch - 职务列表内联编辑 Alpine 工厂函数
|
||||||
|
* 失焦或回车时自动 PATCH 保存单个字段到后端
|
||||||
|
*
|
||||||
|
* @param {number} positionId - 职务 ID
|
||||||
|
* @param {string} field - 要更新的字段名
|
||||||
|
* @param {number|null} initial - 初始值(null = 不限)
|
||||||
|
*/
|
||||||
|
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(=不限)
|
||||||
|
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 ||
|
||||||
|
'{{ csrf_token() }}',
|
||||||
|
'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>
|
||||||
|
@endsection
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
|
|||||||
Route::get('/positions', [\App\Http\Controllers\Admin\PositionController::class, 'index'])->name('positions.index');
|
Route::get('/positions', [\App\Http\Controllers\Admin\PositionController::class, 'index'])->name('positions.index');
|
||||||
Route::put('/departments/{department}', [\App\Http\Controllers\Admin\DepartmentController::class, 'update'])->name('departments.update');
|
Route::put('/departments/{department}', [\App\Http\Controllers\Admin\DepartmentController::class, 'update'])->name('departments.update');
|
||||||
Route::put('/positions/{position}', [\App\Http\Controllers\Admin\PositionController::class, 'update'])->name('positions.update');
|
Route::put('/positions/{position}', [\App\Http\Controllers\Admin\PositionController::class, 'update'])->name('positions.update');
|
||||||
|
Route::patch('/positions/{position}/patch', [\App\Http\Controllers\Admin\PositionController::class, 'quickPatch'])->name('positions.quick_patch');
|
||||||
Route::post('/positions/reward-config', [\App\Http\Controllers\Admin\PositionController::class, 'saveRewardConfig'])->name('positions.reward_config');
|
Route::post('/positions/reward-config', [\App\Http\Controllers\Admin\PositionController::class, 'saveRewardConfig'])->name('positions.reward_config');
|
||||||
|
|
||||||
// 大卡片通知广播(仅超级管理员,安全隔离:普通用户无此接口)
|
// 大卡片通知广播(仅超级管理员,安全隔离:普通用户无此接口)
|
||||||
|
|||||||
Reference in New Issue
Block a user