init: UserBaseFront (frontend)

This commit is contained in:
chenxing
2026-08-10 14:41:13 +08:00
commit eea881b616
174 changed files with 32833 additions and 0 deletions
+15
View File
@@ -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>
+419
View File
@@ -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>
+724
View File
@@ -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>
+513
View File
@@ -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>
+15
View File
@@ -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>
+158
View File
@@ -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>
+300
View File
@@ -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>
+301
View File
@@ -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>
+7
View File
@@ -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 };
}
+64
View File
@@ -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>
+253
View File
@@ -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>
+335
View File
@@ -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>
+304
View File
@@ -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>
+218
View File
@@ -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>
+97
View File
@@ -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>
+295
View File
@@ -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>
+132
View File
@@ -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>
+173
View File
@@ -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>
+398
View File
@@ -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>
+214
View File
@@ -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>
+295
View File
@@ -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>
+129
View File
@@ -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>
+194
View File
@@ -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>
+50
View File
@@ -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>
+452
View File
@@ -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>
+276
View File
@@ -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>
+183
View File
@@ -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>
+185
View File
@@ -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>
+212
View File
@@ -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>
+109
View File
@@ -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>