功能:职务列表内联编辑 + 全局奖励配置自动保存

职务列表三列内联编辑(失焦/回车自动保存,无需打开编辑弹窗):
- 人数上限: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:
2026-03-01 11:28:15 +08:00
parent baaa7087b0
commit 89d93c92ed
3 changed files with 168 additions and 39 deletions

View File

@@ -72,13 +72,35 @@ class PositionController extends Controller
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
*/
public function saveRewardConfig(Request $request): RedirectResponse
public function saveRewardConfig(Request $request): \Illuminate\Http\JsonResponse|RedirectResponse
{
$request->validate([
'reward_recipient_daily_max' => 'required|integer|min:0|max:9999',
@@ -98,6 +120,11 @@ class PositionController extends Controller
$label = $value === '0' ? '不限' : "{$value}";
// AJAX 请求返回 JSON普通表单提交返回重定向
if ($request->expectsJson()) {
return response()->json(['status' => 'success', 'message' => "全局接收次数上限已更新为:{$label}"]);
}
return redirect()->route('admin.positions.index')
->with('success', "全局接收次数上限已更新为:{$label}");
}

View File

@@ -101,30 +101,61 @@
{{ session('error') }}</div>
@endif
{{-- 全局奖励接收上限配置卡片 --}}
<div class="mb-6 bg-amber-50 border border-amber-200 rounded-xl p-5">
<div class="flex items-start justify-between gap-4 flex-wrap">
{{-- 全局奖励接收上限配置卡片(失焦/回车自动保存) --}}
<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> 表示不限制。
当前配置:<b>{{ $globalRecipientDailyMax > 0 ? $globalRecipientDailyMax . ' 次' : '不限' }}</b>
</p>
</div>
<form action="{{ route('admin.positions.reward_config') }}" method="POST"
class="flex items-center gap-2 shrink-0">
@csrf
<div class="flex items-center gap-2 shrink-0">
<label class="text-xs text-amber-700 font-bold whitespace-nowrap">每日上限:</label>
<input type="number" name="reward_recipient_daily_max" value="{{ $globalRecipientDailyMax }}"
min="0" max="9999"
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">
<span class="text-xs text-amber-600">0=不限)</span>
<button type="submit"
class="h-8 px-3 bg-amber-500 text-white text-xs font-bold rounded-md hover:bg-amber-600 transition">
保存
</button>
</form>
<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>
@@ -170,32 +201,44 @@
<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-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">
<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-4 py-3 text-center text-gray-600">
@if ($pos->max_reward === null)
<span class="text-gray-400 text-xs">不限</span>
@elseif ($pos->max_reward === 0)
<span class="text-xs text-red-500 font-bold">禁止</span>
@else
<span
class="text-xs font-mono text-amber-700">{{ number_format($pos->max_reward) }}</span>
@endif
{{-- 单次奖励上限:内联编辑 --}}
<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-4 py-3 text-center text-gray-600">
@if ($pos->daily_reward_limit === null)
<span class="text-gray-400 text-xs">不限</span>
@elseif ($pos->daily_reward_limit === 0)
<span class="text-xs text-red-500 font-bold">禁止</span>
@else
<span
class="text-xs font-mono text-indigo-700">{{ number_format($pos->daily_reward_limit) }}</span>
@endif
{{-- 单日发放总上限:内联编辑 --}}
<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)
@@ -229,8 +272,9 @@
</button>
@endif
@if (Auth::id() === 1)
<form action="{{ route('admin.positions.destroy', $pos->id) }}" method="POST"
class="inline" onsubmit="return confirm('确定删除职务【{{ $pos->name }}】?')">
<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">
@@ -256,7 +300,8 @@
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">
<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>
@@ -359,3 +404,59 @@
</div>
</div>
@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

View File

@@ -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::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::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');
// 大卡片通知广播(仅超级管理员,安全隔离:普通用户无此接口)