20260803225403

This commit is contained in:
oneao committed 2026-08-03 22:54:03 +08:00
1 parent efd5a2bc93
commit e17bbe9db3
40 files changed
+5516 -3910

No files matched your search

@@ -0,0 +1,219 @@
<script setup>
import { computed } from "vue";
import { useAppStore } from "@/stores/app";
const props = defineProps({
functionNodes: { type: Array, default: () => [] },
pageNodes: { type: Array, default: () => [] },
actions: { type: Array, default: () => [] },
functionIds: { type: Array, default: () => [] },
pageIds: { type: Array, default: () => [] },
actionIds: { type: Array, default: () => [] },
});
const emit = defineEmits(["update:functionIds", "update:pageIds", "update:actionIds"]);
const appStore = useAppStore();
const functionEntries = computed(() => {
const byId = new Map(props.functionNodes.map((item) => [String(item.b_id), item]));
return props.functionNodes
.filter((item) => item.b_node_type === "entry")
.map((item) => {
const path = [];
let parent = byId.get(String(item.b_parent_id || ""));
while (parent) {
path.unshift(appStore.t(parent.b_i18n, parent.b_name));
parent = byId.get(String(parent.b_parent_id || ""));
}
return { ...item, path: path.join(" / ") };
});
});
const pageGroups = computed(() => {
const groups = new Map();
for (const item of props.pageNodes) {
const key = String(item.b_page_module_id);
if (!groups.has(key)) {
groups.set(key, {
key,
name: appStore.t(item.b_page_i18n, item.b_page_name || item.b_page_code),
items: [],
});
}
groups.get(key).items.push(item);
}
return [...groups.values()];
});
const actionGroups = computed(() => {
const groups = new Map();
for (const item of props.actions) {
const key = String(item.b_module_id);
if (!groups.has(key)) {
groups.set(key, {
key,
name: appStore.t(item.b_module_i18n, item.b_module_name || item.b_module_code),
items: [],
});
}
groups.get(key).items.push(item);
}
return [...groups.values()];
});
function toggle(model, id, checked) {
const values = new Set(props[model].map(String));
if (checked) values.add(String(id));
else values.delete(String(id));
emit(`update:${model}`, [...values]);
}
function setAll(model, rows, checked) {
const values = new Set(props[model].map(String));
for (const row of rows) {
if (checked) values.add(String(row.b_id));
else values.delete(String(row.b_id));
}
emit(`update:${model}`, [...values]);
}
</script>
<template>
<a-tabs class="grant-tabs">
<a-tab-pane key="functions" tab="功能入口">
<div class="grant-toolbar">
<span>已选 {{ functionIds.length }} 项</span>
<a-button type="link" size="small" @click="setAll('functionIds', functionEntries, true)"
>全选</a-button
>
<a-button type="link" size="small" @click="setAll('functionIds', functionEntries, false)"
>清空</a-button
>
</div>
<div class="grant-list">
<label v-for="item in functionEntries" :key="item.b_id" class="grant-row">
<a-checkbox
:checked="functionIds.map(String).includes(String(item.b_id))"
@change="toggle('functionIds', item.b_id, $event.target.checked)"
/>
<span>{{ appStore.t(item.b_i18n, item.b_name) }}</span>
<small>{{ item.path }}</small>
</label>
</div>
</a-tab-pane>
<a-tab-pane key="pages" tab="页面区域">
<div class="grant-toolbar">
<span>已选 {{ pageIds.length }} 项</span>
<a-button type="link" size="small" @click="setAll('pageIds', pageNodes, true)"
>全选</a-button
>
<a-button type="link" size="small" @click="setAll('pageIds', pageNodes, false)"
>清空</a-button
>
</div>
<div class="grant-list">
<section v-for="group in pageGroups" :key="group.key" class="grant-group">
<strong>{{ group.name }}</strong>
<label v-for="item in group.items" :key="item.b_id" class="grant-row">
<a-checkbox
:checked="pageIds.map(String).includes(String(item.b_id))"
@change="toggle('pageIds', item.b_id, $event.target.checked)"
/>
<span>{{ appStore.t(item.b_i18n, item.b_name) }}</span>
<small>{{ item.b_code }}</small>
</label>
</section>
<a-empty v-if="!pageGroups.length" description="没有需要单独授权的页面区域" />
</div>
</a-tab-pane>
<a-tab-pane key="actions" tab="模块操作">
<div class="grant-toolbar">
<span>已选 {{ actionIds.length }} 项</span>
<a-button type="link" size="small" @click="setAll('actionIds', actions, true)"
>全选</a-button
>
<a-button type="link" size="small" @click="setAll('actionIds', actions, false)"
>清空</a-button
>
</div>
<div class="grant-list">
<section v-for="group in actionGroups" :key="group.key" class="grant-group">
<strong>{{ group.name }}</strong>
<label v-for="item in group.items" :key="item.b_id" class="grant-row">
<a-checkbox
:checked="actionIds.map(String).includes(String(item.b_id))"
@change="toggle('actionIds', item.b_id, $event.target.checked)"
/>
<span>{{ appStore.t(item.b_i18n, item.b_name) }}</span>
<small>{{ item.b_code }}</small>
</label>
</section>
</div>
</a-tab-pane>
</a-tabs>
</template>
<style scoped lang="scss">
.grant-tabs {
height: 100%;
}
.grant-tabs :deep(.ant-tabs-content-holder),
.grant-tabs :deep(.ant-tabs-content),
.grant-tabs :deep(.ant-tabs-tabpane) {
height: 100%;
min-height: 0;
}
.grant-tabs :deep(.ant-tabs-tabpane) {
display: flex;
flex-direction: column;
}
.grant-toolbar {
display: flex;
flex: none;
align-items: center;
min-height: 34px;
color: var(--fms-text-secondary, #666);
}
.grant-toolbar span {
margin-right: auto;
}
.grant-list {
flex: 1;
min-height: 0;
overflow: auto;
border: 1px solid var(--fms-border);
}
.grant-group > strong {
display: block;
padding: 8px 12px;
border-bottom: 1px solid var(--fms-border);
background: var(--fms-bg, #f5f7fa);
font-size: 13px;
}
.grant-row {
display: grid;
min-height: 38px;
grid-template-columns: 24px minmax(140px, 1fr) minmax(120px, 1fr);
align-items: center;
padding: 6px 12px;
border-bottom: 1px solid var(--fms-border);
cursor: pointer;
}
.grant-row:last-child {
border-bottom: 0;
}
.grant-row small {
overflow: hidden;
color: var(--fms-text-secondary, #8c8c8c);
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,257 @@
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { message, Modal } from "antdv-next";
import { Plus, Save, Trash2 } from "@lucide/vue";
import { usePermissionStore } from "@/stores/permissions";
import PermissionGrantEditor from "./PermissionGrantEditor.vue";
const props = defineProps({
open: { type: Boolean, default: false },
controller: { type: Object, required: true },
});
const emit = defineEmits(["update:open"]);
const permissionStore = usePermissionStore();
const createOpen = ref(false);
const createForm = reactive({ b_code: "", b_name: "", b_xh: 0 });
const editForm = reactive({ b_id: "", b_name: "", b_canuse: true, b_xh: 0 });
const selectedRole = computed(() =>
props.controller.roles.value.find(
(item) => String(item.b_id) === String(props.controller.selectedRoleId.value),
),
);
watch(selectedRole, (role) => {
Object.assign(editForm, {
b_id: String(role?.b_id || ""),
b_name: role?.b_name || "",
b_canuse: Number(role?.b_canuse) === 1,
b_xh: Number(role?.b_xh) || 0,
});
});
async function selectRole(id) {
try {
await props.controller.loadRole(id);
} catch (error) {
message.error(error.message || String(error));
}
}
function showCreate() {
Object.assign(createForm, { b_code: "", b_name: "", b_xh: 0 });
createOpen.value = true;
}
async function createRole() {
if (!/^[a-z][a-z0-9_]*$/.test(createForm.b_code) || !createForm.b_name.trim()) {
message.warning("请填写小写技术编码和角色名称");
return;
}
try {
await props.controller.createRole(createForm);
createOpen.value = false;
message.success("角色已创建");
} catch (error) {
message.error(error.message || String(error));
}
}
async function saveRole() {
try {
await props.controller.saveRole(editForm);
permissionStore.clear();
await permissionStore.load();
message.success("角色与授权已保存");
} catch (error) {
message.error(error.message || String(error));
}
}
function removeRole() {
if (!selectedRole.value) return;
Modal.confirm({
title: `确定删除角色「${selectedRole.value.b_name}」?`,
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
await props.controller.removeRole(selectedRole.value.b_id);
permissionStore.clear();
await permissionStore.load();
message.success("角色已删除");
} catch (error) {
message.error(error.message || String(error));
throw error;
}
},
});
}
</script>
<template>
<a-modal
:open="open"
title="角色与主授权"
width="1080px"
:footer="null"
:body-style="{ height: '650px', padding: 0 }"
@cancel="emit('update:open', false)"
>
<div class="role-layout">
<aside class="role-list-pane">
<div class="role-toolbar">
<strong>角色</strong>
<a-button type="text" title="新增角色" @click="showCreate"><Plus :size="16" /></a-button>
</div>
<button
v-for="role in controller.roles.value"
:key="role.b_id"
type="button"
class="role-item"
:class="{ active: String(role.b_id) === controller.selectedRoleId.value }"
@click="selectRole(role.b_id)"
>
<span>{{ role.b_name }}</span>
<small
>{{ role.b_code
}}<template v-if="Number(role.b_is_super) === 1"> · 超级角色</template></small
>
</button>
</aside>
<main v-if="selectedRole" class="role-detail">
<div class="role-detail-toolbar">
<a-input v-model:value="editForm.b_name" aria-label="角色名称" />
<a-input-number v-model:value="editForm.b_xh" aria-label="排序" :precision="0" />
<a-switch
v-model:checked="editForm.b_canuse"
checked-children="启用"
un-checked-children="停用"
/>
<a-button danger :disabled="Number(selectedRole.b_is_super) === 1" @click="removeRole">
<Trash2 :size="15" />删除
</a-button>
<a-button type="primary" :loading="controller.saving.value" @click="saveRole">
<Save :size="15" />保存
</a-button>
</div>
<a-alert
v-if="Number(selectedRole.b_is_super) === 1"
type="info"
show-icon
message="超级角色自动拥有全部启用权限,无需逐项配置。"
/>
<PermissionGrantEditor
v-else
class="role-grants"
:function-nodes="controller.functionNodes.value"
:page-nodes="controller.pageNodes.value"
:actions="controller.actions.value"
:function-ids="controller.roleFunctionIds.value"
:page-ids="controller.rolePageIds.value"
:action-ids="controller.roleActionIds.value"
@update:function-ids="controller.roleFunctionIds.value = $event"
@update:page-ids="controller.rolePageIds.value = $event"
@update:action-ids="controller.roleActionIds.value = $event"
/>
</main>
<a-empty v-else class="role-empty" description="请选择角色" />
</div>
<a-modal v-model:open="createOpen" title="新增角色" @ok="createRole">
<a-form layout="vertical">
<a-form-item label="技术编码" required
><a-input v-model:value="createForm.b_code"
/></a-form-item>
<a-form-item label="名称" required
><a-input v-model:value="createForm.b_name"
/></a-form-item>
<a-form-item label="排序"
><a-input-number v-model:value="createForm.b_xh" :precision="0"
/></a-form-item>
</a-form>
</a-modal>
</a-modal>
</template>
<style scoped lang="scss">
.role-layout {
display: grid;
height: 100%;
grid-template-columns: 230px minmax(0, 1fr);
}
.role-list-pane {
overflow: auto;
border-right: 1px solid var(--fms-border);
}
.role-toolbar,
.role-detail-toolbar {
display: flex;
min-height: 48px;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--fms-border);
}
.role-toolbar strong {
margin-right: auto;
}
.role-item {
display: flex;
width: 100%;
flex-direction: column;
gap: 2px;
padding: 9px 12px;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.role-item:hover,
.role-item.active {
background: var(--fms-bg, #f5f7fa);
}
.role-item small {
color: var(--fms-text-secondary, #8c8c8c);
}
.role-detail {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
padding: 0 16px 16px;
}
.role-detail-toolbar {
margin: 0 -16px 12px;
}
.role-detail-toolbar :deep(.ant-input) {
width: 220px;
}
.role-detail-toolbar :deep(.ant-input-number) {
width: 90px;
}
.role-detail-toolbar :deep(.ant-switch) {
margin-right: auto;
}
.role-grants {
flex: 1;
min-height: 0;
}
.role-empty {
margin-top: 180px;
}
</style>
@@ -0,0 +1,309 @@
import { ref } from "vue";
import { loadDataApi, nextIdApi, saveObjectApi } from "@/services/api";
function idSet(rows, field) {
return new Set(rows.map((item) => String(item[field])));
}
function grantDescriptor(
table,
ownerField,
targetField,
ownerId,
selectedIds,
existingRows,
extra,
) {
const selected = new Set(selectedIds.map(String));
const existing = idSet(existingRows, targetField);
return {
table,
ownerField,
targetField,
ownerId: String(ownerId),
extra,
inserts: [...selected].filter((id) => !existing.has(id)),
deletes: existingRows
.filter((row) => !selected.has(String(row[targetField])))
.map((row) => ({ b_id: row.b_id })),
};
}
async function saveGrantDescriptors(descriptors, leadingRequests = []) {
const insertCount = descriptors.reduce((total, item) => total + item.inserts.length, 0);
let ids = [];
if (insertCount) {
const response = await nextIdApi(insertCount);
ids = Array.isArray(response.data) ? response.data.map(String) : [String(response.data)];
}
let idIndex = 0;
const requests = [...leadingRequests];
for (const descriptor of descriptors) {
const inserts = descriptor.inserts.map((targetId) => ({
b_id: ids[idIndex++],
[descriptor.ownerField]: descriptor.ownerId,
[descriptor.targetField]: targetId,
...(descriptor.extra || {}),
}));
if (inserts.length || descriptor.deletes.length) {
requests.push({
table: descriptor.table,
key_field: "b_id",
inserts,
deletes: descriptor.deletes,
});
}
}
if (requests.length) await saveObjectApi(requests);
}
export function usePermissionAdministration(operatorId) {
const roles = ref([]);
const functionNodes = ref([]);
const pageNodes = ref([]);
const actions = ref([]);
const selectedRoleId = ref("");
const roleFunctionIds = ref([]);
const rolePageIds = ref([]);
const roleActionIds = ref([]);
const selectedUserId = ref("");
const userRoleIds = ref([]);
const userFunctionIds = ref([]);
const userPageIds = ref([]);
const userActionIds = ref([]);
const roleRows = ref({ functions: [], pages: [], actions: [] });
const userRows = ref({ roles: [], functions: [], pages: [], actions: [] });
const loading = ref(false);
const saving = ref(false);
async function loadCatalogs() {
loading.value = true;
try {
const responses = await Promise.all([
loadDataApi("b_role", "", "b_xh ASC, b_id ASC"),
loadDataApi("v_s_function_node", "b_canuse = 1", "b_xh ASC, b_id ASC"),
loadDataApi(
"v_s_page_node",
"b_canuse = 1 AND b_permission_required = 1",
"b_page_module_id ASC, b_xh ASC, b_id ASC",
),
loadDataApi("v_s_module_action", "b_canuse = 1", "b_module_id ASC, b_xh ASC, b_id ASC"),
]);
roles.value = responses[0].data || [];
functionNodes.value = responses[1].data || [];
pageNodes.value = responses[2].data || [];
actions.value = responses[3].data || [];
} finally {
loading.value = false;
}
}
async function loadRole(roleId) {
selectedRoleId.value = String(roleId || "");
if (!selectedRoleId.value) return;
const responses = await Promise.all([
loadDataApi("b_role_function_node", `b_role_id = ${selectedRoleId.value}`),
loadDataApi("b_role_page_node", `b_role_id = ${selectedRoleId.value}`),
loadDataApi("b_role_action", `b_role_id = ${selectedRoleId.value}`),
]);
roleRows.value = {
functions: responses[0].data || [],
pages: responses[1].data || [],
actions: responses[2].data || [],
};
roleFunctionIds.value = [...idSet(roleRows.value.functions, "b_function_node_id")];
rolePageIds.value = [...idSet(roleRows.value.pages, "b_page_node_id")];
roleActionIds.value = [...idSet(roleRows.value.actions, "b_action_id")];
}
async function createRole(values) {
const response = await nextIdApi();
const id = String(response.data);
await saveObjectApi([
{
table: "b_role",
key_field: "b_id",
inserts: [
{
b_id: id,
b_code: String(values.b_code || "").trim(),
b_name: String(values.b_name || "").trim(),
b_i18n: `meta.role.${id}`,
b_is_super: values.b_is_super ? 1 : 0,
b_canuse: 1,
b_xh: Number(values.b_xh) || 0,
},
],
},
]);
await loadCatalogs();
await loadRole(id);
}
async function saveRole(values) {
const roleId = selectedRoleId.value;
if (!roleId) return;
saving.value = true;
try {
await saveGrantDescriptors(
[
grantDescriptor(
"b_role_function_node",
"b_role_id",
"b_function_node_id",
roleId,
roleFunctionIds.value,
roleRows.value.functions,
),
grantDescriptor(
"b_role_page_node",
"b_role_id",
"b_page_node_id",
roleId,
rolePageIds.value,
roleRows.value.pages,
),
grantDescriptor(
"b_role_action",
"b_role_id",
"b_action_id",
roleId,
roleActionIds.value,
roleRows.value.actions,
),
],
[
{
table: "b_role",
key_field: "b_id",
updates: [
{
b_id: roleId,
b_name: String(values.b_name || "").trim(),
b_canuse: values.b_canuse ? 1 : 0,
b_xh: Number(values.b_xh) || 0,
},
],
},
],
);
await loadCatalogs();
await loadRole(roleId);
} finally {
saving.value = false;
}
}
async function removeRole(roleId) {
const role = roles.value.find((item) => String(item.b_id) === String(roleId));
if (Number(role?.b_is_super) === 1) throw new Error("超级管理员角色不能删除");
const userRoles = await loadDataApi("b_user_role", `b_role_id = ${roleId}`);
if ((userRoles.data || []).length) throw new Error("该角色仍分配给用户");
await loadRole(roleId);
const dataScopes = await loadDataApi("b_role_data_scope", `b_role_id = ${roleId}`);
await saveObjectApi([
{ table: "b_role_function_node", key_field: "b_id", deletes: roleRows.value.functions },
{ table: "b_role_page_node", key_field: "b_id", deletes: roleRows.value.pages },
{ table: "b_role_action", key_field: "b_id", deletes: roleRows.value.actions },
{ table: "b_role_data_scope", key_field: "b_id", deletes: dataScopes.data || [] },
{ table: "b_role", key_field: "b_id", deletes: [{ b_id: String(roleId) }] },
]);
selectedRoleId.value = "";
await loadCatalogs();
}
async function loadUser(userId) {
selectedUserId.value = String(userId || "");
const responses = await Promise.all([
loadDataApi("b_user_role", `b_user_id = ${selectedUserId.value}`),
loadDataApi("b_user_function_node", `b_user_id = ${selectedUserId.value}`),
loadDataApi("b_user_page_node", `b_user_id = ${selectedUserId.value}`),
loadDataApi("b_user_action", `b_user_id = ${selectedUserId.value}`),
]);
userRows.value = {
roles: responses[0].data || [],
functions: responses[1].data || [],
pages: responses[2].data || [],
actions: responses[3].data || [],
};
userRoleIds.value = [...idSet(userRows.value.roles, "b_role_id")];
userFunctionIds.value = [...idSet(userRows.value.functions, "b_function_node_id")];
userPageIds.value = [...idSet(userRows.value.pages, "b_page_node_id")];
userActionIds.value = [...idSet(userRows.value.actions, "b_action_id")];
}
async function saveUserPermissions() {
const userId = selectedUserId.value;
if (!userId) return;
const extra = { b_inputuser_id: String(operatorId.value || operatorId) };
saving.value = true;
try {
await saveGrantDescriptors([
grantDescriptor(
"b_user_role",
"b_user_id",
"b_role_id",
userId,
userRoleIds.value,
userRows.value.roles,
extra,
),
grantDescriptor(
"b_user_function_node",
"b_user_id",
"b_function_node_id",
userId,
userFunctionIds.value,
userRows.value.functions,
extra,
),
grantDescriptor(
"b_user_page_node",
"b_user_id",
"b_page_node_id",
userId,
userPageIds.value,
userRows.value.pages,
extra,
),
grantDescriptor(
"b_user_action",
"b_user_id",
"b_action_id",
userId,
userActionIds.value,
userRows.value.actions,
extra,
),
]);
await loadUser(userId);
} finally {
saving.value = false;
}
}
return {
roles,
functionNodes,
pageNodes,
actions,
selectedRoleId,
roleFunctionIds,
rolePageIds,
roleActionIds,
selectedUserId,
userRoleIds,
userFunctionIds,
userPageIds,
userActionIds,
loading,
saving,
loadCatalogs,
loadRole,
createRole,
saveRole,
removeRole,
loadUser,
saveUserPermissions,
};
}
+120 -586
View File
@@ -1,428 +1,124 @@
<script setup>
import { ref, reactive, computed } from "vue";
import { computed, ref } from "vue";
import { message } from "antdv-next";
import { Plus, ChevronRight, ChevronDown } from "@lucide/vue";
import { ShieldCheck } from "@lucide/vue";
import FmsModuleListPage from "@/components/fms-module-list/FmsModuleListPage.vue";
import { useAuthStore } from "@/stores/auth";
import { usePermissionStore } from "@/stores/permissions";
import PermissionGrantEditor from "./components/PermissionGrantEditor.vue";
import RoleManagerModal from "./components/RoleManagerModal.vue";
import { usePermissionAdministration } from "./composables/usePermissionAdministration";
const listRef = ref(null);
const authStore = useAuthStore();
const permissionStore = usePermissionStore();
const operatorId = computed(() => String(authStore.user?.id || ""));
const permissions = usePermissionAdministration(operatorId);
const roleManagerOpen = ref(false);
const userPermissionOpen = ref(false);
const selectedUser = ref(null);
// ── Mock data ──
const mockUser = {
b_id: "1001",
b_account: "zhangsan",
b_name: "张三",
b_phone: "13800138000",
b_department: "技术部",
b_canuse: "1",
b_salesman: "0",
b_docman: "0",
b_dealman: "1",
b_othermachinelogin: "0",
b_visible_customer: "1",
b_ismanager: "0",
b_purview: "1",
b_spview: "0",
b_subcompany: "",
b_inputuser: "admin",
b_inputdatetime: "2025-01-15 10:30:00",
};
async function ensureCatalogs() {
if (!permissions.roles.value.length) await permissions.loadCatalogs();
}
// 模块+权限合并的树数据:每个模块挂载自己的 power 列表
const mockModuleTree = [
{
b_id: "g1",
b_name: "基础资料",
children: [
{
b_id: "m11", b_code: "m_customer", b_name: "客户管理",
powers: ["查看", "新增", "修改", "删除", "导出"],
},
{
b_id: "m12", b_code: "m_supplier", b_name: "供应商管理",
powers: ["查看", "新增", "修改", "删除"],
},
{
b_id: "m13", b_code: "m_product", b_name: "产品管理",
powers: ["查看", "新增", "修改"],
},
],
},
{
b_id: "g2",
b_name: "业务操作",
children: [
{
b_id: "m21", b_code: "m_order", b_name: "订单管理",
powers: ["查看", "新增", "修改", "删除", "导出", "审核"],
},
{
b_id: "m22", b_code: "m_ship", b_name: "发货管理",
powers: ["查看", "新增", "发货", "确认"],
},
],
},
{
b_id: "g3",
b_name: "报表与分析",
children: [
{
b_id: "m31", b_code: "m_sales_report", b_name: "销售报表",
powers: ["查看", "导出"],
},
{
b_id: "m32", b_code: "m_stock_report", b_name: "库存报表",
powers: ["查看"],
},
],
},
];
// 该用户当前的全部权限状态:模块授权 + 每个模块的操作权限
// key = moduleId, value = Set of power names(含 "module" 表示模块已授权)
const mockUserGrants = {
m11: new Set(["module", "查看", "新增", "修改"]),
m13: new Set(["module", "查看"]),
m21: new Set(["module", "查看", "新增", "修改", "审核"]),
};
// ── Modal state ──
const modalVisible = ref(false);
const modalTitle = ref("新增用户");
const saving = ref(false);
const activeTab = ref("basic");
// Form data
const formData = reactive({
b_account: "",
b_name: "",
b_phone: "",
b_department: "",
b_canuse: true,
b_salesman: false,
b_docman: false,
b_dealman: false,
b_othermachinelogin: false,
b_visible_customer: false,
b_ismanager: false,
b_purview: true,
b_spview: false,
});
const password = ref("");
const passwordConfirm = ref("");
// 合并后的权限状态:{ [moduleId]: Set<powerName> },"module" 表示已授权该模块
const moduleGrants = ref({});
// 左侧菜单树:当前选中模块及分组展开状态
const selectedModId = ref(null);
const collapsedGroups = ref(new Set(["g3"])); // 默认折叠"报表与分析"
const selectedModule = computed(() => {
if (!selectedModId.value) return null;
for (const group of mockModuleTree) {
const found = group.children.find((m) => m.b_id === selectedModId.value);
if (found) return found;
async function openRoleManager() {
try {
await ensureCatalogs();
roleManagerOpen.value = true;
if (!permissions.selectedRoleId.value && permissions.roles.value.length) {
await permissions.loadRole(permissions.roles.value[0].b_id);
}
} catch (error) {
message.error(error.message || String(error));
}
return null;
});
function selectModule(modId) {
selectedModId.value = modId;
}
function toggleGroupExpand(gid) {
const next = new Set(collapsedGroups.value);
if (next.has(gid)) next.delete(gid);
else next.add(gid);
collapsedGroups.value = next;
}
function isModuleGranted(modId) {
return moduleGrants.value[modId]?.has("module") ?? false;
}
function isPowerGranted(modId, powerName) {
return moduleGrants.value[modId]?.has(powerName) ?? false;
}
function toggleModuleGrant(modId) {
const next = { ...moduleGrants.value };
if (isModuleGranted(modId)) {
// 取消模块授权 → 清除该模块所有权限
delete next[modId];
} else {
next[modId] = new Set(["module"]);
async function openUserPermissions(record) {
try {
selectedUser.value = record;
await ensureCatalogs();
await permissions.loadUser(record.b_id);
userPermissionOpen.value = true;
} catch (error) {
message.error(error.message || String(error));
}
moduleGrants.value = next;
}
function togglePower(modId, powerName) {
const next = { ...moduleGrants.value };
const current = next[modId];
if (!current) return;
const nextSet = new Set(current);
if (nextSet.has(powerName)) nextSet.delete(powerName);
else nextSet.add(powerName);
next[modId] = nextSet;
moduleGrants.value = next;
}
function toggleAllPowers(mod) {
const modId = mod.b_id;
const current = moduleGrants.value[modId];
if (!current) return;
const next = { ...moduleGrants.value };
const nextSet = new Set(current);
const allGranted = mod.powers.every((p) => nextSet.has(p));
if (allGranted) {
for (const p of mod.powers) nextSet.delete(p);
} else {
for (const p of mod.powers) nextSet.add(p);
async function saveUserPermissions() {
try {
await permissions.saveUserPermissions();
permissionStore.clear();
await permissionStore.load();
userPermissionOpen.value = false;
message.success("用户角色与附加授权已保存");
} catch (error) {
message.error(error.message || String(error));
}
next[modId] = nextSet;
moduleGrants.value = next;
}
function resetForm() {
formData.b_account = "";
formData.b_name = "";
formData.b_phone = "";
formData.b_department = "";
formData.b_canuse = true;
formData.b_salesman = false;
formData.b_docman = false;
formData.b_dealman = false;
formData.b_othermachinelogin = false;
formData.b_visible_customer = false;
formData.b_ismanager = false;
formData.b_purview = true;
formData.b_spview = false;
password.value = "";
passwordConfirm.value = "";
moduleGrants.value = {};
selectedModId.value = null;
collapsedGroups.value = new Set(["g3"]);
}
function fillEditForm(user) {
formData.b_account = user.b_account || "";
formData.b_name = user.b_name || "";
formData.b_phone = user.b_phone || "";
formData.b_department = user.b_department || "";
formData.b_canuse = user.b_canuse === "1" || user.b_canuse === 1;
formData.b_salesman = user.b_salesman === "1" || user.b_salesman === 1;
formData.b_docman = user.b_docman === "1" || user.b_docman === 1;
formData.b_dealman = user.b_dealman === "1" || user.b_dealman === 1;
formData.b_othermachinelogin = user.b_othermachinelogin === "1" || user.b_othermachinelogin === 1;
formData.b_visible_customer = user.b_visible_customer === "1" || user.b_visible_customer === 1;
formData.b_ismanager = user.b_ismanager === "1" || user.b_ismanager === 1;
formData.b_purview = user.b_purview === "1" || user.b_purview === 1;
formData.b_spview = user.b_spview === "1" || user.b_spview === 1;
password.value = "";
passwordConfirm.value = "";
}
// ── Handlers ──
function handleAdd() {
modalTitle.value = "新增用户";
activeTab.value = "basic";
resetForm();
modalVisible.value = true;
}
function handleEdit(row) {
modalTitle.value = `编辑用户 - ${row.b_name || row.b_account || ""}`;
activeTab.value = "basic";
fillEditForm(mockUser);
// 深拷贝 mock user grants
const grants = {};
for (const [modId, powers] of Object.entries(mockUserGrants)) {
grants[modId] = new Set(powers);
}
moduleGrants.value = grants;
selectedModId.value = mockModuleTree[0].children[0].b_id;
collapsedGroups.value = new Set();
modalVisible.value = true;
}
const rowActions = [
{ key: "edit", type: "action", label: "编辑", onClick: handleEdit },
{
key: "permissions",
permissionCode: "update",
type: "action",
label: "授权",
onClick: openUserPermissions,
},
];
// Save (demo only)
async function handleSave() {
saving.value = true;
await new Promise((r) => setTimeout(r, 600));
saving.value = false;
modalVisible.value = false;
message.success("(Demo)保存成功 — 未真实调用接口");
}
</script>
<template>
<div class="user-page">
<FmsModuleListPage
ref="listRef"
data-code="b_user"
:row-actions="rowActions"
@row-action="({ record }) => { if (record?.action?.key === 'edit') handleEdit(record.record) }"
>
<FmsModuleListPage module-code="b_user" :row-actions="rowActions">
<template #actions>
<a-button type="primary" @click="handleAdd">
<Plus :size="15" />
新增用户
<a-button v-if="permissionStore.canAction('b_user', 'update')" @click="openRoleManager">
<ShieldCheck :size="15" />角色维护
</a-button>
</template>
</FmsModuleListPage>
<!-- Demo Edit Modal -->
<RoleManagerModal v-model:open="roleManagerOpen" :controller="permissions" />
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:width="1000"
:confirm-loading="saving"
:body-style="{ padding: '0 24px 16px', maxHeight: '70vh', overflow: 'auto' }"
@ok="handleSave"
@cancel="modalVisible = false"
v-model:open="userPermissionOpen"
:title="`用户授权 - ${selectedUser?.b_name || selectedUser?.b_account || selectedUser?.b_id || ''}`"
width="1000px"
:confirm-loading="permissions.saving.value"
:body-style="{ height: '640px', padding: '12px 20px' }"
@ok="saveUserPermissions"
>
<a-tabs v-model:active-key="activeTab">
<!-- Tab 1: 基本信息(含密码) -->
<a-tab-pane key="basic" tab="基本信息">
<div class="demo-basic-info">
<a-row :gutter="[24, 16]">
<a-col :span="12">
<label class="demo-field-label">登录账号 <span class="demo-required">*</span></label>
<a-input v-model:value="formData.b_account" placeholder="请输入登录账号" />
</a-col>
<a-col :span="12">
<label class="demo-field-label">用户姓名 <span class="demo-required">*</span></label>
<a-input v-model:value="formData.b_name" placeholder="请输入用户姓名" />
</a-col>
<a-col :span="12">
<label class="demo-field-label">手机号</label>
<a-input v-model:value="formData.b_phone" placeholder="请输入手机号" />
</a-col>
<a-col :span="12">
<label class="demo-field-label">部门</label>
<a-input v-model:value="formData.b_department" placeholder="请输入部门" />
</a-col>
<a-col :span="12">
<label class="demo-field-label">新密码</label>
<a-input-password v-model:value="password" placeholder="留空则不修改" />
</a-col>
<a-col :span="12">
<label class="demo-field-label">确认密码</label>
<a-input-password v-model:value="passwordConfirm" placeholder="请再次输入" />
</a-col>
</a-row>
<!-- 标志位 -->
<div class="demo-flags-card">
<div class="demo-card-title">标志位</div>
<a-row :gutter="[16, 12]">
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_canuse">启用</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_salesman">销售员</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_docman">单据主管</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_dealman">可处理事务</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_othermachinelogin">允许异地登录</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_visible_customer">可见客户</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_ismanager">管理员</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_purview">操作权限</a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formData.b_spview">审批权限</a-checkbox>
</a-col>
</a-row>
</div>
<div class="user-permission-layout">
<section class="role-section">
<strong>所属角色</strong>
<div class="role-options">
<a-checkbox-group v-model:value="permissions.userRoleIds.value">
<a-checkbox
v-for="role in permissions.roles.value"
:key="role.b_id"
:value="String(role.b_id)"
:disabled="Number(role.b_canuse) !== 1"
>
{{ role.b_name }}
</a-checkbox>
</a-checkbox-group>
</div>
</a-tab-pane>
<!-- Tab 2: 菜单权限(左树右操作列表) -->
<a-tab-pane key="module-power" tab="菜单权限">
<div class="perm-layout">
<!-- 左侧:菜单树 -->
<div class="perm-tree">
<div v-for="group in mockModuleTree" :key="group.b_id" class="tree-group">
<div class="tree-group-hd" @click="toggleGroupExpand(group.b_id)">
<span class="tree-group-ic">
<ChevronRight v-if="collapsedGroups.has(group.b_id)" :size="14" />
<ChevronDown v-else :size="14" />
</span>
<span class="tree-group-label">{{ group.b_name }}</span>
<span class="tree-group-count">{{ group.children.length }}</span>
</div>
<div v-show="!collapsedGroups.has(group.b_id)" class="tree-group-body">
<div
v-for="mod in group.children"
:key="mod.b_id"
class="tree-item"
:class="{ 'is-active': selectedModId === mod.b_id }"
@click="selectModule(mod.b_id)"
>
<a-checkbox
:checked="isModuleGranted(mod.b_id)"
@click.stop
@change="toggleModuleGrant(mod.b_id)"
/>
<span class="tree-item-name">{{ mod.b_name }}</span>
</div>
</div>
</div>
</div>
<!-- 右侧:操作权限列表 -->
<div class="perm-detail">
<template v-if="selectedModule">
<div class="perm-detail-hd">
<span class="perm-detail-title">{{ selectedModule.b_name }}</span>
<span class="perm-detail-sub">{{ selectedModule.b_code }}</span>
</div>
<div class="perm-detail-body">
<template v-if="isModuleGranted(selectedModule.b_id)">
<label class="perm-detail-all">
<a-checkbox
:checked="selectedModule.powers.every((p) => isPowerGranted(selectedModule.b_id, p))"
:indeterminate="selectedModule.powers.some((p) => isPowerGranted(selectedModule.b_id, p)) && !selectedModule.powers.every((p) => isPowerGranted(selectedModule.b_id, p))"
@change="toggleAllPowers(selectedModule)"
/>
<span>全选操作权限</span>
</label>
<div class="perm-pow-list">
<label
v-for="pow in selectedModule.powers"
:key="pow"
class="perm-pow-item"
>
<a-checkbox
:checked="isPowerGranted(selectedModule.b_id, pow)"
@change="togglePower(selectedModule.b_id, pow)"
/>
<span>{{ pow }}</span>
</label>
</div>
</template>
<div v-else class="perm-detail-tip">
勾选左侧「{{ selectedModule.b_name }}」以授权访问,再配置具体操作权限
</div>
</div>
</template>
<div v-else class="perm-detail-empty">
请选择左侧菜单
</div>
</div>
</div>
</a-tab-pane>
</a-tabs>
</section>
<section class="additive-section">
<strong>附加授权</strong>
<PermissionGrantEditor
class="user-grants"
:function-nodes="permissions.functionNodes.value"
:page-nodes="permissions.pageNodes.value"
:actions="permissions.actions.value"
:function-ids="permissions.userFunctionIds.value"
:page-ids="permissions.userPageIds.value"
:action-ids="permissions.userActionIds.value"
@update:function-ids="permissions.userFunctionIds.value = $event"
@update:page-ids="permissions.userPageIds.value = $event"
@update:action-ids="permissions.userActionIds.value = $event"
/>
</section>
</div>
</a-modal>
</div>
</template>
@@ -442,207 +138,45 @@ async function handleSave() {
border-radius: 0;
}
// ── Modal internal styles ──
.demo-basic-info {
padding-top: 4px;
.user-permission-layout {
display: grid;
height: 100%;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
gap: 14px;
}
.demo-field-label {
.role-section,
.additive-section {
min-height: 0;
}
.role-section > strong,
.additive-section > strong {
display: block;
margin-bottom: 5px;
font-size: 13px;
color: #333;
margin-bottom: 8px;
}
.demo-required {
color: #ff4d4f;
}
.demo-flags-card {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.demo-card-title {
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
color: #666;
}
// ── Menu permission: left tree + right list ──
.perm-layout {
display: flex;
height: 440px;
border: 1px solid #f0f0f0;
border-radius: 6px;
overflow: hidden;
}
// 左侧:菜单树
.perm-tree {
width: 240px;
min-width: 240px;
padding: 6px 0;
border-right: 1px solid #f0f0f0;
overflow-y: auto;
background: #fafbfc;
}
.tree-group-hd {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
cursor: pointer;
user-select: none;
transition: background 0.1s;
&:hover {
background: #f0f1f3;
}
}
.tree-group-ic {
display: flex;
align-items: center;
color: #999;
}
.tree-group-label {
flex: 1;
font-size: 13px;
font-weight: 600;
color: #1a1a1a;
}
.tree-group-count {
font-size: 11px;
color: #bbb;
}
.tree-group-body {
display: flex;
flex-direction: column;
}
.tree-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px 8px 30px;
cursor: pointer;
transition: background 0.1s;
&:hover {
background: #ebedf0;
}
&.is-active {
background: #e6f0ff;
&:hover {
background: #dcebff;
}
}
}
.tree-item-name {
font-size: 13px;
color: #333;
user-select: none;
}
// 右侧:操作权限列表
.perm-detail {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: #fff;
}
.perm-detail-hd {
display: flex;
align-items: baseline;
gap: 8px;
padding: 14px 20px;
border-bottom: 1px solid #f0f0f0;
}
.perm-detail-title {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
}
.perm-detail-sub {
font-size: 12px;
color: #bbb;
}
.perm-detail-body {
flex: 1;
padding: 12px 20px;
overflow-y: auto;
}
.perm-detail-all {
display: flex;
align-items: center;
gap: 8px;
.role-options {
max-height: 92px;
overflow: auto;
padding: 10px 12px;
margin-bottom: 6px;
border-radius: 6px;
background: #f6f7f9;
font-size: 13px;
color: #333;
cursor: pointer;
user-select: none;
border: 1px solid var(--fms-border);
}
.perm-pow-list {
.role-options :deep(.ant-checkbox-group) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px 12px;
}
.additive-section {
display: flex;
flex-direction: column;
}
.perm-pow-item {
display: flex;
align-items: center;
gap: 8px;
padding: 11px 12px;
border-bottom: 1px solid #f5f5f5;
font-size: 13px;
color: #333;
cursor: pointer;
user-select: none;
&:last-child {
border-bottom: 0;
}
&:hover {
background: #fafbfc;
}
}
.perm-detail-tip {
margin-top: 8px;
padding: 36px 16px;
text-align: center;
font-size: 13px;
color: #999;
background: #fafbfc;
border-radius: 6px;
}
.perm-detail-empty {
.user-grants {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: #bbb;
min-height: 0;
}
</style>