init: UserBaseFront (frontend)
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<Card :bordered="false" dis-hover>
|
||||
<p slot="title">接口日志</p>
|
||||
<Alert type="info" show-icon>功能开发中,敬请期待...</Alert>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useDefaultStore } from "@/store";
|
||||
const store = useDefaultStore();
|
||||
</script>
|
||||
@@ -0,0 +1,419 @@
|
||||
<template>
|
||||
<div class="page-wrapper tree-wrapper">
|
||||
<GroupTreeSearchPanel class="tree-container app-open-tree" v-model:curTreeNode="selectedSchoolNode"
|
||||
:treeApi="schoolTreeApi" :defaultSearchVisible="true" :showCloseButton="false" searchPlaceholder="输入学校名称搜索"
|
||||
cacheKey="bsch.app-open.school-node" @on-change="handleSchoolChange" />
|
||||
|
||||
<div class="main-container app-open-main">
|
||||
<div class="search-wrap authz-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<Select v-model="selectedCategoryId" class="type-filter-select" placeholder="全部应用类型" clearable>
|
||||
<Option v-for="opt in categoryOptions" :key="opt.value" :value="opt.value">{{ opt.label }}
|
||||
</Option>
|
||||
</Select>
|
||||
<h5 class="title mb-0">应用开通</h5>
|
||||
<div class="school-text">
|
||||
当前学校:
|
||||
<span class="school-name">{{ currentSchoolName || '未选择学校' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<Input v-model="searchKeyword" placeholder="输入应用名称搜索,回车筛选" clearable suffix="ios-search"
|
||||
style="width: 260px" @on-enter="onSearch" @on-clear="onClearSearch" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert v-if="!selectedSchoolId" type="warning" show-icon>
|
||||
请选择学校后再进行应用开通设置
|
||||
</Alert>
|
||||
|
||||
<div v-else class="authz-panel" ref="authzPanelRef">
|
||||
<Spin fix v-if="panelLoading" />
|
||||
<Collapse v-model="activePanels" class="authz-collapse">
|
||||
<Panel v-for="cat in visibleCategories" :key="cat.id" :name="String(cat.id)">
|
||||
<span class="panel-title">{{ cat.name }}</span>
|
||||
<template #content>
|
||||
<CheckboxGroup :model-value="selectedAppIds" @on-change="onAppCheckChange"
|
||||
class="app-check-group">
|
||||
<Checkbox v-for="app in (cat.app || [])" :key="app.id" :label="Number(app.id)"
|
||||
:data-app-id="Number(app.id)"
|
||||
:class="['app-check-item', matchedAppIds.includes(Number(app.id)) ? 'app-match' : '']">
|
||||
{{ app.name }}
|
||||
</Checkbox>
|
||||
</CheckboxGroup>
|
||||
</template>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
|
||||
<div v-if="visibleCategories.length === 0" class="empty-tip">暂无可配置的应用类型</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Apis from "@/api";
|
||||
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
|
||||
import { useDefaultStore } from "@/store";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
|
||||
defineOptions({ name: "AppOpen" });
|
||||
|
||||
type AppItem = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type AppCategory = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
app?: AppItem[];
|
||||
};
|
||||
|
||||
type DictItem = {
|
||||
k: number | string;
|
||||
v: string;
|
||||
};
|
||||
|
||||
const store = useDefaultStore();
|
||||
const { $ScrollTop } = useCurrentInstance();
|
||||
|
||||
const schoolTreeApi = () => Apis.SysGroup.getSchoolTree({ cacheFor: null });
|
||||
|
||||
const selectedSchoolNode = ref<any>({});
|
||||
const categoryList = ref<AppCategory[]>([]);
|
||||
const selectedCategoryId = ref<number | null>(null);
|
||||
const selectedAppIds = ref<number[]>([]);
|
||||
const prevAppIds = ref<number[]>([]);
|
||||
const pageLoading = ref(false);
|
||||
const authzLoading = ref(false);
|
||||
const activePanels = ref<string[]>([]);
|
||||
|
||||
const searchKeyword = ref("");
|
||||
const appliedKeyword = ref("");
|
||||
const authzPanelRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const fallbackModeItems: DictItem[] = [
|
||||
{ k: 0, v: "追加" },
|
||||
{ k: 1, v: "覆盖" },
|
||||
{ k: 2, v: "删除" },
|
||||
];
|
||||
|
||||
const selectedSchoolId = computed(() => {
|
||||
const id = Number(selectedSchoolNode.value?.id);
|
||||
return Number.isFinite(id) ? id : null;
|
||||
});
|
||||
const currentSchoolName = computed(() => selectedSchoolNode.value?.name || "");
|
||||
const panelLoading = computed(() => pageLoading.value || authzLoading.value);
|
||||
|
||||
const schoolAppModeItems = computed<DictItem[]>(() => {
|
||||
const dict = (store.getSysDict as any) || {};
|
||||
const preferredKeys = [
|
||||
"schoolappmode",
|
||||
"schoolAppMode",
|
||||
"groupauthzmode",
|
||||
"groupAuthzMode",
|
||||
"authzmode",
|
||||
"authzMode",
|
||||
];
|
||||
|
||||
const isMatch = (list: any) => {
|
||||
if (!Array.isArray(list)) return false;
|
||||
const keys = list.map((x: any) => Number(x?.k ?? x?.key));
|
||||
return keys.includes(0) && keys.includes(1) && keys.includes(2);
|
||||
};
|
||||
|
||||
for (const key of preferredKeys) {
|
||||
if (isMatch(dict[key])) {
|
||||
return dict[key].map((x: any) => ({ k: Number(x.k ?? x.key), v: String(x.v ?? x.value ?? x.name) }));
|
||||
}
|
||||
}
|
||||
|
||||
const firstMatched = Object.values(dict).find(isMatch) as any[] | undefined;
|
||||
if (firstMatched) {
|
||||
return firstMatched.map((x: any) => ({ k: Number(x.k ?? x.key), v: String(x.v ?? x.value ?? x.name) }));
|
||||
}
|
||||
|
||||
return fallbackModeItems;
|
||||
});
|
||||
|
||||
/* 开通模式:勾选=追加(0),取消=删除(2) */
|
||||
const addMode = computed(() => Number(schoolAppModeItems.value.find((x) => Number(x.k) === 0)?.k ?? 0));
|
||||
const deleteMode = computed(() => Number(schoolAppModeItems.value.find((x) => Number(x.k) === 2)?.k ?? 2));
|
||||
|
||||
/* 仅列出有应用的类型,无应用的类型不显示 */
|
||||
const categoryOptions = computed(() =>
|
||||
categoryList.value
|
||||
.filter((c) => Array.isArray(c.app) && c.app.length > 0)
|
||||
.map((c) => ({ value: Number(c.id), label: String(c.name || `应用类型${c.id}`) }))
|
||||
);
|
||||
|
||||
/* 全部=不过滤;选中某类型=只显示该类型;均无应用的类型不显示 */
|
||||
const visibleCategories = computed(() => {
|
||||
const all = categoryList.value.filter((c) => Array.isArray(c.app) && c.app.length > 0);
|
||||
if (selectedCategoryId.value == null || selectedCategoryId.value === "") return all;
|
||||
return all.filter((c) => Number(c.id) === Number(selectedCategoryId.value));
|
||||
});
|
||||
|
||||
watch(
|
||||
visibleCategories,
|
||||
(list) => {
|
||||
activePanels.value = list.map((c) => String(c.id));
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
/* 回车筛选关键词下,所有符合(名称模糊匹配)的应用 id(仅基于可见类型,跨类型) */
|
||||
const matchedAppIds = computed(() => {
|
||||
const kw = (appliedKeyword.value || "").trim().toLowerCase();
|
||||
if (!kw) return [];
|
||||
const ids: number[] = [];
|
||||
visibleCategories.value.forEach((cat) =>
|
||||
(cat.app || []).forEach((app) => {
|
||||
if ((app.name || "").toLowerCase().includes(kw)) ids.push(Number(app.id));
|
||||
})
|
||||
);
|
||||
return ids;
|
||||
});
|
||||
|
||||
function uniqueNumberArray(list: Array<number | string | undefined | null>) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
list
|
||||
.map((x) => Number(x))
|
||||
.filter((x) => Number.isFinite(x))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureSysDict() {
|
||||
if (Object.keys(store.getSysDict || {}).length === 0) {
|
||||
await store.fetchSysDict();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCategoryList() {
|
||||
pageLoading.value = true;
|
||||
try {
|
||||
const data = await Apis.AppSettings.appAuthzList({ cacheFor: null });
|
||||
categoryList.value = Array.isArray(data) ? data : [];
|
||||
/* 默认「全部」:不预选具体类型,显示 placeholder */
|
||||
selectedCategoryId.value = null;
|
||||
} finally {
|
||||
pageLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAuthorizedAppIds(schoolId: number) {
|
||||
authzLoading.value = true;
|
||||
try {
|
||||
const data = await Apis.GroupAuthz.getSchoolAppId({ params: { nodeId: schoolId }, cacheFor: null });
|
||||
const ids = uniqueNumberArray(Array.isArray(data) ? data : []);
|
||||
selectedAppIds.value = [...ids];
|
||||
prevAppIds.value = [...ids];
|
||||
} finally {
|
||||
authzLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSchoolChange(node: any) {
|
||||
selectedSchoolNode.value = node || {};
|
||||
const nodeId = Number(node?.id);
|
||||
if (!Number.isFinite(nodeId)) {
|
||||
selectedAppIds.value = [];
|
||||
prevAppIds.value = [];
|
||||
return;
|
||||
}
|
||||
await loadAuthorizedAppIds(nodeId);
|
||||
}
|
||||
|
||||
/* 勾选/取消立即保存,无需额外保存按钮 */
|
||||
function onAppCheckChange(newVal: number[]) {
|
||||
const before = [...prevAppIds.value];
|
||||
const prev = new Set(before);
|
||||
const next = new Set(newVal);
|
||||
|
||||
let changedId: number | null = null;
|
||||
let add = false;
|
||||
next.forEach((id) => {
|
||||
if (!prev.has(id)) {
|
||||
changedId = id;
|
||||
add = true;
|
||||
}
|
||||
});
|
||||
prev.forEach((id) => {
|
||||
if (!next.has(id)) {
|
||||
changedId = id;
|
||||
add = false;
|
||||
}
|
||||
});
|
||||
|
||||
prevAppIds.value = [...newVal];
|
||||
selectedAppIds.value = newVal;
|
||||
|
||||
if (changedId != null && selectedSchoolId.value != null) {
|
||||
saveSingleApp(changedId, add, before);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSingleApp(appid: number, add: boolean, before: number[]) {
|
||||
try {
|
||||
await Apis.GroupAuthz.setSchoolApp({
|
||||
data: {
|
||||
appid,
|
||||
mode: add ? addMode.value : deleteMode.value,
|
||||
schid: selectedSchoolId.value!,
|
||||
},
|
||||
cacheFor: null,
|
||||
});
|
||||
Message.success(add ? "已开通" : "已取消开通");
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "操作失败");
|
||||
selectedAppIds.value = [...before];
|
||||
prevAppIds.value = [...before];
|
||||
}
|
||||
}
|
||||
|
||||
/* 回车才筛选:与应用页查询逻辑一致(@on-search = 回车/点搜索图标) */
|
||||
async function onSearch() {
|
||||
appliedKeyword.value = searchKeyword.value;
|
||||
await nextTick();
|
||||
scrollToFirstMatch();
|
||||
}
|
||||
|
||||
function onClearSearch() {
|
||||
appliedKeyword.value = "";
|
||||
}
|
||||
|
||||
/* 用 Vue3 ref 计算目标位置,借助 iView 的 $ScrollTop 优雅滚动至首个匹配项 */
|
||||
function scrollToFirstMatch() {
|
||||
const panel = authzPanelRef.value;
|
||||
if (!panel) return;
|
||||
if (matchedAppIds.value.length === 0) {
|
||||
if ((appliedKeyword.value || "").trim()) Message.info("未找到匹配的应用");
|
||||
return;
|
||||
}
|
||||
const firstId = matchedAppIds.value[0];
|
||||
const el = panel.querySelector(`[data-app-id="${firstId}"]`) as HTMLElement | null;
|
||||
if (!el) return;
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const delta = elRect.top - panelRect.top;
|
||||
const targetTop = Math.max(0, Math.min(panel.scrollTop + delta - 16, panel.scrollHeight - panel.clientHeight));
|
||||
$ScrollTop(panel, targetTop, 300);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
ensureSysDict(),
|
||||
loadCategoryList(),
|
||||
]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-open-tree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============ 左侧列(仅学校树) ============ */
|
||||
.left-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e8eaec;
|
||||
}
|
||||
|
||||
/* search-wrap 左侧的应用类型 Select 筛选 */
|
||||
.type-filter-select {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============ 右侧主区 ============ */
|
||||
.app-open-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.authz-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.school-text {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.school-name {
|
||||
color: #2d8cf0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.authz-panel {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-check-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* 常态即占位 padding/圆角,命中只切换背景色,避免尺寸变化导致页面跳动 */
|
||||
.app-check-item {
|
||||
min-width: 180px;
|
||||
margin-right: 0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 搜索命中高亮(与 tree 选中色一致) */
|
||||
.app-check-item.app-match {
|
||||
background-color: #ffe9c9;
|
||||
box-shadow: 0 0 0 1px #ffd591 inset;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
color: #999;
|
||||
padding: 4px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,724 @@
|
||||
<template>
|
||||
<div class="page-wrapper tree-wrapper">
|
||||
<GroupTreeSearchPanel class="tree-container group-auth-tree" v-model:curTreeNode="selectedGroupNode"
|
||||
:treeApi="groupTreeApi" :defaultSearchVisible="true" :showCloseButton="false" searchPlaceholder="输入组名称搜索"
|
||||
cacheKey="bsch.group-auth.group-node" @on-change="handleGroupChange" />
|
||||
|
||||
<div class="main-container group-auth-main">
|
||||
<div class="search-wrap authz-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<Select v-model="selectedCategoryId" class="type-filter-select" placeholder="全部应用类型" clearable>
|
||||
<Option v-for="opt in categoryOptions" :key="opt.value" :value="opt.value">{{ opt.label }}
|
||||
</Option>
|
||||
</Select>
|
||||
<h5 class="title mb-0">组授权</h5>
|
||||
<div class="group-text">
|
||||
当前组:
|
||||
<span class="group-name">{{ currentGroupName || '未选择组' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<Input v-model="searchKeyword" placeholder="输入应用或模块名称搜索,回车过滤" clearable suffix="ios-search"
|
||||
style="width: 260px" @on-enter="onSearch" @on-clear="onClearSearch" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert v-if="!selectedGroupId" type="warning" show-icon>
|
||||
请选择组后再进行授权设置
|
||||
</Alert>
|
||||
|
||||
<div v-else class="authz-panel">
|
||||
<Spin fix v-if="panelLoading" />
|
||||
|
||||
<div v-if="hasVisibleCategories" class="cat-columns">
|
||||
<div v-for="(col, ci) in columns" :key="ci" class="cat-col">
|
||||
<div v-for="item in col" :key="String(item.cat.id)" class="cat-card">
|
||||
<div class="cat-card-header" @click="toggleCollapse(item.cat.id)">
|
||||
<Icon class="collapse-icon"
|
||||
:type="collapsedMap[String(item.cat.id)] ? 'ios-arrow-forward' : 'ios-arrow-down'" />
|
||||
<span class="cat-name" :title="item.cat.name">{{ item.cat.name }}</span>
|
||||
<span class="cat-count">{{ item.treeData.length }}个应用</span>
|
||||
<span class="inherit-cap">子组继承</span>
|
||||
</div>
|
||||
<div v-show="!collapsedMap[String(item.cat.id)]" class="cat-card-body">
|
||||
<MyTree :treeData="item.treeData" :setting="treeSetting" :autoClick="false"
|
||||
@on-tree-check="onTreeCheck" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-tip">
|
||||
<template v-if="nonEmptyCategories.length === 0">
|
||||
当前组节点未开通任何应用,<router-link class="open-link" :to="{ name: 'AppOpen' }">去开通</router-link>
|
||||
</template>
|
||||
<template v-else>未找到匹配的应用或模块</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref, shallowRef, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Apis from "@/api";
|
||||
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
|
||||
import MyTree from "@/components/MyTree.vue";
|
||||
import { useDefaultStore } from "@/store";
|
||||
|
||||
defineOptions({ name: "GroupAuth" });
|
||||
|
||||
type AppModuleVo = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
level?: number;
|
||||
checked?: boolean;
|
||||
inheritFlag?: boolean;
|
||||
children?: AppModuleVo[];
|
||||
};
|
||||
|
||||
type AppItem = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
sortNo?: number;
|
||||
modules?: AppModuleVo[];
|
||||
};
|
||||
|
||||
type CategoryItem = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
apps?: AppItem[];
|
||||
};
|
||||
|
||||
type DictItem = {
|
||||
k: number | string;
|
||||
v: string;
|
||||
};
|
||||
|
||||
/** zTree 节点:应用为一级节点,模块为其子节点 */
|
||||
type AuthzTreeNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
open: boolean;
|
||||
checked: boolean;
|
||||
inheritFlag: boolean;
|
||||
nodeType: "app" | "module";
|
||||
rawId: number;
|
||||
appId: number;
|
||||
children?: AuthzTreeNode[];
|
||||
};
|
||||
|
||||
type CategoryTreeItem = {
|
||||
cat: CategoryItem;
|
||||
treeData: AuthzTreeNode[];
|
||||
weight: number;
|
||||
};
|
||||
|
||||
const COLUMN_COUNT = 3;
|
||||
|
||||
const jq = () => (window as any).$;
|
||||
|
||||
const store = useDefaultStore();
|
||||
|
||||
const groupTreeApi = () => Apis.SysGroup.getTree({ cacheFor: null });
|
||||
|
||||
const selectedGroupNode = ref<any>({});
|
||||
// shallowRef:节点勾选状态的局部变更不触发树重建,只有整体重新加载才重建
|
||||
const categoryList = shallowRef<CategoryItem[]>([]);
|
||||
const selectedCategoryId = ref<number | null>(null);
|
||||
const pageLoading = ref(false);
|
||||
const searchKeyword = ref("");
|
||||
const appliedKeyword = ref("");
|
||||
const collapsedMap = reactive<Record<string, boolean>>({});
|
||||
|
||||
const fallbackModeItems: DictItem[] = [
|
||||
{ k: 0, v: "追加" },
|
||||
{ k: 1, v: "覆盖" },
|
||||
{ k: 2, v: "删除" },
|
||||
];
|
||||
|
||||
const selectedGroupId = computed(() => {
|
||||
const id = Number(selectedGroupNode.value?.id);
|
||||
return Number.isFinite(id) ? id : null;
|
||||
});
|
||||
const currentGroupName = computed(() => selectedGroupNode.value?.name || "");
|
||||
const panelLoading = computed(() => pageLoading.value);
|
||||
|
||||
const authzModeItems = computed<DictItem[]>(() => {
|
||||
const dict = (store.getSysDict as any) || {};
|
||||
const preferredKeys = [
|
||||
"groupauthzmode",
|
||||
"groupAuthzMode",
|
||||
"authzmode",
|
||||
"authzMode",
|
||||
"schoolappmode",
|
||||
"schoolAppMode",
|
||||
];
|
||||
|
||||
const isMatch = (list: any) => {
|
||||
if (!Array.isArray(list)) return false;
|
||||
const keys = list.map((x: any) => Number(x?.k ?? x?.key));
|
||||
return keys.includes(0) && keys.includes(1) && keys.includes(2);
|
||||
};
|
||||
|
||||
for (const key of preferredKeys) {
|
||||
if (isMatch(dict[key])) {
|
||||
return dict[key].map((x: any) => ({ k: Number(x.k ?? x.key), v: String(x.v ?? x.value ?? x.name) }));
|
||||
}
|
||||
}
|
||||
|
||||
const firstMatched = Object.values(dict).find(isMatch) as any[] | undefined;
|
||||
if (firstMatched) {
|
||||
return firstMatched.map((x: any) => ({ k: Number(x.k ?? x.key), v: String(x.v ?? x.value ?? x.name) }));
|
||||
}
|
||||
|
||||
return fallbackModeItems;
|
||||
});
|
||||
|
||||
const addMode = computed(() => Number(authzModeItems.value.find((x) => Number(x.k) === 0)?.k ?? 0));
|
||||
const deleteMode = computed(() => Number(authzModeItems.value.find((x) => Number(x.k) === 2)?.k ?? 2));
|
||||
|
||||
// 仅保留有已开通应用的分类
|
||||
const nonEmptyCategories = computed(() => (categoryList.value || []).filter((c) => (c.apps || []).length > 0));
|
||||
|
||||
const categoryOptions = computed(() =>
|
||||
nonEmptyCategories.value.map((c) => ({ value: Number(c.id), label: String(c.name || `应用分类${c.id}`) }))
|
||||
);
|
||||
|
||||
// 切换组后若当前选中的分类已不存在,则回退到"全部"
|
||||
watch(categoryOptions, (opts) => {
|
||||
if (selectedCategoryId.value != null && !opts.some((o) => o.value === Number(selectedCategoryId.value))) {
|
||||
selectedCategoryId.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
function moduleNameHit(node: AppModuleVo, kw: string): boolean {
|
||||
if ((node.name || "").toLowerCase().includes(kw)) return true;
|
||||
return (node.children || []).some((c) => moduleNameHit(c, kw));
|
||||
}
|
||||
|
||||
function countTotalModules(nodes: AppModuleVo[]): number {
|
||||
let count = 0;
|
||||
for (const node of nodes) {
|
||||
count++;
|
||||
count += countTotalModules(node.children || []);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function isAppChecked(app: AppItem): boolean {
|
||||
const modules = app.modules || [];
|
||||
if (modules.length === 0) return false;
|
||||
return modules.every((m) => isModuleAllChecked(m));
|
||||
}
|
||||
|
||||
function isAppInherit(app: AppItem): boolean {
|
||||
const modules = app.modules || [];
|
||||
if (modules.length === 0) return false;
|
||||
return modules.every((m) => isModuleAllInherit(m));
|
||||
}
|
||||
|
||||
function isModuleAllChecked(node: AppModuleVo): boolean {
|
||||
if (!node.checked) return false;
|
||||
return (node.children || []).every((c) => isModuleAllChecked(c));
|
||||
}
|
||||
|
||||
function isModuleAllInherit(node: AppModuleVo): boolean {
|
||||
if (!node.inheritFlag) return false;
|
||||
return (node.children || []).every((c) => isModuleAllInherit(c));
|
||||
}
|
||||
|
||||
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
|
||||
return {
|
||||
id: `mod_${m.id}`,
|
||||
name: String(m.name || ""),
|
||||
open: true,
|
||||
checked: !!m.checked,
|
||||
inheritFlag: !!m.inheritFlag,
|
||||
nodeType: "module",
|
||||
rawId: Number(m.id),
|
||||
appId,
|
||||
children: (m.children || []).map((c) => buildModuleNode(c, appId)),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAppNode(app: AppItem): AuthzTreeNode {
|
||||
const appId = Number(app.id);
|
||||
return {
|
||||
id: `app_${appId}`,
|
||||
name: String(app.name || ""),
|
||||
open: true,
|
||||
checked: isAppChecked(app),
|
||||
inheritFlag: isAppInherit(app),
|
||||
nodeType: "app",
|
||||
rawId: appId,
|
||||
appId,
|
||||
children: (app.modules || []).map((m) => buildModuleNode(m, appId)),
|
||||
};
|
||||
}
|
||||
|
||||
// 分类 -> zTree 数据(应用为一级节点,模块为子节点);weight 用于三列均衡
|
||||
const categoryTreeList = computed<CategoryTreeItem[]>(() => {
|
||||
const kw = (appliedKeyword.value || "").trim().toLowerCase();
|
||||
return nonEmptyCategories.value
|
||||
.filter((cat) => selectedCategoryId.value == null || selectedCategoryId.value === "" || Number(cat.id) === Number(selectedCategoryId.value))
|
||||
.map((cat) => {
|
||||
const apps = (cat.apps || []).filter((app) => {
|
||||
if (!kw) return true;
|
||||
if ((app.name || "").toLowerCase().includes(kw)) return true;
|
||||
return (app.modules || []).some((m) => moduleNameHit(m, kw));
|
||||
});
|
||||
return { cat, apps };
|
||||
})
|
||||
.filter((x) => x.apps.length > 0)
|
||||
.map(({ cat, apps }) => ({
|
||||
cat,
|
||||
treeData: apps.map((app) => buildAppNode(app)),
|
||||
weight: 2 + apps.reduce((sum, a) => sum + 1 + countTotalModules(a.modules || []), 0),
|
||||
}));
|
||||
});
|
||||
|
||||
const hasVisibleCategories = computed(() => categoryTreeList.value.length > 0);
|
||||
|
||||
// 简易瀑布流:每个分类卡片放入当前累计权重最小的列
|
||||
const columns = computed<CategoryTreeItem[][]>(() => {
|
||||
const cols: CategoryTreeItem[][] = Array.from({ length: COLUMN_COUNT }, () => []);
|
||||
const weights = new Array(COLUMN_COUNT).fill(0);
|
||||
for (const item of categoryTreeList.value) {
|
||||
let idx = 0;
|
||||
for (let i = 1; i < COLUMN_COUNT; i++) {
|
||||
if (weights[i] < weights[idx]) idx = i;
|
||||
}
|
||||
cols[idx].push(item);
|
||||
weights[idx] += item.weight;
|
||||
}
|
||||
return cols;
|
||||
});
|
||||
|
||||
function toggleCollapse(catId: number | undefined) {
|
||||
const key = String(catId);
|
||||
collapsedMap[key] = !collapsedMap[key];
|
||||
}
|
||||
|
||||
// ---------------- zTree 配置 ----------------
|
||||
|
||||
/** 在每个节点行尾追加"子组继承"复选框 */
|
||||
function addDiyDom(treeId: string, treeNode: any) {
|
||||
const $ = jq();
|
||||
if (!$) return;
|
||||
const inheritId = `${treeNode.tId}_inherit`;
|
||||
if ($(`#${inheritId}`).length > 0) return;
|
||||
$(`#${treeNode.tId}_a`).after(
|
||||
`<span class="inherit-wrap" title="子组继承"><input id="${inheritId}" type="checkbox" class="inherit-chk" /></span>`
|
||||
);
|
||||
const el = $(`#${inheritId}`);
|
||||
el.prop("checked", !!treeNode.inheritFlag);
|
||||
el.prop("disabled", !treeNode.checked);
|
||||
el.on("change", function (this: HTMLInputElement) {
|
||||
onInheritToggle(treeId, treeNode, this.checked);
|
||||
});
|
||||
}
|
||||
|
||||
const treeSetting = {
|
||||
view: {
|
||||
dblClickExpand: false,
|
||||
showIcon: false,
|
||||
addDiyDom,
|
||||
},
|
||||
check: {
|
||||
enable: true,
|
||||
chkStyle: "checkbox",
|
||||
// 不自动级联:模块逐条独立授权;应用节点勾选后由代码手动同步子节点
|
||||
chkboxType: { Y: "", N: "" },
|
||||
},
|
||||
callback: {},
|
||||
};
|
||||
|
||||
function getTreeObjByNode(node: any) {
|
||||
const $ = jq();
|
||||
const treeId = String(node?.tId || "").replace(/_\d+$/, "");
|
||||
return $?.fn?.zTree?.getZTreeObj(treeId) || null;
|
||||
}
|
||||
|
||||
/** 模块勾选变化后,按本地数据回算应用节点的勾选/继承态并同步到 zTree */
|
||||
function syncAppNodeState(treeObj: any, appId: number) {
|
||||
if (!treeObj) return;
|
||||
const app = findLocalApp(appId);
|
||||
if (!app) return;
|
||||
const appNode = treeObj.getNodesByFilter((n: any) => n.id === `app_${appId}`, true);
|
||||
if (!appNode) return;
|
||||
appNode.checked = isAppChecked(app);
|
||||
appNode.inheritFlag = isAppInherit(app);
|
||||
treeObj.checkNode(appNode, appNode.checked, false);
|
||||
}
|
||||
|
||||
/** 按 zTree 当前节点状态刷新所有"子组继承"复选框的勾选与禁用态 */
|
||||
function syncInheritDoms(treeObj: any) {
|
||||
const $ = jq();
|
||||
if (!treeObj || !$) return;
|
||||
const nodes = treeObj.transformToArray(treeObj.getNodes()) || [];
|
||||
nodes.forEach((n: any) => {
|
||||
const el = $(`#${n.tId}_inherit`);
|
||||
if (!el.length) return;
|
||||
el.prop("checked", !!n.inheritFlag);
|
||||
el.prop("disabled", !n.checked);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- 数据加载 ----------------
|
||||
|
||||
async function ensureSysDict() {
|
||||
if (Object.keys(store.getSysDict || {}).length === 0) {
|
||||
await store.fetchSysDict();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroupModuleTree(groupId: number) {
|
||||
pageLoading.value = true;
|
||||
try {
|
||||
const data = await Apis.GroupAuthz.groupModuleTreeByCategory({ params: { groupId }, cacheFor: null });
|
||||
categoryList.value = Array.isArray(data) ? data : [];
|
||||
} finally {
|
||||
pageLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadCurrent() {
|
||||
if (selectedGroupId.value) {
|
||||
await loadGroupModuleTree(selectedGroupId.value);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGroupChange(node: any) {
|
||||
selectedGroupNode.value = node || {};
|
||||
const nodeId = Number(node?.id);
|
||||
if (!Number.isFinite(nodeId)) {
|
||||
categoryList.value = [];
|
||||
return;
|
||||
}
|
||||
await loadGroupModuleTree(nodeId);
|
||||
}
|
||||
|
||||
// ---------------- 本地数据同步(不触发树重建) ----------------
|
||||
|
||||
function findLocalApp(appId: number): AppItem | null {
|
||||
for (const cat of categoryList.value || []) {
|
||||
const app = (cat.apps || []).find((a) => Number(a.id) === appId);
|
||||
if (app) return app;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function walkModules(nodes: AppModuleVo[], fn: (n: AppModuleVo) => void) {
|
||||
for (const node of nodes) {
|
||||
fn(node);
|
||||
walkModules(node.children || [], fn);
|
||||
}
|
||||
}
|
||||
|
||||
function setLocalAppChecked(appId: number, checked: boolean) {
|
||||
const app = findLocalApp(appId);
|
||||
if (!app) return;
|
||||
walkModules(app.modules || [], (n) => {
|
||||
n.checked = checked;
|
||||
if (!checked) n.inheritFlag = false;
|
||||
});
|
||||
}
|
||||
|
||||
function setLocalAppInherit(appId: number, inherit: boolean) {
|
||||
const app = findLocalApp(appId);
|
||||
if (!app) return;
|
||||
walkModules(app.modules || [], (n) => {
|
||||
if (n.checked) n.inheritFlag = inherit;
|
||||
});
|
||||
}
|
||||
|
||||
function setLocalModuleState(appId: number, moduleId: number, patch: Partial<AppModuleVo>) {
|
||||
const app = findLocalApp(appId);
|
||||
if (!app) return;
|
||||
walkModules(app.modules || [], (n) => {
|
||||
if (Number(n.id) === moduleId) Object.assign(n, patch);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- 授权操作 ----------------
|
||||
|
||||
/** 勾选/取消勾选:应用节点 => moduleId=0 整应用处理;模块节点 => 单模块处理 */
|
||||
async function onTreeCheck(node: any) {
|
||||
const groupId = selectedGroupId.value;
|
||||
if (!groupId || !node) return;
|
||||
|
||||
const checked = !!node.checked;
|
||||
const isApp = node.nodeType === "app";
|
||||
const treeObj = getTreeObjByNode(node);
|
||||
|
||||
try {
|
||||
await Apis.GroupAuthz.setGroupModule({
|
||||
data: [{
|
||||
groupId,
|
||||
appId: node.appId,
|
||||
moduleId: isApp ? 0 : node.rawId,
|
||||
mode: checked ? addMode.value : deleteMode.value,
|
||||
isAll: checked && node.inheritFlag ? 1 : 0,
|
||||
}],
|
||||
cacheFor: null,
|
||||
});
|
||||
|
||||
if (isApp) {
|
||||
setLocalAppChecked(node.appId, checked);
|
||||
// 应用节点整批操作:手动同步 zTree 子节点勾选态与继承标记
|
||||
if (treeObj) {
|
||||
const affected = treeObj.transformToArray([node]) || [];
|
||||
affected.forEach((n: any) => {
|
||||
if (n !== node) treeObj.checkNode(n, checked, false);
|
||||
n.checked = checked;
|
||||
if (!checked) n.inheritFlag = false;
|
||||
});
|
||||
}
|
||||
Message.success(checked ? "已授权该应用全部模块" : "已取消该应用全部模块授权");
|
||||
} else {
|
||||
if (!checked) node.inheritFlag = false;
|
||||
setLocalModuleState(node.appId, node.rawId, checked ? { checked: true } : { checked: false, inheritFlag: false });
|
||||
syncAppNodeState(treeObj, node.appId);
|
||||
}
|
||||
syncInheritDoms(treeObj);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "操作失败");
|
||||
await reloadCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
/** 子组继承开关:应用节点 => moduleId=0 整应用处理;模块节点 => 单模块处理 */
|
||||
async function onInheritToggle(treeId: string, node: any, inherit: boolean) {
|
||||
const $ = jq();
|
||||
const groupId = selectedGroupId.value;
|
||||
const revert = () => $ && $(`#${node.tId}_inherit`).prop("checked", !inherit);
|
||||
|
||||
if (!groupId || !node.checked) {
|
||||
revert();
|
||||
return;
|
||||
}
|
||||
|
||||
const isApp = node.nodeType === "app";
|
||||
const treeObj = $?.fn?.zTree?.getZTreeObj(treeId) || null;
|
||||
|
||||
try {
|
||||
await Apis.GroupAuthz.setGroupModule({
|
||||
data: [{
|
||||
groupId,
|
||||
appId: node.appId,
|
||||
moduleId: isApp ? 0 : node.rawId,
|
||||
mode: addMode.value,
|
||||
isAll: inherit ? 1 : 0,
|
||||
}],
|
||||
cacheFor: null,
|
||||
});
|
||||
|
||||
node.inheritFlag = inherit;
|
||||
if (isApp) {
|
||||
setLocalAppInherit(node.appId, inherit);
|
||||
if (treeObj) {
|
||||
const affected = treeObj.transformToArray([node]) || [];
|
||||
affected.forEach((n: any) => {
|
||||
if (n.checked) n.inheritFlag = inherit;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setLocalModuleState(node.appId, node.rawId, { inheritFlag: inherit });
|
||||
}
|
||||
syncInheritDoms(treeObj);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "操作失败");
|
||||
await reloadCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 搜索 ----------------
|
||||
|
||||
function onSearch() {
|
||||
appliedKeyword.value = searchKeyword.value;
|
||||
}
|
||||
|
||||
function onClearSearch() {
|
||||
appliedKeyword.value = "";
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await ensureSysDict();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.group-auth-tree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group-auth-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.authz-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.type-filter-select {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-text {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
color: #2d8cf0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.authz-panel {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 三列平铺布局 */
|
||||
.cat-columns {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cat-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cat-card {
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cat-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: #f0f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cat-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
margin-left: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cat-count {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inherit-cap {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
flex-shrink: 0;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.cat-card-body {
|
||||
padding: 6px 8px;
|
||||
/* 子组继承复选框的绝对定位锚点:保证所有层级节点的复选框右对齐成一列 */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
color: #999;
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.open-link {
|
||||
color: #2d8cf0;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* zTree 节点行尾的"子组继承"复选框:
|
||||
float 会跟随节点缩进产生阶梯,改为绝对定位锚定卡片体右边界,所有层级右对齐成一列 */
|
||||
:deep(.inherit-wrap) {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
:deep(.inherit-chk) {
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ztree li a) {
|
||||
max-width: calc(100% - 90px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,513 @@
|
||||
<template>
|
||||
<div class="main-wrapper tree-wrapper">
|
||||
<div class="tree-container">
|
||||
<Input v-model="userKeyword" placeholder="输入用户,回车查找" suffix="ios-search" search clearable
|
||||
class="search-input" @on-search="onSearchUser" @on-clear="onClearUser" />
|
||||
<GroupTreeSearchPanel ref="groupTreeRef" :treeApi="fetchGroupTree" :autoClick="true"
|
||||
:showCloseButton="false" cacheKey="permission-view.group" @on-change="onGroupChange" />
|
||||
</div>
|
||||
<div class="main-container">
|
||||
<div class="user-panel">
|
||||
<div class="panel-title">
|
||||
<template v-if="mode === 'group'">组用户列表</template>
|
||||
<template v-else>用户搜索结果</template>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div v-if="userLoading" class="loading-tip">加载中...</div>
|
||||
<div v-else-if="userList.length === 0" class="empty-tip">
|
||||
<template v-if="mode === 'group'">请选择左侧组节点</template>
|
||||
<template v-else>未找到匹配用户</template>
|
||||
</div>
|
||||
<div v-else class="user-list">
|
||||
<div v-for="user in userList" :key="user.id" class="user-item"
|
||||
:class="{ active: selectedUser?.id === user.id }" @click="onSelectUser(user)">
|
||||
<div class="user-name">{{ userDisplayName(user) }}</div>
|
||||
<div v-if="mode === 'user' && user.groupPath" class="user-path">{{ user.groupPath }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="perm-panel">
|
||||
<div class="panel-title">
|
||||
<template v-if="selectedUser">{{ selectedUser.realName || selectedUser.account }}({{
|
||||
selectedUser.account }}) 权限浏览</template>
|
||||
<template v-else>权限浏览</template>
|
||||
</div>
|
||||
<div v-if="!selectedUser" class="panel-body">
|
||||
<div class="empty-tip">请选择左侧用户查看权限</div>
|
||||
</div>
|
||||
<div v-else class="perm-body">
|
||||
<!-- 左列:所属部门(上) + 所属角色(下) -->
|
||||
<div class="perm-side">
|
||||
<div class="side-block">
|
||||
<div class="side-title">所属部门</div>
|
||||
<div class="side-body">
|
||||
<div v-if="treeLoading" class="loading-tip small">加载中...</div>
|
||||
<div v-else-if="deptTree.length === 0" class="empty-tip small">暂无部门归属</div>
|
||||
<MyTree v-else :treeData="deptTree" :setting="treeSetting" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-block">
|
||||
<div class="side-title">所属角色</div>
|
||||
<div class="side-body">
|
||||
<div v-if="treeLoading" class="loading-tip small">加载中...</div>
|
||||
<div v-else-if="roleTree.length === 0" class="empty-tip small">暂无所属角色</div>
|
||||
<MyTree v-else :treeData="roleTree" :setting="treeSetting" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右列:已授权应用(原有) -->
|
||||
<div class="perm-main">
|
||||
<div class="side-title">已授权应用</div>
|
||||
<div class="side-body">
|
||||
<div v-if="treeLoading" class="loading-tip">权限加载中...</div>
|
||||
<div v-else-if="permissionTree.length === 0" class="empty-tip">该用户暂无已授权应用模块</div>
|
||||
<MyTree v-else ref="permTreeRef" :treeData="permissionTree" :setting="treeSetting" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { Input, Message } from "view-ui-plus";
|
||||
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
|
||||
import MyTree from "@/components/MyTree.vue";
|
||||
import Apis from "@/api";
|
||||
|
||||
interface UserItem {
|
||||
id?: number;
|
||||
account?: string;
|
||||
realName?: string;
|
||||
isMain?: number;
|
||||
groupPath?: string;
|
||||
}
|
||||
|
||||
interface ModuleNode {
|
||||
id?: number;
|
||||
parentId?: number;
|
||||
name?: string;
|
||||
customId?: string;
|
||||
path?: string;
|
||||
level?: number;
|
||||
sortNo?: number;
|
||||
children?: ModuleNode[];
|
||||
}
|
||||
|
||||
interface AppNode {
|
||||
id?: number;
|
||||
name?: string;
|
||||
appKey?: number;
|
||||
pcIcon?: string;
|
||||
sortNo?: number;
|
||||
modules?: ModuleNode[];
|
||||
}
|
||||
|
||||
interface CategoryNode {
|
||||
id?: number;
|
||||
name?: string;
|
||||
sortNo?: number;
|
||||
apps?: AppNode[];
|
||||
}
|
||||
|
||||
interface GroupNode {
|
||||
id?: number;
|
||||
name?: string;
|
||||
own?: boolean;
|
||||
children?: GroupNode[];
|
||||
}
|
||||
|
||||
interface RoleItem {
|
||||
id?: number;
|
||||
name?: string;
|
||||
allAppFlag?: boolean;
|
||||
appId?: number;
|
||||
appName?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const groupTreeRef = ref<InstanceType<typeof GroupTreeSearchPanel> | null>(null);
|
||||
const permTreeRef = ref<InstanceType<typeof MyTree> | null>(null);
|
||||
|
||||
const mode = ref<"group" | "user">("group");
|
||||
const userKeyword = ref("");
|
||||
const selectedGroup = ref<any>(null);
|
||||
const selectedUser = ref<UserItem | null>(null);
|
||||
const userList = ref<UserItem[]>([]);
|
||||
const permissionTree = ref<any[]>([]);
|
||||
const deptTree = ref<any[]>([]);
|
||||
const roleTree = ref<any[]>([]);
|
||||
const userLoading = ref(false);
|
||||
const treeLoading = ref(false);
|
||||
|
||||
const treeSetting = ref({
|
||||
view: { dblClickExpand: false, showIcon: false, showLine: true },
|
||||
edit: { enable: false, showRemoveBtn: false, showRenameBtn: false, drag: false },
|
||||
check: { enable: false },
|
||||
data: {
|
||||
simpleData: { enable: false },
|
||||
key: { children: "children" },
|
||||
},
|
||||
callback: {},
|
||||
});
|
||||
|
||||
function fetchGroupTree() {
|
||||
return Apis.SysGroup.getTree();
|
||||
}
|
||||
|
||||
function userDisplayName(user: UserItem): string {
|
||||
const name = user.realName || user.account || "";
|
||||
const account = user.account || "";
|
||||
return name === account ? name : `${name}(${account})`;
|
||||
}
|
||||
|
||||
function onGroupChange(node: any) {
|
||||
mode.value = "group";
|
||||
selectedGroup.value = node;
|
||||
selectedUser.value = null;
|
||||
clearPermPanels();
|
||||
loadGroupUsers(node?.id);
|
||||
}
|
||||
|
||||
function clearPermPanels() {
|
||||
permissionTree.value = [];
|
||||
deptTree.value = [];
|
||||
roleTree.value = [];
|
||||
}
|
||||
|
||||
async function loadGroupUsers(groupId?: number) {
|
||||
if (!groupId) {
|
||||
userList.value = [];
|
||||
return;
|
||||
}
|
||||
userLoading.value = true;
|
||||
try {
|
||||
userList.value = (await Apis.GroupAuthz.groupUsers({ params: { groupId }, cacheFor: null })) as UserItem[];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "加载组用户失败");
|
||||
userList.value = [];
|
||||
} finally {
|
||||
userLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSearchUser() {
|
||||
const kw = userKeyword.value.trim();
|
||||
if (!kw) {
|
||||
onClearUser();
|
||||
return;
|
||||
}
|
||||
mode.value = "user";
|
||||
selectedGroup.value = null;
|
||||
selectedUser.value = null;
|
||||
clearPermPanels();
|
||||
userLoading.value = true;
|
||||
try {
|
||||
userList.value = (await Apis.GroupAuthz.searchUsers({ params: { keyword: kw }, cacheFor: null })) as UserItem[];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "搜索用户失败");
|
||||
userList.value = [];
|
||||
} finally {
|
||||
userLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onClearUser() {
|
||||
userKeyword.value = "";
|
||||
mode.value = "group";
|
||||
selectedUser.value = null;
|
||||
clearPermPanels();
|
||||
if (selectedGroup.value?.id) {
|
||||
loadGroupUsers(Number(selectedGroup.value.id));
|
||||
} else {
|
||||
userList.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function onSelectUser(user: UserItem) {
|
||||
selectedUser.value = user;
|
||||
treeLoading.value = true;
|
||||
const userId = user.id as number;
|
||||
const groupId = selectedGroup.value?.id;
|
||||
// 三块数据并行加载,互不阻塞
|
||||
// userGroupTree 为新增接口:apiDefinitions 未重新生成(npm run api:gen)时方法不存在,降级为友好报错
|
||||
const userGroupTreeApi = (Apis.GroupAuthz as any).userGroupTree
|
||||
? Apis.GroupAuthz.userGroupTree({ params: { userId }, cacheFor: null })
|
||||
: Promise.reject(new Error("所属部门接口未生成,请重启后端后执行 npm run api:gen"));
|
||||
const [permRes, deptRes, roleRes] = await Promise.allSettled([
|
||||
Apis.GroupAuthz.userPermissionTree({
|
||||
params: { userId, groupId: groupId ? Number(groupId) : undefined },
|
||||
cacheFor: null,
|
||||
}),
|
||||
userGroupTreeApi,
|
||||
Apis.GroupAuthz.userRoles({ params: { userId }, cacheFor: null }),
|
||||
]);
|
||||
if (permRes.status === "fulfilled") {
|
||||
permissionTree.value = formatPermissionTree((permRes.value as CategoryNode[]) || []);
|
||||
} else {
|
||||
Message.error(permRes.reason?.message || "加载权限树失败");
|
||||
permissionTree.value = [];
|
||||
}
|
||||
if (deptRes.status === "fulfilled") {
|
||||
deptTree.value = formatDeptTree((deptRes.value as GroupNode[]) || []);
|
||||
} else {
|
||||
Message.error(deptRes.reason?.message || "加载所属部门失败");
|
||||
deptTree.value = [];
|
||||
}
|
||||
if (roleRes.status === "fulfilled") {
|
||||
roleTree.value = formatRoleTree((roleRes.value as RoleItem[]) || []);
|
||||
} else {
|
||||
Message.error(roleRes.reason?.message || "加载所属角色失败");
|
||||
roleTree.value = [];
|
||||
}
|
||||
treeLoading.value = false;
|
||||
}
|
||||
|
||||
// 所属部门:祖先链树,用户直接归属的组加「本组」标记
|
||||
function formatDeptTree(nodes: GroupNode[]): any[] {
|
||||
return (nodes || []).map((n) => ({
|
||||
id: `grp_${n.id}`,
|
||||
name: n.own ? `${n.name || ""}(本组)` : n.name || "",
|
||||
open: true,
|
||||
children: formatDeptTree(n.children || []),
|
||||
isParent: (n.children || []).length > 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// 所属角色:2 级树 —— 一级为「平台角色」(全应用) / 应用名分组,二级为角色
|
||||
function formatRoleTree(roles: RoleItem[]): any[] {
|
||||
const groups = new Map<string, any>();
|
||||
for (const r of roles) {
|
||||
const key = r.allAppFlag ? "平台角色" : r.appName || "未知应用";
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, { id: `rgrp_${key}`, name: key, open: true, isParent: true, children: [] });
|
||||
}
|
||||
groups.get(key).children.push({
|
||||
id: `role_${r.id}`,
|
||||
name: r.disabled ? `${r.name}(已禁用)` : r.name,
|
||||
});
|
||||
}
|
||||
// 平台角色置顶,其余按应用名
|
||||
return [...groups.values()].sort((a, b) =>
|
||||
a.name === "平台角色" ? -1 : b.name === "平台角色" ? 1 : a.name.localeCompare(b.name)
|
||||
);
|
||||
}
|
||||
|
||||
function formatPermissionTree(categories: CategoryNode[]): any[] {
|
||||
const result: any[] = [];
|
||||
for (const cat of categories) {
|
||||
const catNode: any = {
|
||||
id: `cat_${cat.id}`,
|
||||
name: cat.name || `应用类型${cat.id}`,
|
||||
open: true,
|
||||
children: formatApps(cat.apps || [], cat.id),
|
||||
};
|
||||
catNode.isParent = catNode.children.length > 0;
|
||||
result.push(catNode);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatApps(apps: AppNode[], catId?: number): any[] {
|
||||
return (apps || []).map((app) => ({
|
||||
id: `app_${app.id}_${catId || 0}`,
|
||||
name: app.name || `应用${app.id}`,
|
||||
open: true,
|
||||
children: formatModules(app.modules || []),
|
||||
}));
|
||||
}
|
||||
|
||||
function formatModules(modules: ModuleNode[]): any[] {
|
||||
return (modules || []).map((m) => {
|
||||
const children = formatModules(m.children || []);
|
||||
return {
|
||||
id: `mod_${m.id}`,
|
||||
name: m.name || `模块${m.id}`,
|
||||
open: true,
|
||||
children,
|
||||
isParent: children.length > 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tree-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
border-right: 1px solid #e8eaec;
|
||||
}
|
||||
|
||||
.user-search {
|
||||
padding: 12px 16px;
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.perm-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
padding: 10px 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
background: #fafafa;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 权限浏览 3 块布局:左列(所属部门/所属角色) + 右列(已授权应用) */
|
||||
.perm-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.perm-side {
|
||||
width: 260px;
|
||||
min-width: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-right: 1px solid #e8eaec;
|
||||
}
|
||||
|
||||
.side-block {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&+.side-block {
|
||||
border-top: 1px solid #e8eaec;
|
||||
}
|
||||
}
|
||||
|
||||
.perm-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.side-title {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 16px;
|
||||
font-weight: 600;
|
||||
color: #515a6e;
|
||||
font-size: 13px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.side-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.empty-tip.small,
|
||||
.loading-tip.small {
|
||||
padding: 20px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-item {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff3e0;
|
||||
}
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.user-path {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
line-height: 1.3;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.empty-tip,
|
||||
.loading-tip {
|
||||
color: #999;
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-tip {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
|
||||
:deep(.group-tree-search-panel) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.ztree) {
|
||||
font-size: 14px;
|
||||
|
||||
li {
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<Card :bordered="false" dis-hover>
|
||||
<p slot="title">系统日志</p>
|
||||
<Alert type="info" show-icon>功能开发中,敬请期待...</Alert>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useDefaultStore } from "@/store";
|
||||
const store = useDefaultStore();
|
||||
</script>
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<Modal v-model="show" :mask-closable="false" width="600" :title="titleComp" @on-visible-change="visibleChange">
|
||||
<Form ref="formRef" class="mr-15" :model="form" :rules="rules" :label-width="100">
|
||||
<FormItem :label="bookInfo.name + '名称:'" prop="name">
|
||||
<Input v-model="form.name" :placeholder="`请输入${bookInfo.name}名称`" />
|
||||
</FormItem>
|
||||
<Row class="mb-20">
|
||||
<Col span="12">
|
||||
<FormItem label="年级" prop="grade">
|
||||
<Select v-model="form.grade" transfer clearable placeholder="选择年级">
|
||||
<Option v-for="item in dictData.grade" :value="item.id" :key="item.id">{{ item.name }}
|
||||
</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="学期:" prop="semester">
|
||||
<Select v-model="form.semester" transfer placeholder="选择学期">
|
||||
<Option :value="0">上学期</Option>
|
||||
<Option :value="1">下学期</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row class="mb-20">
|
||||
<Col span="12">
|
||||
<FormItem label="学科:" prop="subject">
|
||||
<Select v-model="form.subject" transfer clearable placeholder="选择科目">
|
||||
<Option v-for="item in dictData.subject" :value="item.code" :key="item.code">{{ item.name }}
|
||||
</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="出版社:" prop="press">
|
||||
<Input v-model="form.press" placeholder="输入出版社" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row class="mb-20">
|
||||
<Col span="12">
|
||||
<FormItem label="出版年份:" prop="outTime">
|
||||
<DatePicker v-model="form.outTime" type="year" placeholder="请选择年份" format="yyyy" transfer
|
||||
:editable="false" :clearable="true" @on-change="v => form.outTime = v"></DatePicker>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="loading" @click="handleSubmit">提交</Button>
|
||||
<Button @click="handleClose">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch, computed, nextTick } from 'vue'
|
||||
import { BooxType, BooxTypeDict } from '@/types/index'
|
||||
import useBookInfo from '../useBookInfo'
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
id: { type: [String, Number], required: true },// 0 新增 / 其它 编辑
|
||||
bookType: { type: Number, default: 1, required: true }//教材|教辅
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'success'])
|
||||
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: val => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const { data: dictData } = useRequest(Apis.Base.getSystemDict({
|
||||
cacheFor: 1000 * 60 * 60
|
||||
}), {
|
||||
initialData: {
|
||||
grade: [],
|
||||
press: [],
|
||||
subject: [],
|
||||
}
|
||||
});
|
||||
|
||||
const isAdd = computed(() => props.id == 0)
|
||||
|
||||
const formRef = ref()
|
||||
const loading = ref(false)
|
||||
|
||||
const { bookInfo } = useBookInfo()
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入名称' }],
|
||||
subject: [{ required: true, message: '请选择学科' }],
|
||||
grade: [{ required: true, message: '请选择年级' }],
|
||||
press: [{ required: false, message: '请输入出版社' }],
|
||||
semester: [{ required: true, message: '请选择学期' }],
|
||||
outTime: [{ required: false, message: '请选择年份' }],
|
||||
|
||||
}
|
||||
|
||||
// 打开弹窗时拉取详情(编辑模式)
|
||||
watch(() => props.modelValue, async val => {
|
||||
if (!val) return
|
||||
if (isAdd.value) {
|
||||
// 新增重置空表单
|
||||
nextTick(() => formRef.value?.resetFields())
|
||||
Object.assign(form.value, { ...form.value, bookType: props.bookType })
|
||||
} else {
|
||||
const resp = await Apis.Knowledge.getTextbookById({ params: { id: props.id } })
|
||||
Object.assign(form.value, resp)
|
||||
}
|
||||
})
|
||||
const titleComp = computed(() => {
|
||||
return (isAdd.value ? '新增' : '编辑') + bookInfo.value.name
|
||||
})
|
||||
|
||||
function visibleChange(v: any) {
|
||||
if (!v) setTimeout(() => {
|
||||
formRef.value?.resetFields()
|
||||
}, 100);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
let validate = await formRef.value.validate()
|
||||
loading.value = true
|
||||
try {
|
||||
if (!validate) throw '请填写红色星号内容'
|
||||
await sendForm()
|
||||
handleClose()
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
Message.info(error)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const {
|
||||
form,
|
||||
send: sendForm,
|
||||
} = useForm((dto) => Apis.Knowledge.saveTextbook({ data: dto }), {
|
||||
initialForm: {
|
||||
id: null,
|
||||
name: '',
|
||||
subject: '',
|
||||
grade: '',
|
||||
press: '',
|
||||
semester: '',
|
||||
outTime: ''
|
||||
},
|
||||
resetAfterSubmiting: false
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<div class="main-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="fix mb-12">
|
||||
<Button type="primary" @click="curEditId = 0, eidtModelShow = true" class="fl">
|
||||
添加{{ bookInfo.name }}
|
||||
</Button>
|
||||
<Button type="success" @click="uploadType = 2, uploadModelShow = true" class="fr ml-12">
|
||||
导入{{ bookInfo.name }}和目录树
|
||||
</Button>
|
||||
<Button type="success" @click="uploadType = 1, uploadModelShow = true" class="fr ml-12">
|
||||
导入{{ bookInfo.name }}
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
<div class="search-wrap fix">
|
||||
<Select class="search-wrap_sel" v-model="dataParams.subject" clearable placeholder="选择科目">
|
||||
<Option v-for="item in dictData.subject" :value="item.code" :key="item.code">{{ item.name }}
|
||||
</Option>
|
||||
</Select>
|
||||
|
||||
<Select class="search-wrap_sel ml-12" v-model="dataParams.grade" clearable placeholder="选择年级">
|
||||
<Option v-for="item in dictData.grade" :value="item.id" :key="item.id">{{ item.name }}
|
||||
</Option>
|
||||
</Select>
|
||||
|
||||
<Select class="search-wrap_sel ml-12" v-model="dataParams.press" clearable placeholder="选择出版社">
|
||||
<Option v-for="item in pressList" :value="item.v" :key="item.k">{{ item.v }}
|
||||
</Option>
|
||||
</Select>
|
||||
<Select class="search-wrap_sel ml-12" v-model="dataParams.semester" clearable placeholder="选择学期">
|
||||
<Option :value="0">上学期</Option>
|
||||
<Option :value="1">下学期</Option>
|
||||
</Select>
|
||||
|
||||
<Input class="fr ml-12" v-model="dataParams.name" placeholder="输入关键字筛选" suffix="ios-search" clearable
|
||||
style="width:200px" />
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<TablePageAlova ref="tableRef" :dataApi="tableApi" :columns="columns" :dataParams="dataParams"
|
||||
:tableHeight="tableHeight">
|
||||
</TablePageAlova>
|
||||
<Drawer width="400" :title="drawerTitle" :closable="false" v-model="showDrawer">
|
||||
<MyTree :treeData="knowTreeData" :setting="treeSetting2" :autoClick="false" />
|
||||
</Drawer>
|
||||
</div>
|
||||
<div class="modal-wrap">
|
||||
<EditModel v-model="eidtModelShow" :id="curEditId" :bookType="bookInfo.type" @success="reloadTable" />
|
||||
|
||||
<Modal v-model="uploadModelShow" :mask-closable="false" width="600" :title="modalTitle">
|
||||
<BschImport ref="importRef" :tempFileUrl="tempFileUrl" :uploadUrl="uploadUrl" :uploadData="uploadData"
|
||||
@on-success="reloadTable" />
|
||||
<template #footer>
|
||||
<Button @click="uploadModelShow = false">关闭</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
<Modal v-model="knowledgeModelShow" :mask-closable="false" width="900" title="目录树管理">
|
||||
<Knowledge :id="curKnowId" :name="curKnowName" v-if="knowledgeModelShow" />
|
||||
<template #footer>
|
||||
<Button @click="knowledgeModelShow = false">关闭</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
|
||||
<!-- BschImport -->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from "vue";
|
||||
import TablePageAlova from '@/components/TablePageAlova.vue'
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import Knowledge from './Knowledge.vue'
|
||||
import { useDefaultStore } from "@/store";
|
||||
import EditModel from './EditModel.vue'
|
||||
import MyTree from '@/components/MyTree.vue'
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import BschImport from '@/components/BschImport.vue'
|
||||
import useBookInfo from "../useBookInfo";
|
||||
import { useRouter } from "vue-router";
|
||||
import { OperatorEnum } from "@/types";
|
||||
|
||||
const store = useDefaultStore();
|
||||
const { $conf, $bus, $utils } = useCurrentInstance();
|
||||
const { bookInfo } = useBookInfo()
|
||||
const eidtModelShow = ref(false);
|
||||
const knowledgeModelShow = ref(false);
|
||||
const uploadType = ref(0);
|
||||
const uploadModelShow = ref(false);
|
||||
const curEditId = ref(0)
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
|
||||
const curKnowId = ref(0)
|
||||
const curKnowName = ref('')
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const dataParams = ref({
|
||||
pageSize: '',
|
||||
pageIndex: '',
|
||||
grade: null,
|
||||
semester: null,
|
||||
subject: null,
|
||||
press: null,
|
||||
name: null,
|
||||
bookType: bookInfo.value.type,
|
||||
})
|
||||
|
||||
|
||||
const treeSetting2 = ref({
|
||||
view: {
|
||||
dblClickExpand: false,
|
||||
showIcon: false,
|
||||
},
|
||||
edit: {
|
||||
enable: true,
|
||||
showRemoveBtn: false,
|
||||
showRenameBtn: false,
|
||||
drag: false,
|
||||
},
|
||||
check: {
|
||||
enable: false,
|
||||
},
|
||||
callback: {},
|
||||
})
|
||||
|
||||
const tableHeight = ref($utils.useTableHeight({ isOperat: true, isPage: true, isSearch: true }));
|
||||
|
||||
const { data: dictData } = useRequest(Apis.Base.getSystemDict({
|
||||
cacheFor: 1000 * 60 * 60
|
||||
}), {
|
||||
initialData: {
|
||||
grade: [],
|
||||
textPress: [],
|
||||
teachingPress: [],
|
||||
subject: [],
|
||||
}
|
||||
});
|
||||
|
||||
const { data: knowTreeData, send: sendKnowledgTree } =
|
||||
useRequest((textbookId: number) => Apis.Knowledge.getTreeByTextbookId({
|
||||
params: { textbookId },
|
||||
transform: (res: any) => {
|
||||
let arr = res || [];
|
||||
return arr.map((item: any) => {
|
||||
item.open = true;
|
||||
return item;
|
||||
})
|
||||
},
|
||||
}), {
|
||||
initialData: [],
|
||||
immediate: false
|
||||
});
|
||||
|
||||
|
||||
|
||||
const showDrawer = ref(false);
|
||||
const drawerTitle = ref('');
|
||||
const columns = ref([
|
||||
{ type: 'index', title: '序号', width: 80 },
|
||||
{ title: bookInfo.value.name + '名称', key: 'name' },
|
||||
{ title: '年级', key: 'gradeName' },
|
||||
{
|
||||
title: '学期',
|
||||
key: 'semester',
|
||||
render: (h: any, params: any) => {
|
||||
return h('span', params.row.semester == 0 ? '上学期' : '下学期');
|
||||
}
|
||||
},
|
||||
{ title: '科目', key: 'subjectName' },
|
||||
{ title: '出版社', key: 'press' },
|
||||
{ title: '出版年份', key: 'outTime' },
|
||||
// {
|
||||
// title: '封面',
|
||||
// key: 'coverImage',
|
||||
// width: 120,
|
||||
// render: (row: any, { row: r }: any) => {
|
||||
// const src = r.coverImage || new URL('@/assets/image/default-bg1.png', import.meta.url).href
|
||||
// return h('img', {
|
||||
// src,
|
||||
// style: {
|
||||
// width: '20px',
|
||||
// height: '30px',
|
||||
// objectFit: 'cover',
|
||||
// borderRadius: '4px'
|
||||
// },
|
||||
// alt: '封面'
|
||||
// })
|
||||
// }
|
||||
// },
|
||||
{
|
||||
title: '操作',
|
||||
width: 240,
|
||||
align: 'center',
|
||||
render: (h: any, params: any) => {
|
||||
const disabled = params.row.disabled;
|
||||
return [
|
||||
|
||||
|
||||
$utils.operatingTextHandle(h, '目录树管理', '', () => {
|
||||
// router.push({
|
||||
// path: '/' + bookInfo.value.path + '/manage/knowledge',
|
||||
// query: { id: params.row.id, name: params.row.name },
|
||||
// });
|
||||
curKnowId.value = params.row.id;
|
||||
curKnowName.value = params.row.name;
|
||||
|
||||
knowledgeModelShow.value = true;
|
||||
|
||||
}, disabled),
|
||||
$utils.operatingTextHandle(h, '查看', '', () => {
|
||||
drawerTitle.value = params.row.name;
|
||||
showDrawer.value = true;
|
||||
setTimeout(() => {
|
||||
sendKnowledgTree(params.row.id);
|
||||
}, 100);
|
||||
}),
|
||||
$utils.operatingTextHandle(h, '编辑', '', () => {
|
||||
curEditId.value = params.row.id;
|
||||
eidtModelShow.value = true;
|
||||
}, disabled),
|
||||
$utils.operatingTextHandle(h, '删除', 'text-error', () => {
|
||||
removeSave(params.row.id)
|
||||
}, disabled),
|
||||
];
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
return '导入' + bookInfo.value.name + (uploadType.value === 1 ? '' : '和知识树');
|
||||
})
|
||||
const tempFileUrl = computed(() => {
|
||||
const method = uploadType.value == 1 ? 'getTextbookTemplate' : 'getTextbookKnowledgeTemplate';
|
||||
let url = $conf.serviceHost + `/api/knowledge/${method}?bookType=` + bookInfo.value.type;
|
||||
return url;
|
||||
})
|
||||
const uploadUrl = computed(() => {
|
||||
const method = uploadType.value == 1 ? '/api/knowledge/importTextbook' : '/api/knowledge/importTextbookInfo';
|
||||
return $conf.serviceHost + method;
|
||||
})
|
||||
const uploadData = computed(() => {
|
||||
return { bookType: bookInfo.value.type };
|
||||
})
|
||||
|
||||
const pressList = computed(() => {
|
||||
return bookInfo.value.type == 1 ? dictData.value.textPress : dictData.value.teachingPress
|
||||
})
|
||||
|
||||
const clean = (obj: Record<string, any>) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(obj).filter(([, v]) => v != null && v !== '')
|
||||
)
|
||||
|
||||
const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
||||
return Apis.Knowledge.searchTextbooks({
|
||||
params: clean({ ...dataParams.value, ...params }),
|
||||
cacheFor: null,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
onBeforeMount(() => {
|
||||
$bus.emit('resetLayout', { identity: bookInfo.value.identity, modId: (bookInfo.value.baseModId + 4) });
|
||||
});
|
||||
|
||||
/**
|
||||
* removeSave
|
||||
*/
|
||||
async function removeSave(id: number) {
|
||||
await MyConfirm({
|
||||
msg: "确定删除该教材吗?",
|
||||
info: "关联目录树也将会被删除,删除后不可恢复!",
|
||||
async onOk() {
|
||||
try {
|
||||
await Apis.Knowledge.deleteTextbook({ params: { id } });
|
||||
Message.success("操作成功!");
|
||||
tableRef.value?.reloadData(OperatorEnum.del);
|
||||
|
||||
} catch (error) {
|
||||
Message.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function reloadTable() {
|
||||
if (!curEditId.value) tableRef.value?.reloadData(OperatorEnum.add);
|
||||
else tableRef.value?.reloadData(OperatorEnum.edit);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<div class="main-wrapper" style="min-height: 400px;">
|
||||
<div class="main-container">
|
||||
<div class="search-wrap ">
|
||||
<h5 class="title mt-4 fl">{{ title }}</h5>
|
||||
<Button type="success" class="fr" @click="uploadModelShow = true">导入目录树</Button>
|
||||
</div>
|
||||
<Table row-key="id" :columns="columns" :loading="tableLoading" :indent-size="20" :data="tableDataComp">
|
||||
<template #loading>
|
||||
<div class="table-spin-load">加载中</div>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
<div class="modal-wrap">
|
||||
<Modal v-model="editNodeModal.modal" :title="editNodeModal.title" width="450px" :mask-closable="false">
|
||||
<Form ref="refFormData" :label-width="100" :model="editNodeModal" class="myform mb-20 mr-20">
|
||||
<Form-Item label="节点名称:" prop="name"
|
||||
:rules="{ required: true, message: '请输入节点名称', trigger: 'blur' }">
|
||||
<Input ref="refNodeNameInput" maxlength="30" v-model="editNodeModal.name" />
|
||||
</Form-Item>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="editNodeModal.loading" @click="saveEditNode">确认</Button>
|
||||
<Button :loading="editNodeModal.loading" @click="editNodeModal.modal = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 排序弹窗 -->
|
||||
<Modal v-model="sortModal.modal" title="子节点排序" width="450px" :mask-closable="false">
|
||||
<div class="sort-wrap">
|
||||
<draggable v-model="sortModal.list" item-key="id" handle=".drag-handle" animation="150">
|
||||
<template #item="{ element }">
|
||||
<li class="sort-item">
|
||||
<span class="drag-handle">≡</span>
|
||||
<span class="name">{{ element.name }}</span>
|
||||
</li>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="sortModal.loading" @click="saveSort">保存排序</Button>
|
||||
<Button :loading="sortModal.loading" @click="sortModal.modal = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-model="uploadModelShow" :mask-closable="false" width="600" title="导入目录树">
|
||||
<BschImport ref="importRef" :tempFileUrl="tempFileUrl" :uploadUrl="uploadUrl" :uploadData="uploadData"
|
||||
@on-success="reloadTable" />
|
||||
|
||||
<template #footer>
|
||||
<Button @click="uploadModelShow = false">关闭</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import useBookInfo from "../useBookInfo";
|
||||
import draggable from 'vuedraggable'
|
||||
import BschImport from '@/components/BschImport.vue'
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
|
||||
interface Props {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
id: 0,
|
||||
name: '',
|
||||
});
|
||||
|
||||
const { bookInfo } = useBookInfo()
|
||||
const { $conf, $bus, $utils } = useCurrentInstance();
|
||||
const bookId = ref<number>()
|
||||
|
||||
|
||||
const title = computed(() => {
|
||||
return props.name;
|
||||
});
|
||||
const refFormData = useTemplateRef('refFormData')
|
||||
const refNodeNameInput = useTemplateRef('refNodeNameInput')
|
||||
onBeforeMount(() => {
|
||||
|
||||
bookId.value = Number(props.id);
|
||||
sendTable();
|
||||
});
|
||||
|
||||
|
||||
const uploadModelShow = ref(false);
|
||||
const tempFileUrl = computed(() => {
|
||||
let url = $conf.serviceHost + "/api/knowledge/getKnowledgeTemplate";
|
||||
return url;
|
||||
})
|
||||
const uploadUrl = computed(() => {
|
||||
return $conf.serviceHost + "/api/knowledge/importKnowledge";
|
||||
})
|
||||
const uploadData = computed(() => {
|
||||
return { textBookId: bookId.value };
|
||||
})
|
||||
|
||||
|
||||
|
||||
const editNodeModal = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
title: '',
|
||||
type: 0,
|
||||
modal: false,
|
||||
loading: false,
|
||||
})
|
||||
|
||||
const sortModal = reactive({
|
||||
modal: false,
|
||||
loading: false,
|
||||
parentId: 0, // 当前要排序的父节点
|
||||
list: [] as { id: number; name: string }[]
|
||||
})
|
||||
|
||||
const columns = ref([
|
||||
{ title: '目录树结构', key: 'name', tree: true },
|
||||
{
|
||||
title: '操作',
|
||||
width: 300,
|
||||
align: 'center',
|
||||
render: (h: any, params: any) => {
|
||||
const { id, name, children } = params.row;
|
||||
const addChild = $utils.operatingTextHandle(h, '添加子节点', '', () => showAddNode(id));
|
||||
const sortNode = $utils.operatingTextHandle(h, '子节点排序', '', () => showSortNode(params.row), !children)
|
||||
const editNode = $utils.operatingTextHandle(h, '编辑', '', () => showEditNode(id, name));
|
||||
const delNode = $utils.operatingTextHandle(h, '删除', 'text-error', () => showDeleteNode(id, name))
|
||||
return [addChild, editNode, delNode, sortNode];
|
||||
}
|
||||
}
|
||||
])
|
||||
const tableDataComp = computed(() => {
|
||||
function recursion(tree: any[], level: number): any[] {
|
||||
tree.forEach(element => {
|
||||
element._showChildren = true;
|
||||
element.enable = element.show;
|
||||
element.level = level;
|
||||
if (!!element.children && element.children.length > 0)
|
||||
element.children = recursion(element.children, level + 1);
|
||||
else if (!element.isParent) {
|
||||
delete element.children;
|
||||
}
|
||||
});
|
||||
return tree;
|
||||
}
|
||||
return recursion(tableData.value, 1)
|
||||
})
|
||||
const { loading: tableLoading, data: tableData, send: sendTable } = useRequest(
|
||||
() => Apis.Knowledge.getTreeByTextbookId({ params: { textbookId: bookId.value }, cacheFor: null }),
|
||||
{
|
||||
initialData: [],
|
||||
immediate: false,
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
function showAddNode(pid: number) {
|
||||
editNodeModal.title = '新增子节点';
|
||||
editNodeModal.modal = true;
|
||||
editNodeModal.id = pid;
|
||||
editNodeModal.type = 1;
|
||||
editNodeModal.name = '';
|
||||
nextTick().then(() => {
|
||||
refFormData.value?.resetFields()
|
||||
refNodeNameInput.value?.focus()
|
||||
})
|
||||
}
|
||||
function showEditNode(id: number, name: string) {
|
||||
editNodeModal.title = '编辑节点';
|
||||
editNodeModal.modal = true;
|
||||
editNodeModal.id = id;
|
||||
editNodeModal.type = 2;
|
||||
nextTick().then(() => {
|
||||
refFormData.value?.resetFields()
|
||||
refNodeNameInput.value.focus()
|
||||
editNodeModal.name = name;
|
||||
})
|
||||
}
|
||||
|
||||
function showSortNode(parent: any) {
|
||||
/* 仅当存在子节点才允许排序 */
|
||||
if (!parent.children || !parent.children.length) {
|
||||
return Message.warning('暂无子节点,无需排序')
|
||||
}
|
||||
sortModal.parentId = parent.id
|
||||
sortModal.list = parent.children.map((c: any) => ({ id: c.id, name: c.name }))
|
||||
sortModal.modal = true
|
||||
}
|
||||
|
||||
async function showDeleteNode(id: number, name: string) {
|
||||
Modal.confirm({
|
||||
title: '删除目录树节点',
|
||||
content: `确定删除名为【${name}】的目录树节点记录?`,
|
||||
onOk: async () => {
|
||||
await Apis.Knowledge.deleteKnowledge({ params: { id } })
|
||||
Message.success('删除成功!');
|
||||
sendTable()
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
async function saveEditNode() {
|
||||
let isAdd = editNodeModal.type == 1 ? true : false;
|
||||
const validate = await refFormData.value.validate();
|
||||
if (!validate) {
|
||||
Message.error('请填写红色星号内容!');
|
||||
return;
|
||||
}
|
||||
const { id, name } = editNodeModal;
|
||||
editNodeModal.loading = true;
|
||||
if (isAdd) {
|
||||
await Apis.Knowledge.addKnowledge({ params: { name, parentId: id } })
|
||||
sendTable()
|
||||
}
|
||||
else {
|
||||
await Apis.Knowledge.updateKnowledge({ params: { name, id } })
|
||||
sendTable();
|
||||
}
|
||||
|
||||
editNodeModal.loading = false;
|
||||
editNodeModal.modal = false;
|
||||
}
|
||||
|
||||
|
||||
/* 真正保存排序 */
|
||||
async function saveSort() {
|
||||
sortModal.loading = true
|
||||
try {
|
||||
/* 把排序后的 id 顺序传给后台 */
|
||||
const idList = sortModal.list.map(i => i.id)
|
||||
await Apis.Knowledge.updateOrderCode({ data: idList })
|
||||
Message.success('排序已保存')
|
||||
sortModal.modal = false
|
||||
sendTable() // 刷新表格
|
||||
} finally {
|
||||
sortModal.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function reloadTable() {
|
||||
sendTable();
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
h5.title {
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
line-height: 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 3px;
|
||||
background: #5979ea;
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sort-wrap {
|
||||
padding: 0 12px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sort-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.drag-handle {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
margin-right: 8px;
|
||||
color: #999;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { computed } from "vue";
|
||||
export default function useBookInfo() {
|
||||
const bookInfo = computed(() => {
|
||||
return { identity: 2, baseModId: 2000, name: "教材", path: "textbook", type: 1 };
|
||||
});
|
||||
return { bookInfo };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<AppForm v-model="visible" :initForm="initFormData" :categoryList="categoryList" :isEdit="false"
|
||||
:loading="submitting" @submit="handleSubmit" @cancel="visible = false" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import AppForm from "./AppForm.vue";
|
||||
|
||||
/* ---------- 对外暴露:由父组件(AppList)调用 ---------- */
|
||||
const visible = ref(false);
|
||||
|
||||
// 应用类型列表——由父组件通过 expose 传入,或内部自行拉取
|
||||
const categoryList = ref<any[]>([]);
|
||||
|
||||
// 新增模式:始终传空初始值
|
||||
const initFormData = ref<Record<string, any>>({});
|
||||
|
||||
// 成功后回调
|
||||
let onSuccessCallback: (() => void) | null = null;
|
||||
|
||||
// useForm 托管提交
|
||||
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
||||
(dto) => Apis.AppSettings.addApp({ data: dto }),
|
||||
{ initialForm: { name: '', categoryId: undefined, desc: '', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
|
||||
);
|
||||
|
||||
onSuccess(() => {
|
||||
Message.success('新增应用成功!');
|
||||
visible.value = false;
|
||||
onSuccessCallback?.();
|
||||
});
|
||||
onError((event) => {
|
||||
Message.error(event?.error?.message || '新增失败');
|
||||
});
|
||||
|
||||
/* ---------- 打开弹窗 ---------- */
|
||||
function open(categories: any[], defaultCategoryId?: number | null, onSuccess?: () => void) {
|
||||
categoryList.value = categories || [];
|
||||
// 若列表已筛选某应用类型,则新增弹窗自动带入该类型;选「全部」时不带入
|
||||
initFormData.value = defaultCategoryId != null ? { categoryId: defaultCategoryId } : {};
|
||||
onSuccessCallback = onSuccess || null;
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
/* ---------- 提交:由 useForm 接管 loading 与异常处理 ---------- */
|
||||
async function handleSubmit(formData: Record<string, any>) {
|
||||
form.value = {
|
||||
name: formData.name,
|
||||
categoryId: formData.categoryId,
|
||||
desc: formData.desc,
|
||||
clients: formData.clients,
|
||||
pcUrl: formData.pcUrl,
|
||||
pcIcon: formData.pcIcon,
|
||||
appUrl: formData.appUrl,
|
||||
appIcon: formData.appIcon,
|
||||
officialAccountUrl: formData.officialAccountUrl,
|
||||
dingtalkUrl: formData.dingtalkUrl,
|
||||
};
|
||||
await sendForm();
|
||||
}
|
||||
|
||||
defineExpose({ open, visible });
|
||||
</script>
|
||||
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div class="main-wrapper">
|
||||
<div class="main-container">
|
||||
<Alert show-icon>设置说明:拖动排序,被使用的分类不能删除</Alert>
|
||||
<Button class="mb-5" type="primary" @click="addModuleShow">新增应用类型</Button>
|
||||
<draggable class="type_set-wrap fix" tag="ul" v-model="typeList" :group="'appType'" @change="typeSortSave"
|
||||
item-key="id">
|
||||
<template #item="{ element }">
|
||||
<li class="fl">
|
||||
<div class="type_set-box">
|
||||
<div class="type_name">
|
||||
<span class="content">{{ element.name }}</span>
|
||||
</div>
|
||||
<div class="type_action flex">
|
||||
<div class="action_edit" @click="showSetting(element)">
|
||||
<span>编辑</span>
|
||||
</div>
|
||||
|
||||
<div class="action_delete" @click="removeSave(element)">
|
||||
<span>删除</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-wrap">
|
||||
<Modal v-model="editModal" :mask-closable="false" width="600"
|
||||
:title="currentId == null ? '新增应用类型' : '编辑应用类型'">
|
||||
<Form ref="formData" class="mt-8 mr-4 mb-12" :model="form" :rules="ruleValidate" :label-width="100">
|
||||
<FormItem label="类型名称:" prop="name">
|
||||
<Input placeholder="请输入应用类型名称" v-model="form.name" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="submitting" @click="onEditSave">提交</Button>
|
||||
<Button @click="onReset">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import draggable from 'vuedraggable'
|
||||
import { debounce } from "lodash";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
|
||||
const { data: typeList, send: sendTypeList } = useRequest(
|
||||
Apis.AppSettings.appTypeList({
|
||||
transform: (res: any) => {
|
||||
return res ?? [];
|
||||
},
|
||||
cacheFor: null,
|
||||
}),
|
||||
{ initialData: [], immediate: true }
|
||||
)
|
||||
|
||||
const refFormData = useTemplateRef('formData');
|
||||
const editModal = ref(false);
|
||||
const currentId = ref<number | null>(null);
|
||||
const submitting = ref(false);
|
||||
const form = ref<{ id: number | null; name: string }>({
|
||||
id: null,
|
||||
name: '',
|
||||
});
|
||||
|
||||
const ruleValidate = ref({
|
||||
name: [{ required: true, message: "请输入应用类型名称", trigger: "blur" }],
|
||||
});
|
||||
|
||||
async function addModuleShow() {
|
||||
currentId.value = null;
|
||||
form.value = { id: null, name: '' };
|
||||
await refFormData.value?.resetFields();
|
||||
editModal.value = true;
|
||||
}
|
||||
|
||||
//拖动排序:提交当前顺序的 id 数组
|
||||
async function typeSortSave() {
|
||||
const ids = (typeList.value ?? []).map((s: any) => s.id);
|
||||
await Apis.AppSettings.appTypeSort({ data: ids });
|
||||
}
|
||||
|
||||
async function showSetting(element: any) {
|
||||
currentId.value = element.id;
|
||||
form.value = {
|
||||
id: element.id,
|
||||
name: element.name,
|
||||
};
|
||||
editModal.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* removeSave
|
||||
*/
|
||||
async function removeSave(element: any) {
|
||||
await MyConfirm({
|
||||
msg: "确定删除该应用类型吗?",
|
||||
info: "删除后不可恢复!",
|
||||
async onOk() {
|
||||
try {
|
||||
await Apis.AppSettings.deleteType({ pathParams: { id: element.id } });
|
||||
Message.success("操作成功!");
|
||||
await sendTypeList();
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const onEditSave = debounce(onRawEditSave, 500)
|
||||
async function onRawEditSave() {
|
||||
try {
|
||||
const validate = await refFormData.value?.validate();
|
||||
if (!validate) return false;
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (currentId.value == null) {
|
||||
await Apis.AppSettings.addType({
|
||||
data: {
|
||||
name: form.value.name,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await Apis.AppSettings.editType({
|
||||
pathParams: { id: currentId.value },
|
||||
data: {
|
||||
id: currentId.value,
|
||||
name: form.value.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
Message.success("操作成功!");
|
||||
editModal.value = false;
|
||||
await sendTypeList();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
refFormData.value?.resetFields();
|
||||
currentId.value = null;
|
||||
editModal.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.type_set-wrap {
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
|
||||
.type_set-box {
|
||||
width: 222px;
|
||||
background: #ffffff;
|
||||
margin-right: 15px;
|
||||
margin-bottom: 15px;
|
||||
|
||||
.type_name {
|
||||
height: 66px;
|
||||
border: 1px solid #e9e9e9;
|
||||
border-bottom: none;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
line-height: 66px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: move;
|
||||
|
||||
span.content {
|
||||
height: 24px;
|
||||
font-size: 15px;
|
||||
font-family: PingFangSC-Semibold, PingFang SC;
|
||||
font-weight: 500;
|
||||
color: #272829;
|
||||
line-height: 24px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 222px;
|
||||
display: inline-block;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.type_action {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 222px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
background: #f7f7f7;
|
||||
border-radius: 0px 0px 2px 2px;
|
||||
border-top: none;
|
||||
border: 1px solid #e9e9e9;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
div {
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div.disabled {
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
div:hover {
|
||||
color: #6c91f0;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 14px;
|
||||
width: 1px;
|
||||
background: #e8e8e8;
|
||||
float: left;
|
||||
margin-left: 111px;
|
||||
margin-top: -22px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<Modal v-model="show" :mask-closable="false" width="800" scrollable :title="title"
|
||||
@on-visible-change="onVisibleChange">
|
||||
<Form ref="formRef" :model="form" :rules="rules" :label-width="130" class="app-form width-95">
|
||||
<!-- 第一行:应用名称 / 应用类型 -->
|
||||
<Row>
|
||||
<Col span="12">
|
||||
<FormItem label="应用名称:" prop="name">
|
||||
<Input v-model="form.name" placeholder="请输入应用名称" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="应用类型:" prop="categoryId">
|
||||
<Select v-model="form.categoryId" transfer placeholder="请选择应用类型">
|
||||
<Option v-for="item in categoryList" :key="item.id" :value="item.id" :label="item.name" />
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
<!-- 第二行:应用描述(整行) -->
|
||||
<Row>
|
||||
<Col span="24">
|
||||
<FormItem label="应用描述:" prop="desc">
|
||||
<Input v-model="form.desc" type="text" placeholder="请输入应用描述" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第三行:appKey / appSecret(编辑模式才显示) -->
|
||||
<Row v-if="isEdit">
|
||||
<Col span="12">
|
||||
<FormItem label="appKey:">
|
||||
<Input :model-value="form.appKey?.toString()" readonly />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="appSecret:">
|
||||
<div class="secret-row">
|
||||
<Input :model-value="form.appSecret" readonly style="flex:1" />
|
||||
<Button type="default" class="ml-8" @click="$emit('reset-secret')">重置</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第四行:应用前端类型(tag 多选) -->
|
||||
<Row>
|
||||
<Col span="24">
|
||||
<FormItem label="应用前端类型:">
|
||||
<Select v-model="form.clients" multiple transfer placeholder="请选择前端类型" class="client-select">
|
||||
<Option v-for="opt in clientOptions" :key="opt.value" :value="opt.value" :label="opt.label" />
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第五行:Pc应用地址 / Pc应用图标 -->
|
||||
<Row>
|
||||
<Col span="12">
|
||||
<FormItem label="Pc应用地址:">
|
||||
<Input v-model="form.pcUrl" placeholder="请输入Pc应用地址" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="Pc应用图标:">
|
||||
<Upload :on-success="(res: any) => onUploadSuccess(res, 'pcIcon')" :show-upload-list="false"
|
||||
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
|
||||
:with-credentials="true" :headers="{ yschapirequest: 1 }">
|
||||
<div v-if="!form.pcIcon" class="icon-upload-wrap">
|
||||
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
|
||||
</div>
|
||||
<div class="icon-preview-wrap" v-else>
|
||||
<SvgIcon @click.stop="form.pcIcon = null" class="cloase-btn" name="widget-close" size="18"
|
||||
color="#fff" />
|
||||
<img :src="previewUrl(form.pcIcon)" alt="" />
|
||||
</div>
|
||||
</Upload>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第六行:App接口地址 / App应用图标 -->
|
||||
<Row>
|
||||
<Col span="12">
|
||||
<FormItem label="App接口地址:">
|
||||
<Input v-model="form.appUrl" placeholder="请输入App接口地址" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="App应用图标:">
|
||||
<Upload :on-success="(res: any) => onUploadSuccess(res, 'appIcon')" :show-upload-list="false"
|
||||
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
|
||||
:with-credentials="true" :headers="{ yschapirequest: 1 }">
|
||||
<div v-if="!form.appIcon" class="icon-upload-wrap">
|
||||
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
|
||||
</div>
|
||||
<div class="icon-preview-wrap" v-else>
|
||||
<SvgIcon @click.stop="form.appIcon = null" class="cloase-btn" name="widget-close" size="18"
|
||||
color="#fff" />
|
||||
<img :src="previewUrl(form.appIcon)" alt="" />
|
||||
</div>
|
||||
</Upload>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第七行:公众号应用地址 / 钉钉应用地址 -->
|
||||
<Row>
|
||||
<Col span="12">
|
||||
<FormItem label="公众号应用地址:">
|
||||
<Input v-model="form.officialAccountUrl" placeholder="请输入公众号应用地址" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="钉钉应用地址:">
|
||||
<Input v-model="form.dingtalkUrl" placeholder="请输入钉钉应用地址" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="loading" @click="handleSubmit">提交</Button>
|
||||
<Button @click="handleCancel">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import { computed, reactive, watch } from "vue";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
|
||||
const { $bus, $utils, $conf } = useCurrentInstance();
|
||||
/* ---------- Props / Emits ---------- */
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean;
|
||||
initForm?: Record<string, any>;
|
||||
categoryList?: any[];
|
||||
isEdit?: boolean;
|
||||
loading?: boolean;
|
||||
}>(), {
|
||||
initForm: () => ({}),
|
||||
categoryList: () => [],
|
||||
isEdit: false,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'submit', 'cancel', 'reset-secret']);
|
||||
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: val => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const title = computed(() => props.isEdit ? '编辑应用' : '新增应用');
|
||||
|
||||
/* ---------- 表单数据 ---------- */
|
||||
const defaultForm = (): Record<string, any> => ({
|
||||
id: null,
|
||||
name: '',
|
||||
categoryId: undefined as number | undefined,
|
||||
desc: '',
|
||||
appKey: null,
|
||||
appSecret: '',
|
||||
clients: [] as number[],
|
||||
pcUrl: '',
|
||||
pcIcon: '',
|
||||
appUrl: '',
|
||||
appIcon: '',
|
||||
officialAccountUrl: '',
|
||||
dingtalkUrl: '',
|
||||
});
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
|
||||
// 外部 initForm 变化时回填
|
||||
watch(() => props.initForm, (val) => {
|
||||
if (val && Object.keys(val).length > 0) {
|
||||
Object.assign(form, defaultForm(), val);
|
||||
} else {
|
||||
Object.assign(form, defaultForm());
|
||||
}
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
/* ---------- 上传相关 ---------- */
|
||||
// 上传地址使用 $conf.serviceHost 作为基地址
|
||||
const uploadUrl = computed(() => $conf?.serviceHost + '/api/base/UploadFile');
|
||||
|
||||
function previewUrl(path: string | undefined): string {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http')) return path;
|
||||
return ($conf?.serviceHost || '') + path;
|
||||
}
|
||||
|
||||
function onUploadSuccess(response: any, field: 'pcIcon' | 'appIcon') {
|
||||
if (response?.code === 0 || response?.code === 200) {
|
||||
(form as any)[field] = response.data ?? response.url ?? '';
|
||||
} else {
|
||||
Message.error((response?.msg || response?.message) || '上传失败');
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 前端类型选项 ---------- */
|
||||
const clientOptions = [
|
||||
{ value: 1, label: 'Pc浏览器' },
|
||||
{ value: 2, label: '移动App' },
|
||||
];
|
||||
|
||||
/* ---------- 校验规则 ---------- */
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
|
||||
categoryId: [{ required: true, type: 'number' as const, message: '请选择应用类型', trigger: 'change' }],
|
||||
};
|
||||
|
||||
/* ---------- 提交 ---------- */
|
||||
const formRef = ref();
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await formRef.value?.validate();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 提交前清理空值
|
||||
const submitData = { ...form };
|
||||
emit('submit', submitData);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel');
|
||||
}
|
||||
|
||||
function onVisibleChange(val: boolean) {
|
||||
if (!val) {
|
||||
emit('cancel');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 表单整体:宽度 95% 避免横向滚动(width-95 工具类控制宽度,scss 生成) */
|
||||
.app-form {
|
||||
margin: 0 auto;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 12px;
|
||||
|
||||
/* FormItem 改为 block,避免默认 inline-block 在 flex Col 内垂直间距失效 */
|
||||
:deep(.ivu-form-item) {
|
||||
display: block;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.ivu-form-item-label) {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
}
|
||||
|
||||
/* 行间距改由 Row 统一控制(Row 是 flex 容器,FormItem 默认无 margin-bottom) */
|
||||
:deep(.ivu-row) {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
:deep(.ivu-row:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.secret-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 图标上传区域 */
|
||||
.icon-upload-wrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px dashed #dcdee2;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #577afb;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-preview-wrap {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cloase-btn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border-radius: 0 0 0 6px;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(220, 50, 50, 0.8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 客户端多选样式 */
|
||||
.client-select {
|
||||
width: 100%;
|
||||
|
||||
:deep(.ivu-select-selection) {
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div class="page-wrapper tree-wrapper">
|
||||
<div class="tree-container app-type-tree">
|
||||
<div class="tree-scroll">
|
||||
<ul class="grade-ul">
|
||||
<li :class="dataParams.categoryId == null ? 'active' : ''" @click="onCategoryClick(null)">
|
||||
全部
|
||||
</li>
|
||||
<li v-for="item in appTypeList" :key="item.id"
|
||||
:class="item.id == dataParams.categoryId ? 'active' : ''" @click="onCategoryClick(item.id)">
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tree-footer">
|
||||
<Button type="text" @click="goAppType">管理应用类型</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main-container">
|
||||
<div class="search-wrap fix">
|
||||
<Button class="fl" type="primary" @click="handleAdd">新增应用</Button>
|
||||
<Button class="fl ml-8" type="default" v-if="dataParams.categoryId != null"
|
||||
@click="sortModalVisible = true">排序管理</Button>
|
||||
<Input class="fr" v-model="keyword" placeholder="输入应用名称或appKey筛选" search clearable suffix="ios-search"
|
||||
style="width:220px" @on-search="onSearch" @on-clear="onSearch" />
|
||||
</div>
|
||||
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columnsComp" :tableHeight="tableHeight" />
|
||||
</div>
|
||||
|
||||
<!-- 容器组件:新增 / 编辑 -->
|
||||
<AddAppContainer ref="addRef" />
|
||||
<EditAppContainer ref="editRef" />
|
||||
|
||||
<!-- 排序管理 -->
|
||||
<AppSort v-model="sortModalVisible" :categoryId="dataParams.categoryId" :categoryName="currentCategoryName"
|
||||
@saved="onSortSaved" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from "vue";
|
||||
defineOptions({ name: "AppList" });
|
||||
import { useRouter } from "vue-router";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import AddAppContainer from "./AddAppContainer.vue";
|
||||
import EditAppContainer from "./EditAppContainer.vue";
|
||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||
import { OperatorEnum } from '@/types';
|
||||
import AppSort from './AppSort.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const { $utils } = useCurrentInstance();
|
||||
|
||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
|
||||
const sortModalVisible = ref(false);
|
||||
const keyword = ref("");
|
||||
|
||||
const dataParams = ref<{
|
||||
categoryId: number | null;
|
||||
name?: string;
|
||||
}>({
|
||||
categoryId: null,
|
||||
name: "",
|
||||
});
|
||||
|
||||
|
||||
/* ---------------- 左侧分类切换:立即刷新 ---------------- */
|
||||
function onCategoryClick(id: number | null) {
|
||||
dataParams.value.categoryId = id;
|
||||
tableRef.value?.refreshData();
|
||||
}
|
||||
|
||||
/* ---------------- 输入框搜索:仅在触发搜索时提交 ---------------- */
|
||||
function onSearch() {
|
||||
dataParams.value.name = keyword.value.trim() || undefined;
|
||||
tableRef.value?.refreshData();
|
||||
}
|
||||
|
||||
/* ---------------- 左侧应用类型 ---------------- */
|
||||
const { data: appTypeList, send: fetchTypeList } = useRequest(
|
||||
Apis.AppSettings.appTypeList({
|
||||
transform: (res: any) => {
|
||||
const list: any[] = res ?? [];
|
||||
return list.slice().sort((a, b) => (a.sortNo ?? 0) - (b.sortNo ?? 0));
|
||||
},
|
||||
cacheFor: null,
|
||||
}),
|
||||
{ initialData: [] }
|
||||
);
|
||||
|
||||
const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
||||
return Apis.AppSettings.appList({
|
||||
params: {
|
||||
categoryId: dataParams.value.categoryId ?? undefined,
|
||||
name: dataParams.value.name || undefined,
|
||||
pageIndex: params.pageIndex,
|
||||
pageSize: params.pageSize,
|
||||
},
|
||||
cacheFor: null,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const typeNameMap = computed(() => {
|
||||
const map: Record<number, string> = {};
|
||||
(appTypeList.value ?? []).forEach((t: any) => (map[t.id] = t.name));
|
||||
return map;
|
||||
});
|
||||
|
||||
const currentCategoryName = computed(() => {
|
||||
if (dataParams.value.categoryId == null) return "";
|
||||
return typeNameMap.value[dataParams.value.categoryId] ?? "";
|
||||
});
|
||||
|
||||
/* ---------------- 排序保存后刷新列表 ---------------- */
|
||||
function onSortSaved() {
|
||||
tableRef.value?.reloadData(OperatorEnum.sel);
|
||||
}
|
||||
|
||||
const tableHeight = ref(
|
||||
$utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true })
|
||||
);
|
||||
|
||||
/* ---------------- 表格固定列(不含“应用类型”列) ---------------- */
|
||||
const columns = [
|
||||
{ type: "index", title: "序号", width: 70 },
|
||||
|
||||
{ title: "应用名称", key: "name", width: 160 },
|
||||
{ title: "描述", key: "desc", ellipsis: true, tooltip: true, minWidth: 140 },
|
||||
{
|
||||
title: "前端",
|
||||
key: "clients",
|
||||
width: 180,
|
||||
render: (h: any, { row }: any) => {
|
||||
const map: Record<number, string> = { 1: "Pc浏览器", 2: "移动App" };
|
||||
const arr: number[] = Array.isArray(row.clients) ? row.clients : [];
|
||||
if (!arr.length) return h("span", "-");
|
||||
return h("span", arr.map((v) => map[v] ?? v).join("、"));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
align: 'center',
|
||||
width: 280,
|
||||
render: (h: any, { row }: any) => {
|
||||
return [
|
||||
$utils.operatingTextHandle(h, '编辑', '', () => handleEdit(row)),
|
||||
$utils.operatingTextHandle(h, '模块管理', '', () => goModuleManage(row)),
|
||||
$utils.operatingTextHandle(h, '删除', 'text-error', () => handleDelete(row)),
|
||||
];
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
// 条件列:仅在「未筛选应用类型」时插入到“应用名称”之后
|
||||
const typeColumn = {
|
||||
title: "应用类型",
|
||||
key: "categoryId",
|
||||
width: 110,
|
||||
render: (h: any, { row }: any) => h("span", typeNameMap.value[row.categoryId] ?? "-"),
|
||||
};
|
||||
|
||||
/* ---------------- 表格最终数据源(动态列) ---------------- */
|
||||
const columnsComp = computed(() => {
|
||||
const cols = [...columns];
|
||||
// 未筛选应用类型时,展示“应用类型”列
|
||||
if (!dataParams.value.categoryId) {
|
||||
const idx = cols.findIndex((c) => c.key === "name");
|
||||
cols.splice(idx + 1, 0, typeColumn);
|
||||
}
|
||||
return cols;
|
||||
});
|
||||
|
||||
/* ---------------- 新增应用 ---------------- */
|
||||
const addRef = ref();
|
||||
|
||||
function handleAdd() {
|
||||
addRef.value?.open(appTypeList.value, dataParams.value.categoryId, () => {
|
||||
tableRef.value?.reloadData(OperatorEnum.add);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 编辑应用 ---------------- */
|
||||
const editRef = ref();
|
||||
|
||||
function handleEdit(row: any) {
|
||||
editRef.value?.open(row.id, row, appTypeList.value, () => {
|
||||
tableRef.value?.reloadData(OperatorEnum.edit);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 删除应用 ---------------- */
|
||||
async function handleDelete(row: any) {
|
||||
await MyConfirm({
|
||||
msg: `确定删除应用「${row.name || row.id}」吗?`,
|
||||
info: "删除后不可恢复!",
|
||||
async onOk() {
|
||||
try {
|
||||
await Apis.AppSettings.deleteApp({ pathParams: { id: row.id } });
|
||||
Message.success('删除成功!');
|
||||
tableRef.value?.reloadData(OperatorEnum.del);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 导航 ---------------- */
|
||||
const goAppType = () => {
|
||||
router.push("/app-manage/category");
|
||||
};
|
||||
|
||||
const goModuleManage = (row: any) => {
|
||||
router.push({
|
||||
path: "/app-manage/module",
|
||||
query: { id: row.id, name: row.name },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-type-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.tree-scroll {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.tree-footer {
|
||||
flex-shrink: 0;
|
||||
padding-top: 8px;
|
||||
text-align: center;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #eeeeee;
|
||||
|
||||
.ivu-btn-text {
|
||||
color: #577afb;
|
||||
}
|
||||
}
|
||||
|
||||
.grade-ul {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
li {
|
||||
cursor: pointer;
|
||||
background-color: #fafafa;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
li.active {
|
||||
color: #577afb;
|
||||
border-color: #577afb;
|
||||
}
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-link {
|
||||
cursor: pointer;
|
||||
color: #577afb;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
color: #3d5afe;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ed4014;
|
||||
|
||||
&:hover {
|
||||
color: #c51d04;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-divider {
|
||||
margin: 0 8px;
|
||||
color: #dcdee2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<Modal v-model="modalVisible" :title="'应用排序 - ' + categoryName" width="560" :mask-closable="false"
|
||||
@on-visible-change="onVisibleChange">
|
||||
<Alert show-icon>拖动左侧手柄进行排序,排序结果会实时记录</Alert>
|
||||
|
||||
<div class="sort-loading" v-if="loading">
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<draggable v-else class="sort-list" v-model="sortList" item-key="id" handle=".drag-handle" @change="onDragChange">
|
||||
<template #item="{ element, index }">
|
||||
<div class="sort-item">
|
||||
<span class="drag-handle">⠿</span>
|
||||
<span class="sort-index">{{ index + 1 }}</span>
|
||||
<span class="sort-name">{{ element.name || element.id }}</span>
|
||||
<span class="sort-no">排序号: {{ element.sortNo ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<div class="sort-empty" v-if="!loading && sortList.length === 0">
|
||||
暂无应用数据
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="saving" @click="handleSave" :disabled="sortList.length === 0">
|
||||
保存排序
|
||||
</Button>
|
||||
<Button @click="modalVisible = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from "vue";
|
||||
import draggable from "vuedraggable";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
categoryId: number | null;
|
||||
categoryName: string;
|
||||
}>(),
|
||||
{
|
||||
categoryName: "",
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", val: boolean): void;
|
||||
(e: "saved"): void;
|
||||
}>();
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const sortList = ref<{ id: number; name?: string; sortNo?: number }[]>([]);
|
||||
const hasChanged = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
modalVisible.value = val;
|
||||
if (val) {
|
||||
fetchSortList();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(modalVisible, (val) => {
|
||||
emit("update:modelValue", val);
|
||||
});
|
||||
|
||||
function onVisibleChange(visible: boolean) {
|
||||
if (!visible) {
|
||||
hasChanged.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSortList() {
|
||||
if (!props.categoryId) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await Apis.AppSettings.appSortList({
|
||||
params: { categoryId: props.categoryId },
|
||||
cacheFor: null,
|
||||
});
|
||||
sortList.value = (res ?? []).slice().sort((a: any, b: any) => (a.sortNo ?? 0) - (b.sortNo ?? 0));
|
||||
hasChanged.value = false;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "获取排序列表失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDragChange() {
|
||||
hasChanged.value = true;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!hasChanged.value) {
|
||||
Message.info("排序未发生变化");
|
||||
modalVisible.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const ids = sortList.value.map((item) => item.id);
|
||||
await Apis.AppSettings.appSort({ data: ids });
|
||||
Message.success("排序保存成功!");
|
||||
hasChanged.value = false;
|
||||
modalVisible.value = false;
|
||||
emit("saved");
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "排序保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sort-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sort-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 6px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f4ff;
|
||||
border-color: #c0d0ff;
|
||||
}
|
||||
}
|
||||
|
||||
/* 拖拽中:当前被拖拽的元素高亮(明显橙色,不放大避免横向滚动条) */
|
||||
.sort-list :deep(.sort-item.sortable-chosen),
|
||||
.sort-list :deep(.sort-item.sortable-drag) {
|
||||
background: #fff7e6;
|
||||
border-color: #fa8c16;
|
||||
box-shadow: 0 6px 16px rgba(250, 140, 22, 0.4);
|
||||
cursor: grabbing;
|
||||
|
||||
.drag-handle {
|
||||
color: #fa8c16;
|
||||
}
|
||||
}
|
||||
|
||||
/* 拖拽中:落点占位位置(虚线提示) */
|
||||
.sort-list :deep(.sort-item.sortable-ghost) {
|
||||
background: #fffbe6;
|
||||
border: 1px dashed #fa8c16;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
color: #999;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.sort-index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #577afb;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.sort-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort-no {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.sort-loading,
|
||||
.sort-empty {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<AppForm v-model="visible" :initForm="detailData" :categoryList="categoryList" :isEdit="true" :loading="submitting"
|
||||
@submit="handleSubmit" @cancel="visible = false" @reset-secret="handleResetSecret" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import AppForm from "./AppForm.vue";
|
||||
import MyConfirm from '@/components/MyConfirm'
|
||||
|
||||
/* ---------- 对外暴露 ---------- */
|
||||
const visible = ref(false);
|
||||
const categoryList = ref<any[]>([]);
|
||||
|
||||
// 编辑模式:从 appList 中根据 id 取出详情回填(无独立详情接口)
|
||||
const detailData = ref<Record<string, any>>({});
|
||||
const currentId = ref<number | null>(null);
|
||||
|
||||
// 成功后回调
|
||||
let onSuccessCallback: (() => void) | null = null;
|
||||
|
||||
// useForm 托管提交——currentId 是 ref,闭包内 .value 在 sendForm 时取当前值
|
||||
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
||||
(dto) => Apis.AppSettings.editApp({ pathParams: { id: currentId.value }, data: dto }),
|
||||
{ initialForm: { id: null, name: '', categoryId: undefined, desc: '', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
|
||||
);
|
||||
|
||||
onSuccess(() => {
|
||||
Message.success('编辑应用成功!');
|
||||
visible.value = false;
|
||||
onSuccessCallback?.();
|
||||
});
|
||||
onError((event) => {
|
||||
Message.error(event?.error?.message || '编辑失败');
|
||||
});
|
||||
|
||||
/* ---------- 打开编辑弹窗 ---------- */
|
||||
/**
|
||||
* @param id - 应用 ID
|
||||
* @param row - 当前行完整数据(来自表格列表),直接用作回填数据
|
||||
* @param categories - 应用类型列表
|
||||
* @param onSuccess - 编辑成功后的回调
|
||||
*/
|
||||
function open(id: number, row: any, categories: any[], onSuccess?: () => void) {
|
||||
currentId.value = id;
|
||||
categoryList.value = categories || [];
|
||||
// 直接用行数据回填(appList 接口返回的字段与表单一致)
|
||||
detailData.value = { ...row };
|
||||
onSuccessCallback = onSuccess || null;
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
/* ---------- 提交:由 useForm 接管 loading 与异常处理 ---------- */
|
||||
async function handleSubmit(formData: Record<string, any>) {
|
||||
if (!currentId.value) return;
|
||||
form.value = {
|
||||
id: currentId.value,
|
||||
name: formData.name,
|
||||
categoryId: formData.categoryId,
|
||||
desc: formData.desc,
|
||||
clients: formData.clients,
|
||||
pcUrl: formData.pcUrl,
|
||||
pcIcon: formData.pcIcon,
|
||||
appUrl: formData.appUrl,
|
||||
appIcon: formData.appIcon,
|
||||
officialAccountUrl: formData.officialAccountUrl,
|
||||
dingtalkUrl: formData.dingtalkUrl,
|
||||
};
|
||||
await sendForm();
|
||||
}
|
||||
|
||||
/* ---------- 重置密钥(非表单提交,直接 MyConfirm + await) ---------- */
|
||||
async function handleResetSecret() {
|
||||
if (!currentId.value) return;
|
||||
try {
|
||||
await MyConfirm({
|
||||
msg: '确定要重置该应用的 Secret 吗?',
|
||||
info: '重置后原 Secret 将立即失效!',
|
||||
async onOk() {
|
||||
const newSecret = await Apis.AppSettings.resetAppSecret({
|
||||
pathParams: { id: currentId.value! },
|
||||
});
|
||||
Message.success('密钥已重置!新 Secret: ' + (newSecret || ''));
|
||||
// 更新表单中的 secret 回显值
|
||||
if (newSecret) {
|
||||
detailData.value = { ...detailData.value, appSecret: newSecret };
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open, visible });
|
||||
</script>
|
||||
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<div class="main-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="search-wrap">
|
||||
<Button class="fl" type="primary" @click="handleAdd">新增模块</Button>
|
||||
<Button class="fr" icon="ios-arrow-back" @click="goBack">返回</Button>
|
||||
</div>
|
||||
<h4 class="mt-10 second-title">{{ appName }} - 模块管理</h4>
|
||||
<Table class="module-table" :columns="columns" :data="flatData" border>
|
||||
<template #name="{ row }">
|
||||
<div class="module-name-cell" :style="{ paddingLeft: row._depth * 28 + 'px' }">
|
||||
<SvgIcon v-if="row._depth > 0" name="tree-line" :size="16" color="#999" />
|
||||
<Icon type="ios-cube-outline" size="16" />
|
||||
<span class="module-name-text">{{ row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑弹窗 -->
|
||||
<Modal v-model="editModal" :mask-closable="false" width="600" :title="modalTitle">
|
||||
<Form ref="formData" class="mt-8 mr-4 mb-12" :model="form" :rules="ruleValidate" :label-width="110">
|
||||
<FormItem v-if="form.parentId != null" label="父模块名称:">
|
||||
<Input :model-value="parentNameForDisplay" disabled />
|
||||
</FormItem>
|
||||
<FormItem label="模块名称:" prop="name">
|
||||
<Input placeholder="请输入模块名称" v-model="form.name" />
|
||||
</FormItem>
|
||||
<FormItem label="模块路径:" prop="path">
|
||||
<Input placeholder="请输入模块路径" v-model="form.path" />
|
||||
</FormItem>
|
||||
<FormItem label="自定义ID:" prop="customId">
|
||||
<InputNumber :style="{ width: '100%' }" placeholder="请输入自定义ID" v-model="form.customId" />
|
||||
</FormItem>
|
||||
<FormItem label="排序号:" prop="sortNo">
|
||||
<InputNumber :style="{ width: '100%' }" placeholder="请输入排序号" v-model="form.sortNo" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="submitting" @click="onSave">提交</Button>
|
||||
<Button @click="onReset">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { debounce } from "lodash";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
|
||||
const { $utils } = useCurrentInstance();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const appId = computed(() => Number(route.query.id));
|
||||
const appName = computed(() => (route.query.name as string) || '');
|
||||
|
||||
|
||||
/* ---------------- 数据加载(无分页) ---------------- */
|
||||
const { data: moduleList, send: fetchModuleList } = useRequest(
|
||||
Apis.AppSettings.moduleList({
|
||||
params: { appId: appId.value },
|
||||
cacheFor: null,
|
||||
}),
|
||||
{ initialData: [], immediate: true }
|
||||
);
|
||||
|
||||
/* ---------------- 递归扁平化(伪树形,支持任意层级) ----------------
|
||||
* 注意:必须剥掉 children,否则 view-ui-plus Table 会把行当成树节点处理,
|
||||
* 触发内部树形展开逻辑并读到 null 子节点,报 `_isChecked` of null 错误。 */
|
||||
function flattenTree(list: any[], depth = 0): any[] {
|
||||
const result: any[] = [];
|
||||
(list ?? []).forEach((node: any) => {
|
||||
const { children, ...rest } = node;
|
||||
const hasChildren = Array.isArray(children) && children.length > 0;
|
||||
result.push({ ...rest, _depth: depth, _isParent: node.parentId == null, _hasChildren: hasChildren });
|
||||
if (hasChildren) {
|
||||
result.push(...flattenTree(children, depth + 1));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const flatData = computed(() => flattenTree(moduleList.value));
|
||||
|
||||
/* ---------------- 表格列 ---------------- */
|
||||
const columns = [
|
||||
{ title: "模块名称", key: "name", minWidth: 260, slot: "name" },
|
||||
{ title: "ID", key: "customId", width: 120, align: "center", render: (h: any, { row }: any) => h("span", row.customId || "-") },
|
||||
{ title: "路径", key: "path", minWidth: 200, render: (h: any, { row }: any) => h("span", row.path || "-") },
|
||||
{ title: "排序号", key: "sortNo", width: 100, align: "center", render: (h: any, { row }: any) => h("span", row.sortNo ?? "-") },
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 220,
|
||||
align: "center",
|
||||
render: (h: any, { row }: any) => {
|
||||
const actions: any[] = [
|
||||
$utils.operatingTextHandle(h, '编辑', '', () => handleEdit(row)),
|
||||
$utils.operatingTextHandle(h, '删除', 'text-error', () => handleDelete(row)),
|
||||
];
|
||||
if (row.level < 5) {
|
||||
actions.splice(1, 0, $utils.operatingTextHandle(h, '添加子模块', '', () => handleAddChild(row)));
|
||||
}
|
||||
return h("div", { style: { display: "flex", justifyContent: "center", gap: "4px" } }, actions);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/* ---------------- 弹窗状态 ---------------- */
|
||||
const refFormData = useTemplateRef('formData');
|
||||
const editModal = ref(false);
|
||||
const currentId = ref<number | null>(null);
|
||||
const submitting = ref(false);
|
||||
const parentNameForDisplay = ref('');
|
||||
|
||||
const form = ref<{
|
||||
id: number | null;
|
||||
name: string;
|
||||
path: string;
|
||||
customId: number | null;
|
||||
parentId: number | null;
|
||||
sortNo: number | null;
|
||||
}>({
|
||||
id: null,
|
||||
name: '',
|
||||
path: '',
|
||||
customId: null,
|
||||
parentId: null,
|
||||
sortNo: null,
|
||||
});
|
||||
|
||||
const ruleValidate = ref({
|
||||
name: [{ required: true, message: "请输入模块名称", trigger: "blur" }],
|
||||
path: [{ required: true, message: "请输入模块路径", trigger: "blur" }],
|
||||
customId: [{ required: true, type: "number", message: "请输入自定义ID", trigger: "change" }],
|
||||
});
|
||||
|
||||
const openingIsChild = ref(false);
|
||||
const modalTitle = computed(() => {
|
||||
if (currentId.value != null) return '编辑模块';
|
||||
return openingIsChild.value ? '新增子模块' : '新增模块';
|
||||
});
|
||||
|
||||
/* ---------------- 返回应用列表(keepAlive 记忆筛选) ---------------- */
|
||||
function goBack() {
|
||||
router.push("/app-manage/list");
|
||||
}
|
||||
|
||||
/* ---------------- 由嵌套 moduleList 递归构建 id->name 映射(兜底后端未返回 parentName) ---------------- */
|
||||
function buildIdNameMap(list: any[]): Record<number, string> {
|
||||
const map: Record<number, string> = {};
|
||||
(list ?? []).forEach((n: any) => {
|
||||
if (n.id != null) map[n.id] = n.name;
|
||||
if (Array.isArray(n.children) && n.children.length) Object.assign(map, buildIdNameMap(n.children));
|
||||
});
|
||||
return map;
|
||||
}
|
||||
const parentNameMap = computed(() => buildIdNameMap(moduleList.value));
|
||||
|
||||
/* ---------------- 弹窗赋值统一入口(先打开弹窗 → nextTick 内 resetFields 清空校验 → 再写入值) ----------------
|
||||
* 注意:view-ui-plus 的 resetFields() 会把所有字段重置回 mount 初值(parentId 会变成 null),
|
||||
* 因此必须先 resetFields,再赋值,否则值被清掉:
|
||||
* - 「父模块名称」整行因 v-if(parentId!=null) 不成立而消失
|
||||
* - 保存时子模块的 parentId 被清空、变成顶层模块 */
|
||||
function openEditModal(item: any) {
|
||||
currentId.value = item?.id ?? null;
|
||||
editModal.value = true;
|
||||
nextTick(() => {
|
||||
refFormData.value?.resetFields();
|
||||
const f = form.value;
|
||||
f.id = item?.id ?? null;
|
||||
f.name = item?.name || '';
|
||||
f.path = item?.path || '';
|
||||
f.customId = item?.customId != null ? Number(item.customId) : null;
|
||||
f.parentId = item?.parentId ?? null;
|
||||
f.sortNo = item?.sortNo != null ? Number(item.sortNo) : null;
|
||||
parentNameForDisplay.value = f.parentId != null
|
||||
? (parentNameMap.value[f.parentId] || '')
|
||||
: '';
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 新增模块 ---------------- */
|
||||
function handleAdd() {
|
||||
openingIsChild.value = false;
|
||||
openEditModal({ id: null, name: '', path: '', customId: null, parentId: null, sortNo: null });
|
||||
}
|
||||
|
||||
/* ---------------- 新增子模块 ---------------- */
|
||||
function handleAddChild(parent: any) {
|
||||
openingIsChild.value = true;
|
||||
openEditModal({ id: null, name: '', path: '', customId: null, parentId: parent.id, sortNo: null });
|
||||
}
|
||||
|
||||
/* ---------------- 编辑 ---------------- */
|
||||
function handleEdit(item: any) {
|
||||
openingIsChild.value = item.parentId != null;
|
||||
openEditModal(item);
|
||||
}
|
||||
|
||||
/* ---------------- 删除 ---------------- */
|
||||
async function handleDelete(item: any) {
|
||||
await MyConfirm({
|
||||
msg: `确定删除模块「${item.name || item.id}」吗?`,
|
||||
info: item._hasChildren ? "该模块下存在子模块,删除后子模块也将被删除!" : "删除后不可恢复!",
|
||||
async onOk() {
|
||||
try {
|
||||
await Apis.AppSettings.deleteModule({ pathParams: { id: item.id } });
|
||||
Message.success("删除成功!");
|
||||
await fetchModuleList();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 提交(防抖) ---------------- */
|
||||
const onSave = debounce(onRawSave, 500);
|
||||
|
||||
async function onRawSave() {
|
||||
try {
|
||||
const valid = await refFormData.value?.validate();
|
||||
if (!valid) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (currentId.value == null) {
|
||||
await Apis.AppSettings.addModule({
|
||||
data: {
|
||||
appId: appId.value,
|
||||
name: form.value.name,
|
||||
path: form.value.path,
|
||||
customId: form.value.customId == null ? '' : String(form.value.customId),
|
||||
parentId: form.value.parentId ?? undefined,
|
||||
sortNo: form.value.sortNo == null ? undefined : Number(form.value.sortNo),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await Apis.AppSettings.editModule({
|
||||
pathParams: { id: currentId.value },
|
||||
data: {
|
||||
id: currentId.value,
|
||||
appId: appId.value,
|
||||
name: form.value.name,
|
||||
path: form.value.path,
|
||||
customId: form.value.customId == null ? '' : String(form.value.customId),
|
||||
parentId: form.value.parentId ?? undefined,
|
||||
sortNo: form.value.sortNo == null ? undefined : Number(form.value.sortNo),
|
||||
},
|
||||
});
|
||||
}
|
||||
Message.success("操作成功!");
|
||||
editModal.value = false;
|
||||
await fetchModuleList();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
refFormData.value?.resetFields();
|
||||
currentId.value = null;
|
||||
editModal.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.module-table {
|
||||
:deep(.ivu-table-cell) {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.module-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.module-name-text {
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<Modal v-model="visible" title="批量添加" :width="520" :mask-closable="false">
|
||||
<div class="batch-body">
|
||||
<Form :label-width="90">
|
||||
<Form-Item label="父组ID:">
|
||||
<span class="batch-val">{{ defaultPid ?? '-' }}</span>
|
||||
</Form-Item>
|
||||
<Form-Item label="组类型:">
|
||||
<Select v-model="typeValue" placeholder="请选择组类型">
|
||||
<Option v-for="t in groupTypeOptions" :key="t.value" :value="t.value">{{ t.label }}</Option>
|
||||
</Select>
|
||||
</Form-Item>
|
||||
<Form-Item label="子组名称:">
|
||||
<Input v-model="rawNames" type="textarea" :rows="8" placeholder="一行一个子组名称" />
|
||||
</Form-Item>
|
||||
</Form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="loading" @click="submit">确认</Button>
|
||||
<Button @click="visible = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { useDefaultStore } from "@/store";
|
||||
import Apis from "@/api";
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false })
|
||||
|
||||
const props = defineProps<{
|
||||
treeData: any[]
|
||||
defaultPid?: number | null
|
||||
defaultType?: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ success: [] }>()
|
||||
|
||||
const store = useDefaultStore();
|
||||
const loading = ref(false)
|
||||
const rawNames = ref('')
|
||||
const typeValue = ref<number | null>(props.defaultType ?? null)
|
||||
|
||||
const groupTypeOptions = computed(() => {
|
||||
const dict: any = (store.getSysDict as any)?.grouptype || [];
|
||||
const map = (x: any) => ({ value: Number(x.key ?? x.k), label: String(x.value ?? x.v ?? x.name) });
|
||||
const list = Array.isArray(dict) ? dict.map(map) : Object.entries(dict).map(([k, v]) => ({ value: Number(k), label: String(v) }));
|
||||
if (list.length === 0) list.push({ value: 4, label: '班级组' });
|
||||
return list;
|
||||
})
|
||||
|
||||
function normalizeNames() {
|
||||
return rawNames.value
|
||||
.split(/\r?\n/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!props.defaultPid) {
|
||||
Message.warning('请先选择父组');
|
||||
return;
|
||||
}
|
||||
if (!typeValue.value) {
|
||||
Message.warning('请选择组类型');
|
||||
return;
|
||||
}
|
||||
const names = normalizeNames();
|
||||
if (!names.length) {
|
||||
Message.warning('请输入子组名称');
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
await Apis.SysGroup.batchAdd({
|
||||
data: {
|
||||
pid: props.defaultPid,
|
||||
type: typeValue.value,
|
||||
items: names.map((name) => ({ name })),
|
||||
}
|
||||
});
|
||||
Message.success('添加成功');
|
||||
emit('success');
|
||||
visible.value = false;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '添加失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, (v) => {
|
||||
if (v) {
|
||||
rawNames.value = '';
|
||||
typeValue.value = props.defaultType ?? groupTypeOptions.value[0]?.value ?? null;
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.batch-body {
|
||||
.batch-val {
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<Modal v-model="visible" title="移动组" :width="450" :mask-closable="false">
|
||||
<div class="move-body">
|
||||
<div class="move-row">
|
||||
<span class="move-label">待移动组:</span>
|
||||
<span class="move-path">{{ movePath }}</span>
|
||||
</div>
|
||||
<div class="move-row move-label">移动至:</div>
|
||||
<GroupTreeSearchPanel v-if="visible" v-model:curTreeNode="selectedNode" :treeApi="moveTreeApi"
|
||||
:defaultSearchVisible="true" :showCloseButton="false" @on-change="onPickTarget" />
|
||||
<div v-if="targetName" class="move-target">已选择目标:{{ targetName }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="loading" :disabled="!targetPid" @click="confirmMove">确认</Button>
|
||||
<Button @click="visible = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from "vue";
|
||||
import GroupTreeSearchPanel from '@/components/GroupTreeSearchPanel.vue'
|
||||
import { Message } from "view-ui-plus";
|
||||
import Apis from "@/api";
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false })
|
||||
const props = defineProps<{
|
||||
treeData: any[]
|
||||
moveId: number
|
||||
moveName: string
|
||||
movePath: string
|
||||
}>()
|
||||
const emit = defineEmits<{ saved: [payload: { movedId: number; newPid: number }] }>()
|
||||
|
||||
const selectedNode = ref<any>({})
|
||||
const targetPid = ref<number | null>(null)
|
||||
const targetName = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const moveTreeApi = async () => props.treeData
|
||||
|
||||
function findInTree(list: any[], id: number): any {
|
||||
for (const n of list || []) {
|
||||
if (n.id === id) return n;
|
||||
if (n.children?.length) {
|
||||
const r = findInTree(n.children, id);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isDescendant(node: any, candidateId: number): boolean {
|
||||
const stack = [...(node?.children || [])];
|
||||
while (stack.length) {
|
||||
const c = stack.pop();
|
||||
if (c.id === candidateId) return true;
|
||||
if (c.children?.length) stack.push(...c.children);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onPickTarget(node: any) {
|
||||
if (node.id === props.moveId) {
|
||||
Message.warning('不能移动到自身');
|
||||
return;
|
||||
}
|
||||
const src = findInTree(props.treeData, props.moveId);
|
||||
if (src && isDescendant(src, node.id)) {
|
||||
Message.warning('不能移动到自身的下级节点');
|
||||
return;
|
||||
}
|
||||
targetPid.value = node.id;
|
||||
targetName.value = node.name;
|
||||
}
|
||||
|
||||
async function confirmMove() {
|
||||
if (!targetPid.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
await Apis.SysGroup.move({ params: { moveId: props.moveId, pid: targetPid.value } });
|
||||
Message.success('移动成功');
|
||||
emit('saved', { movedId: props.moveId, newPid: targetPid.value });
|
||||
visible.value = false;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '移动失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, (v) => {
|
||||
if (v) {
|
||||
targetPid.value = null;
|
||||
targetName.value = '';
|
||||
selectedNode.value = {};
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.move-body {
|
||||
.move-row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.move-label {
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.move-path {
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.move-target {
|
||||
margin-top: 8px;
|
||||
color: #2d8cf0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ztree {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<Modal v-model="visible" title="子组排序" :width="520" :mask-closable="false">
|
||||
<Alert show-icon>拖动左侧手柄进行排序,排序结果会实时记录</Alert>
|
||||
<draggable class="sort-list" v-model="localChildren" item-key="id" handle=".drag-handle" @change="onDragChange">
|
||||
<template #item="{ element, index }">
|
||||
<div class="sort-item">
|
||||
<span class="drag-handle">⠿</span>
|
||||
<span class="sort-index">{{ index + 1 }}</span>
|
||||
<span class="sort-item-name">{{ element.name || element.id }}</span>
|
||||
<span class="sort-no">排序号: {{ element.sort ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<div class="sort-empty" v-if="localChildren.length === 0">
|
||||
暂无子组
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="saving" @click="saveSort" :disabled="localChildren.length === 0">
|
||||
保存排序
|
||||
</Button>
|
||||
<Button @click="visible = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from "vue";
|
||||
import draggable from "vuedraggable";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Apis from "@/api";
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false })
|
||||
|
||||
const props = defineProps<{
|
||||
parentId: number | null
|
||||
parentName: string
|
||||
children: any[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
|
||||
const localChildren = ref<any[]>([])
|
||||
const saving = ref(false)
|
||||
const hasChanged = ref(false)
|
||||
|
||||
function resetLocal() {
|
||||
localChildren.value = (props.children || []).map((x) => ({ ...x }))
|
||||
hasChanged.value = false
|
||||
}
|
||||
|
||||
function onDragChange() {
|
||||
hasChanged.value = true
|
||||
}
|
||||
|
||||
async function saveSort() {
|
||||
if (!props.parentId) {
|
||||
Message.warning('请先选择一个组')
|
||||
return
|
||||
}
|
||||
if (!hasChanged.value) {
|
||||
Message.info('排序未发生变化')
|
||||
visible.value = false
|
||||
return
|
||||
}
|
||||
const ids = localChildren.value.map((x) => Number(x.id)).filter((x) => Number.isFinite(x))
|
||||
saving.value = true
|
||||
try {
|
||||
await Apis.SysGroup.sortChildren({ params: { pid: props.parentId, ids } })
|
||||
Message.success('保存成功')
|
||||
hasChanged.value = false
|
||||
emit('saved')
|
||||
visible.value = false
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, (v) => {
|
||||
if (v) resetLocal()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sort-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sort-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 6px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f4ff;
|
||||
border-color: #c0d0ff;
|
||||
}
|
||||
}
|
||||
|
||||
/* 拖拽中:当前被拖拽的元素高亮 */
|
||||
.sort-list :deep(.sort-item.sortable-chosen),
|
||||
.sort-list :deep(.sort-item.sortable-drag) {
|
||||
background: #fff7e6;
|
||||
border-color: #fa8c16;
|
||||
box-shadow: 0 6px 16px rgba(250, 140, 22, 0.4);
|
||||
cursor: grabbing;
|
||||
|
||||
.drag-handle {
|
||||
color: #fa8c16;
|
||||
}
|
||||
}
|
||||
|
||||
/* 拖拽中:落点占位位置 */
|
||||
.sort-list :deep(.sort-item.sortable-ghost) {
|
||||
background: #fffbe6;
|
||||
border: 1px dashed #fa8c16;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
color: #999;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.sort-index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #577afb;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.sort-item-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort-loading,
|
||||
.sort-empty {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,398 @@
|
||||
<template>
|
||||
<div class="group-user-tree">
|
||||
<ul class="opera-ul">
|
||||
<li class="add-node-li" @click="showAddNode">
|
||||
<Tooltip content="新增子组" placement="top" transfer>
|
||||
<SvgIcon name="tree-icon-1" />
|
||||
</Tooltip>
|
||||
</li>
|
||||
<Tooltip content="编辑组" placement="top" transfer>
|
||||
<li @click="showEditNode">
|
||||
<SvgIcon name="tree-icon-2" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
<Tooltip content="子组排序" placement="top" transfer>
|
||||
<li @click="showSortNode">
|
||||
<SvgIcon name="tree-icon-3" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
<Tooltip content="移动组" placement="top" transfer>
|
||||
<li @click="showMoveNode">
|
||||
<SvgIcon name="tree-icon-4" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
<Tooltip content="删除组" placement="top" transfer>
|
||||
<li @click="showDeleteNode">
|
||||
<SvgIcon name="tree-icon-5" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
<Tooltip content="搜索组" placement="top" transfer>
|
||||
<li class="search-node-li" @click="refTreePanel?.openSearch()">
|
||||
<Icon type="md-search" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
</ul>
|
||||
|
||||
<GroupTreeSearchPanel ref="refTreePanel" v-model:curTreeNode="curTreeNode" :treeApi="treeApi"
|
||||
:defaultSearchVisible="false" :showCloseButton="true" @on-change="treeLink" @on-tree-data="onTreeData" />
|
||||
|
||||
<Modal v-model="editNodeModal.modal" :title="editNodeModal.title" :width="450" :mask-closable="false">
|
||||
<Form ref="refFormData" :label-width="100" :model="editNodeModal" class="myform mb-20 mr-20">
|
||||
<Form-Item label="当前位置:">
|
||||
{{ curNodePath.join(' > ') }}
|
||||
</Form-Item>
|
||||
<Form-Item label="组类型:" prop="type"
|
||||
:rules="{ required: true, type: 'number', message: '请选择组类型', trigger: 'change' }">
|
||||
<Select v-model="editNodeModal.type" placeholder="请选择组类型" transfer>
|
||||
<Option v-for="t in groupTypeOptions" :key="t.value" :value="t.value">{{ t.label }}</Option>
|
||||
</Select>
|
||||
</Form-Item>
|
||||
<Form-Item label="节点名称:" prop="name" :rules="{ required: true, message: '请输入节点名称', trigger: 'blur' }">
|
||||
<Input ref="refNodeNameInput" maxlength="30" v-model="editNodeModal.name" />
|
||||
</Form-Item>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="link" v-if="!editNodeModal.id" @click="switchToBatch">切换至批量添加</Button>
|
||||
<Button type="primary" :loading="editNodeModal.loading" @click="saveEditNode">确认</Button>
|
||||
<Button @click="editNodeModal.modal = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<BatchAddGroup v-model:visible="batchModal" :tree-data="treeData" :default-pid="curTreeNode?.id"
|
||||
:default-type="batchDefaultType" @success="onBatchSuccess" />
|
||||
|
||||
<GroupSort v-model:visible="sortModal" :parent-id="sortParentId" :parent-name="sortParentName"
|
||||
:children="sortChildrenSource" @saved="onSortSaved" />
|
||||
|
||||
<GroupMove v-model:visible="moveModal" :tree-data="treeData" :move-id="moveNodeId" :move-name="moveNodeName"
|
||||
:move-path="moveNodePath" @saved="onMoveSaved" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, onMounted, reactive, ref, useTemplateRef } from "vue";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { Message } from "view-ui-plus";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import GroupTreeSearchPanel from '@/components/GroupTreeSearchPanel.vue'
|
||||
import { useDefaultStore } from "@/store";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import Apis from "@/api";
|
||||
import BatchAddGroup from "./BatchAddGroup.vue";
|
||||
import GroupSort from "./GroupSort.vue";
|
||||
import GroupMove from "./GroupMove.vue";
|
||||
import Utils from "@/utils/index.ts";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
treeApi?: () => Promise<any[]>;
|
||||
}>(), {
|
||||
treeApi: () => Apis.SysGroup.getTree(),
|
||||
})
|
||||
const treeApi = props.treeApi
|
||||
|
||||
const emit = defineEmits<{
|
||||
'on-change': [treeNode: any]
|
||||
'on-tree-data': [tree: any[]]
|
||||
}>()
|
||||
|
||||
const curTreeNode = defineModel<any>('curTreeNode', { default: {} })
|
||||
|
||||
const store = useDefaultStore();
|
||||
|
||||
const refTreePanel = useTemplateRef('refTreePanel');
|
||||
const refFormData = useTemplateRef('refFormData')
|
||||
const refNodeNameInput = useTemplateRef('refNodeNameInput')
|
||||
|
||||
const treeData = ref<any[]>([])
|
||||
|
||||
const batchModal = ref(false)
|
||||
const batchDefaultType = ref<number | null>(null)
|
||||
|
||||
const sortModal = ref(false)
|
||||
const sortParentId = ref<number | null>(null)
|
||||
const sortParentName = ref('')
|
||||
const sortChildrenSource = ref<any[]>([])
|
||||
|
||||
const moveModal = ref(false)
|
||||
const moveNodeId = ref<number | null>(null)
|
||||
const moveNodeName = ref('')
|
||||
const moveNodePath = ref('')
|
||||
|
||||
const editNodeModal = reactive({
|
||||
modal: false,
|
||||
title: '',
|
||||
loading: false,
|
||||
name: null as any,
|
||||
id: null as any,
|
||||
parentId: null as any,
|
||||
type: null as any,
|
||||
})
|
||||
|
||||
const groupTypeOptions = computed(() => {
|
||||
const dict: any = (store.getSysDict as any)?.grouptype || [];
|
||||
const map = (x: any) => ({ value: Number(x.key ?? x.k), label: String(x.value ?? x.v ?? x.name) });
|
||||
const list = Array.isArray(dict) ? dict.map(map) : Object.entries(dict).map(([k, v]) => ({ value: Number(k), label: String(v) }));
|
||||
if (list.length === 0) list.push({ value: 4, label: '班级组' });
|
||||
return list;
|
||||
})
|
||||
|
||||
const curNodePath = computed(() => {
|
||||
if (!curTreeNode.value || !curTreeNode.value.getPath) return [];
|
||||
return curTreeNode.value.getPath().map((x: any) => x.name)
|
||||
})
|
||||
|
||||
function onTreeData(tree: any[]) {
|
||||
treeData.value = tree;
|
||||
emit('on-tree-data', cloneDeep(tree));
|
||||
}
|
||||
|
||||
function treeLink(treeNode: any) {
|
||||
const copyNode = cloneDeep(treeNode)
|
||||
copyNode.children = [];
|
||||
curTreeNode.value = copyNode;
|
||||
emit('on-change', copyNode)
|
||||
}
|
||||
|
||||
function currentNode() {
|
||||
return curTreeNode.value?.id ? curTreeNode.value : (treeData.value[0] || {});
|
||||
}
|
||||
|
||||
async function showAddNode() {
|
||||
const node = currentNode();
|
||||
editNodeModal.title = '新增子组';
|
||||
refFormData.value?.resetFields();
|
||||
await Utils.sleep(0);
|
||||
editNodeModal.modal = true;
|
||||
editNodeModal.id = null;
|
||||
editNodeModal.name = null;
|
||||
editNodeModal.parentId = node?.id ?? null;
|
||||
editNodeModal.type = node?.type ?? groupTypeOptions.value[0]?.value ?? null;
|
||||
setTimeout(() => {
|
||||
|
||||
refNodeNameInput.value?.focus();
|
||||
}, 300);
|
||||
|
||||
}
|
||||
|
||||
async function showEditNode() {
|
||||
const node = currentNode();
|
||||
editNodeModal.title = '编辑组';
|
||||
refFormData.value?.resetFields();
|
||||
await Utils.sleep(0);
|
||||
editNodeModal.modal = true;
|
||||
editNodeModal.id = node?.id ?? null;
|
||||
editNodeModal.name = node?.name ?? '';
|
||||
editNodeModal.parentId = node?.parentId ?? null;
|
||||
editNodeModal.type = node?.type ?? null;
|
||||
|
||||
nextTick().then(() => refNodeNameInput.value?.focus())
|
||||
}
|
||||
|
||||
function switchToBatch() {
|
||||
batchDefaultType.value = editNodeModal.type ?? null;
|
||||
editNodeModal.modal = false;
|
||||
batchModal.value = true;
|
||||
}
|
||||
|
||||
function findNode(list: any[], id: number): any {
|
||||
for (const n of list || []) {
|
||||
if (n.id === id) return n;
|
||||
if (n.children?.length) {
|
||||
const r = findNode(n.children, id);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function showSortNode() {
|
||||
const node = currentNode();
|
||||
if (!node?.id) {
|
||||
Message.warning('请先选择一个组');
|
||||
return;
|
||||
}
|
||||
const found = findNode(treeData.value, node.id);
|
||||
sortChildrenSource.value = found?.children ?? [];
|
||||
sortParentId.value = node.id;
|
||||
sortParentName.value = node.name ?? '';
|
||||
sortModal.value = true;
|
||||
}
|
||||
|
||||
function showMoveNode() {
|
||||
const node = currentNode();
|
||||
if (!node?.id) {
|
||||
Message.warning('请先选择一个组');
|
||||
return;
|
||||
}
|
||||
moveNodeId.value = node.id;
|
||||
moveNodeName.value = node.name ?? '';
|
||||
moveNodePath.value = node.getPath ? node.getPath().map((x: any) => x.name).join('/') : (node.name ?? '');
|
||||
moveModal.value = true;
|
||||
}
|
||||
|
||||
function detachNode(list: any[], id: number): any {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (list[i].id === id) {
|
||||
const [n] = list.splice(i, 1);
|
||||
return n;
|
||||
}
|
||||
if (list[i].children?.length) {
|
||||
const r = detachNode(list[i].children, id);
|
||||
if (r) {
|
||||
if (list[i].children.length === 0) {
|
||||
delete list[i].children;
|
||||
list[i].isParent = false;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function attachNode(list: any[], pid: number, node: any): boolean {
|
||||
for (const n of list) {
|
||||
if (n.id === pid) {
|
||||
n.children = n.children || [];
|
||||
n.children.push(node);
|
||||
n.isParent = true;
|
||||
return true;
|
||||
}
|
||||
if (n.children?.length && attachNode(n.children, pid, node)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onMoveSaved({ movedId, newPid }: { movedId: number; newPid: number }) {
|
||||
const full = findNode(treeData.value, movedId);
|
||||
const isParent = !!(full?.children && full.children.length);
|
||||
const name = full?.name ?? moveNodeName.value;
|
||||
const children = isParent ? cloneDeep(full?.children) : undefined;
|
||||
|
||||
const detached = detachNode(treeData.value, movedId);
|
||||
if (detached) {
|
||||
attachNode(treeData.value, newPid, { ...detached, isParent, children: isParent ? detached.children : undefined });
|
||||
}
|
||||
|
||||
refTreePanel.value?.removeNode(movedId);
|
||||
refTreePanel.value?.addNote(newPid, movedId, name, isParent, children);
|
||||
refTreePanel.value?.selectNode(newPid, true);
|
||||
}
|
||||
|
||||
function showDeleteNode() {
|
||||
const node = currentNode();
|
||||
if (!node?.id) {
|
||||
Message.warning('请先选择一个组');
|
||||
return;
|
||||
}
|
||||
MyConfirm({
|
||||
title: '删除组',
|
||||
msg: `确定删除名为【${node?.name}】的组?`,
|
||||
info: '删除后不可恢复!',
|
||||
async onOk() {
|
||||
try {
|
||||
await Apis.SysGroup.delete({ pathParams: { id: node?.id } });
|
||||
Message.success('删除成功!');
|
||||
refTreePanel.value?.removeNode(node?.id);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function saveEditNode() {
|
||||
const validate = await refFormData.value?.validate();
|
||||
if (!validate) {
|
||||
Message.error('请填写红色星号内容!');
|
||||
return;
|
||||
}
|
||||
const { id, name } = editNodeModal;
|
||||
editNodeModal.loading = true;
|
||||
try {
|
||||
if (!id) {
|
||||
const parentId = editNodeModal.parentId;
|
||||
const data: any = { name, parentId, type: Number(editNodeModal.type) };
|
||||
await Apis.SysGroup.add({ data });
|
||||
Message.success('新增成功');
|
||||
await refTreePanel.value?.refreshTree(parentId);
|
||||
} else {
|
||||
await Apis.SysGroup.edit({ pathParams: { id }, data: { name, type: Number(editNodeModal.type) } });
|
||||
Message.success('编辑成功');
|
||||
await refTreePanel.value?.refreshTree(id);
|
||||
}
|
||||
editNodeModal.modal = false;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '保存失败');
|
||||
} finally {
|
||||
editNodeModal.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onBatchSuccess() {
|
||||
refTreePanel.value?.refreshTree(curTreeNode.value?.id);
|
||||
}
|
||||
|
||||
function onSortSaved() {
|
||||
refTreePanel.value?.refreshTree(curTreeNode.value?.id);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
refTreePanel.value?.refreshTree(curTreeNode.value?.id);
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.group-user-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.opera-ul {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
padding-bottom: 10px;
|
||||
|
||||
li {
|
||||
padding: 10px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
|
||||
&.active,
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.ivu-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
li.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.add-node-li {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-node-li {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="page-wrapper tree-wrapper">
|
||||
<Group class="tree-container" v-model:curTreeNode="selectedNode" :treeApi="treeApi" @on-change="onTreeChange"
|
||||
@on-tree-data="onTreeData" />
|
||||
<div class="main-container">
|
||||
<div class="search-wrap">
|
||||
<Button type="primary" @click="showAddUser">
|
||||
<Icon type="md-add" style="margin-right: 4px" />新增用户
|
||||
</Button>
|
||||
|
||||
<Input placeholder="输入用户名 / 真实姓名搜索" class="fr" search clearable suffix="ios-search" style="width: 220px"
|
||||
v-model="keyword" @on-search="onSearch" @on-clear="onSearch" />
|
||||
</div>
|
||||
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
||||
|
||||
<!-- 容器组件:新增 / 编辑 -->
|
||||
<AddUserContainer ref="addRef" />
|
||||
<EditUserContainer ref="editRef" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from "vue";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||
import { OperatorEnum } from '@/types';
|
||||
import Group from './group/index.vue'
|
||||
import AddUserContainer from './user/AddUserContainer.vue';
|
||||
import EditUserContainer from './user/EditUserContainer.vue';
|
||||
|
||||
defineOptions({ name: "GroupUser" });
|
||||
|
||||
const { $utils } = useCurrentInstance();
|
||||
|
||||
const treeApi = () => Apis.SysGroup.getTree();
|
||||
|
||||
/* ================= 组织树 ================= */
|
||||
const selectedNode = ref<any>({})
|
||||
const treeData = ref<any[]>([])
|
||||
|
||||
function onTreeChange(node: any) {
|
||||
selectedNode.value = node;
|
||||
dataParams.value.groupId = node?.id ?? null;
|
||||
tableRef.value?.refreshData();
|
||||
}
|
||||
function onTreeData(tree: any[]) {
|
||||
treeData.value = tree;
|
||||
}
|
||||
function onSearch() {
|
||||
dataParams.value.name = keyword.value.trim() || undefined;
|
||||
tableRef.value?.refreshData();
|
||||
}
|
||||
|
||||
/* ================= 所在组下拉(拍平组织树) ================= */
|
||||
const groupOptions = computed(() => {
|
||||
const flat: { id: number; name: string }[] = [];
|
||||
const walk = (list: any[], prefix = '') => {
|
||||
list.forEach(n => {
|
||||
flat.push({ id: n.id, name: prefix ? `${prefix} / ${n.name}` : n.name });
|
||||
if (n.children?.length) walk(n.children, prefix ? `${prefix} / ${n.name}` : n.name);
|
||||
});
|
||||
};
|
||||
walk(treeData.value);
|
||||
return flat;
|
||||
})
|
||||
|
||||
/* ================= 表格数据源 ================= */
|
||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
const keyword = ref('')
|
||||
const dataParams = ref<{
|
||||
name: string | undefined;
|
||||
groupId: number | null;
|
||||
}>({
|
||||
name: undefined,
|
||||
groupId: null,
|
||||
});
|
||||
|
||||
const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
||||
return Apis.SysUser.list({
|
||||
params: {
|
||||
name: dataParams.value.name,
|
||||
groupId: dataParams.value.groupId ?? undefined,
|
||||
// 选中组织时包含子组用户;未选中时不传
|
||||
withSub: dataParams.value.groupId != null ? 1 : undefined,
|
||||
pageIndex: params.pageIndex,
|
||||
pageSize: params.pageSize,
|
||||
},
|
||||
cacheFor: null,
|
||||
});
|
||||
};
|
||||
|
||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }))
|
||||
|
||||
/* ================= 用户身份(roles[])展示 ================= */
|
||||
const userTypeMap: Record<number, string> = {
|
||||
1: '管理员', 2: '老师', 3: '校领导', 4: '局领导',
|
||||
5: '学生', 6: '家长', 7: '工作号',
|
||||
};
|
||||
const roleMap: Record<number, string> = {
|
||||
1: '超级管理员', 2: '学校管理员', 3: '普通用户',
|
||||
};
|
||||
function describeRoles(roles: any): string {
|
||||
if (!Array.isArray(roles) || roles.length === 0) return '-';
|
||||
const userTypes: string[] = [];
|
||||
const specificRoles: string[] = [];
|
||||
roles.forEach((r: any) => {
|
||||
const n = Number(r);
|
||||
if (userTypeMap[n]) userTypes.push(userTypeMap[n]);
|
||||
else if (roleMap[n]) specificRoles.push(roleMap[n]);
|
||||
});
|
||||
const parts: string[] = [];
|
||||
if (userTypes.length) parts.push(userTypes.join('/'));
|
||||
if (specificRoles.length) parts.push(specificRoles.join('/'));
|
||||
return parts.length ? parts.join(' · ') : '-';
|
||||
}
|
||||
|
||||
/* ================= 表格列 ================= */
|
||||
const columns = [
|
||||
{ type: 'index', title: '序号', width: 70, align: 'center' },
|
||||
{ title: '用户名', key: 'account', minWidth: 120 },
|
||||
{ title: '真实姓名', key: 'realName', minWidth: 120 },
|
||||
{ title: '手机号', key: 'phone', minWidth: 140 },
|
||||
{
|
||||
title: '用户身份', key: 'roles', minWidth: 180,
|
||||
render: (h: any, { row }: any) => h('span', describeRoles(row.roles)),
|
||||
},
|
||||
{
|
||||
title: '状态', key: 'disabled', width: 90, align: 'center',
|
||||
render: (h: any, { row }: any) =>
|
||||
h('Tag', { color: row.disabled ? 'default' : 'success' },
|
||||
row.disabled ? '已禁用' : '启用'),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 220, align: 'center', fixed: 'right' as const,
|
||||
render: (h: any, { row }: any) => {
|
||||
const opList: any[] = [
|
||||
h('a', {
|
||||
style: { marginRight: '8px', color: '#577afb' },
|
||||
onClick: () => handleEdit(row),
|
||||
}, '编辑'),
|
||||
h('a', {
|
||||
style: { marginRight: '8px', color: '#ed4014' },
|
||||
onClick: () => handleDelete(row),
|
||||
}, '删除'),
|
||||
h('a', {
|
||||
style: { color: row.disabled ? '#19be6b' : '#ff9900' },
|
||||
onClick: () => handleToggleStatus(row),
|
||||
}, row.disabled ? '启用' : '禁用'),
|
||||
];
|
||||
return h('div', opList);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/* ================= 新增 ================= */
|
||||
const addRef = ref();
|
||||
function showAddUser() {
|
||||
const curGroupId = selectedNode.value?.id ?? null;
|
||||
addRef.value?.open(groupOptions.value, curGroupId, () => {
|
||||
tableRef.value?.reloadData(OperatorEnum.add);
|
||||
});
|
||||
}
|
||||
|
||||
/* ================= 编辑 ================= */
|
||||
const editRef = ref();
|
||||
function handleEdit(row: any) {
|
||||
editRef.value?.open(row, groupOptions.value, () => {
|
||||
tableRef.value?.reloadData(OperatorEnum.edit);
|
||||
});
|
||||
}
|
||||
|
||||
/* ================= 删除 ================= */
|
||||
async function handleDelete(row: any) {
|
||||
await MyConfirm({
|
||||
msg: `确定删除用户「${row.realName || row.account}」吗?`,
|
||||
info: '删除后不可恢复!',
|
||||
async onOk() {
|
||||
try {
|
||||
await Apis.SysUser.delete({ pathParams: { id: row.id } });
|
||||
Message.success('删除成功!');
|
||||
tableRef.value?.reloadData(OperatorEnum.del);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ================= 禁用 / 启用 ================= */
|
||||
async function handleToggleStatus(row: any) {
|
||||
const isDisabled = !!row.disabled;
|
||||
await MyConfirm({
|
||||
msg: `确定要${isDisabled ? '启用' : '禁用'}用户「${row.realName || row.account}」吗?`,
|
||||
async onOk() {
|
||||
try {
|
||||
if (isDisabled) {
|
||||
await Apis.SysUser.enable({ pathParams: { id: row.id } });
|
||||
} else {
|
||||
await Apis.SysUser.disable({ pathParams: { id: row.id } });
|
||||
}
|
||||
Message.success(isDisabled ? '已启用!' : '已禁用!');
|
||||
tableRef.value?.reloadData(OperatorEnum.sel);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '操作失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<UserForm v-model="visible" :initForm="initFormData" :groupOptions="groupOptions" :isEdit="false"
|
||||
:loading="submitting" @submit="handleSubmit" @cancel="visible = false" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import UserForm from "./UserForm.vue";
|
||||
|
||||
/* ---------- 对外暴露:由父组件(group-user/index)调用 ---------- */
|
||||
const visible = ref(false);
|
||||
|
||||
// 所在组下拉数据(拍平后的组织树)
|
||||
const groupOptions = ref<any[]>([]);
|
||||
|
||||
// 新增模式:始终传空初始值;若带 defaultGroupId 则预填 groupId
|
||||
const initFormData = ref<Record<string, any>>({});
|
||||
|
||||
// 成功后回调
|
||||
let onSuccessCallback: (() => void) | null = null;
|
||||
|
||||
// useForm 托管提交:loading=submitting、sendForm 发请求、onSuccess/onError 处理结果
|
||||
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
||||
(dto) => Apis.SysUser.add({ data: dto }),
|
||||
{ initialForm: { account: '', realName: '', password: '', confirmPassword: '', idcard: '', phone: '', phone2: '', gender: 0, groupId: undefined, userType: [], role: undefined }, resetAfterSubmiting: true }
|
||||
);
|
||||
|
||||
onSuccess(() => {
|
||||
Message.success('新增用户成功!');
|
||||
visible.value = false;
|
||||
onSuccessCallback?.();
|
||||
});
|
||||
onError((event) => {
|
||||
Message.error(event?.error?.message || '新增失败');
|
||||
});
|
||||
|
||||
/* ---------- 打开弹窗 ---------- */
|
||||
function open(groups: any[], defaultGroupId?: number | null, onSuccess?: () => void) {
|
||||
groupOptions.value = groups || [];
|
||||
// 若已选中某个组织节点,新增弹窗自动带入该组;未选时不带入
|
||||
initFormData.value = defaultGroupId != null ? { groupId: defaultGroupId } : {};
|
||||
onSuccessCallback = onSuccess || null;
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
/* ---------- 提交:由 useForm 接管 loading 与异常处理 ---------- */
|
||||
async function handleSubmit(formData: Record<string, any>) {
|
||||
form.value = buildPayload(formData);
|
||||
await sendForm();
|
||||
}
|
||||
|
||||
/**
|
||||
* 把表单字段映射成 SysUserRequest:
|
||||
* - 只提交后端 SysUserRequest 里声明的字段
|
||||
* - "用户身份"(userType checkbox) + "角色属性"(role select) 合并为 roles[](去重)
|
||||
*/
|
||||
function buildPayload(formData: Record<string, any>) {
|
||||
const roleSet = new Set<number>();
|
||||
(formData.userType || []).forEach((v: any) => roleSet.add(Number(v)));
|
||||
if (formData.role != null && formData.role !== '') roleSet.add(Number(formData.role));
|
||||
|
||||
return {
|
||||
account: formData.account,
|
||||
password: formData.password,
|
||||
confirmPassword: formData.confirmPassword,
|
||||
realName: formData.realName,
|
||||
idcard: formData.idcard || undefined,
|
||||
phone: formData.phone,
|
||||
groupId: formData.groupId,
|
||||
roles: Array.from(roleSet),
|
||||
};
|
||||
}
|
||||
|
||||
defineExpose({ open, visible });
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<UserForm v-model="visible" :initForm="detailData" :groupOptions="groupOptions" :isEdit="true"
|
||||
:loading="submitting" @submit="handleSubmit" @cancel="visible = false" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import UserForm from "./UserForm.vue";
|
||||
|
||||
/* ---------- 对外暴露 ---------- */
|
||||
const visible = ref(false);
|
||||
const groupOptions = ref<any[]>([]);
|
||||
|
||||
// 编辑模式:当前编辑的用户 ID
|
||||
const currentId = ref<number | null>(null);
|
||||
// 编辑模式:回填数据(来自当前行,辅以 roles[] 拆分到 userType/role)
|
||||
const detailData = ref<Record<string, any>>({});
|
||||
|
||||
// 成功后回调
|
||||
let onSuccessCallback: (() => void) | null = null;
|
||||
|
||||
// useForm 托管提交——currentId 是 ref,闭包内 .value 在 sendForm 时取当前值
|
||||
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
||||
(dto) => Apis.SysUser.edit({ pathParams: { id: currentId.value }, data: dto }),
|
||||
{ initialForm: { id: null, account: '', realName: '', password: '', confirmPassword: '', idcard: '', phone: '', phone2: '', gender: 0, groupId: undefined, userType: [], role: undefined }, resetAfterSubmiting: true }
|
||||
);
|
||||
|
||||
onSuccess(() => {
|
||||
Message.success('编辑用户成功!');
|
||||
visible.value = false;
|
||||
onSuccessCallback?.();
|
||||
});
|
||||
onError((event) => {
|
||||
Message.error(event?.error?.message || '编辑失败');
|
||||
});
|
||||
|
||||
/* ---------- 打开编辑弹窗 ---------- */
|
||||
/**
|
||||
* @param row - 当前行数据(来自 SysUser.list 接口返回的 SysUserVo)
|
||||
* @param groups - 所在组下拉(拍平的组织树)
|
||||
* @param onSuccess - 编辑成功后的回调
|
||||
*/
|
||||
function open(row: any, groups: any[], onSuccess?: () => void) {
|
||||
if (!row?.id) {
|
||||
Message.error('用户 ID 不存在');
|
||||
return;
|
||||
}
|
||||
currentId.value = row.id;
|
||||
groupOptions.value = groups || [];
|
||||
detailData.value = buildInitForm(row);
|
||||
onSuccessCallback = onSuccess || null;
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
/* ---------- 把行数据映射成表单回填数据 ---------- */
|
||||
function buildInitForm(row: any) {
|
||||
const roles: number[] = Array.isArray(row.roles) ? row.roles : [];
|
||||
// 用户身份(业务身份)= 1..7;角色属性(具体角色)= 1..3
|
||||
const userType = roles.filter(r => r >= 1 && r <= 7);
|
||||
const role = roles.filter(r => r >= 1 && r <= 3).slice(-1)[0];
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
account: row.account || '',
|
||||
realName: row.realName || '',
|
||||
idcard: row.idcard || '',
|
||||
phone: row.phone || '',
|
||||
groupId: row.groupId,
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
phone2: '',
|
||||
gender: 0,
|
||||
userType,
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- 提交:由 useForm 接管 loading 与异常处理 ---------- */
|
||||
async function handleSubmit(formData: Record<string, any>) {
|
||||
if (!currentId.value) return;
|
||||
form.value = buildPayload(formData, currentId.value);
|
||||
await sendForm();
|
||||
}
|
||||
|
||||
/**
|
||||
* 把表单字段映射成 SysUserRequest:
|
||||
* - 只提交后端 SysUserRequest 里声明的字段
|
||||
* - "用户身份"(userType checkbox) + "角色属性"(role select) 合并为 roles[](去重)
|
||||
* - 编辑时密码留空表示不修改,所以密码字段不传
|
||||
*/
|
||||
function buildPayload(formData: Record<string, any>, id: number) {
|
||||
const roleSet = new Set<number>();
|
||||
(formData.userType || []).forEach((v: any) => roleSet.add(Number(v)));
|
||||
if (formData.role != null && formData.role !== '') roleSet.add(Number(formData.role));
|
||||
|
||||
const payload: any = {
|
||||
id,
|
||||
account: formData.account,
|
||||
realName: formData.realName,
|
||||
idcard: formData.idcard || undefined,
|
||||
phone: formData.phone,
|
||||
groupId: formData.groupId,
|
||||
roles: Array.from(roleSet),
|
||||
};
|
||||
// 编辑时若填写了密码才提交(密码为空表示不修改)
|
||||
if (formData.password) {
|
||||
payload.password = formData.password;
|
||||
payload.confirmPassword = formData.confirmPassword;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
defineExpose({ open, visible });
|
||||
</script>
|
||||
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<Modal v-model="show" :mask-closable="false" width="800" scrollable :title="title"
|
||||
@on-visible-change="onVisibleChange">
|
||||
<Form ref="formRef" :model="form" :rules="rules" :label-width="130" class="user-form width-95">
|
||||
<!-- 第 1 行:用户名 / 真实姓名 -->
|
||||
<Row>
|
||||
<Col span="12">
|
||||
<FormItem label="用户名:" prop="account">
|
||||
<Input v-model="form.account" :disabled="isEdit" placeholder="请输入用户名" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="真实姓名:" prop="realName">
|
||||
<Input v-model="form.realName" placeholder="请输入真实姓名" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第 2 行:密码 / 确认密码(编辑时变为「重置密码」语义,但视觉一致) -->
|
||||
<Row>
|
||||
<Col span="12">
|
||||
<FormItem label="密码:" prop="password">
|
||||
<Input v-model="form.password" type="password" :placeholder="isEdit ? '不修改请留空' : '请输入密码'" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="确认密码:" prop="confirmPassword">
|
||||
<Input v-model="form.confirmPassword" type="password"
|
||||
:placeholder="isEdit ? '不修改请留空' : '请再次确认密码'" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第 3 行:身份证号 / 手机号码 -->
|
||||
<Row>
|
||||
<Col span="12">
|
||||
<FormItem label="身份证号:" prop="idcard">
|
||||
<Input v-model="form.idcard" placeholder="请输入身份证号" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="手机号码:" prop="phone">
|
||||
<Input v-model="form.phone" placeholder="请输入手机号码" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第 4 行:手机号码2 / 性别 -->
|
||||
<Row>
|
||||
<Col span="12">
|
||||
<FormItem label="手机号码2:">
|
||||
<Input v-model="form.phone2" placeholder="请输入手机号码" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="性别:">
|
||||
<RadioGroup v-model="form.gender">
|
||||
<Radio :label="0">女</Radio>
|
||||
<Radio :label="1">男</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第 5 行:所在组(整行) -->
|
||||
<Row>
|
||||
<Col span="24">
|
||||
<FormItem label="所在组:" prop="groupId">
|
||||
<Select v-model="form.groupId" placeholder="请选择所在组" filterable transfer>
|
||||
<Option v-for="g in groupOptions" :key="g.id" :value="g.id" :label="g.name" />
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第 6 行:用户身份(checkbox 多选,整行) -->
|
||||
<Row>
|
||||
<Col span="24">
|
||||
<FormItem label="用户身份:" prop="userType">
|
||||
<CheckboxGroup v-model="form.userType" class="user-type-group">
|
||||
<Checkbox v-for="opt in userTypeOptions" :key="opt.value" :label="opt.value">
|
||||
{{ opt.label }}
|
||||
</Checkbox>
|
||||
</CheckboxGroup>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第 7 行:角色属性(整行) -->
|
||||
<Row>
|
||||
<Col span="24">
|
||||
<FormItem label="角色属性:" prop="role">
|
||||
<Select v-model="form.role" placeholder="请选择" clearable transfer style="width: 220px">
|
||||
<Option v-for="r in roleOptions" :key="r.value" :value="r.value" :label="r.label" />
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="loading" @click="handleSubmit">提交</Button>
|
||||
<Button @click="handleCancel">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from "vue";
|
||||
|
||||
/* ---------- Props / Emits ---------- */
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean;
|
||||
initForm?: Record<string, any>;
|
||||
groupOptions?: any[];
|
||||
isEdit?: boolean;
|
||||
loading?: boolean;
|
||||
}>(), {
|
||||
initForm: () => ({}),
|
||||
groupOptions: () => [],
|
||||
isEdit: false,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'submit', 'cancel']);
|
||||
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: val => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const title = computed(() => props.isEdit ? '编辑用户' : '新增用户');
|
||||
|
||||
/* ---------- 表单数据 ---------- */
|
||||
const defaultForm = (): Record<string, any> => ({
|
||||
id: null,
|
||||
account: '',
|
||||
realName: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
idcard: '',
|
||||
phone: '',
|
||||
phone2: '',
|
||||
gender: 0,
|
||||
groupId: undefined as number | undefined,
|
||||
userType: [] as number[],
|
||||
role: undefined as number | undefined,
|
||||
});
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
|
||||
// 外部 initForm 变化时回填
|
||||
watch(() => props.initForm, (val) => {
|
||||
if (val && Object.keys(val).length > 0) {
|
||||
Object.assign(form, defaultForm(), val);
|
||||
} else {
|
||||
Object.assign(form, defaultForm());
|
||||
}
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
/* ---------- 用户身份(业务身份多选)---------- */
|
||||
const userTypeOptions = [
|
||||
{ value: 1, label: '管理员' },
|
||||
{ value: 2, label: '老师' },
|
||||
{ value: 3, label: '校领导' },
|
||||
{ value: 4, label: '局领导' },
|
||||
{ value: 5, label: '学生' },
|
||||
{ value: 6, label: '家长' },
|
||||
{ value: 7, label: '工作号' },
|
||||
];
|
||||
|
||||
/* ---------- 角色属性(具体角色单选,固定枚举)---------- */
|
||||
const roleOptions = [
|
||||
{ value: 1, label: '超级管理员' },
|
||||
{ value: 2, label: '学校管理员' },
|
||||
{ value: 3, label: '普通用户' },
|
||||
];
|
||||
|
||||
/* ---------- 校验规则 ---------- */
|
||||
// 自定义确认密码校验
|
||||
const validateConfirmPassword = (rule: any, value: string, callback: any) => {
|
||||
if (!props.isEdit) {
|
||||
// 新增:必填 + 必须与密码一致
|
||||
if (!value) {
|
||||
callback(new Error('请再次输入密码'));
|
||||
} else if (value !== form.password) {
|
||||
callback(new Error('两次输入的密码不一致'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
// 编辑:留空表示不修改密码;非空时必须与密码一致
|
||||
if (value && value !== form.password) {
|
||||
callback(new Error('两次输入的密码不一致'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 自定义密码校验(编辑时可留空)
|
||||
const validatePassword = (rule: any, value: string, callback: any) => {
|
||||
if (!props.isEdit && !value) {
|
||||
callback(new Error('请输入密码'));
|
||||
} else if (value && value.length < 6) {
|
||||
callback(new Error('密码长度不能少于 6 位'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const rules = {
|
||||
account: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 30, message: '用户名长度 3-30 位', trigger: 'blur' },
|
||||
],
|
||||
realName: [
|
||||
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
|
||||
],
|
||||
password: [{ validator: validatePassword, trigger: 'blur' }],
|
||||
confirmPassword: [{ validator: validateConfirmPassword, trigger: 'blur' }],
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号码', trigger: 'blur' },
|
||||
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号', trigger: 'blur' },
|
||||
],
|
||||
groupId: [
|
||||
{ required: true, type: 'number' as const, message: '请选择所在组', trigger: 'change' },
|
||||
],
|
||||
userType: [
|
||||
{ required: true, type: 'array' as const, min: 1, message: '请选择至少一个用户身份', trigger: 'change' },
|
||||
],
|
||||
role: [
|
||||
{ required: true, type: 'number' as const, message: '请选择角色属性', trigger: 'change' },
|
||||
],
|
||||
};
|
||||
|
||||
/* ---------- 提交 ---------- */
|
||||
const formRef = ref();
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await formRef.value?.validate();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
emit('submit', { ...form });
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel');
|
||||
}
|
||||
|
||||
function onVisibleChange(val: boolean) {
|
||||
if (!val) {
|
||||
emit('cancel');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-form {
|
||||
margin: 0 auto;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 12px;
|
||||
|
||||
:deep(.ivu-form-item) {
|
||||
display: block;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.ivu-form-item-label) {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
}
|
||||
|
||||
:deep(.ivu-row) {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
:deep(.ivu-row:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 用户身份多选:横向换行,避免撑爆 Col */
|
||||
.user-type-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<Modal v-model="show" :mask-closable="false" width="700" title="查看日志详情" @on-visible-change="onVisibleChange">
|
||||
<Form :label-width="120" class="oplog-detail-form">
|
||||
<FormItem label="id:">
|
||||
<span>{{ row?.id ?? '-' }}</span>
|
||||
</FormItem>
|
||||
<FormItem label="接口名称:">
|
||||
<span>{{ row?.requestUri ?? '-' }}</span>
|
||||
</FormItem>
|
||||
<FormItem label="用户名称:">
|
||||
<span>{{ row?.username ?? '-' }}</span>
|
||||
</FormItem>
|
||||
<FormItem label="调用IP:">
|
||||
<span>{{ row?.requestIp ?? '-' }}</span>
|
||||
</FormItem>
|
||||
<FormItem label="调用结果:">
|
||||
<span :class="resultClass">{{ resultText }}</span>
|
||||
</FormItem>
|
||||
<FormItem label="调用参数:">
|
||||
<pre class="json-pre">{{ formatJson(row?.requestParams) }}</pre>
|
||||
</FormItem>
|
||||
<FormItem label="数据:">
|
||||
<a v-if="row?.id" href="javascript:;" class="download-link" @click.prevent="handleDownload">下载</a>
|
||||
<span v-else>-</span>
|
||||
</FormItem>
|
||||
<FormItem label="创建时间:">
|
||||
<span>{{ formatTime(row?.createTime) }}</span>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="show = false">取消</Button>
|
||||
<Button type="primary" @click="show = false">确定</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
||||
import Utils from '@/utils';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
row?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
row: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: val => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const { $conf } = useCurrentInstance();
|
||||
|
||||
const isSuccess = computed(() => props.row?.statusCode === 200);
|
||||
const resultText = computed(() => isSuccess.value ? '执行成功' : '执行失败');
|
||||
const resultClass = computed(() => isSuccess.value ? 'text-success' : 'text-error');
|
||||
|
||||
function onVisibleChange(val: boolean) {
|
||||
if (!val) emit('update:modelValue', false);
|
||||
}
|
||||
|
||||
function formatJson(val: any): string {
|
||||
if (!val) return '-';
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(val), null, 2);
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(val: any): string {
|
||||
if (!val) return '-';
|
||||
return Utils.dateFormat(val, 'yyyy-MM-dd HH:mm:ss');
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (!props.row?.id) return;
|
||||
window.open(`${$conf.serviceHost}/api/oplog/Download/${props.row.id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.oplog-detail-form {
|
||||
:deep(.ivu-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.ivu-form-item-label) {
|
||||
color: #515a6e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: #19be6b;
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: #ed4014;
|
||||
}
|
||||
|
||||
.json-pre {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
background: #f7f8fa;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.download-link {
|
||||
color: #577afb;
|
||||
|
||||
&:hover {
|
||||
color: #3d5afe;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div class="main-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="search-wrap fix">
|
||||
<Input v-model="dataParams.keyword" placeholder="接口名称 / 用户名" clearable suffix="ios-search"
|
||||
style="width: 220px" @on-search="handleSearch" @on-clear="handleSearch" />
|
||||
|
||||
<Select v-model="dataParams.success" class="ml-12" clearable placeholder="调用结果" style="width: 120px"
|
||||
@on-change="handleSearch">
|
||||
<Option value="true">成功</Option>
|
||||
<Option value="false">失败</Option>
|
||||
</Select>
|
||||
|
||||
<Select v-model="dataParams.logLevel" class="ml-12" clearable placeholder="重要程度" style="width: 120px"
|
||||
@on-change="handleSearch">
|
||||
<Option :value="1">重要</Option>
|
||||
<Option :value="2">次要</Option>
|
||||
<Option :value="3">一般</Option>
|
||||
</Select>
|
||||
|
||||
<DatePicker v-model="startDate" type="date" placeholder="开始时间" format="yyyy-MM-dd" :editable="false"
|
||||
:clearable="true" class="ml-12" style="width: 140px"
|
||||
@on-change="v => onStartChange(v)" />
|
||||
|
||||
<DatePicker v-model="endDate" type="date" placeholder="结束时间" format="yyyy-MM-dd" :editable="false"
|
||||
:clearable="true" class="ml-12" style="width: 140px"
|
||||
@on-change="v => onEndChange(v)" />
|
||||
|
||||
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
|
||||
</div>
|
||||
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
||||
</div>
|
||||
|
||||
<OperationLogDetailModal v-model="detailVisible" :row="currentRow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||
import OperationLogDetailModal from './OperationLogDetailModal.vue';
|
||||
import Utils from '@/utils';
|
||||
|
||||
defineOptions({ name: 'OperationLogList' });
|
||||
|
||||
const { $utils } = useCurrentInstance();
|
||||
|
||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
|
||||
interface DataParams {
|
||||
keyword?: string;
|
||||
success?: string;
|
||||
logLevel?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
const dataParams = ref<DataParams>({
|
||||
keyword: undefined,
|
||||
success: undefined,
|
||||
logLevel: undefined,
|
||||
startTime: undefined,
|
||||
endTime: undefined,
|
||||
});
|
||||
|
||||
const startDate = ref<Date | null>(null);
|
||||
const endDate = ref<Date | null>(null);
|
||||
|
||||
function onStartChange(val: string) {
|
||||
dataParams.value.startTime = val ? `${val} 00:00:00` : undefined;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function onEndChange(val: string) {
|
||||
dataParams.value.endTime = val ? `${val} 23:59:59` : undefined;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
tableRef.value?.refreshData();
|
||||
}
|
||||
|
||||
function clean(obj: Record<string, any>) {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== ''));
|
||||
}
|
||||
|
||||
const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
||||
return (Apis as any).OperationLog.list({
|
||||
params: clean({
|
||||
keyword: dataParams.value.keyword?.trim() || undefined,
|
||||
success: dataParams.value.success,
|
||||
logLevel: dataParams.value.logLevel,
|
||||
startTime: dataParams.value.startTime,
|
||||
endTime: dataParams.value.endTime,
|
||||
pageIndex: params.pageIndex,
|
||||
pageSize: params.pageSize,
|
||||
}),
|
||||
cacheFor: null,
|
||||
});
|
||||
};
|
||||
|
||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
|
||||
|
||||
const logLevelMap: Record<number, { text: string; className: string }> = {
|
||||
1: { text: '重要', className: 'text-error' },
|
||||
2: { text: '次要', className: 'text-warning' },
|
||||
3: { text: '一般', className: 'text-dark' },
|
||||
};
|
||||
|
||||
const operationTypeMap: Record<string, string> = {
|
||||
DELETE: '删除',
|
||||
CREATE: '新增',
|
||||
UPDATE: '更新',
|
||||
QUERY: '查询',
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ type: 'index', title: '序号', width: 70, align: 'center' },
|
||||
{ title: '接口名称', key: 'requestUri', minWidth: 220, ellipsis: true, tooltip: true },
|
||||
{ title: '用户名', key: 'username', width: 140 },
|
||||
{
|
||||
title: '操作类型',
|
||||
key: 'operationType',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (h: any, { row }: any) => h('span', operationTypeMap[row.operationType] ?? row.operationType ?? '-'),
|
||||
},
|
||||
{
|
||||
title: '重要程度',
|
||||
key: 'logLevel',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (h: any, { row }: any) => {
|
||||
const cfg = logLevelMap[row.logLevel];
|
||||
if (!cfg) return h('span', '-');
|
||||
return h('span', { class: cfg.className }, cfg.text);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态码',
|
||||
key: 'statusCode',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (h: any, { row }: any) => h('span', {
|
||||
style: { color: row.statusCode === 200 ? '#19be6b' : '#ed4014' },
|
||||
}, row.statusCode ?? '-'),
|
||||
},
|
||||
{
|
||||
title: '耗时(ms)',
|
||||
key: 'costTime',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: (h: any, { row }: any) => h('span', row.costTime != null ? `${row.costTime} ms` : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作时间',
|
||||
key: 'createTime',
|
||||
width: 170,
|
||||
render: (h: any, { row }: any) => h('span', row.createTime ? Utils.dateFormat(row.createTime, 'yyyy-MM-dd HH:mm:ss') : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
fixed: 'right' as const,
|
||||
render: (h: any, { row }: any) =>
|
||||
h('a', {
|
||||
style: { color: '#577afb' },
|
||||
onClick: () => openDetail(row),
|
||||
}, '查看详情'),
|
||||
},
|
||||
];
|
||||
|
||||
const detailVisible = ref(false);
|
||||
const currentRow = ref<Record<string, any> | null>(null);
|
||||
|
||||
function openDetail(row: any) {
|
||||
currentRow.value = row;
|
||||
detailVisible.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-wrap {
|
||||
.ivu-select,
|
||||
.ivu-input-wrapper,
|
||||
.ivu-date-picker {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<RoleForm v-model="visible" :initForm="initForm" :isEdit="false" :loading="submitting"
|
||||
@submit="handleSubmit" @cancel="visible = false" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import RoleForm from "./RoleForm.vue";
|
||||
|
||||
const visible = ref(false);
|
||||
const initForm = ref<Record<string, any>>({});
|
||||
let onSuccessCallback: (() => void) | null = null;
|
||||
|
||||
// useForm 托管提交(角色新增:data 形态与后端 SysAppRoleRequest 一致)
|
||||
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
||||
(dto) => Apis.AppRole.roleAdd({ data: dto }),
|
||||
{
|
||||
initialForm: { name: "", appId: undefined, allAppFlag: false, remark: "", disabled: false },
|
||||
resetAfterSubmiting: true,
|
||||
}
|
||||
);
|
||||
|
||||
onSuccess(() => {
|
||||
Message.success("新增角色成功!");
|
||||
visible.value = false;
|
||||
onSuccessCallback?.();
|
||||
});
|
||||
onError((event: any) => {
|
||||
Message.error(event?.error?.message || "新增失败");
|
||||
});
|
||||
|
||||
function open(onSuccess?: () => void) {
|
||||
initForm.value = {};
|
||||
onSuccessCallback = onSuccess || null;
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
async function handleSubmit(formData: Record<string, any>) {
|
||||
form.value = {
|
||||
name: formData.name,
|
||||
appId: formData.appId,
|
||||
allAppFlag: formData.allAppFlag,
|
||||
remark: formData.remark,
|
||||
disabled: formData.disabled,
|
||||
};
|
||||
await sendForm();
|
||||
}
|
||||
|
||||
defineExpose({ open, visible });
|
||||
</script>
|
||||
@@ -0,0 +1,452 @@
|
||||
<template>
|
||||
<div class="authz-panel role-authz-tree">
|
||||
<div class="search-wrap authz-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<Select v-model="selectedCategoryId" class="type-filter-select" placeholder="全部应用类型" clearable>
|
||||
<Option v-for="opt in categoryOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</Option>
|
||||
</Select>
|
||||
<h5 class="title mb-0">应用授权</h5>
|
||||
<div class="role-text">
|
||||
当前角色:<span class="role-name">{{ roleName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<Input v-model="searchKeyword" placeholder="输入应用或模块名称搜索,回车过滤" clearable suffix="ios-search"
|
||||
style="width: 260px" @on-enter="onSearch" @on-clear="onClearSearch" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Spin fix v-if="loading" />
|
||||
|
||||
<div v-if="hasVisibleCategories" class="cat-columns">
|
||||
<div v-for="(col, ci) in columns" :key="ci" class="cat-col">
|
||||
<div v-for="item in col" :key="String(item.cat.id)" class="cat-card">
|
||||
<div class="cat-card-header" @click="toggleCollapse(item.cat.id)">
|
||||
<Icon class="collapse-icon"
|
||||
:type="collapsedMap[String(item.cat.id)] ? 'ios-arrow-forward' : 'ios-arrow-down'" />
|
||||
<span class="cat-name" :title="item.cat.name">{{ item.cat.name }}</span>
|
||||
<span class="cat-count">{{ item.treeData.length }}个应用</span>
|
||||
</div>
|
||||
<div v-show="!collapsedMap[String(item.cat.id)]" class="cat-card-body">
|
||||
<MyTree :treeData="item.treeData" :setting="treeSetting" :autoClick="false"
|
||||
@on-tree-check="onTreeCheck" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-tip">
|
||||
<template v-if="categoryList.length === 0">该角色暂无可授权的应用</template>
|
||||
<template v-else>未找到匹配的应用或模块</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, shallowRef, reactive, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { debounce } from "lodash";
|
||||
import Apis from "@/api";
|
||||
import MyTree from "@/components/MyTree.vue";
|
||||
|
||||
const props = defineProps<{ roleId: number | null; roleName?: string }>();
|
||||
|
||||
type AppModuleVo = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
level?: number;
|
||||
checked?: boolean;
|
||||
children?: AppModuleVo[];
|
||||
};
|
||||
type AppItem = { id?: number; name?: string; sortNo?: number; modules?: AppModuleVo[] };
|
||||
type CategoryItem = { id?: number; name?: string; apps?: AppItem[] };
|
||||
type AuthzTreeNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
open: boolean;
|
||||
checked: boolean;
|
||||
nodeType: "app" | "module";
|
||||
rawId: number;
|
||||
appId: number;
|
||||
children?: AuthzTreeNode[];
|
||||
};
|
||||
type CategoryTreeItem = { cat: CategoryItem; treeData: AuthzTreeNode[]; weight: number };
|
||||
|
||||
const COLUMN_COUNT = 3;
|
||||
const jq = () => (window as any).$;
|
||||
|
||||
const loading = ref(false);
|
||||
const categoryList = shallowRef<CategoryItem[]>([]);
|
||||
const selectedCategoryId = ref<number | null>(null);
|
||||
const searchKeyword = ref("");
|
||||
const appliedKeyword = ref("");
|
||||
const collapsedMap = reactive<Record<string, boolean>>({});
|
||||
|
||||
const treeSetting = {
|
||||
view: { dblClickExpand: false, showIcon: false },
|
||||
check: {
|
||||
enable: true,
|
||||
chkStyle: "checkbox",
|
||||
// 不自动级联:模块逐条独立授权(角色无"子组继承"概念)
|
||||
chkboxType: { Y: "", N: "" },
|
||||
},
|
||||
callback: {},
|
||||
};
|
||||
|
||||
// ---------------- 工具 ----------------
|
||||
function moduleNameHit(node: AppModuleVo, kw: string): boolean {
|
||||
if ((node.name || "").toLowerCase().includes(kw)) return true;
|
||||
return (node.children || []).some((c) => moduleNameHit(c, kw));
|
||||
}
|
||||
function countTotalModules(nodes: AppModuleVo[]): number {
|
||||
let count = 0;
|
||||
for (const node of nodes) {
|
||||
count++;
|
||||
count += countTotalModules(node.children || []);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
function isAppChecked(app: AppItem): boolean {
|
||||
const modules = app.modules || [];
|
||||
if (modules.length === 0) return false;
|
||||
return modules.every((m) => isModuleAllChecked(m));
|
||||
}
|
||||
function isModuleAllChecked(node: AppModuleVo): boolean {
|
||||
if (!node.checked) return false;
|
||||
return (node.children || []).every((c) => isModuleAllChecked(c));
|
||||
}
|
||||
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
|
||||
return {
|
||||
id: `mod_${m.id}`,
|
||||
name: String(m.name || ""),
|
||||
open: true,
|
||||
checked: !!m.checked,
|
||||
nodeType: "module",
|
||||
rawId: Number(m.id),
|
||||
appId,
|
||||
children: (m.children || []).map((c) => buildModuleNode(c, appId)),
|
||||
};
|
||||
}
|
||||
function buildAppNode(app: AppItem): AuthzTreeNode {
|
||||
const appId = Number(app.id);
|
||||
return {
|
||||
id: `app_${appId}`,
|
||||
name: String(app.name || ""),
|
||||
open: true,
|
||||
checked: isAppChecked(app),
|
||||
nodeType: "app",
|
||||
rawId: appId,
|
||||
appId,
|
||||
children: (app.modules || []).map((m) => buildModuleNode(m, appId)),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- 数据加载 ----------------
|
||||
async function loadRoleModuleTree(roleId: number) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [apps, appListRes, typeRes] = await Promise.all([
|
||||
Apis.AppRole.roleModuleTree({ params: { roleId }, cacheFor: null }),
|
||||
Apis.AppSettings.appList({ params: { pageIndex: 1, pageSize: 9999 }, cacheFor: null }),
|
||||
Apis.AppSettings.appTypeList({ cacheFor: null }),
|
||||
]);
|
||||
const appArr: any[] = appListRes?.content ?? appListRes ?? [];
|
||||
const catMap = new Map<number, number>();
|
||||
appArr.forEach((a: any) => { if (a.id != null) catMap.set(Number(a.id), Number(a.categoryId)); });
|
||||
const typeMap = new Map<number, string>();
|
||||
(typeRes ?? []).forEach((t: any) => typeMap.set(Number(t.id), String(t.name || `分类${t.id}`)));
|
||||
|
||||
const groups = new Map<number, CategoryItem>();
|
||||
(apps ?? []).forEach((app: any) => {
|
||||
const cid = catMap.get(Number(app.id)) ?? -1;
|
||||
if (!groups.has(cid)) {
|
||||
groups.set(cid, { id: cid, name: typeMap.get(cid) || (cid === -1 ? "未分类" : `分类${cid}`), apps: [] });
|
||||
}
|
||||
groups.get(cid)!.apps!.push(app);
|
||||
});
|
||||
const list = Array.from(groups.values());
|
||||
list.forEach((g) => (g.apps = (g.apps || []).slice().sort((a: any, b: any) => (a.sortNo ?? 0) - (b.sortNo ?? 0))));
|
||||
list.sort((a, b) => Number(a.id) - Number(b.id));
|
||||
categoryList.value = list;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const categoryOptions = computed(() =>
|
||||
categoryList.value.map((c) => ({ value: Number(c.id), label: String(c.name || `分类${c.id}`) }))
|
||||
);
|
||||
watch(categoryOptions, (opts) => {
|
||||
if (selectedCategoryId.value != null && !opts.some((o) => o.value === Number(selectedCategoryId.value))) {
|
||||
selectedCategoryId.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
const categoryTreeList = computed<CategoryTreeItem[]>(() => {
|
||||
const kw = (appliedKeyword.value || "").trim().toLowerCase();
|
||||
return categoryList.value
|
||||
.filter((cat) => selectedCategoryId.value == null || Number(cat.id) === Number(selectedCategoryId.value))
|
||||
.map((cat) => {
|
||||
const apps = (cat.apps || []).filter((app) => {
|
||||
if (!kw) return true;
|
||||
if ((app.name || "").toLowerCase().includes(kw)) return true;
|
||||
return (app.modules || []).some((m) => moduleNameHit(m, kw));
|
||||
});
|
||||
return { cat, apps };
|
||||
})
|
||||
.filter((x) => x.apps.length > 0)
|
||||
.map(({ cat, apps }) => ({
|
||||
cat,
|
||||
treeData: apps.map((app) => buildAppNode(app)),
|
||||
weight: 2 + apps.reduce((sum, a) => sum + 1 + countTotalModules(a.modules || []), 0),
|
||||
}));
|
||||
});
|
||||
|
||||
const hasVisibleCategories = computed(() => categoryTreeList.value.length > 0);
|
||||
|
||||
const columns = computed<CategoryTreeItem[][]>(() => {
|
||||
const cols: CategoryTreeItem[][] = Array.from({ length: COLUMN_COUNT }, () => []);
|
||||
const weights = new Array(COLUMN_COUNT).fill(0);
|
||||
for (const item of categoryTreeList.value) {
|
||||
let idx = 0;
|
||||
for (let i = 1; i < COLUMN_COUNT; i++) if (weights[i] < weights[idx]) idx = i;
|
||||
cols[idx].push(item);
|
||||
weights[idx] += item.weight;
|
||||
}
|
||||
return cols;
|
||||
});
|
||||
|
||||
function toggleCollapse(catId: number | undefined) {
|
||||
const key = String(catId);
|
||||
collapsedMap[key] = !collapsedMap[key];
|
||||
}
|
||||
|
||||
// ---------------- 本地数据同步 ----------------
|
||||
function findLocalApp(appId: number): AppItem | null {
|
||||
for (const cat of categoryList.value || []) {
|
||||
const app = (cat.apps || []).find((a) => Number(a.id) === appId);
|
||||
if (app) return app;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function walkModules(nodes: AppModuleVo[], fn: (n: AppModuleVo) => void) {
|
||||
for (const node of nodes) {
|
||||
fn(node);
|
||||
walkModules(node.children || [], fn);
|
||||
}
|
||||
}
|
||||
function setLocalAppChecked(appId: number, checked: boolean) {
|
||||
const app = findLocalApp(appId);
|
||||
if (!app) return;
|
||||
walkModules(app.modules || [], (n) => { n.checked = checked; });
|
||||
}
|
||||
function setLocalModuleState(appId: number, moduleId: number, patch: Partial<AppModuleVo>) {
|
||||
const app = findLocalApp(appId);
|
||||
if (!app) return;
|
||||
walkModules(app.modules || [], (n) => { if (Number(n.id) === moduleId) Object.assign(n, patch); });
|
||||
}
|
||||
function getTreeObjByNode(node: any) {
|
||||
const $ = jq();
|
||||
const treeId = String(node?.tId || "").replace(/_\d+$/, "");
|
||||
return $?.fn?.zTree?.getZTreeObj(treeId) || null;
|
||||
}
|
||||
function syncAppNodeState(treeObj: any, appId: number) {
|
||||
if (!treeObj) return;
|
||||
const app = findLocalApp(appId);
|
||||
if (!app) return;
|
||||
const appNode = treeObj.getNodesByFilter((n: any) => n.id === `app_${appId}`, true);
|
||||
if (!appNode) return;
|
||||
appNode.checked = isAppChecked(app);
|
||||
treeObj.checkNode(appNode, appNode.checked, false);
|
||||
}
|
||||
|
||||
// ---------------- 授权操作(勾选即保存,全量覆盖 + 防抖) ----------------
|
||||
function onTreeCheck(node: any) {
|
||||
const roleId = props.roleId;
|
||||
if (!roleId || !node) return;
|
||||
const checked = !!node.checked;
|
||||
const isApp = node.nodeType === "app";
|
||||
const treeObj = getTreeObjByNode(node);
|
||||
|
||||
if (isApp) {
|
||||
setLocalAppChecked(node.appId, checked);
|
||||
if (treeObj) {
|
||||
const affected = treeObj.transformToArray([node]) || [];
|
||||
affected.forEach((n: any) => {
|
||||
if (n !== node) treeObj.checkNode(n, checked, false);
|
||||
n.checked = checked;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setLocalModuleState(node.appId, node.rawId, { checked });
|
||||
syncAppNodeState(treeObj, node.appId);
|
||||
}
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
const saveNow = async () => {
|
||||
const roleId = props.roleId;
|
||||
if (!roleId) return;
|
||||
const moduleIds: number[] = [];
|
||||
for (const cat of categoryList.value || []) {
|
||||
for (const app of cat.apps || []) {
|
||||
walkModules(app.modules || [], (n) => { if (n.checked) moduleIds.push(Number(n.id)); });
|
||||
}
|
||||
}
|
||||
try {
|
||||
await Apis.AppRole.saveRoleModules({ data: { roleId, moduleIds }, cacheFor: null });
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "保存失败");
|
||||
await loadRoleModuleTree(roleId);
|
||||
}
|
||||
};
|
||||
const scheduleSave = debounce(saveNow, 400);
|
||||
|
||||
// ---------------- 搜索 ----------------
|
||||
function onSearch() {
|
||||
appliedKeyword.value = searchKeyword.value;
|
||||
}
|
||||
function onClearSearch() {
|
||||
appliedKeyword.value = "";
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.roleId,
|
||||
(id) => {
|
||||
if (id != null) loadRoleModuleTree(id);
|
||||
else categoryList.value = [];
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.role-authz-tree {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.authz-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #fff;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.type-filter-select {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.role-text {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.role-name {
|
||||
color: #2d8cf0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cat-columns {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.cat-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cat-card {
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cat-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: #f0f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cat-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
margin-left: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cat-count {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cat-card-body {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
color: #999;
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.ztree li a) {
|
||||
max-width: calc(100% - 30px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<RoleForm v-model="visible" :initForm="detailData" :isEdit="true" :loading="submitting"
|
||||
@submit="handleSubmit" @cancel="visible = false" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import RoleForm from "./RoleForm.vue";
|
||||
|
||||
const visible = ref(false);
|
||||
const detailData = ref<Record<string, any>>({});
|
||||
const currentId = ref<number | null>(null);
|
||||
let onSuccessCallback: (() => void) | null = null;
|
||||
|
||||
// useForm 托管提交(角色编辑:仅可改 name/remark/disabled;allAppFlag 与 appId 后端禁止修改)
|
||||
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
||||
(dto) => Apis.AppRole.roleEdit({ pathParams: { id: currentId.value! }, data: dto }),
|
||||
{
|
||||
initialForm: { name: "", appId: undefined, allAppFlag: false, remark: "", disabled: false },
|
||||
resetAfterSubmiting: true,
|
||||
}
|
||||
);
|
||||
|
||||
onSuccess(() => {
|
||||
Message.success("编辑角色成功!");
|
||||
visible.value = false;
|
||||
onSuccessCallback?.();
|
||||
});
|
||||
onError((event: any) => {
|
||||
Message.error(event?.error?.message || "编辑失败");
|
||||
});
|
||||
|
||||
function open(id: number, row: any, onSuccess?: () => void) {
|
||||
currentId.value = id;
|
||||
// 直接用行数据回填(roleList 返回的字段与表单一致)
|
||||
detailData.value = { ...row };
|
||||
onSuccessCallback = onSuccess || null;
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
async function handleSubmit(formData: Record<string, any>) {
|
||||
if (!currentId.value) return;
|
||||
form.value = {
|
||||
id: currentId.value,
|
||||
name: formData.name,
|
||||
appId: formData.appId,
|
||||
allAppFlag: formData.allAppFlag,
|
||||
remark: formData.remark,
|
||||
disabled: formData.disabled,
|
||||
};
|
||||
await sendForm();
|
||||
}
|
||||
|
||||
defineExpose({ open, visible });
|
||||
</script>
|
||||
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div class="page-wrapper role-authz-wrapper">
|
||||
<!-- 左:角色列表(可滚动,应对大量角色;顶部 2 个筛选) -->
|
||||
<div class="role-list-panel">
|
||||
<div class="role-filter">
|
||||
<Select v-model="filterAppId" placeholder="按绑定应用筛选" filterable clearable
|
||||
@on-change="onFilterChange">
|
||||
<Option :value="FILTER_ALL">全部</Option>
|
||||
<Option :value="FILTER_GLOBAL_APP">全局应用</Option>
|
||||
<Option v-for="app in appOptions" :key="app.id" :value="app.id">{{ app.name }}</Option>
|
||||
</Select>
|
||||
<Input v-model="keyword" placeholder="角色名称模糊查询" search clearable suffix="ios-search"
|
||||
@on-search="onSearch" @on-clear="onSearch" />
|
||||
</div>
|
||||
|
||||
<div class="role-list-scroll">
|
||||
<div v-for="role in filteredRoles" :key="role.id"
|
||||
:class="['role-item', selectedRoleId === role.id ? 'active' : '']" @click="selectRole(role)">
|
||||
<div class="role-name">{{ role.name }}</div>
|
||||
<div class="role-app">
|
||||
<Tag v-if="role.allAppFlag" color="blue" size="small">全应用</Tag>
|
||||
<span v-else>{{ role.appName || "-" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!filteredRoles.length" class="role-empty">无匹配角色</div>
|
||||
</div>
|
||||
|
||||
<div class="role-list-footer">
|
||||
<Button type="text" @click="goManage">角色管理</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右:选中角色的授权面板(应用授权 / 用户管理) -->
|
||||
<div class="authz-panel">
|
||||
<div v-if="selectedRole" class="authz-inner">
|
||||
<div class="authz-bar">
|
||||
<span class="authz-title">当前角色:<b>{{ selectedRole.name }}</b></span>
|
||||
<span class="authz-app">
|
||||
<Tag v-if="selectedRole.allAppFlag" color="blue">全应用</Tag>
|
||||
<span v-else>{{ selectedRole.appName || "-" }}</span>
|
||||
</span>
|
||||
<Tag v-if="selectedRole.disabled" color="red">已禁用</Tag>
|
||||
</div>
|
||||
<!-- Tabs 仅作页签头;内容用 v-show 切换,无切换动效 -->
|
||||
<Tabs v-model="activeTab" type="card" class="authz-tabs" :animated="false">
|
||||
<TabPane label="应用授权" name="module" />
|
||||
<TabPane label="用户管理" name="user" />
|
||||
</Tabs>
|
||||
<div class="authz-tab-content">
|
||||
<div v-show="activeTab === 'module'">
|
||||
<AppModuleCheckTree :key="selectedRole.id" :roleId="selectedRole.id" :roleName="selectedRole.name" />
|
||||
</div>
|
||||
<div v-show="activeTab === 'user'">
|
||||
<RoleUserManage :key="selectedRole.id" :roleId="selectedRole.id" :roleName="selectedRole.name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Alert v-else type="info" show-icon>请选择左侧角色以进行应用授权与用户管理。</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import AppModuleCheckTree from "./AppModuleCheckTree.vue";
|
||||
import RoleUserManage from "./RoleUserManage.vue";
|
||||
|
||||
defineOptions({ name: "RoleAuthzList" });
|
||||
|
||||
const router = useRouter();
|
||||
const { $utils } = useCurrentInstance();
|
||||
|
||||
// 筛选下拉的两个虚拟项:0=全部;-1=全局应用(仅 allAppFlag 角色)
|
||||
const FILTER_ALL = 0;
|
||||
const FILTER_GLOBAL_APP = -1;
|
||||
|
||||
const keyword = ref("");
|
||||
const filterAppId = ref<number | null>(null);
|
||||
const selectedRoleId = ref<number | null>(null);
|
||||
const activeTab = ref<'module' | 'user'>('module');
|
||||
|
||||
// 应用下拉(用于“按应用筛选”)
|
||||
const { data: appOptions } = useRequest(
|
||||
Apis.AppSettings.appList({
|
||||
params: { pageIndex: 1, pageSize: 9999 },
|
||||
transform: (res: any) => res?.content ?? [],
|
||||
cacheFor: null,
|
||||
}),
|
||||
{ initialData: [] }
|
||||
);
|
||||
|
||||
// 角色全量(非分页,角色基数约 40-50,单次拉取 + 客户端筛选)
|
||||
const { data: roleData } = useRequest(
|
||||
Apis.AppRole.roleList({
|
||||
params: { pageIndex: 1, pageSize: 9999 },
|
||||
transform: (res: any) => res?.content ?? [],
|
||||
cacheFor: null,
|
||||
}),
|
||||
{ initialData: [] }
|
||||
);
|
||||
|
||||
const allRoles = computed<Array<any>>(() => roleData.value ?? []);
|
||||
const selectedRole = computed(() => allRoles.value.find((r: any) => r.id === selectedRoleId.value) ?? null);
|
||||
|
||||
// 筛选:全部/未选=不过滤;全局应用=仅 allAppFlag 角色;具体应用=该应用角色 + 全应用角色(原有逻辑)
|
||||
const filteredRoles = computed(() => {
|
||||
const kw = keyword.value.trim();
|
||||
return allRoles.value.filter((r: any) => {
|
||||
let matchApp: boolean;
|
||||
if (!filterAppId.value || filterAppId.value === FILTER_ALL) {
|
||||
matchApp = true;
|
||||
} else if (filterAppId.value === FILTER_GLOBAL_APP) {
|
||||
matchApp = !!r.allAppFlag;
|
||||
} else {
|
||||
matchApp = r.allAppFlag || r.appId === filterAppId.value;
|
||||
}
|
||||
const matchName = !kw || (r.name || "").includes(kw);
|
||||
return matchApp && matchName;
|
||||
});
|
||||
});
|
||||
|
||||
// 默认自动选中第一条,直接进入授权界面
|
||||
watch(
|
||||
allRoles,
|
||||
(list) => {
|
||||
if (!selectedRoleId.value && list && list.length) {
|
||||
selectedRoleId.value = list[0].id;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function selectRole(role: any) {
|
||||
selectedRoleId.value = role.id;
|
||||
activeTab.value = 'module';
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
// 仅客户端过滤,computed 已响应
|
||||
}
|
||||
|
||||
// 搜索手动触发(on-search / on-clear),computed 已响应 keyword
|
||||
function onSearch() {
|
||||
// no-op: 依赖 computed
|
||||
}
|
||||
|
||||
function goManage() {
|
||||
router.push("/role-authz/manage");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.role-authz-wrapper {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.role-list-panel {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #ebedf0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.role-filter {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.role-list-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.role-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #b8c6ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #577afb;
|
||||
background: #f3f6ff;
|
||||
}
|
||||
}
|
||||
|
||||
.role-item .role-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-weight: 500;
|
||||
color: #272829;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.role-item .role-app {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.role-empty {
|
||||
text-align: center;
|
||||
color: #bbb;
|
||||
padding: 30px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.role-list-footer {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 0;
|
||||
text-align: center;
|
||||
border-top: 1px solid #eeeeee;
|
||||
|
||||
.ivu-btn-text {
|
||||
color: #577afb;
|
||||
}
|
||||
}
|
||||
|
||||
.authz-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.authz-inner {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.authz-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
|
||||
.authz-title b {
|
||||
color: #577afb;
|
||||
}
|
||||
|
||||
.authz-app {
|
||||
color: #515a6e;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.authz-tabs {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.authz-tab-content {
|
||||
padding-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<Modal v-model="show" :mask-closable="false" width="640" scrollable :title="title"
|
||||
@on-visible-change="onVisibleChange">
|
||||
<Form ref="formRef" :model="form" :rules="rules" :label-width="110" class="role-form width-95">
|
||||
<FormItem label="角色名称:" prop="name">
|
||||
<Input v-model="form.name" placeholder="请输入角色名称" />
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="关联应用:" prop="appId">
|
||||
<Select v-model="selVal" transfer filterable :disabled="isEdit" placeholder="选择具体应用或「全应用」"
|
||||
@on-change="onSelChange">
|
||||
<Option value="__ALL__" label="全应用(可跨应用授权)" />
|
||||
<OptionGroup v-for="g in appGroups" :key="g.categoryId" :label="g.categoryName">
|
||||
<Option v-for="app in g.apps" :key="app.id" :value="app.id" :label="app.name" />
|
||||
</OptionGroup>
|
||||
</Select>
|
||||
<p v-if="isEdit" class="tip">角色类型与所属应用不可修改,如需变更请删除后重建。</p>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="备注:" prop="remark">
|
||||
<Input v-model="form.remark" type="textarea" :rows="2" placeholder="可选" />
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="状态:" prop="disabled">
|
||||
<i-switch v-model="form.disabled" :true-value="true" :false-value="false" />
|
||||
<span class="ml-8">{{ form.disabled ? "禁用" : "启用" }}</span>
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="loading" @click="handleSubmit">提交</Button>
|
||||
<Button @click="handleCancel">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean;
|
||||
initForm?: Record<string, any>;
|
||||
isEdit?: boolean;
|
||||
loading?: boolean;
|
||||
}>(), {
|
||||
initForm: () => ({}),
|
||||
isEdit: false,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'submit', 'cancel']);
|
||||
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
const title = computed(() => (props.isEdit ? "编辑角色" : "新增角色"));
|
||||
|
||||
const defaultForm = (): Record<string, any> => ({
|
||||
name: "",
|
||||
appId: undefined as number | undefined,
|
||||
allAppFlag: false,
|
||||
remark: "",
|
||||
disabled: false,
|
||||
});
|
||||
const form = reactive(defaultForm());
|
||||
// 选择器值:'__ALL__' 表示全应用;数字表示具体 appId
|
||||
const selVal = ref<any>(undefined);
|
||||
|
||||
// 应用分组(按应用类型)供 Select 的 OptionGroup;全应用角色不归属某分类,单独一个虚拟选项
|
||||
const { data: appTypeList } = useRequest(
|
||||
Apis.AppSettings.appTypeList({ cacheFor: null }),
|
||||
{ initialData: [] }
|
||||
);
|
||||
const { data: appListRes } = useRequest(
|
||||
Apis.AppSettings.appList({ params: { pageIndex: 1, pageSize: 9999 }, cacheFor: null }),
|
||||
{ initialData: { content: [] } }
|
||||
);
|
||||
const appGroups = computed(() => {
|
||||
const types = (appTypeList.value ?? []).slice().sort((a: any, b: any) => (a.sortNo ?? 0) - (b.sortNo ?? 0));
|
||||
const apps = appListRes.value?.content ?? [];
|
||||
return types.map((t: any) => ({
|
||||
categoryId: t.id,
|
||||
categoryName: t.name,
|
||||
apps: apps.filter((a: any) => a.categoryId === t.id),
|
||||
}));
|
||||
});
|
||||
|
||||
// 外部 initForm 变化时回填(编辑模式:全应用 → '__ALL__',否则取 appId)
|
||||
watch(() => props.initForm, (val) => {
|
||||
const f = defaultForm();
|
||||
if (val && Object.keys(val).length) {
|
||||
Object.assign(f, val);
|
||||
}
|
||||
Object.assign(form, f);
|
||||
selVal.value = f.allAppFlag ? "__ALL__" : (f.appId ?? undefined);
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
function onSelChange(v: any) {
|
||||
selVal.value = v;
|
||||
if (v === "__ALL__") {
|
||||
form.allAppFlag = true;
|
||||
form.appId = null;
|
||||
} else {
|
||||
form.allAppFlag = false;
|
||||
form.appId = v as number;
|
||||
}
|
||||
}
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: "请输入角色名称", trigger: "blur" }],
|
||||
appId: [{
|
||||
validator: (rule: any, value: any, cb: any) => {
|
||||
if (!form.allAppFlag && form.appId == null) {
|
||||
cb(new Error("请选择关联应用或「全应用」"));
|
||||
} else {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
trigger: "change",
|
||||
}],
|
||||
};
|
||||
|
||||
const formRef = ref();
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await formRef.value?.validate();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
emit("submit", {
|
||||
name: form.name,
|
||||
appId: form.appId,
|
||||
allAppFlag: form.allAppFlag,
|
||||
remark: form.remark,
|
||||
disabled: form.disabled,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("cancel");
|
||||
}
|
||||
|
||||
function onVisibleChange(val: boolean) {
|
||||
if (!val) {
|
||||
emit("cancel");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.role-form {
|
||||
margin: 0 auto;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 12px;
|
||||
|
||||
:deep(.ivu-form-item) {
|
||||
display: block;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.ivu-form-item-label) {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
}
|
||||
|
||||
:deep(.ivu-row) {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
:deep(.ivu-row:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-top: 4px;
|
||||
color: #ed4014;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="main-wrapper">
|
||||
<div class="main-container">
|
||||
<Alert show-icon>角色管理:在此新增 / 编辑 / 删除业务角色。普通角色关联单一应用,全应用角色作用于全部应用。</Alert>
|
||||
<div class="toolbar fix">
|
||||
<Button class="fl" type="text" @click="goAuthz">← 角色授权</Button>
|
||||
<Button class="fl" type="primary" @click="handleAdd">新增角色</Button>
|
||||
<Input class="fr" v-model="keyword" placeholder="输入角色名称筛选" search clearable suffix="ios-search"
|
||||
style="width:240px" @on-search="onSearch" @on-clear="onSearch" />
|
||||
</div>
|
||||
|
||||
<div class="role-grid">
|
||||
<div v-for="role in filteredRoles" :key="role.id" class="role-card">
|
||||
<div class="role-card-hd">
|
||||
<span class="role-card-name">{{ role.name }}</span>
|
||||
<Tag v-if="role.allAppFlag" color="blue">全应用</Tag>
|
||||
<Tag v-else color="default">普通</Tag>
|
||||
</div>
|
||||
<div class="role-card-sub">{{ role.allAppFlag ? "全部应用" : (role.appName || "-") }}</div>
|
||||
<div class="role-card-ft">
|
||||
<span class="act" @click="handleEdit(role)">编辑</span>
|
||||
<span class="act act-del" @click="handleDelete(role)">删除</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!filteredRoles.length" class="role-empty">暂无角色</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddRoleContainer ref="addRef" />
|
||||
<EditRoleContainer ref="editRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import AddRoleContainer from "./AddRoleContainer.vue";
|
||||
import EditRoleContainer from "./EditRoleContainer.vue";
|
||||
|
||||
defineOptions({ name: "RoleManage" });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const keyword = ref("");
|
||||
|
||||
// 非分页:一次性拉全量角色(约 40-50 条),参照 AppCategory 的管理范式
|
||||
const { data: roleData, send: sendRoles } = useRequest(
|
||||
Apis.AppRole.roleList({
|
||||
params: { pageIndex: 1, pageSize: 9999 },
|
||||
transform: (res: any) => res?.content ?? [],
|
||||
cacheFor: null,
|
||||
}),
|
||||
{ initialData: [] }
|
||||
);
|
||||
|
||||
const allRoles = computed<Array<any>>(() => roleData.value ?? []);
|
||||
|
||||
// 名称手动筛选(on-search / on-clear 才触发)
|
||||
const filteredRoles = computed(() => {
|
||||
const kw = keyword.value.trim();
|
||||
if (!kw) return allRoles.value;
|
||||
return allRoles.value.filter((r: any) => (r.name || "").includes(kw));
|
||||
});
|
||||
|
||||
function onSearch() {
|
||||
// computed 已响应 keyword
|
||||
}
|
||||
|
||||
const addRef = ref();
|
||||
function handleAdd() {
|
||||
addRef.value?.open(() => sendRoles());
|
||||
}
|
||||
|
||||
const editRef = ref();
|
||||
function handleEdit(row: any) {
|
||||
editRef.value?.open(row.id, row, () => sendRoles());
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await MyConfirm({
|
||||
msg: `确定删除角色「${row.name || row.id}」吗?`,
|
||||
info: "删除后将一并清理其模块授权与用户绑定,不可恢复!",
|
||||
async onOk() {
|
||||
try {
|
||||
await Apis.AppRole.roleDelete({ pathParams: { id: row.id } });
|
||||
Message.success("删除成功!");
|
||||
await sendRoles();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "删除失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function goAuthz() {
|
||||
router.push("/role-authz/list");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.toolbar {
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
|
||||
.role-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
width: 240px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e9e9e9;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
|
||||
.role-card-hd {
|
||||
height: 56px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 0 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.role-card-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #272829;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.role-card-sub {
|
||||
height: 38px;
|
||||
line-height: 38px;
|
||||
padding: 0 15px;
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.role-card-ft {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
background: #f7f7f7;
|
||||
border-top: 1px solid #e9e9e9;
|
||||
|
||||
.act {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: #577afb;
|
||||
|
||||
&:hover {
|
||||
color: #6c91f0;
|
||||
}
|
||||
}
|
||||
|
||||
.act-del {
|
||||
color: #ed4014;
|
||||
|
||||
&:hover {
|
||||
color: #f16643;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.role-empty {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #bbb;
|
||||
padding: 40px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<Modal v-model="visible" title="添加用户" width="820" :mask-closable="false" @on-visible-change="onVisibleChange">
|
||||
<div class="bind-modal-body">
|
||||
<div class="bind-tree">
|
||||
<GroupTreeSearchPanel ref="refTree" :treeApi="treeApi" :defaultSearchVisible="true"
|
||||
:showCloseButton="false" @on-change="onTreeChange" />
|
||||
</div>
|
||||
<div class="bind-users">
|
||||
<div class="bind-users-bar">
|
||||
<Input v-model="kw" placeholder="按账号/姓名筛选" clearable class="bind-kw" />
|
||||
<span class="bind-sel-tip">已选 {{ selectedIds.size }} 人</span>
|
||||
</div>
|
||||
<div class="bind-users-list">
|
||||
<div v-if="!groupId" class="bind-tip">请选择左侧组织节点</div>
|
||||
<div v-else-if="!candidates.length" class="bind-tip">该组织下暂无用户</div>
|
||||
<div v-for="u in filteredCandidates" :key="u.id" class="bind-user-item">
|
||||
<Checkbox :model-value="isChecked(u)" :disabled="isExisting(u)" @on-change="(v: any) => toggle(u, v)">
|
||||
<span class="bind-user-name">{{ u.realName || u.account }}</span>
|
||||
<span class="bind-user-sub">{{ u.account }}<template v-if="u.groupPath"> · {{ u.groupPath }}</template></span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button @click="visible = false">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="save">确认添加</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Apis from "@/api";
|
||||
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
roleId: number;
|
||||
existingUserIds: number[];
|
||||
visible: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
"update:visible": [boolean];
|
||||
success: [];
|
||||
}>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (v) => emit("update:visible", v),
|
||||
});
|
||||
|
||||
const refTree = ref<any>(null);
|
||||
const groupId = ref<number | null>(null);
|
||||
const candidates = ref<any[]>([]);
|
||||
const selectedIds = ref<Set<number>>(new Set());
|
||||
const kw = ref("");
|
||||
const saving = ref(false);
|
||||
|
||||
// 组织树数据源(与组用户管理一致)
|
||||
const treeApi = () => Apis.SysGroup.getTree();
|
||||
|
||||
const existingSet = computed(() => new Set(props.existingUserIds ?? []));
|
||||
|
||||
const filteredCandidates = computed(() => {
|
||||
const k = kw.value.trim().toLowerCase();
|
||||
if (!k) return candidates.value;
|
||||
return candidates.value.filter(
|
||||
(u: any) =>
|
||||
(u.account || "").toLowerCase().includes(k) ||
|
||||
(u.realName || "").toLowerCase().includes(k)
|
||||
);
|
||||
});
|
||||
|
||||
function isExisting(u: any) {
|
||||
return existingSet.value.has(u.id);
|
||||
}
|
||||
function isChecked(u: any) {
|
||||
return isExisting(u) || selectedIds.value.has(u.id);
|
||||
}
|
||||
function toggle(u: any, v: boolean) {
|
||||
if (isExisting(u)) return;
|
||||
if (v) selectedIds.value.add(u.id);
|
||||
else selectedIds.value.delete(u.id);
|
||||
// 触发响应式
|
||||
selectedIds.value = new Set(selectedIds.value);
|
||||
}
|
||||
|
||||
async function onTreeChange(node: any) {
|
||||
groupId.value = node?.id ?? null;
|
||||
if (!groupId.value) {
|
||||
candidates.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await Apis.GroupAuthz.groupUsers({ params: { groupId: groupId.value }, cacheFor: null });
|
||||
candidates.value = (res as any[]) ?? [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "加载用户失败");
|
||||
candidates.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
groupId.value = null;
|
||||
candidates.value = [];
|
||||
selectedIds.value = new Set();
|
||||
kw.value = "";
|
||||
}
|
||||
|
||||
function onVisibleChange(v: boolean) {
|
||||
if (v) reset();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
try {
|
||||
// 全量覆盖:现有绑定 ∪ 本次新选
|
||||
const all = new Set<number>([...existingSet.value, ...selectedIds.value]);
|
||||
await Apis.AppRole.saveRoleUsers(
|
||||
{ data: { roleId: props.roleId, userIds: [...all] }, cacheFor: null }
|
||||
);
|
||||
Message.success("添加成功");
|
||||
emit("success");
|
||||
visible.value = false;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 若外部直接改 visible 同步内部状态
|
||||
watch(
|
||||
() => props.visible,
|
||||
(v) => {
|
||||
if (v) reset();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bind-modal-body {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 460px;
|
||||
}
|
||||
|
||||
.bind-tree {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #ebedf0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bind-users {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bind-users-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bind-kw {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bind-sel-tip {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bind-users-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.bind-tip {
|
||||
text-align: center;
|
||||
color: #bbb;
|
||||
padding: 40px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bind-user-item {
|
||||
padding: 7px 4px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
|
||||
.bind-user-name {
|
||||
font-weight: 500;
|
||||
color: #272829;
|
||||
}
|
||||
|
||||
.bind-user-sub {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="role-user-manage">
|
||||
<div class="rum-toolbar">
|
||||
<span class="rum-count">共 {{ userList.length }} 位用户</span>
|
||||
<Button type="primary" icon="md-person-add" :disabled="!roleId" @click="openBind">添加用户</Button>
|
||||
</div>
|
||||
|
||||
<Table :columns="columns" :data="userList" :loading="loading" size="small" border></Table>
|
||||
|
||||
<RoleUserBindModal v-model:visible="bindVisible" :role-id="roleId" :existing-user-ids="existingIds"
|
||||
@success="onBindSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Apis from "@/api";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import RoleUserBindModal from "./RoleUserBindModal.vue";
|
||||
|
||||
const { $utils } = useCurrentInstance();
|
||||
|
||||
const props = defineProps<{
|
||||
roleId: number;
|
||||
roleName?: string;
|
||||
}>();
|
||||
|
||||
const userList = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const bindVisible = ref(false);
|
||||
|
||||
// 当前角色已绑定的用户 id 集合(传给弹窗做去重/禁用)
|
||||
const existingIds = computed(() => userList.value.map((u: any) => u.id));
|
||||
|
||||
const columns = [
|
||||
{ title: "账号", key: "account", minWidth: 140 },
|
||||
{ title: "姓名", key: "realName", minWidth: 110 },
|
||||
{
|
||||
title: "操作", key: "action", width: 90, align: "center",
|
||||
render: (h: any, { row }: any) => $utils.operatingTextHandle(h, "删除", "text-error", () => removeUser(row)),
|
||||
},
|
||||
];
|
||||
|
||||
async function loadUsers() {
|
||||
if (!props.roleId) {
|
||||
userList.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await Apis.AppRole.roleUsers({ params: { roleId: props.roleId }, cacheFor: null });
|
||||
userList.value = (res as any[]) ?? [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "加载角色用户失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeUser(row: any) {
|
||||
MyConfirm({
|
||||
title: "移除用户",
|
||||
msg: `确定将用户【${row.realName || row.account}】从此角色移除?`,
|
||||
info: "移除后该用户不再拥有此角色所授权的权限。",
|
||||
async onOk() {
|
||||
try {
|
||||
await Apis.AppRole.removeRoleUser(
|
||||
{ params: { roleId: props.roleId, userId: row.id }, cacheFor: null }
|
||||
);
|
||||
Message.success("已移除");
|
||||
await loadUsers();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "移除失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openBind() {
|
||||
bindVisible.value = true;
|
||||
}
|
||||
|
||||
function onBindSuccess() {
|
||||
bindVisible.value = false;
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
watch(() => props.roleId, () => loadUsers(), { immediate: true });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.role-user-manage {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.rum-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rum-count {
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user