补全用户分组入口并统一侧边栏字号 (#127)

This commit is contained in:
WuYongJie 2026-09-08 16:14:28 +08:00
parent d876822a6e
commit b65702147c
11 changed files with 183 additions and 8 deletions

View File

@ -35,6 +35,46 @@ class UserGroups extends Controller
return $this->form(UserAccessGroup::forCompany()->findOrFail($group));
}
public function editMembership(int $user)
{
$member = $this->memberForCompany($user);
$groups = UserAccessGroup::forCompany()->orderBy('name')->get();
return view('system_settings.groups.membership', [
'member' => $member,
'groups' => $groups,
'selected' => UserAccessGroup::forUser($member)->pluck('id')->all(),
]);
}
public function updateMembership(int $user, Request $request)
{
$member = $this->memberForCompany($user);
$availableGroupIds = UserAccessGroup::forCompany()->pluck('id')->all();
$data = $request->validate([
'groups' => ['nullable', 'array'],
'groups.*' => ['integer', 'distinct', Rule::in($availableGroupIds)],
], [], ['groups' => '用户分组', 'groups.*' => '用户分组']);
$selected = array_values($data['groups'] ?? []);
DB::transaction(function () use ($member, $availableGroupIds, $selected) {
DB::table('user_access_group_members')
->where('user_id', $member->id)
->whereIn('group_id', $availableGroupIds)
->delete();
if ($selected !== []) {
DB::table('user_access_group_members')->insert(array_map(
fn ($groupId) => ['group_id' => $groupId, 'user_id' => $member->id],
$selected
));
}
});
flash('用户分组已更新。')->success();
return redirect()->route('system-settings.users.index');
}
private function form(?UserAccessGroup $group = null)
{
return view('system_settings.groups.form', ['group' => $group, 'members' => $this->members(), 'selected' => $group?->users()->pluck('users.id')->all() ?? []]);
@ -86,4 +126,12 @@ class UserGroups extends Controller
->whereHas('roles', fn ($query) => $query->whereIn('name', ['admin', 'user']))
->whereDoesntHave('roles', fn ($query) => $query->where('name', 'super_admin'))->orderBy('name')->get();
}
private function memberForCompany(int $user): User
{
$member = $this->members()->firstWhere('id', $user);
abort_unless($member, 404);
return $member;
}
}

View File

@ -24,7 +24,7 @@ class Users extends Controller
public function index()
{
$users = $this->queryForActor()->with('roles')->orderBy('id')->get();
$users = $this->queryForActor()->with(['roles', 'dingtalkIdentity'])->orderBy('id')->get();
$groupNamesByUser = [];
foreach (UserAccessGroup::forCompany()->with('users')->get() as $group) {
foreach ($group->users as $member) {
@ -142,6 +142,10 @@ class Users extends Controller
return back()->withErrors(['user' => '系统至少需要保留一名启用的主管理员。']);
}
if ($managedUser->dingtalkIdentity()->exists()) {
return back()->withErrors(['user' => '钉钉绑定用户不能直接删除。需要停止访问时请停用账号,避免钉钉身份残留后无法再次登录。']);
}
$managedUser->delete();
flash('用户已删除。')->success();

5
public/css/app.css vendored

File diff suppressed because one or more lines are too long

View File

@ -363,6 +363,42 @@ body {
line-height: 1rem !important;
}
.finance-theme-sidebar .main-menu #sidenav-collapse-main > ul > li,
.finance-theme-sidebar .main-menu #sidenav-collapse-main > ul > details {
padding-bottom: 0 !important;
}
.finance-theme-sidebar .main-menu #sidenav-collapse-main > ul > li > a,
.finance-theme-sidebar .main-menu #sidenav-collapse-main > ul > details > summary > div {
min-height: 2.4rem;
margin: 0.125rem 0;
padding: 0.55rem 0.75rem !important;
border-radius: 0.25rem;
font-size: 0.75rem !important;
line-height: 1rem !important;
}
.finance-theme-sidebar .main-menu #sidenav-collapse-main > ul > li > a > div,
.finance-theme-sidebar .main-menu #sidenav-collapse-main > ul > details > summary > div > div {
width: 1.25rem !important;
height: 1.25rem !important;
margin-inline-end: 0.65rem !important;
}
.finance-theme-sidebar .main-menu #sidenav-collapse-main > ul > li > a .material-icons,
.finance-theme-sidebar .main-menu #sidenav-collapse-main > ul > li > a .material-icons-outlined,
.finance-theme-sidebar .main-menu #sidenav-collapse-main > ul > details > summary > div .material-icons,
.finance-theme-sidebar .main-menu #sidenav-collapse-main > ul > details > summary > div .material-icons-outlined {
font-size: 1rem !important;
line-height: 1rem !important;
}
.finance-theme-sidebar .main-menu #sidenav-collapse-main > ul > details > summary > div > .material-icons:last-child {
right: auto !important;
left: auto !important;
inset-inline-end: 0.75rem !important;
}
.finance-theme-sidebar .main-menu a.font-semibold,
.finance-theme-sidebar .main-menu summary.font-semibold,
.finance-theme-sidebar .main-menu div.font-semibold {

View File

@ -5,7 +5,7 @@
<div class="my-6" data-settings-workspace>
@include('system_settings.partials.navigation')
<nav class="mb-4 text-sm" aria-label="页面位置"><a class="settings-accent" href="{{ route('system-settings.users.index') }}">用户管理</a><span class="mx-2">/</span><span aria-current="page">用户分组</span></nav>
<p class="mb-4 text-sm text-gray-600">用户可加入多个分组,获得各组页面权限的并集。分组不授予管理员身份;用户单独配置优先。</p>
<p class="mb-4 text-sm text-gray-600">用户可加入多个分组,获得各组页面权限的并集。可在这里按分组批量维护成员,也可从“用户管理”逐人加入现有分组。</p>
@if ($errors->any())<div role="alert" class="mb-4 text-sm text-red-700">{{ $errors->first() }}</div>@endif
<div class="overflow-x-auto rounded-xl border border-gray-200 bg-white">
<table class="min-w-full text-sm text-left">
@ -17,7 +17,7 @@
<td class="px-5 py-4">{{ $group->users_count }}</td>
<td class="px-5 py-4">{{ empty($group->page_permissions) ? '未开放页面' : '已配置' }}</td>
<td class="px-5 py-4 text-right whitespace-nowrap">
<a class="settings-accent mr-3" href="{{ route('system-settings.groups.edit', $group->id) }}">管理成员</a>
<a class="settings-accent mr-3" href="{{ route('system-settings.groups.edit', $group->id) }}">添加/移除成员</a>
<a class="settings-accent mr-3" href="{{ route('system-settings.sidebar.edit', ['subject' => 'group_'.$group->id]) }}">设置页面权限</a>
@if ($group->users_count === 0)
<form class="inline" method="POST" action="{{ route('system-settings.groups.destroy', $group->id) }}" onsubmit="return confirm('确定删除此空分组吗?')">

View File

@ -0,0 +1,32 @@
<x-layouts.admin>
<x-slot name="title">管理用户分组</x-slot>
<x-slot name="content">
<div class="my-6" data-settings-workspace>
@include('system_settings.partials.navigation')
<nav class="mb-4 text-sm" aria-label="页面位置"><a class="settings-accent" href="{{ route('system-settings.users.index') }}">用户管理</a><span class="mx-2">/</span><span aria-current="page">管理分组</span></nav>
<form method="POST" action="{{ route('system-settings.groups.membership.update', $member->id) }}" class="rounded-xl border border-gray-200 bg-white p-6">
@csrf @method('PATCH')
@if ($errors->any())<div role="alert" class="mb-4 text-sm text-red-700">@foreach($errors->all() as $error)<p>{{ $error }}</p>@endforeach</div>@endif
<div class="border-b border-gray-200 pb-5">
<h2 class="text-base font-semibold text-gray-900">{{ $member->name }}</h2>
<p class="mt-1 break-all text-sm text-gray-600">{{ $member->email }}</p>
<p class="mt-3 text-sm text-gray-600">勾选一个或多个现有分组。用户会获得各分组页面权限的并集;用户单独配置的页面权限仍然优先。</p>
</div>
<fieldset class="mt-6">
<legend class="text-sm font-semibold">现有分组</legend>
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
@forelse ($groups as $group)
<label class="flex items-start gap-3 rounded-lg border border-gray-200 px-4 py-3 text-sm hover:bg-gray-50">
<input type="checkbox" name="groups[]" value="{{ $group->id }}" @checked(in_array($group->id, old('groups', $errors->any() ? [] : $selected))) class="mt-1 rounded settings-accent settings-field-focus">
<span><span class="font-semibold text-gray-800">{{ $group->name }}</span><span class="mt-1 block text-xs text-gray-600">{{ empty($group->page_permissions) ? '暂未开放页面' : count($group->page_permissions).' 项页面权限' }}</span></span>
</label>
@empty
<div class="md:col-span-2 xl:col-span-3 rounded-lg bg-gray-50 px-4 py-6 text-sm text-gray-600">还没有可加入的分组。请先<a class="settings-accent hover:underline" href="{{ route('system-settings.groups.create') }}">创建分组</a>。</div>
@endforelse
</div>
</fieldset>
<div class="mt-6 flex justify-end gap-4 border-t border-gray-200 pt-4"><a class="settings-accent" href="{{ route('system-settings.users.index') }}">取消</a><button class="settings-primary" type="submit">保存用户分组</button></div>
</form>
</div>
</x-slot>
</x-layouts.admin>

View File

@ -6,7 +6,7 @@
window.caiwuSettingsNavigation = true;
let pending;
const supports = url => url.origin === location.origin
&& /\/system-settings\/(?:(?:users|groups)(?:\/create|\/\d+\/edit)?|permissions|sidebar)$/.test(url.pathname);
&& /\/system-settings\/(?:(?:users|groups)(?:\/create|\/\d+\/edit)?|users\/\d+\/groups|permissions|sidebar)$/.test(url.pathname);
async function navigate(url, push = true) {
const current = document.querySelector('[data-settings-workspace]');
if (!current) return;

View File

@ -68,9 +68,12 @@
@if (\App\Utilities\SystemAccess::canManageUser(user(), $managedUser))
<a href="{{ route('system-settings.users.edit', $managedUser->id) }}" class="mr-4 settings-accent hover:underline">修改</a>
@if (in_array($roleName, [\App\Utilities\SystemAccess::ADMIN, \App\Utilities\SystemAccess::USER], true))
@if (\App\Utilities\SystemAccess::isSuperAdmin())
<a href="{{ route('system-settings.groups.membership.edit', $managedUser->id) }}" class="mr-4 settings-accent hover:underline">管理分组</a>
@endif
<a href="{{ route('system-settings.sidebar.edit', ['subject' => 'user_'.$managedUser->id]) }}" class="mr-4 settings-accent hover:underline">页面权限</a>
@endif
@if ($managedUser->id !== user()->id)
@if ($managedUser->id !== user()->id && ! $managedUser->dingtalkIdentity)
<form method="POST" action="{{ route('system-settings.users.destroy', $managedUser->id) }}" class="inline" onsubmit="return confirm('确定删除该用户吗?')">
@csrf
@method('DELETE')

View File

@ -176,6 +176,8 @@ Route::group(['as' => 'system-settings.', 'prefix' => 'system-settings'], functi
Route::get('users/create', 'SystemSettings\Users@create')->name('users.create');
Route::post('users', 'SystemSettings\Users@store')->name('users.store');
Route::get('users/{user}/edit', 'SystemSettings\Users@edit')->name('users.edit');
Route::get('users/{user}/groups', 'SystemSettings\UserGroups@editMembership')->name('groups.membership.edit');
Route::patch('users/{user}/groups', 'SystemSettings\UserGroups@updateMembership')->name('groups.membership.update');
Route::patch('users/{user}', 'SystemSettings\Users@update')->name('users.update');
Route::delete('users/{user}', 'SystemSettings\Users@destroy')->name('users.destroy');

View File

@ -24,6 +24,40 @@ class UserAccessGroupsTest extends FeatureTestCase
$this->get(route('system-settings.groups.index'))->assertOk()->assertSeeText('未开放页面');
}
public function test_later_user_can_be_added_to_existing_groups_from_user_management(): void
{
$first = $this->member();
$group = $this->group('现有核算组', $first, ['store-management/basic-information']);
$group->users()->detach($first->id);
$later = $this->member();
$this->loginAs()->get(route('system-settings.users.index'))->assertOk()
->assertSee(route('system-settings.groups.membership.edit', $later->id), false)
->assertSeeText('管理分组');
$this->get(route('system-settings.groups.membership.edit', $later->id))->assertOk()
->assertSeeText('现有核算组')->assertSeeText($later->name);
$this->patch(route('system-settings.groups.membership.update', $later->id), ['groups' => [$group->id]])
->assertRedirect(route('system-settings.users.index'));
$this->assertTrue($group->users()->whereKey($later->id)->exists());
$this->assertSame(['store-management/basic-information'], SidebarPermissions::allowedKeys($later));
}
public function test_user_membership_editor_is_company_scoped_and_super_admin_only(): void
{
$member = $this->member();
$group = $this->group('本公司分组', $member);
$admin = $this->member('admin');
$this->withExceptionHandling()->loginAs($admin)
->get(route('system-settings.groups.membership.edit', $member->id))->assertForbidden();
$this->loginAs()->withHeaders(['X-Requested-With' => 'XMLHttpRequest'])
->get(route('system-settings.groups.membership.edit', $this->user->id))->assertNotFound();
$this->patchJson(route('system-settings.groups.membership.update', $member->id), ['groups' => [$group->id + 9999]])
->assertUnprocessable();
$this->assertSame([$group->id], UserAccessGroup::forUser($member)->pluck('id')->all());
}
public function test_multiple_group_permissions_are_unioned_and_enforced_on_real_routes(): void
{
$member = $this->member();

View File

@ -2,6 +2,7 @@
namespace Tests\Feature\SystemSettings;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use App\Models\Auth\User;
@ -127,6 +128,22 @@ class UserPermissionsTest extends FeatureTestCase
$this->assertSame(['subject-trademark-rules'], $pages->pluck('slug')->all());
}
public function test_dingtalk_bound_user_cannot_be_deleted_into_an_orphaned_identity(): void
{
$target = $this->account('user');
DingTalkIdentity::query()->create([
'user_id' => $target->id,
'union_id' => 'fixture-delete-guard-union',
'dingtalk_user_id' => 'fixture-delete-guard-user',
'nickname' => '在职财务',
]);
$this->loginAs()->delete(route('system-settings.users.destroy', $target->id))
->assertSessionHasErrors('user');
$this->assertNotNull($target->fresh());
$this->assertDatabaseHas('dingtalk_identities', ['user_id' => $target->id]);
}
private function account(string $role): User
{
$name = 'fixture-'.$role.'-'.User::count();