Files
UserbaseFront/src/views/AppOpen.vue
T
2026-08-18 23:31:27 +08:00

420 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>