diff --git a/app/Http/Controllers/SystemSettings/Sidebar.php b/app/Http/Controllers/SystemSettings/Sidebar.php
index 819180e6..4563d8d1 100644
--- a/app/Http/Controllers/SystemSettings/Sidebar.php
+++ b/app/Http/Controllers/SystemSettings/Sidebar.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\SystemSettings;
use App\Models\Auth\User;
+use App\Models\Auth\UserAccessGroup;
use App\Utilities\BusinessNavigation;
use App\Utilities\SidebarPermissions;
use App\Utilities\SystemAccess;
@@ -35,13 +36,29 @@ class Sidebar extends Controller
$settings = SidebarPermissions::settings();
$configured = in_array($subject, $settings['configured'] ?? [], true);
+ $selected = $configured ? (array) ($settings['subjects'][$subject] ?? []) : SidebarPermissions::allKeys();
+ $source = $configured ? '当前使用单独配置。' : '未单独配置,使用系统默认完整页面。';
+ $canInherit = str_starts_with($subject, 'user_');
+ if (str_starts_with($subject, 'group_')) {
+ $group = UserAccessGroup::forCompany()->findOrFail(substr($subject, 6));
+ $selected = $group->page_permissions ?? [];
+ $configured = true;
+ $source = '分组权限与用户的其他分组权限合并;用户例外配置优先。新分组默认没有页面权限。';
+ } elseif ($canInherit && ! $configured) {
+ $target = User::findOrFail(substr($subject, 5));
+ $selected = SidebarPermissions::allowedKeys($target) ?? SidebarPermissions::allKeys();
+ $groups = UserAccessGroup::forUser($target);
+ $source = $groups->isNotEmpty() ? '正在继承分组:' . $groups->pluck('name')->implode('、') : '正在继承角色或系统默认权限。';
+ }
return view('system_settings.sidebar.edit', [
'navigation' => BusinessNavigation::all(),
'subjects' => $subjects,
'subject' => $subject,
'configured' => $configured,
- 'selected' => $configured ? (array) ($settings['subjects'][$subject] ?? []) : SidebarPermissions::allKeys(),
+ 'selected' => $selected,
+ 'source' => $source,
+ 'canInherit' => $canInherit,
]);
}
@@ -54,14 +71,30 @@ class Sidebar extends Controller
'subject' => ['required', Rule::in(array_keys($subjects))],
'permissions' => ['nullable', 'array'],
'permissions.*' => ['string', Rule::in(SidebarPermissions::allKeys())],
+ 'mode' => ['nullable', Rule::in(['custom', 'inherit'])],
]);
+ if ($request->input('mode') === 'inherit' && ! str_starts_with($subject, 'user_')) {
+ return back()->withErrors(['mode' => '只有用户例外配置可以恢复继承。']);
+ }
+ if (str_starts_with($subject, 'group_')) {
+ UserAccessGroup::forCompany()->findOrFail(substr($subject, 6))->update([
+ 'page_permissions' => array_values(array_unique((array) $request->input('permissions', []))),
+ ]);
+ flash('分组页面权限已更新,成员将在下次访问时使用新权限。')->success();
+ return redirect()->route('system-settings.sidebar.edit', ['subject' => $subject]);
+ }
+
$settings = SidebarPermissions::settings();
$settings['configured'] = array_values(array_unique(array_merge(
(array) ($settings['configured'] ?? []),
[$subject]
)));
$settings['subjects'][$subject] = array_values(array_unique((array) $request->input('permissions', [])));
+ if ($request->input('mode') === 'inherit') {
+ $settings['configured'] = array_values(array_diff($settings['configured'], [$subject]));
+ unset($settings['subjects'][$subject]);
+ }
setting()->set('system.sidebar_permissions', json_encode($settings, JSON_UNESCAPED_UNICODE));
setting()->save();
@@ -104,6 +137,12 @@ class Sidebar extends Controller
$subjects['user_' . $managedUser->id] = '指定用户:' . $managedUser->name . ' - ' . ($labels[$role] ?? $role);
}
+ if (SystemAccess::isSuperAdmin()) {
+ foreach (UserAccessGroup::forCompany()->orderBy('name')->get() as $group) {
+ $subjects['group_' . $group->id] = '分组:' . $group->name;
+ }
+ }
+
return $subjects;
}
}
diff --git a/app/Http/Controllers/SystemSettings/UserGroups.php b/app/Http/Controllers/SystemSettings/UserGroups.php
new file mode 100644
index 00000000..8fe25d91
--- /dev/null
+++ b/app/Http/Controllers/SystemSettings/UserGroups.php
@@ -0,0 +1,89 @@
+middleware(function ($request, $next) {
+ abort_unless(SystemAccess::isSuperAdmin(), 403);
+ return $next($request);
+ });
+ }
+
+ public function index()
+ {
+ return view('system_settings.groups.index', ['groups' => UserAccessGroup::forCompany()->withCount('users')->orderBy('name')->get()]);
+ }
+
+ public function create()
+ {
+ return $this->form();
+ }
+
+ public function edit(int $group)
+ {
+ return $this->form(UserAccessGroup::forCompany()->findOrFail($group));
+ }
+
+ private function form(?UserAccessGroup $group = null)
+ {
+ return view('system_settings.groups.form', ['group' => $group, 'members' => $this->members(), 'selected' => $group?->users()->pluck('users.id')->all() ?? []]);
+ }
+
+ public function store(Request $request)
+ {
+ $group = new UserAccessGroup;
+ $group->company_id = company_id();
+ $group->page_permissions = [];
+ return $this->save($request, $group);
+ }
+
+ public function update(Request $request, int $group)
+ {
+ return $this->save($request, UserAccessGroup::forCompany()->findOrFail($group));
+ }
+
+ private function save(Request $request, UserAccessGroup $group)
+ {
+ $data = $request->validate([
+ 'name' => ['required', 'string', 'max:80', Rule::unique('user_access_groups', 'name')->where('company_id', company_id())->ignore($group->id)],
+ 'members' => ['nullable', 'array'],
+ 'members.*' => ['integer', 'distinct', Rule::in($this->members()->pluck('id')->all())],
+ ], [], ['name' => '分组名称', 'members' => '成员', 'members.*' => '成员']);
+ DB::transaction(function () use ($group, $data) {
+ $group->name = $data['name'];
+ $group->save();
+ $group->users()->sync($data['members'] ?? []);
+ });
+ flash('分组与成员已保存。页面权限在权限管理中设置。')->success();
+ return redirect()->route('system-settings.groups.index');
+ }
+
+ public function destroy(int $group)
+ {
+ $group = UserAccessGroup::forCompany()->findOrFail($group);
+ if ($group->users()->exists()) {
+ return back()->withErrors(['group' => '请先移出分组成员,再删除空分组。移出最后一个分组的用户将恢复角色或系统默认权限。']);
+ }
+ $group->delete();
+ flash('空分组已删除。')->success();
+ return redirect()->route('system-settings.groups.index');
+ }
+
+ private function members()
+ {
+ return User::whereHas('companies', fn ($query) => $query->where('companies.id', company_id()))
+ ->whereHas('roles', fn ($query) => $query->whereIn('name', ['admin', 'user']))
+ ->whereDoesntHave('roles', fn ($query) => $query->where('name', 'super_admin'))->orderBy('name')->get();
+ }
+}
diff --git a/app/Http/Controllers/SystemSettings/Users.php b/app/Http/Controllers/SystemSettings/Users.php
index d235de0c..4fe68757 100644
--- a/app/Http/Controllers/SystemSettings/Users.php
+++ b/app/Http/Controllers/SystemSettings/Users.php
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\SystemSettings;
use App\Models\Auth\Role;
use App\Models\Auth\User;
+use App\Models\Auth\UserAccessGroup;
use App\Utilities\SystemAccess;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
@@ -24,10 +25,17 @@ class Users extends Controller
public function index()
{
$users = $this->queryForActor()->with('roles')->orderBy('id')->get();
+ $groupNamesByUser = [];
+ foreach (UserAccessGroup::forCompany()->with('users')->get() as $group) {
+ foreach ($group->users as $member) {
+ $groupNamesByUser[$member->id][] = $group->name;
+ }
+ }
return view('system_settings.users.index', [
'users' => $users,
'roleLabels' => SystemAccess::roleLabels(),
+ 'groupNamesByUser' => $groupNamesByUser,
]);
}
diff --git a/app/Http/Livewire/Menu/Settings.php b/app/Http/Livewire/Menu/Settings.php
index 6debfa40..2532ccf5 100644
--- a/app/Http/Livewire/Menu/Settings.php
+++ b/app/Http/Livewire/Menu/Settings.php
@@ -39,6 +39,7 @@ class Settings extends Component
'icon' => $icon,
'search_keywords' => $keywords,
'active' => fn () => request()->routeIs(Str::beforeLast($route, '.') . '.*')
+ || ($route === 'system-settings.users.index' && request()->routeIs('system-settings.groups.*'))
|| ($route === 'system-settings.permissions.index' && request()->routeIs('system-settings.sidebar.*')),
]);
diff --git a/app/Models/Auth/UserAccessGroup.php b/app/Models/Auth/UserAccessGroup.php
new file mode 100644
index 00000000..66de10b8
--- /dev/null
+++ b/app/Models/Auth/UserAccessGroup.php
@@ -0,0 +1,27 @@
+ 'array'];
+
+ public function scopeForCompany($query)
+ {
+ return $query->where('company_id', company_id());
+ }
+
+ public function users()
+ {
+ return $this->belongsToMany(User::class, 'user_access_group_members', 'group_id', 'user_id');
+ }
+
+ public static function forUser(User $user)
+ {
+ return static::forCompany()->whereHas('users', fn ($query) => $query->where('users.id', $user->id))->orderBy('name')->get();
+ }
+}
diff --git a/app/Utilities/SidebarPermissions.php b/app/Utilities/SidebarPermissions.php
index 04e4575d..40c45c90 100644
--- a/app/Utilities/SidebarPermissions.php
+++ b/app/Utilities/SidebarPermissions.php
@@ -3,6 +3,7 @@
namespace App\Utilities;
use App\Models\Auth\User;
+use App\Models\Auth\UserAccessGroup;
class SidebarPermissions
{
@@ -36,7 +37,17 @@ class SidebarPermissions
}
$settings = self::settings();
- $subject = self::subjectKey($user);
+ $userSubject = 'user_' . $user->id;
+ if (in_array($userSubject, $settings['configured'] ?? [], true)) {
+ return array_values(array_unique((array) ($settings['subjects'][$userSubject] ?? [])));
+ }
+
+ $groups = UserAccessGroup::forUser($user);
+ if ($groups->isNotEmpty()) {
+ return $groups->flatMap(fn ($group) => $group->page_permissions ?? [])->unique()->values()->all();
+ }
+
+ $subject = 'role_' . SystemAccess::roleName($user);
if (! in_array($subject, $settings['configured'] ?? [], true)) {
return null;
diff --git a/database/migrations/2026_09_08_150000_create_user_access_groups.php b/database/migrations/2026_09_08_150000_create_user_access_groups.php
new file mode 100644
index 00000000..ba6e96ac
--- /dev/null
+++ b/database/migrations/2026_09_08_150000_create_user_access_groups.php
@@ -0,0 +1,33 @@
+increments('id');
+ $table->unsignedInteger('company_id');
+ $table->string('name', 80);
+ $table->json('page_permissions');
+ $table->timestamps();
+ $table->unique(['company_id', 'name']);
+ $table->foreign('company_id')->references('id')->on('companies')->cascadeOnDelete();
+ });
+ Schema::create('user_access_group_members', function (Blueprint $table) {
+ $table->unsignedInteger('group_id');
+ $table->unsignedInteger('user_id');
+ $table->primary(['group_id', 'user_id']);
+ $table->foreign('group_id')->references('id')->on('user_access_groups')->cascadeOnDelete();
+ $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('user_access_group_members');
+ Schema::dropIfExists('user_access_groups');
+ }
+};
diff --git a/docs/用户分组与页面权限.md b/docs/用户分组与页面权限.md
new file mode 100644
index 00000000..b373f02a
--- /dev/null
+++ b/docs/用户分组与页面权限.md
@@ -0,0 +1,33 @@
+# 用户分组与页面权限
+
+用户管理的“页面权限”和权限管理的“用户可访问页面”共用同一编辑器和权限解析器,不是两套授权系统。
+
+## 使用
+
+主管理员在用户管理中创建分组、添加成员,再从分组列表进入“设置页面权限”。用户可加入多个分组,分组名称可修改,关联使用稳定 ID。分组只控制业务页面,不授予管理员身份或店铺密码读取等专属操作权限。
+
+新组默认没有页面权限,可先建空组、配置页面,再添加成员。已有单独配置的用户会显示提示,可在其页面权限编辑器点击“恢复继承分组权限”。只有主管理员可以创建、修改成员、配置或删除分组;删除前必须先移出成员。
+
+## 唯一的优先级
+
+1. 主管理员拥有全部业务页面。
+2. 用户单独配置覆盖分组和角色;显式空配置表示无页面,不会自动回退。
+3. 没有用户单独配置且有分组时,各组允许页面取并集;空组不贡献权限,所有组都为空时无页面。
+4. 没有分组时沿用角色配置。
+5. 用户、分组、角色均未配置时,保留负责人要求的默认完整业务导航。
+
+移出最后一个分组会恢复角色或系统默认权限,编辑成员页面有明确提示。现有用户不会被自动加入新组。分组和成员限定当前公司,其他公司 ID 不能用来配置权限。
+
+所有业务入口仍通过 SidebarPermissions 解析同一份有效权限。菜单隐藏不是安全边界,已有页面、导出与数据接口的后端鉴权继续生效。主管理员身份变更仍使用原来的专用身份校验,分组不会修改角色。
+
+## 存储与发布
+
+新增系统配置库表 `user_access_groups`(公司、名称、页面权限)和 `user_access_group_members`(组与用户关系)。不复制上游数据,不修改既有身份、用户单独权限或默认配置。需要执行 `2026_09_08_150000_create_user_access_groups`;生产迁移必须独立取得授权并备份系统配置库。回滚代码时保留新表以免丢失分组配置。
+
+## 参考与取舍
+
+- [Microsoft Azure RBAC 最佳实践](https://learn.microsoft.com/en-us/azure/role-based-access-control/best-practices):优先将权限赋予组以降低逐人维护成本,使用稳定 ID,并控制管理员权限范围。
+- [AWS IAM 用户组](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_groups.html):一个用户可以属于多个组,组不嵌套。本系统采用多分组且不引入嵌套组。
+- [OWASP 授权指南](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html):每次请求验证权限,后端统一判断,并测试越权边界。
+
+本系统的“用户单独配置覆盖”是为兼容现有配置作出的产品选择,不照搬云平台的整套策略引擎。默认完整页面是现有负责人要求;新建组采用空权限,便于逐步转向按岗位授权。
diff --git a/resources/views/system_settings/groups/form.blade.php b/resources/views/system_settings/groups/form.blade.php
new file mode 100644
index 00000000..0348bcbf
--- /dev/null
+++ b/resources/views/system_settings/groups/form.blade.php
@@ -0,0 +1,30 @@
+ 用户可加入多个分组,获得各组页面权限的并集。分组不授予管理员身份;用户单独配置优先。