Merge pull request #180 from aklivecai/v2.0

vue后台更新
This commit is contained in:
孟帅 2025-07-12 14:27:36 +08:00 committed by GitHub
commit 9f2038cc4b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 469 additions and 50 deletions

View File

@ -13,6 +13,7 @@ VITE_DROP_CONSOLE=true
#VITE_PROXY = [["/appApi","http://localhost:8001"],["/upload","http://localhost:8001/upload"]]
VITE_PROXY=[["/admin","http://localhost:8000/admin"]]
# API 接口地址
VITE_GLOB_API_URL=
@ -24,3 +25,10 @@ VITE_GLOB_IMG_URL=
# 接口前缀
VITE_GLOB_API_URL_PREFIX=/admin
# 快速登录账号密码配置,没有配置则不开启快速登录
## 格式为 [["账号名称","账号密码","角色名称"],["账号名称","账号密码"],["账号名称"]]
## 帐号名称不能为空,密码为空则和帐号名称相同
## 角色名称可选,如果不填写则默认使用账号名称作为角色名称
## 账号名称和角色名称可以重复,账号名称和密码可以重复
VITE_APP_DEMO_ACCOUNT=[["admin","123456","超管"],["test","123456","管理员"],["ameng","123456","租户"],["abai","123456","商户"],["asong","123456","用户"]]

View File

@ -34,7 +34,7 @@ export function wrapperEnv(envConf: Recordable): ViteEnv {
} catch (error) {}
}
ret[envName] = realName;
process.env[envName] = realName;
// process.env[envName] = realName;
}
return ret;
}

43
web/src/debug/account.ts Normal file
View File

@ -0,0 +1,43 @@
import { isArray } from "@/utils/is";
interface Account {
/**
*
*/
name: string;
/**
*
*/
username: string;
/**
*
*/
password: string;
}
/**
*
* @returns {[]Account}
*/
export function getDemoAccounts() {
let envConf = import.meta.env.VITE_APP_DEMO_ACCOUNT || "";
// 帐号密码一样
// [["username"],["username","password"],["username","password","name"]]
try {
let accounts = JSON.parse(envConf);
if (accounts && isArray(accounts)) {
return accounts.map((item: String[]) => {
let [username = "", password = "", name = ""] = item;
username = username;
password = password || username;
name = name || username;
return {
name,
username,
password,
} as Account;
});
}
} catch (error) {}
return [] as Account[];
}

View File

@ -0,0 +1,49 @@
import { ref, Ref } from "vue";
type Resettable =
/**
*
* 1. `Ref<boolean>`访
* 2. `() => void`
* 3. `(value: boolean) => void`
* 4.`() => void``true`
* 5.`() => void``false`
*/
[Ref<boolean>, () => void, () => void] & /**
*
* - `loading: Ref<T>`
* - `startLoading: () => void`
* - `endLoading: () => void`
*/ {
/** 加载状态引用 */
bool: Ref<boolean>;
toggle: () => void;
setBool: (value: boolean) => void;
setTrue: () => void;
setFalse: () => void;
};
export default function useBoolean(initValue = false): Resettable {
const bool = ref(initValue);
function setBool(value: boolean) {
bool.value = value;
}
function setTrue() {
setBool(true);
}
function setFalse() {
setBool(false);
}
function toggle() {
setBool(!bool.value);
}
// 返回带加载状态引用、开始加载和结束加载方法的扩展数组
return (Object.assign([bool, toggle, setBool, setTrue, setFalse], {
bool,
toggle,
setBool,
setTrue,
setFalse,
}) as unknown) as Resettable;
}

View File

@ -0,0 +1,241 @@
<script setup lang="ts">
import { watchEffect, computed, nextTick, ref } from 'vue';
import { useRouter } from 'vue-router';
import useBoolean from "@/hooks/useBoolean";
import { useMagicKeys } from "@vueuse/core";
import { useAsyncRouteStore } from '@/store/modules/asyncRoute';
import { adaModalWidth } from '@/utils/hotgo';
import { tree2FlatArray } from '@/utils/tree';
import { cloneDeep, debounce } from 'lodash-es';
const routeStore = useAsyncRouteStore();
const meun = routeStore.getMenus
const menusFlat = computed(()=>{
const copyMeun = cloneDeep(meun)
//
return tree2FlatArray(copyMeun)
})
//
const searchValue = ref('')
//
const selectedIndex = ref<number>(0)
const { bool: showModal, setTrue: openModal, setFalse: closeModal, toggle: toggleModal } = useBoolean(false)
//
const { bool: keyboardFlag, setTrue: setKeyboardTrue, setFalse: setKeyboardFalse } = useBoolean(false)
const { ctrl_k, arrowup, arrowdown, enter } = useMagicKeys({
passive: false,
onEventFired(e) {
if (e.ctrlKey && e.key === 'k' && e.type === 'keydown')
e.preventDefault()
},
})
//
watchEffect(() => {
if (ctrl_k.value)
toggleModal()
})
//
const options = ref([])
const getFilterMenuOptions = debounce(()=> {
selectedIndex.value = 0
if (!searchValue.value){
options.value = []
return
}
const list = menusFlat.value.filter((item) => {
const conditions = [
item.name?.includes(searchValue.value),
(item?.meta?.title || '')?.includes(searchValue.value),
item.path?.includes(searchValue.value),
]
return conditions.some(condition => condition)
}).map((item) => {
return {
label: item?.meta?.title || item.name,
icon: item.icon,
value: item.path
}
})
options.value = list
}, 200)
const router = useRouter()
const dialogWidth = computed(() => {
return adaModalWidth(600);
});
//
function handleClose() {
searchValue.value = ''
selectedIndex.value = 0
closeModal()
}
//
function handleInputChange() {
getFilterMenuOptions()
}
//
function handleSelect(value: string) {
handleClose()
router.push(value)
nextTick(() => {
searchValue.value = ''
})
}
watchEffect(() => {
//
if (!showModal.value || !options.value.length)
return
// mouseover
setKeyboardTrue()
if (arrowup.value)
handleArrowup()
if (arrowdown.value)
handleArrowdown()
if (enter.value)
handleEnter()
})
const scrollbarRef = ref()
//
function handleArrowup() {
if (selectedIndex.value === 0)
selectedIndex.value = options.value.length - 1
else
selectedIndex.value--
handleScroll(selectedIndex.value)
}
//
function handleArrowdown() {
if (selectedIndex.value === options.value.length - 1)
selectedIndex.value = 0
else
selectedIndex.value++
handleScroll(selectedIndex.value)
}
function handleScroll(currentIndex: number) {
// 6,6
const keepIndex = 5
// gappadding
const elHeight = 70
const distance = currentIndex * elHeight > keepIndex * elHeight ? currentIndex * elHeight - keepIndex * elHeight : 0
scrollbarRef.value?.scrollTo({
top: distance,
})
}
//
function handleEnter() {
const target = options.value[selectedIndex.value]
if (target)
handleSelect(target.value)
}
//
function handleMouseEnter(index: number) {
if (keyboardFlag.value)
return
selectedIndex.value = index
}
</script>
<template>
<div class="flex items-center" @click="openModal" title="点击搜索菜单">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="#000000" d="m19.485 20.154l-6.262-6.262q-.75.639-1.725.989t-1.96.35q-2.402 0-4.066-1.663T3.808 9.503T5.47 5.436t4.064-1.667t4.068 1.664T15.268 9.5q0 1.042-.369 2.017t-.97 1.668l6.262 6.261zM9.539 14.23q1.99 0 3.36-1.37t1.37-3.361t-1.37-3.36t-3.36-1.37t-3.361 1.37t-1.37 3.36t1.37 3.36t3.36 1.37"/></svg>
<n-tag round size="small" class="font-mono cursor-pointer">
Ctrl K
</n-tag>
</div>
<n-modal
v-model:show="showModal"
class="fixed top-[60px] inset-x-0"
size="small"
preset="card"
:segmented="{
content: true,
footer: true,
}"
:style="{
width: dialogWidth,
}"
:closable="false"
@after-leave="handleClose"
>
<template #header>
<n-input v-model:value="searchValue" placeholder="搜索页面/路径" clearable size="large" @input="handleInputChange">
<template #prefix>
<n-icon>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="#6f6a6a" d="m19.485 20.154l-6.262-6.262q-.75.639-1.725.989t-1.96.35q-2.402 0-4.066-1.663T3.808 9.503T5.47 5.436t4.064-1.667t4.068 1.664T15.268 9.5q0 1.042-.369 2.017t-.97 1.668l6.262 6.261zM9.539 14.23q1.99 0 3.36-1.37t1.37-3.361t-1.37-3.36t-3.36-1.37t-3.361 1.37t-1.37 3.36t1.37 3.36t3.36 1.37"/></svg>
</n-icon>
</template>
</n-input>
</template>
<n-scrollbar ref="scrollbarRef" class="h-[450px]">
<ul
v-if="options.length"
class="flex flex-col gap-[8px] p-[8px] p-r-3"
>
<n-el
v-for="(option, index) in options"
:key="option.value" tag="li" role="option"
class="cursor-pointer shadow h-[62px]"
:class="{ 'text-[var(--base-color)] bg-[var(--primary-color-hover)]': index === selectedIndex }"
@click="handleSelect(option.value)"
@mouseenter="handleMouseEnter(index)"
@mousemove="setKeyboardFalse"
>
<div class="grid grid-cols-[1fr_30px] h-full p-2">
<span>{{ option.label }}</span>
<svg class="row-span-2 place-self-center" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="#6f6a6a" d="m13.292 12l-4.6-4.6l.708-.708L14.708 12L9.4 17.308l-.708-.708z"/></svg>
<span class="op-70">{{ option.value }}</span>
</div>
</n-el>
</ul>
<n-empty v-else size="large" class="h-[450px] flex-center" />
</n-scrollbar>
<template #footer>
<n-flex>
<div class="flex items-center gap-1">
<svg width="15" height="15" aria-label="Enter key" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M12 3.53088v3c0 1-1 2-2 2H4M7 11.53088l-3-3 3-3" /></g></svg>
<span>选择</span>
</div>
<div class="flex items-center gap-1">
<svg width="15" height="15" aria-label="Arrow down" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 3.5v8M10.5 8.5l-3 3-3-3" /></g></svg>
<svg width="15" height="15" aria-label="Arrow up" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 11.5v-8M10.5 6.5l-3-3-3 3" /></g></svg>
<span>切换</span>
</div>
<div class="flex items-center gap-1">
<svg width="15" height="15" aria-label="Escape key" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M13.6167 8.936c-.1065.3583-.6883.962-1.4875.962-.7993 0-1.653-.9165-1.653-2.1258v-.5678c0-1.2548.7896-2.1016 1.653-2.1016.8634 0 1.3601.4778 1.4875 1.0724M9 6c-.1352-.4735-.7506-.9219-1.46-.8972-.7092.0246-1.344.57-1.344 1.2166s.4198.8812 1.3445.9805C8.465 7.3992 8.968 7.9337 9 8.5c.032.5663-.454 1.398-1.4595 1.398C6.6593 9.898 6 9 5.963 8.4851m-1.4748.5368c-.2635.5941-.8099.876-1.5443.876s-1.7073-.6248-1.7073-2.204v-.4603c0-1.0416.721-2.131 1.7073-2.131.9864 0 1.6425 1.031 1.5443 2.2492h-2.956" /></g></svg>
<span>关闭</span>
</div>
</n-flex>
</template>
</n-modal>
</template>

View File

@ -69,6 +69,7 @@
</n-breadcrumb>
</div>
<div class="layout-header-right">
<Search />
<!-- <div-->
<!-- class="layout-header-trigger layout-header-trigger-min"-->
<!-- v-for="item in iconList"-->
@ -200,6 +201,8 @@
import { notificationStoreWidthOut } from '@/store/modules/notification';
import { getIcon } from '@/enums/systemMessageEnum';
import Search from './Search.vue';
export default defineComponent({
name: 'PageHeader',
components: {
@ -208,6 +211,7 @@
ProjectSetting,
AsideMenu,
SystemMessage,
Search,
},
props: {
collapsed: {

97
web/src/utils/tree.ts Normal file
View File

@ -0,0 +1,97 @@
/**
*
* @date 2023-01-09
* @returns {array}
*/
export const tree2FlatArray = (tree: any[], childrenKey: string = "children") => {
return tree.reduce((arr, item) => {
var children = item?.[childrenKey] ?? [];
var obj = item;
delete obj[childrenKey];
//解构赋值+默认值
return arr.concat([obj], tree2FlatArray(children)); //children部分进行递归
}, []);
};
/**
*
* @date 2023-01-09
* @returns {object}
*/
export const tree2Objects = (
tree: any[],
key: string = "id",
childrenKey: string = "children"
) => {
let rows = tree2FlatArray(tree, childrenKey);
let objects = {};
rows.forEach((item) => {
objects[item[key]] = item;
});
return objects;
};
/**
*
* @date 2022-04-20
* @param {object} data -
* @param {string} pid="parent_id" -
* @param {string} children="children" -
* @returns {object}
*/
export const treesBy = (
data: any[],
pid: string = "parent_id",
children: string = "children"
) => {
let map = {},
val = <any>[];
data.forEach((item) => {
item[children] = [];
map[item.id] = item;
});
data.forEach((item) => {
const parent = map[item[pid]];
if (!!parent) {
parent[children].push(item);
} else {
val.push(item);
}
});
return val;
};
/**
* ,
* @param {object} org
* @returns {object}
*/
export const mapTree = (org: any, keyId = "v", keyChild = "s", keyName = "n") => {
const haveChildren =
org[keyChild] && Array.isArray(org[keyChild]) && org[keyChild].length > 0;
return {
key: org[keyId],
value: String(org[keyId]),
label: org[keyName],
isLeaf: true,
children: haveChildren ? org[keyChild].map((i) => mapTree(i)) : [],
};
};
/**
* ,
* @param {object} org
* @returns {object}
*/
export const toFlatArray = (
tree: any[],
parentId: string | number,
keyId: string | number = "key"
) => {
return tree.reduce((t, _) => {
const child = _["children"];
return [
...t,
{ [keyId]: _[keyId], parentId },
...(child && child.length ? toFlatArray(child, _[keyId]) : []),
];
}, []);
};

View File

@ -1,4 +1,5 @@
<template>
<template v-if="accounts && accounts.length > 0">
<n-space :vertical="true">
<n-divider>演示角色登录</n-divider>
<n-space justify="center">
@ -8,53 +9,29 @@
type="primary"
@click="login(item.username, item.password)"
>
{{ item.label }}
{{ item.name }}
</n-button>
</n-space>
<n-space justify="center" class="mt-2">
<n-text depth="3">SaaS系统多租户多应用设计</n-text>
</n-space>
</n-space>
</template>
</template>
<script lang="ts" setup>
interface Emits {
(e: 'login', param: { username: string; password: string }): void;
}
import { getDemoAccounts } from "@/debug/account";
interface Emits {
(e: "login", param: { username: string; password: string }): void;
}
const emit = defineEmits<Emits>();
const emit = defineEmits<Emits>();
const accounts = [
{
label: '超管',
username: 'admin',
password: '123456',
},
{
label: '管理员',
username: 'test',
password: '123456',
},
{
label: '租户',
username: 'ameng',
password: '123456',
},
{
label: '商户',
username: 'abai',
password: '123456',
},
{
label: '用户',
username: 'asong',
password: '123456',
},
];
const accounts = getDemoAccounts();
function login(username: string, password: string) {
emit('login', { username, password });
}
function login(username: string, password: string) {
emit("login", { username, password });
}
</script>
<style scoped></style>