Merge branch 'master' into tauri

This commit is contained in:
xlsea
2025-06-05 22:16:58 +08:00
57 changed files with 979 additions and 329 deletions

73
.drone.yml Normal file
View File

@ -0,0 +1,73 @@
kind: pipeline
type: docker
name: Build and Deploy
clone:
depth: 10
volumes:
- name: go_cache
host:
path: /data/drone_cache/go_cache
steps:
- name: restore-cache
image: drillster/drone-volume-cache
volumes:
- name: go_cache
path: /cache
settings:
restore: true
mount:
- ./.npm-cache
- ./node_modules
- name: build
image: node:alpine
pull: if-not-exists
commands:
- export NODE_OPTIONS=--max_old_space_size=6144
- echo ${DRONE_BRANCH}
- echo ${DRONE_TAG}
- echo ${DRONE_COMMIT}
- echo ${DRONE_COMMIT:0-7}
- npm config set registry https://registry.npmmirror.com
- npm install -g pnpm
- pnpm config set registry https://registry.npmmirror.com
- pnpm i
- pnpm build
- name: rebuild-cache
image: drillster/drone-volume-cache
volumes:
- name: go_cache
path: /cache
settings:
rebuild: true
mount:
- ./.npm-cache
- ./node_modules
- name: scp files
image: appleboy/drone-scp
pull: if-not-exists
settings:
host:
from_secret: HOST
username:
from_secret: USERNAME
password:
from_secret: PASSWORD
port:
from_secret: PORT
target:
from_secret: TARGET_PATH
source: dist/*
overwrite: true
rm: true
trigger:
branch:
- master
event:
- push

View File

@ -1,30 +0,0 @@
---
name: Lint Code
permissions:
contents: write
on:
pull_request:
branches: [main]
jobs:
lint:
name: Lint All Code
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Lint Code Base
uses: github/super-linter@v4
env:
VALIDATE_ALL_CODEBASE: false
DEFAULT_BRANCH: main
# To change branch master or main
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FILTER_REGEX_EXCLUDE: (docs|.github)
VALIDATE_MARKDOWN: false

View File

@ -1,25 +0,0 @@
name: Release
permissions:
contents: write
on:
push:
tags:
- "v*"
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-node@v3
with:
node-version: 18.x
- run: npx githublogen
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

43
.github/workflows/deploy.yml vendored Normal file
View File

@ -0,0 +1,43 @@
name: Build and Deploy
on:
push:
branches: [ "master" ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.x
run_install: false
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Build project
run: pnpm build
- name: Upload via SCP
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USERNAME }}
password: ${{ secrets.SSH_PASSWORD }}
port: ${{ secrets.SSH_PORT }}
source: "dist/*"
target: ${{ secrets.TARGET_PATH }}
rm: true
overwrite: true

View File

@ -6,7 +6,7 @@ permissions:
on:
pull_request:
branches: [main]
branches: [master]
jobs:
lint:
@ -23,7 +23,7 @@ jobs:
uses: github/super-linter@v4
env:
VALIDATE_ALL_CODEBASE: false
DEFAULT_BRANCH: main
DEFAULT_BRANCH: master
# To change branch master or main
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FILTER_REGEX_EXCLUDE: (docs|.github)

13
CHANGELOG.md Normal file
View File

@ -0,0 +1,13 @@
# 更新日志
## [v1.0.0](https://gitee.com/xlsea/ruoyi-plus-soybean/releases/tag/v1.0.0) (2025-06-05)
###    🚀 新功能
1.0.0 版本正式发布,此版本不包含工作流与多语言,请期待后续版本发布。
###    ❤️ 贡献者
首次发版不展示过多贡献者,敬请谅解
[![soybeanjs](https://github.com/honghuangdc.png?size=48)](https://github.com/honghuangdc)  [![xlsea](https://github.com/m-xlsea.png?size=48)](https://gitee.com/xlsea)  [![Elio-An](https://github.com/Elio-An.png?size=48)](https://gitee.com/elio-an)  [![wangqiqi95](https://github.com/wangqiqi95.png?size=48)](https://github.com/wangqiqi95) 

View File

@ -7,7 +7,8 @@
<div align="center">
<p>一个基于 <a href="https://gitee.com/dromara/RuoYi-Vue-Plus" target="_blank">RuoYi-Vue-Plus</a> 的后端能力和 <a href="https://github.com/soybeanjs/soybean-admin" target="_blank">Soybean Admin</a> 前端特性的现代化多租户管理系统</p>
<p>
<img src="https://gitee.com/xlsea/ruoyi-plus-soybean/badge/star.svg?theme=blue" alt="Gitee">
<img src="https://gitee.com/xlsea/ruoyi-plus-soybean/badge/star.svg" alt="Gitee">
<img src="https://img.shields.io/github/stars/m-xlsea/ruoyi-plus-soybean" alt="Github">
<img src="https://img.shields.io/badge/Vue-3.5-brightgreen" alt="vue">
<img src="https://img.shields.io/badge/TypeScript-5.8-blue" alt="typescript">
<img src="https://img.shields.io/badge/Vite-6.2-orange" alt="vite">
@ -346,13 +347,14 @@ console.log(t('common.confirm'));
- **邮箱**: xlsea@linux.do
- **作者主页**: https://gitee.com/xlsea
- **作者**: Elio
- **邮箱**: 1983933789@qq.com
- **作者主页**: https://gitee.com/ahcode
## 💬 交流群
<img src="https://foruda.gitee.com/images/1747707357650535622/a9fcf43e_5601833.png" width="300px" />
<img src="https://foruda.gitee.com/images/1748404753216665472/3d8b1a0b_5601833.png" width="300px" />
## 🧧 捐献作者

View File

@ -26,9 +26,13 @@ UPDATE `sys_menu` SET `icon` = 'carbon:operations-record' WHERE `menu_id` = 500;
UPDATE `sys_menu` SET `icon` = 'tabler:login-2' WHERE `menu_id` = 501;
UPDATE `sys_menu` SET `icon` = 'gg:debug' WHERE `menu_id` = 1500;
UPDATE `sys_menu` SET `icon` = 'gg:debug' WHERE `menu_id` = 1506;
UPDATE `sys_menu` SET `path` = 'oss/config', `component` = 'system/oss-config/index', `icon` = 'hugeicons:configuration-01' WHERE `menu_id` = 133;
-- IFrame 类型
UPDATE `sys_menu` SET `component` = 'FrameView', `query_param` = 'https://ruoyi.xlsea.cn/admin/', `is_frame` = 2, `icon` = 'bx:bxl-spring-boot' WHERE `menu_id` = 117;
UPDATE `sys_menu` SET `component` = 'FrameView', `query_param` = 'https://preview.snailjob.opensnail.com/', `is_frame` = 2, `icon` = 'gridicons:scheduled' WHERE `menu_id` = 120;
-- 外链类型
UPDATE `sys_menu` SET `path` = 'https://gitee.com/xlsea/ruoyi-plus-soybean', `component` = 'FrameView', `icon` = 'local-icon-gitee' WHERE `menu_id` = 4;
-- plus-ui 需要禁用的页面
UPDATE `sys_menu` SET `status` = '1' WHERE `menu_id` IN ( '116', '130', '131', '132', '11700', '11701' );

View File

@ -56,12 +56,6 @@ const {
align: 'center',
width: 48
},
{
key: 'index',
title: $t('common.index'),
align: 'center',
width: 64
},
#foreach ($column in $columns)
#if($column.list)
{

View File

@ -1,7 +1,7 @@
{
"name": "ruoyi-vue-plus",
"type": "module",
"version": "1.0.0-beta.1",
"version": "1.0.0",
"description": "RuoYi-Vue-Plus多租户管理系统",
"author": {
"name": "xlsea",

View File

@ -2,40 +2,59 @@ import { computed, onScopeDispose, ref } from 'vue';
import { useRafFn } from '@vueuse/core';
/**
* count down
* A hook for implementing a countdown timer. It uses `requestAnimationFrame` for smooth and accurate timing,
* independent of the screen refresh rate.
*
* @param seconds - count down seconds
* @param initialSeconds - The total number of seconds for the countdown.
*/
export default function useCountDown(seconds: number) {
const FPS_PER_SECOND = 60;
export default function useCountDown(initialSeconds: number) {
const remainingSeconds = ref(0);
const fps = ref(0);
const count = computed(() => Math.ceil(remainingSeconds.value));
const count = computed(() => Math.ceil(fps.value / FPS_PER_SECOND));
const isCounting = computed(() => fps.value > 0);
const isCounting = computed(() => remainingSeconds.value > 0);
const { pause, resume } = useRafFn(
() => {
if (fps.value > 0) {
fps.value -= 1;
} else {
({ delta }) => {
// delta: milliseconds elapsed since the last frame.
// If countdown already reached zero or below, ensure it's 0 and stop.
if (remainingSeconds.value <= 0) {
remainingSeconds.value = 0;
pause();
return;
}
// Calculate seconds passed since the last frame.
const secondsPassed = delta / 1000;
remainingSeconds.value -= secondsPassed;
// If countdown has finished after decrementing.
if (remainingSeconds.value <= 0) {
remainingSeconds.value = 0;
pause();
}
},
{ immediate: false }
{ immediate: false } // The timer does not start automatically.
);
function start(updateSeconds: number = seconds) {
fps.value = FPS_PER_SECOND * updateSeconds;
/**
* Starts the countdown.
*
* @param [updatedSeconds=initialSeconds] - Optionally, start with a new duration. Default is `initialSeconds`
*/
function start(updatedSeconds: number = initialSeconds) {
remainingSeconds.value = updatedSeconds;
resume();
}
/** Stops the countdown and resets the remaining time to 0. */
function stop() {
fps.value = 0;
remainingSeconds.value = 0;
pause();
}
// Ensure the rAF loop is cleaned up when the component is unmounted.
onScopeDispose(() => {
pause();
});

View File

@ -0,0 +1 @@
<svg data-v-0a0a4a97="" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bell-icon size-4"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"></path><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"></path></svg>

After

Width:  |  Height:  |  Size: 353 B

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
import { useAttrs } from 'vue';
import type { DataTableProps } from 'naive-ui';
import type { CreateRowKey } from 'naive-ui/es/data-table/src/interface';
import { useThemeStore } from '@/store/modules/theme';
defineOptions({
name: 'DataTable',
inheritAttrs: false
});
interface Props {
rowKey?: CreateRowKey<any>;
}
defineProps<Props>();
const { table } = useThemeStore();
const attrs: DataTableProps = useAttrs();
</script>
<template>
<NDataTable
:bordered="table.bordered"
:bottom-bordered="table.bottomBordered"
:single-column="table.singleColumn"
:single-line="table.singleLine"
:size="table.size"
:striped="table.striped"
:row-key="rowKey"
v-bind="attrs"
/>
</template>
<style scoped></style>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, useAttrs } from 'vue';
import { type VNode, computed, useAttrs } from 'vue';
import type { ButtonProps, PopoverPlacement } from 'naive-ui';
import { twMerge } from 'tailwind-merge';
@ -11,6 +11,8 @@ defineOptions({
interface Props {
/** Button class */
class?: string;
/** Show popconfirm icon */
showPopconfirmIcon?: boolean;
/** Iconify icon name */
icon?: string;
/** Local icon name */
@ -19,8 +21,8 @@ interface Props {
tooltipContent?: string;
/** Tooltip placement */
tooltipPlacement?: PopoverPlacement;
/** Popconfirm content */
popconfirmContent?: string;
/** Popconfirm content - can be string or VNode */
popconfirmContent?: string | VNode;
zIndex?: number;
quaternary?: boolean;
[key: string]: any;
@ -28,6 +30,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
class: '',
showPopconfirmIcon: true,
icon: '',
localIcon: '',
tooltipContent: '',
@ -59,8 +62,11 @@ const handlePositiveClick = () => {
<template>
<NTooltip :placement="tooltipPlacement" :z-index="zIndex" :disabled="!tooltipContent">
<template #trigger>
<NPopconfirm :disabled="!popconfirmContent" @positive-click="handlePositiveClick">
{{ popconfirmContent }}
<NPopconfirm :show-icon="showPopconfirmIcon" :disabled="!popconfirmContent" @positive-click="handlePositiveClick">
<template #default>
<component :is="popconfirmContent" v-if="typeof popconfirmContent !== 'string'" />
<template v-else>{{ popconfirmContent }}</template>
</template>
<template #trigger>
<NButton
:quaternary="quaternary"

View File

@ -44,8 +44,8 @@ const dictTagData = computed<Api.System.DictData[]>(() => {
:key="item.dictValue"
class="m-1"
:class="[item.cssClass]"
:type="item.listClass"
v-bind="attrs"
:type="item.listClass || 'default'"
>
{{ item.dictLabel }}
</NTag>

View File

@ -33,21 +33,18 @@ const props = withDefaults(defineProps<Props>(), {
const attrs: UploadProps = useAttrs();
const value = defineModel<CommonType.IdType[]>('value', { required: false, default: [] });
let fileNum = 0;
const fileList = ref<UploadFileInfo[]>([]);
const needRelaodData = defineModel<boolean>('needRelaodData', {
default: false
});
const needRelaodData = ref(false);
defineExpose({
refreshList: needRelaodData
needRelaodData
});
watch(
() => fileList.value,
newValue => {
needRelaodData.value = newValue.length > 0;
value.value = newValue.map(item => item.id);
}
);

View File

@ -9,11 +9,13 @@ defineOptions({ name: 'MenuTree' });
interface Props {
immediate?: boolean;
showHeader?: boolean;
[key: string]: any;
}
const props = withDefaults(defineProps<Props>(), {
immediate: true
immediate: true,
showHeader: true
});
const { bool: expandAll } = useBoolean();
@ -106,7 +108,7 @@ defineExpose({
<template>
<div class="w-full flex-col gap-12px">
<div class="w-full flex-center">
<div v-if="showHeader" class="w-full flex-center">
<NCheckbox v-model:checked="expandAll" :checked-value="true" :unchecked-value="false">展开/折叠</NCheckbox>
<NCheckbox
v-model:checked="checkAll"

View File

@ -63,3 +63,11 @@ export const resetCacheStrategyRecord: Record<UnionKey.ResetCacheStrategy, App.I
export const resetCacheStrategyOptions = transformRecordToOption(resetCacheStrategyRecord);
export const DARK_CLASS = 'dark';
export const themeTableSizeRecord: Record<UnionKey.ThemeTableSize, App.I18n.I18nKey> = {
small: 'theme.table.size.small',
medium: 'theme.table.size.medium',
large: 'theme.table.size.large'
};
export const themeTableSizeOptions = transformRecordToOption(themeTableSizeRecord);

View File

@ -3,5 +3,6 @@ export enum SetupStoreId {
Theme = 'theme-store',
Auth = 'auth-store',
Route = 'route-store',
Tab = 'tab-store'
Tab = 'tab-store',
Notice = 'notice-store'
}

View File

@ -0,0 +1,152 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useNoticeStore } from '@/store/modules/notice';
defineOptions({
name: 'MessgaeButton'
});
const show = ref(false);
const noticeStore = useNoticeStore();
const { state } = storeToRefs(noticeStore);
const noticeNum = computed(() => {
return state.value.notices.filter(notice => !notice.read).length || 0;
});
const toGitee = () => {
window.open('https://gitee.com/xlsea/ruoyi-plus-soybean', '_blank');
};
</script>
<template>
<NPopover v-model:show="show" trigger="click" arrow-point-to-center raw class="border-rounded-6px">
<template #trigger>
<NTooltip :disabled="show">
<template #trigger>
<NButton quaternary class="bell-button h-36px text-icon" :focusable="false">
<NBadge :value="noticeNum" :max="99" :offset="[2, -2]">
<div class="bell-icon flex-center gap-8px">
<SvgIcon local-icon="bell" />
</div>
</NBadge>
</NButton>
</template>
消息
</NTooltip>
</template>
<NCard
size="small"
:bordered="false"
class="w-340px"
header-class="p-0"
:segmented="{ content: true, footer: 'soft' }"
>
<template #header>
<span>通知公告</span>
</template>
<template #header-extra>
<NTooltip placement="left" :z-index="98">
<template #trigger>
<NPopconfirm @positive-click="() => noticeStore.readAll()">
<template #trigger>
<NButton quaternary>
<div class="flex-center gap-8px">
<SvgIcon icon="lucide:mail-check" class="text-16px" />
</div>
</NButton>
</template>
确定全部已读吗?
</NPopconfirm>
</template>
一键已读
</NTooltip>
</template>
<div>
<template v-if="state?.notices?.length">
<template v-for="(message, index) in state?.notices" :key="index">
<NDivider v-show="index !== 0" />
<div class="flex cursor-pointer" @click="() => noticeStore.readNotice(message)">
<div class="flex-col justify-between gap-3px">
<NEllipsis class="w-260px">{{ message.message }}</NEllipsis>
<span class="text-#898989">
{{ message.time }}
</span>
</div>
<div>
<NTag :type="message.read ? 'success' : 'error'">{{ message.read ? '已读' : '未读' }}</NTag>
</div>
</div>
</template>
</template>
<NEmpty v-else class="h-180px flex-center" />
</div>
<template #footer>
<div class="flex items-center justify-end">
<NButton type="primary" size="small" @click="toGitee">前往 Gitee</NButton>
</div>
</template>
</NCard>
</NPopover>
</template>
<style scoped lang="scss">
:deep(.n-divider) {
margin: 12px 0;
}
:deep(.n-thing-header) {
margin-bottom: 1px !important;
}
:deep(.n-thing-main__content) {
margin-top: 0 !important;
}
:deep(.messgae-popover) {
padding: 0 !important;
}
:deep(.n-badge-sup) {
padding: 0 5px !important;
font-size: 10px !important;
height: 15px !important;
line-height: 15px !important;
}
.bell-button {
&:hover {
.bell-icon {
animation: bell-ring 1s both;
}
}
}
@keyframes bell-ring {
0%,
100% {
transform-origin: top;
}
15% {
transform: rotateZ(10deg);
}
30% {
transform: rotateZ(-10deg);
}
45% {
transform: rotateZ(5deg);
}
60% {
transform: rotateZ(-5deg);
}
75% {
transform: rotateZ(2deg);
}
}
</style>

View File

@ -10,6 +10,7 @@ import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
import GlobalSearch from '../global-search/index.vue';
import ThemeButton from './components/theme-button.vue';
import UserAvatar from './components/user-avatar.vue';
import MessageButton from './components/message-button.vue';
defineOptions({
name: 'GlobalHeader'
@ -44,7 +45,8 @@ const tenantId = ref<CommonType.IdType>(authStore.userInfo?.user?.tenantId || '0
</div>
<div class="h-full flex-y-center justify-end">
<TenantSelect v-if="!appStore.isMobile" v-model:value="tenantId" class="mr-12px w-150px" />
<GlobalSearch />
<GlobalSearch v-if="themeStore.header.globalSearch.visible" />
<MessageButton />
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
<LangSwitch
v-if="themeStore.header.multilingual.visible"

View File

@ -5,7 +5,6 @@ import { useElementBounding } from '@vueuse/core';
import { PageTab } from '@sa/materials';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useTabStore } from '@/store/modules/tab';
import { isPC } from '@/utils/agent';
import BetterScroll from '@/components/custom/better-scroll.vue';
@ -18,7 +17,6 @@ defineOptions({
const route = useRoute();
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const tabStore = useTabStore();
const bsWrapper = ref<HTMLElement>();
@ -82,12 +80,8 @@ function getContextMenuDisabledKeys(tabId: string) {
return disabledKeys;
}
async function handleCloseTab(tab: App.Global.Tab) {
await tabStore.removeTab(tab.id);
if (themeStore.resetCacheStrategy === 'close') {
routeStore.resetRouteCache(tab.routeKey);
}
function handleCloseTab(tab: App.Global.Tab) {
tabStore.removeTab(tab.id);
}
async function refresh() {

View File

@ -6,6 +6,7 @@ import LayoutMode from './modules/layout-mode.vue';
import ThemeColor from './modules/theme-color.vue';
import PageFun from './modules/page-fun.vue';
import ConfigOperation from './modules/config-operation.vue';
import TableProps from './modules/table-props.vue';
defineOptions({
name: 'ThemeDrawer'
@ -21,6 +22,7 @@ const appStore = useAppStore();
<LayoutMode />
<ThemeColor />
<PageFun />
<TableProps />
<template #footer>
<ConfigOperation />
</template>

View File

@ -130,6 +130,9 @@ const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wra
<SettingItem key="9" :label="$t('theme.header.multilingual.visible')">
<NSwitch v-model:value="themeStore.header.multilingual.visible" />
</SettingItem>
<SettingItem key="10" :label="$t('theme.header.globalSearch.visible')">
<NSwitch v-model:value="themeStore.header.globalSearch.visible" />
</SettingItem>
</TransitionGroup>
</template>

View File

@ -0,0 +1,44 @@
<script setup lang="ts">
import { themeTableSizeOptions } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { translateOptions } from '@/utils/common';
import { $t } from '@/locales';
import SettingItem from '../components/setting-item.vue';
defineOptions({
name: 'TableProps'
});
const themeStore = useThemeStore();
</script>
<template>
<NDivider>{{ $t('theme.tablePropsTitle') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="0" :label="$t('theme.table.size.title')">
<NSelect
v-model:value="themeStore.table.size"
:options="translateOptions(themeTableSizeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem key="1" :label="$t('theme.table.bordered')">
<NSwitch v-model:value="themeStore.table.bordered" />
</SettingItem>
<SettingItem key="2" :label="$t('theme.table.bottomBordered')">
<NSwitch v-model:value="themeStore.table.bottomBordered" />
</SettingItem>
<SettingItem key="3" :label="$t('theme.table.singleColumn')">
<NSwitch v-model:value="themeStore.table.singleColumn" :checked-value="false" :unchecked-value="true" />
</SettingItem>
<SettingItem key="4" :label="$t('theme.table.singleLine')">
<NSwitch v-model:value="themeStore.table.singleLine" :checked-value="false" :unchecked-value="true" />
</SettingItem>
<SettingItem key="5" :label="$t('theme.table.striped')">
<NSwitch v-model:value="themeStore.table.striped" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped></style>

View File

@ -135,6 +135,9 @@ const local: App.I18n.Schema = {
},
multilingual: {
visible: 'Display multilingual button'
},
globalSearch: {
visible: 'Display GlobalSearch button'
}
},
tab: {
@ -165,6 +168,20 @@ const local: App.I18n.Schema = {
visible: 'Watermark Full Screen Visible',
text: 'Watermark Text'
},
tablePropsTitle: 'Table Props',
table: {
size: {
title: 'Table Size',
small: 'Small',
medium: 'Medium',
large: 'Large'
},
bordered: 'Bordered',
bottomBordered: 'Bottom Bordered',
singleColumn: 'Single Column',
singleLine: 'Single Line',
striped: 'Striped'
},
themeDrawerTitle: 'Theme Configuration',
pageFunTitle: 'Page Function',
resetCacheStrategy: {
@ -574,13 +591,14 @@ const local: App.I18n.Schema = {
buttonPermissionList: 'Button Permission List',
emptyMenu: 'Empty Menu',
menuDetail: 'Menu Detail',
cascadeDeleteContent: 'Cascade delete menu will delete the selected menu and all its sub-menus, are you sure?',
iconifyTip: 'iconify address`https://icones.js.org`',
isFrameTip: 'If you choose External Link, the routing address needs to start with `http(s)://`',
isCacheTip:
'If you select yes, it will be cached by `keep-alive`, and the `name` and address of the matching component must be consistent',
visibleTip: 'If you choose Hide, the route will not appear in the sidebar, but it can still be accessed.',
statusTip: 'If you choose to disable, the route will not appear in the sidebar and cannot be accessed.',
permsTip: "Permission string defined in the controller, such as: @SaCheckPermission('system:user:list')",
permsTip: "Permission string defined in the controller, such as: {'@'}SaCheckPermission('system:user:list')",
componentTip:
'The component path to access, such as: `system/user/index`, which is in the `views` directory by default',
pathTip:
@ -598,6 +616,10 @@ const local: App.I18n.Schema = {
required: 'Please select Menu Icon',
invalid: 'Menu Icon cannot be empty'
},
menuIds: {
required: 'Please select Menu',
invalid: 'Menu cannot be empty'
},
menuName: {
required: 'Please enter Menu Name',
invalid: 'Menu Name cannot be empty'
@ -655,7 +677,8 @@ const local: App.I18n.Schema = {
button: 'Button',
addMenu: 'Add Menu',
addChildMenu: 'Add Child Menu',
editMenu: 'Edit Menu'
editMenu: 'Edit Menu',
cascadeDelete: 'Cascade Delete Menu'
},
notice: {
title: 'Notice List',

View File

@ -135,6 +135,9 @@ const local: App.I18n.Schema = {
},
multilingual: {
visible: '显示多语言按钮'
},
globalSearch: {
visible: '显示全局搜索按钮'
}
},
tab: {
@ -165,6 +168,20 @@ const local: App.I18n.Schema = {
visible: '显示全屏水印',
text: '水印文本'
},
tablePropsTitle: '表格配置',
table: {
size: {
title: '表格大小',
small: '小',
medium: '中',
large: '大'
},
bordered: '边框',
bottomBordered: '底部边框',
singleColumn: '设定行的分割线',
singleLine: '设定列的分割线',
striped: '斑马线条纹'
},
themeDrawerTitle: '主题配置',
pageFunTitle: '页面功能',
resetCacheStrategy: {
@ -574,12 +591,13 @@ const local: App.I18n.Schema = {
buttonPermissionList: '按钮权限列表',
emptyMenu: '暂无菜单',
menuDetail: '菜单详情',
cascadeDeleteContent: '级联删除菜单将删除所选中的菜单,是否继续?',
iconifyTip: 'iconify 地址https://icones.js.org',
isFrameTip: '选择是外链则路由地址需要以`http(s)://`开头',
isCacheTip: '选择是则会被`keep-alive`缓存,需要匹配组件的`name`和地址保持一致',
visibleTip: '选择隐藏则路由将不会出现在侧边栏,但仍然可以访问',
statusTip: '选择停用则路由将不会出现在侧边栏,也不能被访问',
permsTip: "控制器中定义的权限字符,如:`@SaCheckPermission('system:user:list')`",
permsTip: "控制器中定义的权限字符,如:`{'@'}SaCheckPermission('system:user:list')`",
componentTip: '访问的组件路径,如:`system/user/index`,默认在`views`目录下',
pathTip:
'Router pathExample`user`If the external network address needs to be accessed in the internal link,then `http(s)://` beginning',
@ -592,6 +610,10 @@ const local: App.I18n.Schema = {
required: '请选择菜单类型',
invalid: '菜单类型不能为空'
},
menuIds: {
required: '请选择菜单',
invalid: '菜单不能为空'
},
icon: {
required: '请选择菜单图标',
invalid: '菜单图标不能为空'
@ -653,7 +675,8 @@ const local: App.I18n.Schema = {
button: '按钮',
addMenu: '新增菜单',
addChildMenu: '新增子菜单',
editMenu: '编辑菜单'
editMenu: '编辑菜单',
cascadeDelete: '级联删除菜单'
},
notice: {
title: '通知公告列表',

View File

@ -25,8 +25,8 @@ export function setupAppVersionNotification() {
const buildTime = await getHtmlBuildTime();
// If build time hasn't changed, no update is needed
if (buildTime === BUILD_TIME) {
// If failed to get build time or build time hasn't changed, no update is needed.
if (!buildTime || buildTime === BUILD_TIME) {
return;
}
@ -88,16 +88,22 @@ export function setupAppVersionNotification() {
}
}
async function getHtmlBuildTime() {
async function getHtmlBuildTime(): Promise<string | null> {
const baseUrl = import.meta.env.VITE_BASE_URL || '/';
const res = await fetch(`${baseUrl}index.html?time=${Date.now()}`);
try {
const res = await fetch(`${baseUrl}index.html?time=${Date.now()}`);
const html = await res.text();
if (!res.ok) {
console.error('getHtmlBuildTime error:', res.status, res.statusText);
return null;
}
const match = html.match(/<meta name="buildTime" content="(.*)">/);
const buildTime = match?.[1] || '';
return buildTime;
const html = await res.text();
const match = html.match(/<meta name="buildTime" content="(.*)">/);
return match?.[1] || null;
} catch (error) {
console.error('getHtmlBuildTime error:', error);
return null;
}
}

View File

@ -123,31 +123,6 @@ const dynamicConstantRoutes: ElegantRoute[] = [
icon: 'material-symbols:account-circle-full',
hideInMenu: true
}
},
{
name: 'system',
path: '/system',
component: 'layout.base',
meta: {
title: 'system',
i18nKey: 'route.system',
localIcon: 'menu-system',
order: 1
},
children: [
{
name: 'system_oss-config',
path: '/system/oss-config',
component: 'view.system_oss-config',
meta: {
title: 'system_oss-config',
i18nKey: 'route.system_oss-config',
constant: true,
hideInMenu: true,
icon: 'hugeicons:configuration-01'
}
}
]
}
];

View File

@ -59,3 +59,11 @@ export function fetchGetTenantPackageMenuTreeSelect(packageId: CommonType.IdType
method: 'get'
});
}
// 级联删除菜单
export function fetchCascadeDeleteMenu(menuIds: CommonType.IdType[]) {
return request<boolean>({
url: `/system/menu/cascade/${menuIds.join(',')}`,
method: 'delete'
});
}

View File

@ -80,6 +80,9 @@ export function fetchResetUserPassword(userId: CommonType.IdType, password: stri
return request<boolean>({
url: '/system/user/resetPwd',
method: 'put',
headers: {
isEncrypt: true
},
data: { userId, password }
});
}

View File

@ -1,5 +1,6 @@
import { reactive } from 'vue';
import { defineStore } from 'pinia';
import { SetupStoreId } from '@/enum';
interface NoticeItem {
title?: string;
@ -8,7 +9,7 @@ interface NoticeItem {
time: string;
}
export const useNoticeStore = defineStore('notice', () => {
export const useNoticeStore = defineStore(SetupStoreId.Notice, () => {
const state = reactive({
notices: [] as NoticeItem[]
});
@ -21,6 +22,10 @@ export const useNoticeStore = defineStore('notice', () => {
state.notices.splice(state.notices.indexOf(notice), 1);
};
const readNotice = (notice: NoticeItem) => {
state.notices[state.notices.indexOf(notice)].read = true;
};
// 实现全部已读
const readAll = () => {
state.notices.forEach((item: any) => {
@ -31,10 +36,12 @@ export const useNoticeStore = defineStore('notice', () => {
const clearNotice = () => {
state.notices = [];
};
return {
state,
addNotice,
removeNotice,
readNotice,
readAll,
clearNotice
};

View File

@ -100,6 +100,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
authRoutes.value = Array.from(authRoutesMap.values());
}
// eslint-disable-next-line complexity
function parseRouter(route: ElegantConstRoute, parent?: ElegantConstRoute) {
route.meta = route.meta ? route.meta : { title: route.name };
const isLayout = route.component === 'Layout';
@ -129,6 +130,10 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
// @ts-expect-error no hidden field
route.meta.hideInMenu = route.hidden;
if (route.meta.hideInMenu && parent) {
// @ts-expect-error parent.name is activeMenu type
route.meta.activeMenu = parent.name;
}
// 是否需要keepAlive
route.meta.keepAlive = !route.meta.noCache;

View File

@ -98,13 +98,22 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
const removeTabIndex = tabs.value.findIndex(tab => tab.id === tabId);
if (removeTabIndex === -1) return;
const removedTabRouteKey = tabs.value[removeTabIndex].routeKey;
const isRemoveActiveTab = activeTabId.value === tabId;
const nextTab = tabs.value[removeTabIndex + 1] || homeTab.value;
// remove tab
tabs.value.splice(removeTabIndex, 1);
// if current tab is removed, then switch to next tab
if (isRemoveActiveTab && nextTab) {
await switchRouteByTab(nextTab);
}
// reset route cache if cache strategy is close
if (themeStore.resetCacheStrategy === 'close') {
routeStore.resetRouteCache(removedTabRouteKey);
}
}
/** remove active tab */
@ -131,9 +140,26 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
*/
async function clearTabs(excludes: string[] = [], clearCache: boolean = false) {
const remainTabIds = [...getFixedTabIds(tabs.value), ...excludes];
const removedTabsIds = tabs.value.map(tab => tab.id).filter(id => !remainTabIds.includes(id));
// Identify tabs to be removed and collect their routeKeys if strategy is 'close'
const tabsToRemove = tabs.value.filter(tab => !remainTabIds.includes(tab.id));
const routeKeysToReset: RouteKey[] = [];
if (themeStore.resetCacheStrategy === 'close') {
for (const tab of tabsToRemove) {
routeKeysToReset.push(tab.routeKey);
}
}
const removedTabsIds = tabsToRemove.map(tab => tab.id);
// If no tabs are actually being removed based on excludes and fixed tabs, exit
if (removedTabsIds.length === 0) {
return;
}
const isRemoveActiveTab = removedTabsIds.includes(activeTabId.value);
// filterTabsByIds returns tabs NOT in removedTabsIds, so these are the tabs that will remain
const updatedTabs = filterTabsByIds(removedTabsIds, tabs.value);
if (clearCache) {
@ -152,13 +178,21 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
if (!isRemoveActiveTab) {
update();
return;
} else {
const activeTabCandidate = updatedTabs[updatedTabs.length - 1] || homeTab.value;
if (activeTabCandidate) {
// Ensure there's a tab to switch to
await switchRouteByTab(activeTabCandidate);
}
// Update the tabs array regardless of switch success or if a candidate was found
update();
}
const activeTab = updatedTabs[updatedTabs.length - 1] || homeTab.value;
await switchRouteByTab(activeTab);
update();
// After tabs are updated and route potentially switched, reset cache for removed tabs
for (const routeKey of routeKeysToReset) {
routeStore.resetRouteCache(routeKey);
}
}
const { routerPushByKey } = useRouterPush();

View File

@ -30,6 +30,9 @@ export const themeSettings: App.Theme.ThemeSetting = {
},
multilingual: {
visible: true
},
globalSearch: {
visible: true
}
},
tab: {
@ -57,6 +60,14 @@ export const themeSettings: App.Theme.ThemeSetting = {
visible: import.meta.env.VITE_WATERMARK === 'Y',
text: 'RuoYi-Vue-Plus'
},
table: {
bordered: true,
bottomBordered: true,
singleColumn: false,
singleLine: true,
size: 'small',
striped: false
},
tokens: {
light: {
colors: {

View File

@ -10,6 +10,9 @@ declare namespace Api {
* backend api module: "monitor"
*/
namespace Monitor {
/** 业务操作类型 */
type BusinessType = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
/** oper log */
type OperLog = Common.CommonRecord<{
/** 日志主键 */
@ -19,13 +22,13 @@ declare namespace Api {
/** 系统模块 */
title: string;
/** 操作类型 */
businessType: number;
businessType: Monitor.BusinessType;
/** 方法名称 */
method: string;
/** 请求方式 */
requestMethod: string;
/** 操作类别 */
operatorType: number;
operatorType: string;
/** 操作人员 */
operName: string;
/** 部门名称 */
@ -41,7 +44,7 @@ declare namespace Api {
/** 返回参数 */
jsonResult: string;
/** 操作状态 */
status: number;
status: Common.EnableStatus;
/** 错误消息 */
errorMsg: string;
/** 操作时间 */
@ -70,7 +73,7 @@ declare namespace Api {
/** 客户端 */
clientKey: string;
/** 设备类型 */
deviceType: string;
deviceType: System.DeviceType;
/** 登录IP地址 */
ipaddr: string;
/** 登录地点 */
@ -80,7 +83,7 @@ declare namespace Api {
/** 操作系统 */
os: string;
/** 登录状态0成功 1失败 */
status: string;
status: Common.EnableStatus;
/** 提示消息 */
msg: string;
/** 访问时间 */
@ -149,7 +152,7 @@ declare namespace Api {
/** 所在部门 */
deptName: string;
/** 设备类型 */
deviceType: string;
deviceType: System.DeviceType;
/** 登录时间 */
loginTime: number;
/** 令牌ID */

View File

@ -34,7 +34,7 @@ declare namespace Api {
/** 显示顺序 */
roleSort: number;
/** 角色状态0正常 1停用 */
status: string;
status: Common.EnableStatus;
/** 是否管理员 */
superAdmin: boolean;
}>;
@ -115,7 +115,7 @@ declare namespace Api {
/** 密码 */
password: string;
/** 帐号状态0正常 1停用 */
status: string;
status: Common.EnableStatus;
/** 最后登录IP */
loginIp: string;
/** 最后登录时间 */
@ -356,7 +356,7 @@ declare namespace Api {
/** 字典键值 */
dictValue: string;
/** 是否默认Y是 N否 */
isDefault: string;
isDefault: Common.YesOrNoStatus;
/** 表格回显样式 */
listClass: NaiveUI.ThemeColor;
/** 备注 */
@ -402,7 +402,7 @@ declare namespace Api {
/** 邮箱 */
email: string;
/** 部门状态0正常 1停用 */
status: string;
status: Common.EnableStatus;
/** 子部门 */
children: Dept[];
}>;
@ -440,7 +440,7 @@ declare namespace Api {
/** 显示顺序 */
postSort: number;
/** 状态0正常 1停用 */
status: string;
status: Common.EnableStatus;
/** 备注 */
remark: string;
}>;
@ -476,7 +476,7 @@ declare namespace Api {
/** 参数键值 */
configValue: string;
/** 是否内置 */
configType: string;
configType: Common.YesOrNoStatus;
/** 备注 */
remark: string;
}>;
@ -523,7 +523,7 @@ declare namespace Api {
/** 用户数量(-1不限制 */
accountCount: number;
/** 租户状态0正常 1停用 */
status: string;
status: Common.EnableStatus;
/** 删除标志0代表存在 1代表删除 */
delFlag: string;
}>;
@ -577,7 +577,7 @@ declare namespace Api {
/** 菜单树选择项是否关联显示 */
menuCheckStrictly: boolean;
/** 状态0正常 1停用 */
status: string;
status: Common.EnableStatus;
/** 删除标志0代表存在 1代表删除 */
delFlag: string;
}>;
@ -602,6 +602,9 @@ declare namespace Api {
/** tenant package select list */
type TenantPackageSelectList = Common.CommonRecord<Pick<TenantPackage, 'packageId' | 'packageName'>>;
/** 通知公告类型 */
type NoticeType = '1' | '2';
/** notice */
type Notice = Common.CommonRecord<{
/** 公告ID */
@ -611,11 +614,11 @@ declare namespace Api {
/** 公告标题 */
noticeTitle: string;
/** 公告类型 */
noticeType: string;
noticeType: System.NoticeType;
/** 公告内容 */
noticeContent: string;
/** 公告状态 */
status: string;
status: Common.EnableStatus;
/** 创建者 */
createByName: string;
/** 备注 */
@ -635,6 +638,12 @@ declare namespace Api {
/** notice list */
type NoticeList = Api.Common.PaginatingQueryRecord<Notice>;
/** 授权类型 */
type GrantType = 'password' | 'sms' | 'password' | 'email' | 'xcx' | 'social';
/** 设备类型 */
type DeviceType = 'pc' | 'android' | 'ios' | 'xcx';
/** client */
type Client = Common.CommonRecord<{
/** id */
@ -646,17 +655,17 @@ declare namespace Api {
/** 客户端秘钥 */
clientSecret: string;
/** 授权类型 */
grantType: string;
grantType: System.GrantType;
/** 授权类型列表 */
grantTypeList: string[];
grantTypeList: System.GrantType[];
/** 设备类型 */
deviceType: string;
deviceType: System.DeviceType;
/** token活跃超时时间 */
activeTimeout: number;
/** token固定超时 */
timeout: number;
/** 状态 */
status: string;
status: Common.EnableStatus;
/** 删除标志0代表存在 1代表删除 */
delFlag: string;
}>;
@ -758,13 +767,13 @@ declare namespace Api {
/** 自定义域名 */
domain: string;
/** 是否httpsY=是,N=否) */
isHttps: Api.Common.YesOrNoStatus;
isHttps: Common.YesOrNoStatus;
/** 域 */
region: string;
/** 桶权限类型 */
accessPolicy: Api.System.OssAccessPolicy;
accessPolicy: System.OssAccessPolicy;
/** 是否默认0=是,1=否) */
status: Api.Common.EnableStatus;
status: Common.EnableStatus;
/** 扩展字段 */
ext1: string;
/** 备注 */

33
src/typings/app.d.ts vendored
View File

@ -58,6 +58,10 @@ declare namespace App {
/** Whether to show the multilingual */
visible: boolean;
};
globalSearch: {
/** Whether to show the GlobalSearch */
visible: boolean;
};
};
/** Tab */
tab: {
@ -109,6 +113,20 @@ declare namespace App {
/** Watermark text */
text: string;
};
table: {
/** Whether to show the table border */
bordered: boolean;
/** Whether to show the table bottom border */
bottomBordered: boolean;
/** Whether to show the table single column */
singleColumn: boolean;
/** Whether to show the table single line */
singleLine: boolean;
/** Whether to show the table size */
size: UnionKey.ThemeTableSize;
/** Whether to show the table striped */
striped: boolean;
};
/** define some theme settings tokens, will transform to css variables */
tokens: {
light: ThemeSettingToken;
@ -401,6 +419,9 @@ declare namespace App {
multilingual: {
visible: string;
};
globalSearch: {
visible: string;
};
};
tab: {
visible: string;
@ -426,6 +447,15 @@ declare namespace App {
visible: string;
text: string;
};
tablePropsTitle: string;
table: {
size: { title: string } & Record<UnionKey.ThemeTableSize, string>;
bordered: string;
bottomBordered: string;
singleColumn: string;
singleLine: string;
striped: string;
};
themeDrawerTitle: string;
pageFunTitle: string;
resetCacheStrategy: { title: string } & Record<UnionKey.ResetCacheStrategy, string>;
@ -680,6 +710,7 @@ declare namespace App {
buttonPermissionList: string;
emptyMenu: string;
menuDetail: string;
cascadeDeleteContent: string;
iconifyTip: string;
isFrameTip: string;
isCacheTip: string;
@ -691,6 +722,7 @@ declare namespace App {
form: {
parentId: FormMsg;
menuType: FormMsg;
menuIds: FormMsg;
icon: FormMsg;
menuName: FormMsg;
orderNum: FormMsg;
@ -717,6 +749,7 @@ declare namespace App {
addMenu: string;
addChildMenu: string;
editMenu: string;
cascadeDelete: string;
};
notice: {
title: string;

View File

@ -14,6 +14,7 @@ declare module 'vue' {
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
CountTo: typeof import('./../components/custom/count-to.vue')['default']
DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
DataTable: typeof import('./../components/common/data-table.vue')['default']
DeptTree: typeof import('./../components/custom/dept-tree.vue')['default']
DeptTreeSelect: typeof import('./../components/custom/dept-tree-select.vue')['default']
DictRadio: typeof import('./../components/custom/dict-radio.vue')['default']
@ -62,6 +63,7 @@ declare module 'vue' {
NA: typeof import('naive-ui')['NA']
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
NBadge: typeof import('naive-ui')['NBadge']
NBreadcrumb: typeof import('naive-ui')['NBreadcrumb']
NBreadcrumbItem: typeof import('naive-ui')['NBreadcrumbItem']
NButton: typeof import('naive-ui')['NButton']
@ -81,6 +83,7 @@ declare module 'vue' {
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
NDropdown: typeof import('naive-ui')['NDropdown']
NDynamicInput: typeof import('naive-ui')['NDynamicInput']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']

View File

@ -51,6 +51,15 @@ declare namespace UnionKey {
*/
type ThemeTabMode = import('@sa/materials').PageTabMode;
/**
* The table size
*
* - small: small size
* - medium: medium size
* - large: large size
*/
type ThemeTableSize = 'small' | 'medium' | 'large';
/** Unocss animate key */
type UnoCssAnimateKey =
| 'pulse'

View File

@ -4,7 +4,7 @@ import { useLoading } from '@sa/hooks';
import { fetchForceLogout, fetchGetOnlineDeviceList } from '@/service/api/monitor';
import { useAppStore } from '@/store/modules/app';
import { useTable } from '@/hooks/common/table';
import { getBrowserIcon, getOsIcon } from '@/utils/format';
import { getBrowserIcon, getOsIcon } from '@/utils/icon-tag-format';
import { $t } from '@/locales';
import ButtonIcon from '@/components/custom/button-icon.vue';
import SvgIcon from '@/components/custom/svg-icon.vue';

View File

@ -184,11 +184,10 @@ async function handleExport() {
@refresh="getData"
/>
</template>
<NDataTable
<DataTable
v-model:checked-row-keys="checkedRowKeys"
:columns="columns"
:data="data"
size="small"
:flex-height="!appStore.isMobile"
:scroll-x="962"
:loading="loading"

View File

@ -209,12 +209,11 @@ function handleExport() {
</template>
</TableHeaderOperation>
</template>
<NDataTable
<DataTable
v-model:checked-row-keys="checkedRowKeys"
v-model:expanded-row-keys="expandedRowKeys"
:columns="columns"
:data="data"
size="small"
:flex-height="!appStore.isMobile"
:scroll-x="962"
:loading="loading"

View File

@ -11,7 +11,7 @@ import { useAuth } from '@/hooks/business/auth';
import { useDownload } from '@/hooks/business/download';
import { useTable, useTableOperate } from '@/hooks/common/table';
import { useDict } from '@/hooks/business/dict';
import { getBrowserIcon, getOsIcon } from '@/utils/format';
import { getBrowserIcon, getOsIcon } from '@/utils/icon-tag-format';
import DictTag from '@/components/custom/dict-tag.vue';
import SvgIcon from '@/components/custom/svg-icon.vue';
import { $t } from '@/locales';

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { getBrowserIcon, getOsIcon } from '@/utils/format';
import { getBrowserIcon, getOsIcon } from '@/utils/icon-tag-format';
import { $t } from '@/locales';
defineOptions({

View File

@ -5,7 +5,7 @@ import { useAppStore } from '@/store/modules/app';
import { useAuth } from '@/hooks/business/auth';
import { useTable } from '@/hooks/common/table';
import { useDict } from '@/hooks/business/dict';
import { getBrowserIcon, getOsIcon } from '@/utils/format';
import { getBrowserIcon, getOsIcon } from '@/utils/icon-tag-format';
import ButtonIcon from '@/components/custom/button-icon.vue';
import DictTag from '@/components/custom/dict-tag.vue';
import SvgIcon from '@/components/custom/svg-icon.vue';

View File

@ -1,6 +1,6 @@
<script setup lang="tsx">
import { NDescriptions, NDescriptionsItem, NTag } from 'naive-ui';
import { getRequestMethodTagType } from '@/utils/format';
import { getRequestMethodTagType } from '@/utils/icon-tag-format';
import { $t } from '@/locales';
import DictTag from '@/components/custom/dict-tag.vue';

View File

@ -47,7 +47,7 @@ function createDefaultModel(): Model {
configName: '',
configKey: '',
configValue: '',
configType: '',
configType: 'Y',
remark: ''
};
}

View File

@ -155,8 +155,13 @@ async function edit(row: TableDataWithIndex<Api.System.Dept>) {
}
async function addInRow(row: TableDataWithIndex<Api.System.Dept>) {
handleAdd();
editingData.value = jsonClone(row);
handleAdd();
}
async function handleAddOperate() {
editingData.value = null;
handleAdd();
}
</script>
@ -170,7 +175,7 @@ async function addInRow(row: TableDataWithIndex<Api.System.Dept>) {
:loading="loading"
:show-add="hasAuth('system:dept:add')"
:show-delete="false"
@add="handleAdd"
@add="handleAddOperate"
@refresh="getData"
>
<template #prefix>

View File

@ -55,7 +55,7 @@ const model: Model = reactive(createDefaultModel());
function createDefaultModel(): Model {
return {
parentId: props.rowData?.deptId,
parentId: '',
deptName: '',
deptCategory: '',
orderNum: null,
@ -80,6 +80,7 @@ const rules: Record<RuleKey, App.Global.FormRule> = {
function handleUpdateModelWhenEdit() {
if (props.operateType === 'add') {
Object.assign(model, createDefaultModel());
model.parentId = props.rowData?.deptId || 0;
}
if (props.operateType === 'edit' && props.rowData) {
@ -185,7 +186,7 @@ watch(visible, () => {
<NDrawer v-model:show="visible" :title="title" display-directive="show" :width="800" class="max-w-90%">
<NDrawerContent :title="title" :native-scrollbar="false" closable>
<NForm ref="formRef" :model="model" :rules="rules">
<NFormItem :label="$t('page.system.dept.parentId')" path="parentId">
<NFormItem v-if="model.parentId != 0" :label="$t('page.system.dept.parentId')" path="parentId">
<NTreeSelect
v-model:value="model.parentId"
:loading="deptLoading"

View File

@ -1,5 +1,5 @@
<script setup lang="tsx">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import type { DataTableColumns, TreeInst, TreeOption } from 'naive-ui';
import { NButton, NDivider, NIcon, NInput, NPopconfirm } from 'naive-ui';
import { useBoolean, useLoading } from '@sa/hooks';
@ -14,6 +14,7 @@ import SvgIcon from '@/components/custom/svg-icon.vue';
import DictTag from '@/components/custom/dict-tag.vue';
import ButtonIcon from '@/components/custom/button-icon.vue';
import MenuOperateDrawer from './modules/menu-operate-drawer.vue';
import MenuCascadeDeleteModal from './modules/menu-cascade-delete-modal.vue';
useDict('sys_show_hide');
useDict('sys_normal_disable');
@ -26,6 +27,7 @@ const editingData = ref<Api.System.Menu>();
const operateType = ref<NaiveUI.TableOperateType>('add');
const { loading, startLoading, endLoading } = useLoading();
const { bool: drawerVisible, setTrue: openDrawer } = useBoolean();
const { bool: cascadeDeleteVisible, setTrue: openCascadeDeleteDrawer } = useBoolean();
const { loading: btnLoading, startLoading: startBtnLoading, endLoading: endBtnLoading } = useLoading();
/** tree pattern name , use tree search */
const name = ref<string>();
@ -36,6 +38,18 @@ const treeData = ref<Api.System.Menu[]>([]);
const checkedKeys = ref<CommonType.IdType[]>([0]);
const expandedKeys = ref<CommonType.IdType[]>([0]);
// 是否为目录类型
const isCatalog = computed(() => currentMenu.value?.menuType === 'M');
// 是否为菜单类型
const isMenu = computed(() => currentMenu.value?.menuType === 'C');
// 外链类型
const isExternalType = computed(() => currentMenu.value?.isFrame === '0');
// iframe类型
const isIframeType = computed(() => currentMenu.value?.isFrame === '2');
const menuTreeRef = ref<TreeInst>();
const btnData = ref<Api.System.MenuList>([]);
@ -292,10 +306,18 @@ const btnColumns: DataTableColumns<Api.System.Menu> = [
v-if="hasAuth('system:menu:add')"
size="small"
icon="material-symbols:add-rounded"
class="h-28px text-icon"
class="h-28px text-icon color-primary"
:tooltip-content="$t('page.system.menu.addMenu')"
@click.stop="handleAddMenu(0)"
/>
<ButtonIcon
v-if="hasAuth('system:menu:add')"
size="small"
icon="material-symbols:delete-outline"
class="h-28px text-icon color-error"
:tooltip-content="$t('page.system.menu.cascadeDelete')"
@click.stop="openCascadeDeleteDrawer"
/>
<ButtonIcon
size="small"
icon="material-symbols:refresh-rounded"
@ -347,7 +369,7 @@ const btnColumns: DataTableColumns<Api.System.Menu> = [
<template #header-extra>
<NSpace>
<NButton
v-if="currentMenu.menuType === 'M' && hasAuth('system:menu:add')"
v-if="isCatalog && hasAuth('system:menu:add')"
size="small"
ghost
type="primary"
@ -391,30 +413,30 @@ const btnColumns: DataTableColumns<Api.System.Menu> = [
label-class="w-20% min-w-88px"
content-class="w-100px"
>
<NDescriptionsItem :label="$t('page.system.menu.menuName')">
<NDescriptionsItem :label="$t('page.system.menu.menuType')">
<NTag class="m-1" size="small" type="primary">{{ menuTypeRecord[currentMenu.menuType!] }}</NTag>
</NDescriptionsItem>
<NDescriptionsItem :label="$t('page.system.menu.status')">
<DictTag size="small" :value="currentMenu.status" dict-code="sys_normal_disable" />
</NDescriptionsItem>
<NDescriptionsItem :label="$t('page.system.menu.addChildMenu')">
<NDescriptionsItem :label="$t('page.system.menu.menuName')">
{{ currentMenu.menuName }}
</NDescriptionsItem>
<NDescriptionsItem v-if="currentMenu.menuType === 'C'" :label="$t('page.system.menu.component')">
<NDescriptionsItem v-if="isMenu" :label="$t('page.system.menu.component')">
{{ currentMenu.component }}
</NDescriptionsItem>
<NDescriptionsItem
:label="currentMenu.isFrame !== '0' ? $t('page.system.menu.path') : $t('page.system.menu.externalPath')"
:label="!isExternalType ? $t('page.system.menu.path') : $t('page.system.menu.externalPath')"
>
{{ currentMenu.path }}
</NDescriptionsItem>
<NDescriptionsItem
v-if="currentMenu.menuType === 'C'"
:label="currentMenu.isFrame !== '2' ? $t('page.system.menu.query') : $t('page.system.menu.iframeQuery')"
v-if="isMenu && !isExternalType"
:label="!isIframeType ? $t('page.system.menu.query') : $t('page.system.menu.iframeQuery')"
>
{{ currentMenu.queryParam }}
</NDescriptionsItem>
<NDescriptionsItem v-if="currentMenu.menuType !== 'M'" :label="$t('page.system.menu.perms')">
<NDescriptionsItem v-if="!isCatalog" :label="$t('page.system.menu.perms')">
{{ currentMenu.perms }}
</NDescriptionsItem>
<NDescriptionsItem :label="$t('page.system.menu.isFrame')">
@ -425,7 +447,7 @@ const btnColumns: DataTableColumns<Api.System.Menu> = [
<NDescriptionsItem :label="$t('page.system.menu.visible')">
<DictTag size="small" :value="currentMenu.visible" dict-code="sys_show_hide" />
</NDescriptionsItem>
<NDescriptionsItem v-if="currentMenu.menuType === 'C'" :label="$t('page.system.menu.isCache')">
<NDescriptionsItem v-if="isMenu" :label="$t('page.system.menu.isCache')">
<NTag v-if="currentMenu.isCache" class="m-1" size="small" :type="tagMap[currentMenu.isCache]">
{{ currentMenu.isCache === '0' ? $t('page.system.menu.cache') : $t('page.system.menu.noCache') }}
</NTag>
@ -465,6 +487,7 @@ const btnColumns: DataTableColumns<Api.System.Menu> = [
:menu-type="createType"
@submitted="handleSubmitted"
/>
<MenuCascadeDeleteModal v-model:visible="cascadeDeleteVisible" @submitted="handleSubmitted" />
</TableSiderLayout>
</template>

View File

@ -0,0 +1,118 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import { useLoading } from '@sa/hooks';
import { fetchCascadeDeleteMenu } from '@/service/api/system';
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
import { $t } from '@/locales';
import MenuTree from '@/components/custom/menu-tree.vue';
defineOptions({
name: 'MenuCascadeDeleteModal'
});
interface Emits {
(e: 'submitted'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const menuTreeRef = ref<InstanceType<typeof MenuTree> | null>(null);
const menuOptions = ref<Api.System.MenuList>([]);
const { loading: menuLoading } = useLoading();
const { formRef, validate, restoreValidation } = useNaiveForm();
const { createRequiredRule } = useFormRules();
type Model = {
menuIds: CommonType.IdType[];
};
const model: Model = reactive(createDefaultModel());
function createDefaultModel(): Model {
return {
menuIds: []
};
}
type RuleKey = Extract<keyof Model, 'menuIds'>;
const rules: Record<RuleKey, App.Global.FormRule> = {
menuIds: createRequiredRule($t('page.system.menu.form.menuIds.invalid'))
};
async function handleUpdateModelWhenEdit() {
menuOptions.value = [];
Object.assign(model, createDefaultModel());
}
function closeDrawer() {
visible.value = false;
}
async function handleSubmit() {
await validate();
window.$dialog?.warning({
title: $t('page.system.menu.cascadeDelete'),
content: $t('page.system.menu.cascadeDeleteContent'),
positiveText: $t('common.delete'),
positiveButtonProps: {
type: 'error'
},
negativeText: $t('common.cancel'),
onPositiveClick: async () => {
const { error } = await fetchCascadeDeleteMenu(model.menuIds);
if (error) return;
window.$message?.success($t('common.deleteSuccess'));
closeDrawer();
emit('submitted');
}
});
}
watch(visible, () => {
if (visible.value) {
handleUpdateModelWhenEdit();
restoreValidation();
}
});
</script>
<template>
<NModal
v-model:show="visible"
:title="$t('page.system.menu.cascadeDelete')"
preset="card"
:bordered="false"
display-directive="show"
class="max-w-90% w-500px"
@close="closeDrawer"
>
<NForm ref="formRef" :model="model" :rules="rules">
<NFormItem :show-label="false" path="menuIds">
<MenuTree
v-if="visible"
ref="menuTreeRef"
v-model:options="menuOptions"
v-model:loading="menuLoading"
v-model:checked-keys="model.menuIds"
:cascade="true"
:show-header="false"
:immediate="true"
/>
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end" :size="16">
<NButton @click="closeDrawer">{{ $t('common.cancel') }}</NButton>
<NButton type="primary" @click="handleSubmit">{{ $t('common.confirm') }}</NButton>
</NSpace>
</template>
</NModal>
</template>
<style scoped></style>

View File

@ -83,8 +83,28 @@ const rules: Record<RuleKey, App.Global.FormRule> = {
component: createRequiredRule($t('page.system.menu.form.component.invalid'))
};
const isBtn = computed(() => model.menuType === 'F');
// 是否为目录类型
const isCatalog = computed(() => model.menuType === 'M');
// 是否为菜单类型
const isMenu = computed(() => model.menuType === 'C');
// 是否为按钮类型
const isBtn = computed(() => model.menuType === 'F');
// 外链类型
const isExternalType = computed(() => model.isFrame === '0');
// 内部类型
const isInternalType = computed(() => model.isFrame === '1');
// iframe类型
const isIframeType = computed(() => model.isFrame === '2');
// 本地图标类型
const isLocalIcon = computed(() => iconType.value === '2');
// 本地图标
const localIcons = getLocalMenuIcons();
const localIconOptions = localIcons.map<SelectOption>(item => ({
label: () => (
@ -102,7 +122,7 @@ function handleInitModel() {
if (props.operateType === 'edit' && props.rowData) {
Object.assign(model, props.rowData);
if (isMenu.value && model.isFrame === '1') {
if (isMenu.value && isInternalType.value) {
model.component = model.component?.slice(0, -6);
}
iconType.value = model.icon?.startsWith('local-icon-') ? '2' : '1';
@ -118,6 +138,44 @@ function closeDrawer() {
visible.value = false;
}
// 处理路径
function processPath(path: string | null | undefined): string {
return path?.startsWith('/') ? path.substring(1) : path || '';
}
// 处理组件
function processComponent(component: string | null | undefined): string {
if (isCatalog.value && isInternalType.value) {
return 'Layout';
}
if (isIframeType.value || isExternalType.value) {
return 'FrameView';
}
if (isMenu.value && isInternalType.value) {
return component?.endsWith('/index') ? component : `${component || ''}/index`;
}
return component || '';
}
function processQueryParam(queryParam: string | null | undefined): string {
// 外链类型不需要查询参数
if (isExternalType.value) {
return '';
}
// 内部链接类型,处理动态参数
if (isInternalType.value && queryList.value.length) {
return JSON.stringify(Object.fromEntries(queryList.value.map(({ key, value }) => [key, value])));
}
// iframe类型直接使用原始参数
if (isIframeType.value) {
return queryParam || '';
}
return '';
}
async function handleSubmit() {
await validate();
@ -133,77 +191,36 @@ async function handleSubmit() {
visible: menuVisible,
status,
perms,
remark
remark,
component,
queryParam
} = model;
let queryParam = model.queryParam;
if (isFrame === '0') {
queryParam = '';
} else if (isFrame === '1' && queryList.value.length) {
const queryObj: { [key: string]: string } = {};
queryList.value.forEach(item => (queryObj[item.key] = item.value));
queryParam = JSON.stringify(queryObj);
}
const path = model.path?.startsWith('/') ? model.path?.substring(1) : model.path;
let component = model.component;
if (isFrame === '1' && menuType === 'M') {
component = 'Layout';
} else if (isFrame === '2') {
component = 'FrameView';
} else if (isMenu.value && model.isFrame === '1') {
component = component?.endsWith('/index') ? component : `${component}/index`;
}
// request
if (props.operateType === 'add') {
const { error } = await fetchCreateMenu({
menuName,
path,
parentId,
orderNum,
queryParam,
isFrame,
isCache,
menuType,
visible: menuVisible,
status,
perms,
icon,
component,
remark
});
if (error) {
return;
}
window.$message?.success($t('common.addSuccess'));
}
if (props.operateType === 'edit') {
const { error } = await fetchUpdateMenu({
menuId,
menuName,
path,
parentId,
orderNum,
queryParam,
isFrame,
isCache,
menuType,
visible: menuVisible,
status,
perms,
icon,
component,
remark
});
if (error) {
return;
}
window.$message?.success($t('common.updateSuccess'));
const payload = {
menuName,
path: processPath(model.path),
parentId,
orderNum,
queryParam: processQueryParam(queryParam),
isFrame,
isCache,
menuType,
visible: menuVisible,
status,
perms,
icon,
component: processComponent(component),
remark
};
const { error } =
props.operateType === 'add' ? await fetchCreateMenu(payload) : await fetchUpdateMenu({ ...payload, menuId });
if (error) {
return;
}
window.$message?.success($t(props.operateType === 'add' ? 'common.addSuccess' : 'common.updateSuccess'));
closeDrawer();
emit('submitted', menuType!);
}
@ -224,7 +241,7 @@ function onCreate() {
</script>
<template>
<NDrawer v-model:show="visible" display-directive="show" :width="800" class="max-w-90%">
<NDrawer v-model:show="visible" display-directive="show" :width="600" class="max-w-90%">
<NDrawerContent :title="drawerTitle" :native-scrollbar="false" closable>
<NForm ref="formRef" :model="model" :rules="rules">
<NGrid responsive="screen" item-responsive>
@ -238,12 +255,7 @@ function onCreate() {
:placeholder="$t('page.system.menu.form.parentId.required')"
/>
</NFormItemGi>
<NFormItemGi
v-if="model.menuType !== 'F'"
:span="24"
:label="$t('page.system.menu.menuType')"
path="menuType"
>
<NFormItemGi v-if="!isBtn" :span="24" :label="$t('page.system.menu.menuType')" path="menuType">
<NRadioGroup v-model:value="model.menuType">
<NRadioButton
v-for="item in menuTypeOptions.filter(item => item.value !== 'F')"
@ -253,22 +265,30 @@ function onCreate() {
/>
</NRadioGroup>
</NFormItemGi>
<NFormItemGi :span="24" :label="$t('page.system.menu.menuName')" path="menuName">
<NFormItemGi span="24" :label="$t('page.system.menu.menuName')" path="menuName">
<NInput v-model:value="model.menuName" :placeholder="$t('page.system.menu.form.menuName.required')" />
</NFormItemGi>
<NFormItemGi v-if="!isBtn" span="24" :label="$t('page.system.menu.iconType')">
<NFormItemGi v-if="!isBtn" span="12" :label="$t('page.system.menu.iconType')">
<NRadioGroup v-model:value="iconType">
<NRadio v-for="item in menuIconTypeOptions" :key="item.value" :value="item.value" :label="item.label" />
</NRadioGroup>
</NFormItemGi>
<NFormItemGi v-if="!isBtn" span="24" path="icon">
<NFormItemGi v-if="!isBtn" span="12" path="icon">
<template #label>
<div class="flex-center">
<FormTip :content="$t('page.system.menu.iconifyTip')" />
<span class="pl-3px">{{ $t('page.system.menu.icon') }}</span>
</div>
</template>
<template v-if="iconType === '1'">
<template v-if="isLocalIcon">
<NSelect
v-model:value="model.icon"
:placeholder="$t('page.system.menu.placeholder.localIconPlaceholder')"
filterable
:options="localIconOptions"
/>
</template>
<template v-else>
<NInput
v-model:value="model.icon"
:placeholder="$t('page.system.menu.placeholder.iconifyIconPlaceholder')"
@ -279,27 +299,51 @@ function onCreate() {
</template>
</NInput>
</template>
<template v-if="iconType === '2'">
<NSelect
v-model:value="model.icon"
:placeholder="$t('page.system.menu.placeholder.localIconPlaceholder')"
filterable
:options="localIconOptions"
/>
</NFormItemGi>
<NFormItemGi v-if="!isBtn" :span="12" path="isFrame">
<template #label>
<div class="flex-center">
<FormTip :content="$t('page.system.menu.isFrameTip')" />
<span>{{ $t('page.system.menu.isFrame') }}</span>
</div>
</template>
<NRadioGroup v-model:value="model.isFrame">
<NSpace>
<NRadio
v-for="option in menuIsFrameOptions"
:key="option.value"
:value="option.value"
:label="option.label"
/>
</NSpace>
</NRadioGroup>
</NFormItemGi>
<NFormItemGi v-if="isMenu" :span="12" path="isCache">
<template #label>
<div class="flex-center">
<FormTip :content="$t('page.system.menu.isCacheTip')" />
<span>{{ $t('page.system.menu.isCache') }}</span>
</div>
</template>
<NRadioGroup v-model:value="model.isCache">
<NSpace>
<NRadio value="0" label="是" />
<NRadio value="1" label="否" />
</NSpace>
</NRadioGroup>
</NFormItemGi>
<NFormItemGi v-if="!isBtn" :span="24" path="path">
<template #label>
<div class="flex-center">
<FormTip :content="$t('page.system.menu.pathTip')" />
<span>
{{ model.isFrame !== '0' ? $t('page.system.menu.path') : $t('page.system.menu.externalPath') }}
{{ !isExternalType ? $t('page.system.menu.path') : $t('page.system.menu.externalPath') }}
</span>
</div>
</template>
<NInput v-model:value="model.path" :placeholder="$t('page.system.menu.form.path.required')" />
</NFormItemGi>
<NFormItemGi v-if="isMenu && model.isFrame === '1'" :span="24" path="component">
<NFormItemGi v-if="isMenu && isInternalType" :span="24" path="component">
<template #label>
<div class="flex-center">
<FormTip :content="$t('page.system.menu.componentTip')" />
@ -313,13 +357,13 @@ function onCreate() {
</NInputGroup>
</NFormItemGi>
<NFormItemGi
v-if="isMenu && model.isFrame !== '0'"
v-if="isMenu && !isExternalType"
span="24"
:show-feedback="!queryList.length"
:label="model.isFrame !== '2' ? $t('page.system.menu.query') : $t('page.system.menu.iframeQuery')"
:label="isInternalType ? $t('page.system.menu.query') : $t('page.system.menu.iframeQuery')"
>
<NDynamicInput
v-if="model.isFrame !== '2'"
v-if="isInternalType"
v-model:value="queryList"
item-style="margin-bottom: 0"
:on-create="onCreate"
@ -369,38 +413,7 @@ function onCreate() {
</template>
<NInput v-model:value="model.perms" :placeholder="$t('page.system.menu.form.perms.required')" />
</NFormItemGi>
<NFormItemGi v-if="!isBtn" :span="12" path="isFrame">
<template #label>
<div class="flex-center">
<FormTip :content="$t('page.system.menu.isFrameTip')" />
<span>{{ $t('page.system.menu.isFrame') }}</span>
</div>
</template>
<NRadioGroup v-model:value="model.isFrame">
<NSpace>
<NRadio
v-for="option in menuIsFrameOptions"
:key="option.value"
:value="option.value"
:label="option.label"
/>
</NSpace>
</NRadioGroup>
</NFormItemGi>
<NFormItemGi v-if="isMenu" :span="12" path="isCache">
<template #label>
<div class="flex-center">
<FormTip :content="$t('page.system.menu.isCacheTip')" />
<span>{{ $t('page.system.menu.isCache') }}</span>
</div>
</template>
<NRadioGroup v-model:value="model.isCache">
<NSpace>
<NRadio value="0" label="是" />
<NRadio value="1" label="否" />
</NSpace>
</NRadioGroup>
</NFormItemGi>
<NFormItemGi v-if="!isBtn" :span="12" :label="$t('page.system.menu.visible')" path="visible">
<template #label>
<div class="flex-center">

View File

@ -34,7 +34,7 @@ function closeDrawer() {
function handleClose() {
closeDrawer();
if (fileUploadRef.value?.refreshList) {
if (fileUploadRef.value?.needRelaodData) {
emit('close');
}
}

View File

@ -135,7 +135,7 @@ const {
text
type="primary"
icon="material-symbols:database"
tooltipContent="数据范权限"
tooltipContent="数据范权限"
onClick={() => handleDataScope(row)}
/>
);

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { h, ref, watch } from 'vue';
import type { UploadFileInfo } from 'naive-ui';
import { getToken } from '@/store/modules/auth/shared';
import { useDownload } from '@/hooks/business/download';
@ -77,7 +77,7 @@ function handleError(options: { file: UploadFileInfo; event?: ProgressEvent }) {
const responseText = event?.target?.responseText;
const msg = JSON.parse(responseText).msg;
message.value = msg;
window.$message?.error(msg || $t('common.importFail'));
window.$message?.error(() => h('div', { innerHTML: msg || $t('common.importFail') }));
success.value = false;
}