init: UserBaseFront (frontend)
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user