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
+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>