420 lines
13 KiB
Vue
420 lines
13 KiB
Vue
<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) {
|
||
console.warn(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>
|