This commit is contained in:
AN
2025-06-11 20:51:46 +08:00
70 changed files with 2485 additions and 1303 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}}

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

@ -0,0 +1,51 @@
name: Build and Deploy
on:
push:
branches: [ "master" ]
paths-ignore:
- 'README.md'
- 'LICENSE'
- 'docs/**'
- '.vscode/**'
- '.github/**'
- '.gitee/**'
- '.codeif/**'
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: on:
pull_request: pull_request:
branches: [main] branches: [master]
jobs: jobs:
lint: lint:
@ -23,7 +23,7 @@ jobs:
uses: github/super-linter@v4 uses: github/super-linter@v4
env: env:
VALIDATE_ALL_CODEBASE: false VALIDATE_ALL_CODEBASE: false
DEFAULT_BRANCH: main DEFAULT_BRANCH: master
# To change branch master or main # To change branch master or main
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FILTER_REGEX_EXCLUDE: (docs|.github) 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"> <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>一个基于 <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> <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/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/TypeScript-5.8-blue" alt="typescript">
<img src="https://img.shields.io/badge/Vite-6.2-orange" alt="vite"> <img src="https://img.shields.io/badge/Vite-6.2-orange" alt="vite">
@ -18,9 +19,17 @@
# 📢 重要通知 # 📢 重要通知
<p style="color: red; font-weight: bold; font-size: 24px;">该项目未首发公测版本,请谨慎在生产环境使用!!!</p> 虽然 1.0.0 版本已经包含了完整的核心功能,但我们仍然建议:
<p style="color: red; font-weight: bold; font-size: 24px;">该项目未首发公测版本,请谨慎在生产环境使用!!!</p> - 在生产环境使用前进行充分测试
<p style="color: red; font-weight: bold; font-size: 24px;">该项目未首发公测版本,请谨慎在生产环境使用!!!</p> - 关注项目更新,及时获取最新版本
- 积极反馈问题,帮助我们快速迭代
**后续规划**
- 工作流引擎集成
- 多语言国际化完善
- 更多企业级功能模块
- 性能优化和稳定性提升
> 如果对该项目感兴趣,可以给一个 Star 支持一下,谢谢! > 如果对该项目感兴趣,可以给一个 Star 支持一下,谢谢!
> 请大家踊跃提交 PR 和 Issue一起完善这个项目 > 请大家踊跃提交 PR 和 Issue一起完善这个项目
@ -353,7 +362,9 @@ console.log(t('common.confirm'));
## 💬 交流群 ## 💬 交流群
<img src="https://foruda.gitee.com/images/1748404753216665472/3d8b1a0b_5601833.png" width="300px" /> <img src="https://foruda.gitee.com/images/1749174520085305975/ad1b54fe_5601833.png" width="300px" />
添加作者微信备注:加群
## 🧧 捐献作者 ## 🧧 捐献作者
@ -416,3 +427,8 @@ console.log(t('common.confirm'));
<img style="border-radius: 50%;" src="https://foruda.gitee.com/images/1747796468040874363/1faa75ce_5601833.jpeg" width="24px" > <img style="border-radius: 50%;" src="https://foruda.gitee.com/images/1747796468040874363/1faa75ce_5601833.jpeg" width="24px" >
<span>莫离支🤴 10元</span> <span>莫离支🤴 10元</span>
</div> </div>
<div style="display: flex; gap: 8px;">
<img style="border-radius: 50%;" src="https://foruda.gitee.com/images/1749191537723692930/0b810403_5601833.jpeg" width="24px" >
<span>Ivan 100元</span>
</div>

View File

@ -1,7 +1,7 @@
{ {
"name": "ruoyi-vue-plus", "name": "ruoyi-vue-plus",
"type": "module", "type": "module",
"version": "1.0.0-beta.1", "version": "1.0.0",
"description": "RuoYi-Vue-Plus多租户管理系统", "description": "RuoYi-Vue-Plus多租户管理系统",
"author": { "author": {
"name": "xlsea", "name": "xlsea",
@ -57,7 +57,7 @@
"@sa/materials": "workspace:*", "@sa/materials": "workspace:*",
"@sa/tinymce": "workspace:*", "@sa/tinymce": "workspace:*",
"@sa/utils": "workspace:*", "@sa/utils": "workspace:*",
"@vueuse/core": "13.1.0", "@vueuse/core": "13.3.0",
"clipboard": "2.0.11", "clipboard": "2.0.11",
"dayjs": "1.11.13", "dayjs": "1.11.13",
"defu": "6.1.4", "defu": "6.1.4",
@ -66,42 +66,42 @@
"jsencrypt": "^3.3.2", "jsencrypt": "^3.3.2",
"json5": "2.2.3", "json5": "2.2.3",
"monaco-editor": "^0.52.0", "monaco-editor": "^0.52.0",
"naive-ui": "2.41.0", "naive-ui": "2.41.1",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"pinia": "3.0.2", "pinia": "3.0.3",
"tailwind-merge": "3.2.0", "tailwind-merge": "3.3.0",
"vue": "3.5.13", "vue": "3.5.16",
"vue-advanced-cropper": "^2.8.9", "vue-advanced-cropper": "^2.8.9",
"vue-draggable-plus": "0.6.0", "vue-draggable-plus": "0.6.0",
"vue-i18n": "11.1.3", "vue-i18n": "11.1.5",
"vue-router": "4.5.1" "vue-router": "4.5.1"
}, },
"devDependencies": { "devDependencies": {
"@elegant-router/vue": "0.3.8", "@elegant-router/vue": "0.3.8",
"@iconify/json": "2.2.337", "@iconify/json": "2.2.347",
"@sa/scripts": "workspace:*", "@sa/scripts": "workspace:*",
"@sa/uno-preset": "workspace:*", "@sa/uno-preset": "workspace:*",
"@soybeanjs/eslint-config": "1.6.0", "@soybeanjs/eslint-config": "1.6.1",
"@types/node": "22.15.17", "@types/node": "22.15.30",
"@types/nprogress": "0.2.3", "@types/nprogress": "0.2.3",
"@unocss/eslint-config": "66.1.1", "@unocss/eslint-config": "66.1.4",
"@unocss/preset-icons": "66.1.1", "@unocss/preset-icons": "66.1.4",
"@unocss/preset-uno": "66.1.1", "@unocss/preset-uno": "66.1.4",
"@unocss/transformer-directives": "66.1.1", "@unocss/transformer-directives": "66.1.4",
"@unocss/transformer-variant-group": "66.1.1", "@unocss/transformer-variant-group": "66.1.4",
"@unocss/vite": "66.1.1", "@unocss/vite": "66.1.4",
"@vitejs/plugin-vue": "5.2.4", "@vitejs/plugin-vue": "5.2.4",
"@vitejs/plugin-vue-jsx": "4.1.2", "@vitejs/plugin-vue-jsx": "4.2.0",
"consola": "3.4.2", "consola": "3.4.2",
"eslint": "9.26.0", "eslint": "9.28.0",
"eslint-plugin-vue": "10.1.0", "eslint-plugin-vue": "10.2.0",
"kolorist": "1.8.0", "kolorist": "1.8.0",
"sass": "1.88.0", "sass": "1.89.1",
"simple-git-hooks": "2.13.0", "simple-git-hooks": "2.13.0",
"tsx": "4.19.4", "tsx": "4.19.4",
"typescript": "5.8.3", "typescript": "5.8.3",
"unplugin-icons": "22.1.0", "unplugin-icons": "22.1.0",
"unplugin-vue-components": "28.5.0", "unplugin-vue-components": "28.7.0",
"vite": "6.3.5", "vite": "6.3.5",
"vite-plugin-monaco-editor": "^1.1.0", "vite-plugin-monaco-editor": "^1.1.0",
"vite-plugin-progress": "0.0.7", "vite-plugin-progress": "0.0.7",

View File

@ -1,6 +1,6 @@
{ {
"name": "@sa/alova", "name": "@sa/alova",
"version": "1.3.13", "version": "1.3.14",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./fetch": "./src/fetch.ts", "./fetch": "./src/fetch.ts",
@ -13,8 +13,8 @@
} }
}, },
"dependencies": { "dependencies": {
"@alova/mock": "2.0.14", "@alova/mock": "2.0.16",
"@sa/utils": "workspace:*", "@sa/utils": "workspace:*",
"alova": "3.2.10" "alova": "3.3.0"
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@sa/axios", "name": "@sa/axios",
"version": "1.3.13", "version": "1.3.14",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },
@ -16,6 +16,6 @@
"qs": "6.14.0" "qs": "6.14.0"
}, },
"devDependencies": { "devDependencies": {
"@types/qs": "6.9.18" "@types/qs": "6.14.0"
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@sa/color", "name": "@sa/color",
"version": "1.3.13", "version": "1.3.14",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },

View File

@ -1,6 +1,6 @@
{ {
"name": "@sa/hooks", "name": "@sa/hooks",
"version": "1.3.13", "version": "1.3.14",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },

View File

@ -1,6 +1,6 @@
{ {
"name": "@sa/materials", "name": "@sa/materials",
"version": "1.3.13", "version": "1.3.14",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },

View File

@ -1,6 +1,6 @@
{ {
"name": "@sa/fetch", "name": "@sa/fetch",
"version": "1.3.13", "version": "1.3.14",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },

View File

@ -1,6 +1,6 @@
{ {
"name": "@sa/scripts", "name": "@sa/scripts",
"version": "1.3.13", "version": "1.3.14",
"bin": { "bin": {
"sa": "./bin.ts" "sa": "./bin.ts"
}, },
@ -14,12 +14,12 @@
}, },
"devDependencies": { "devDependencies": {
"@soybeanjs/changelog": "0.3.24", "@soybeanjs/changelog": "0.3.24",
"bumpp": "10.1.0", "bumpp": "10.1.1",
"c12": "3.0.3", "c12": "3.0.4",
"cac": "6.7.14", "cac": "6.7.14",
"consola": "3.4.2", "consola": "3.4.2",
"enquirer": "2.4.1", "enquirer": "2.4.1",
"execa": "9.5.3", "execa": "9.6.0",
"kolorist": "1.8.0", "kolorist": "1.8.0",
"npm-check-updates": "18.0.1", "npm-check-updates": "18.0.1",
"rimraf": "6.0.1" "rimraf": "6.0.1"

View File

@ -1,6 +1,6 @@
{ {
"name": "@sa/uno-preset", "name": "@sa/uno-preset",
"version": "1.3.13", "version": "1.3.14",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },

View File

@ -1,6 +1,6 @@
{ {
"name": "@sa/utils", "name": "@sa/utils",
"version": "1.3.13", "version": "1.3.14",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },

2217
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

@ -2,7 +2,7 @@
import { useAttrs } from 'vue'; import { useAttrs } from 'vue';
import type { TreeSelectProps } from 'naive-ui'; import type { TreeSelectProps } from 'naive-ui';
import { useLoading } from '@sa/hooks'; import { useLoading } from '@sa/hooks';
import { fetchGetDeptSelect } from '@/service/api/system'; import { fetchGetDeptTree } from '@/service/api/system';
defineOptions({ name: 'DeptTreeSelect' }); defineOptions({ name: 'DeptTreeSelect' });
@ -13,16 +13,21 @@ interface Props {
defineProps<Props>(); defineProps<Props>();
const value = defineModel<CommonType.IdType | null>('value', { required: false }); const value = defineModel<CommonType.IdType | null>('value', { required: false });
const options = defineModel<Api.System.Dept[]>('options', { required: false, default: [] }); const options = defineModel<Api.Common.CommonTreeRecord>('options', { required: false, default: [] });
const expandedKeys = defineModel<CommonType.IdType[]>('expandedKeys', { required: false, default: [] });
const attrs: TreeSelectProps = useAttrs(); const attrs: TreeSelectProps = useAttrs();
const { loading, startLoading, endLoading } = useLoading(); const { loading, startLoading, endLoading } = useLoading();
async function getDeptList() { async function getDeptList() {
startLoading(); startLoading();
const { error, data } = await fetchGetDeptSelect(); const { error, data } = await fetchGetDeptTree();
if (error) return; if (error) return;
options.value = data; options.value = data;
// 设置默认展开的节点
if (data?.length && !expandedKeys.value.length) {
expandedKeys.value = [data[0].id];
}
endLoading(); endLoading();
} }
@ -32,13 +37,13 @@ getDeptList();
<template> <template>
<NTreeSelect <NTreeSelect
v-model:value="value" v-model:value="value"
v-model:expanded-keys="expandedKeys"
filterable filterable
class="h-full" class="h-full"
:loading="loading" :loading="loading"
key-field="deptId" key-field="id"
label-field="deptName" label-field="label"
:options="options" :options="options as []"
:default-expanded-keys="[0]"
v-bind="attrs" v-bind="attrs"
/> />
</template> </template>

View File

@ -3,6 +3,7 @@ import { computed, useAttrs } from 'vue';
import type { TagProps } from 'naive-ui'; import type { TagProps } from 'naive-ui';
import { useDict } from '@/hooks/business/dict'; import { useDict } from '@/hooks/business/dict';
import { isNotNull } from '@/utils/common'; import { isNotNull } from '@/utils/common';
import { $t } from '@/locales';
defineOptions({ name: 'DictTag' }); defineOptions({ name: 'DictTag' });
@ -23,13 +24,18 @@ const props = withDefaults(defineProps<Props>(), {
const attrs = useAttrs() as TagProps; const attrs = useAttrs() as TagProps;
const { transformDictData } = useDict(props.dictCode, props.immediate);
const dictTagData = computed<Api.System.DictData[]>(() => { const dictTagData = computed<Api.System.DictData[]>(() => {
if (props.dictData) { if (props.dictData) {
return [props.dictData]; const dictData = props.dictData;
if (dictData.dictLabel?.startsWith(`dict.${dictData.dictType}.`)) {
dictData.dictLabel = $t(dictData.dictLabel as App.I18n.I18nKey);
}
return [dictData];
} }
// 避免 props.value 为 0 时,无法触发 // 避免 props.value 为 0 时,无法触发
if (props.dictCode && isNotNull(props.value)) { if (props.dictCode && isNotNull(props.value)) {
const { transformDictData } = useDict(props.dictCode, props.immediate);
return transformDictData(props.value) || []; return transformDictData(props.value) || [];
} }
@ -44,8 +50,8 @@ const dictTagData = computed<Api.System.DictData[]>(() => {
:key="item.dictValue" :key="item.dictValue"
class="m-1" class="m-1"
:class="[item.cssClass]" :class="[item.cssClass]"
:type="item.listClass"
v-bind="attrs" v-bind="attrs"
:type="item.listClass || 'default'"
> >
{{ item.dictLabel }} {{ item.dictLabel }}
</NTag> </NTag>

View File

@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, useAttrs, watch } from 'vue'; import { computed, useAttrs } from 'vue';
import type { UploadFileInfo, UploadProps } from 'naive-ui'; import type { UploadFileInfo, UploadProps } from 'naive-ui';
import { fetchBatchDeleteOss } from '@/service/api/system/oss'; import { fetchBatchDeleteOss } from '@/service/api/system/oss';
import { getToken } from '@/store/modules/auth/shared'; import { getToken } from '@/store/modules/auth/shared';
import { getServiceBaseURL } from '@/utils/service'; import { getServiceBaseURL } from '@/utils/service';
import { AcceptType } from '@/enum/business';
defineOptions({ defineOptions({
name: 'FileUpload' name: 'FileUpload'
@ -26,30 +27,24 @@ const props = withDefaults(defineProps<Props>(), {
defaultUpload: true, defaultUpload: true,
showTip: true, showTip: true,
max: 5, max: 5,
accept: '.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.pdf', accept: undefined,
fileSize: 5, fileSize: 5,
uploadType: 'file' uploadType: 'file'
}); });
const accept = computed(() => {
if (props.accept) {
return props.accept;
}
return props.uploadType === 'file' ? AcceptType.File : AcceptType.Image;
});
const attrs: UploadProps = useAttrs(); const attrs: UploadProps = useAttrs();
const value = defineModel<CommonType.IdType[]>('value', { required: false, default: [] });
let fileNum = 0; let fileNum = 0;
const fileList = ref<UploadFileInfo[]>([]); const fileList = defineModel<UploadFileInfo[]>('fileList', {
default: () => []
const needRelaodData = ref<boolean>(false);
defineExpose({
refreshList: needRelaodData,
fileList
}); });
watch(
() => fileList.value,
newValue => {
needRelaodData.value = newValue.length > 0;
value.value = newValue.map(item => item.id);
}
);
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'; const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
const { baseURL } = getServiceBaseURL(import.meta.env, isHttpProxy); const { baseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
@ -64,12 +59,12 @@ function beforeUpload(options: { file: UploadFileInfo; fileList: UploadFileInfo[
const { file } = options; const { file } = options;
// 校检文件类型 // 校检文件类型
if (props.accept) { if (accept.value) {
const fileName = file.name.split('.'); const fileName = file.name.split('.');
const fileExt = `.${fileName[fileName.length - 1]}`; const fileExt = `.${fileName[fileName.length - 1]}`;
const isTypeOk = props.accept.split(',')?.includes(fileExt); const isTypeOk = accept.value.split(',')?.includes(fileExt);
if (!isTypeOk) { if (!isTypeOk) {
window.$message?.error(`文件格式不正确, 请上传 ${props.accept} 格式文件!`); window.$message?.error(`文件格式不正确, 请上传 ${accept.value} 格式文件!`);
return false; return false;
} }
} }
@ -122,11 +117,12 @@ function handleError(options: { file: UploadFileInfo; event?: ProgressEvent }) {
async function handleRemove(file: UploadFileInfo) { async function handleRemove(file: UploadFileInfo) {
if (file.status !== 'finished') { if (file.status !== 'finished') {
return; return false;
} }
const { error } = await fetchBatchDeleteOss([file.id]); const { error } = await fetchBatchDeleteOss([file.id]);
if (error) return; if (error) return false;
window.$message?.success('删除成功'); window.$message?.success('删除成功');
return true;
} }
</script> </script>

View File

@ -9,11 +9,13 @@ defineOptions({ name: 'MenuTree' });
interface Props { interface Props {
immediate?: boolean; immediate?: boolean;
showHeader?: boolean;
[key: string]: any; [key: string]: any;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
immediate: true immediate: true,
showHeader: true
}); });
const { bool: expandAll } = useBoolean(); const { bool: expandAll } = useBoolean();
@ -49,7 +51,6 @@ onMounted(() => {
} }
}); });
// 添加 watch 监听 expandAll 的变化,options有值后计算expandedKeys
watch([expandAll, options], ([newVal]) => { watch([expandAll, options], ([newVal]) => {
if (newVal) { if (newVal) {
// 展开所有节点 // 展开所有节点
@ -80,6 +81,21 @@ function getAllMenuIds(menu: Api.System.MenuList) {
return menuIds; return menuIds;
} }
/** 获取所有叶子节点的 ID没有子节点的节点 */
function getLeafMenuIds(menu: Api.System.MenuList): CommonType.IdType[] {
const leafIds: CommonType.IdType[] = [];
menu.forEach(item => {
if (!item.children || item.children.length === 0) {
// 是叶子节点
leafIds.push(item.id!);
} else {
// 有子节点,递归获取子节点中的叶子节点
leafIds.push(...getLeafMenuIds(item.children));
}
});
return leafIds;
}
function handleCheckedTreeNodeAll(checked: boolean) { function handleCheckedTreeNodeAll(checked: boolean) {
if (checked) { if (checked) {
checkedKeys.value = getAllMenuIds(options.value); checkedKeys.value = getAllMenuIds(options.value);
@ -88,16 +104,30 @@ function handleCheckedTreeNodeAll(checked: boolean) {
checkedKeys.value = []; checkedKeys.value = [];
} }
function getCheckedMenuIds() { function getCheckedMenuIds(isCascade: boolean = false) {
const menuIds = menuTreeRef.value?.getCheckedData()?.keys as string[]; const menuIds = menuTreeRef.value?.getCheckedData()?.keys as string[];
const indeterminateData = menuTreeRef.value?.getIndeterminateData(); const indeterminateData = menuTreeRef.value?.getIndeterminateData();
if (cascade.value) { if (cascade.value || isCascade) {
const parentIds: string[] = indeterminateData?.keys.filter(item => !menuIds?.includes(String(item))) as string[]; const parentIds: string[] = indeterminateData?.keys.filter(item => !menuIds?.includes(String(item))) as string[];
menuIds?.push(...parentIds); menuIds?.push(...parentIds);
} }
return menuIds; return menuIds;
} }
watch(cascade, () => {
if (cascade.value) {
// 获取当前菜单树中的所有叶子节点ID
const allLeafIds = getLeafMenuIds(options.value);
// 筛选出当前选中项中的叶子节点
const selectedLeafIds = checkedKeys.value.filter(id => allLeafIds.includes(id));
// 重新设置选中状态为只包含叶子节点,让组件基于父子联动规则重新计算父节点状态
checkedKeys.value = selectedLeafIds;
return;
}
// 禁用父子联动时,将半选中的父节点也加入到选中列表
checkedKeys.value = getCheckedMenuIds(true);
});
defineExpose({ defineExpose({
getCheckedMenuIds, getCheckedMenuIds,
refresh: getMenuList refresh: getMenuList
@ -106,7 +136,7 @@ defineExpose({
<template> <template>
<div class="w-full flex-col gap-12px"> <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="expandAll" :checked-value="true" :unchecked-value="false">展开/折叠</NCheckbox>
<NCheckbox <NCheckbox
v-model:checked="checkAll" v-model:checked="checkAll"

View File

@ -0,0 +1,68 @@
<script setup lang="ts">
import { ref, useAttrs, watch } from 'vue';
import type { UploadFileInfo } from 'naive-ui';
import { useLoading } from '@sa/hooks';
import { fetchGetOssListByIds } from '@/service/api/system/oss';
import { isNotNull } from '@/utils/common';
import FileUpload from '@/components/custom/file-upload.vue';
defineOptions({
name: 'OssUpload'
});
const attrs = useAttrs();
const value = defineModel<string>('value', { default: '' });
const { loading, startLoading, endLoading } = useLoading();
const fileList = ref<UploadFileInfo[]>([]);
async function handleFetchOssList(ossIds: string[]) {
startLoading();
const { error, data } = await fetchGetOssListByIds(ossIds);
if (error) return;
fileList.value = data.map(item => ({
id: String(item.ossId),
url: item.url,
name: item.originalName,
status: 'finished'
}));
endLoading();
}
watch(
value,
async val => {
const ossIds = val.split(',')?.filter(item => isNotNull(item));
const fileIds = new Set(fileList.value.filter(item => item.status === 'finished').map(item => item.id));
if (ossIds.every(item => fileIds.has(item))) {
return;
}
if (ossIds.length === 0) {
fileList.value = [];
return;
}
await handleFetchOssList(ossIds);
},
{ immediate: true }
);
watch(
fileList,
val => {
value.value = val
.filter(item => item.status === 'finished')
.map(item => item.id)
.join(',');
},
{ deep: true }
);
</script>
<template>
<NSpin v-if="loading" />
<FileUpload v-else v-bind="attrs" v-model:file-list="fileList" />
</template>
<style scoped></style>

View File

@ -63,3 +63,11 @@ export const resetCacheStrategyRecord: Record<UnionKey.ResetCacheStrategy, App.I
export const resetCacheStrategyOptions = transformRecordToOption(resetCacheStrategyRecord); export const resetCacheStrategyOptions = transformRecordToOption(resetCacheStrategyRecord);
export const DARK_CLASS = 'dark'; 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);

4
src/enum/business.ts Normal file
View File

@ -0,0 +1,4 @@
export enum AcceptType {
Image = '.jpg,.jpeg,.png,.gif,.bmp,.webp',
File = '.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.pdf,.zip,.rar,.7z'
}

View File

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

View File

@ -3,6 +3,8 @@ import { storeToRefs } from 'pinia';
import { fetchGetDictDataByType } from '@/service/api/system'; import { fetchGetDictDataByType } from '@/service/api/system';
import { useDictStore } from '@/store/modules/dict'; import { useDictStore } from '@/store/modules/dict';
import { isNull } from '@/utils/common'; import { isNull } from '@/utils/common';
import { $t } from '@/locales';
export function useDict(dictType: string, immediate: boolean = true) { export function useDict(dictType: string, immediate: boolean = true) {
const dictStore = useDictStore(); const dictStore = useDictStore();
const { dictData: dictList } = storeToRefs(dictStore); const { dictData: dictList } = storeToRefs(dictStore);
@ -19,6 +21,11 @@ export function useDict(dictType: string, immediate: boolean = true) {
} }
const { data: dictData, error } = await fetchGetDictDataByType(dictType); const { data: dictData, error } = await fetchGetDictDataByType(dictType);
if (error) return; if (error) return;
dictData.forEach(dict => {
if (dict.dictLabel?.startsWith(`dict.${dictType}.`)) {
dict.dictLabel = $t(dict.dictLabel as App.I18n.I18nKey);
}
});
dictStore.setDict(dictType, dictData); dictStore.setDict(dictType, dictData);
data.value = dictData; data.value = dictData;
} }

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

View File

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

View File

@ -130,6 +130,9 @@ const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wra
<SettingItem key="9" :label="$t('theme.header.multilingual.visible')"> <SettingItem key="9" :label="$t('theme.header.multilingual.visible')">
<NSwitch v-model:value="themeStore.header.multilingual.visible" /> <NSwitch v-model:value="themeStore.header.multilingual.visible" />
</SettingItem> </SettingItem>
<SettingItem key="10" :label="$t('theme.header.globalSearch.visible')">
<NSwitch v-model:value="themeStore.header.globalSearch.visible" />
</SettingItem>
</TransitionGroup> </TransitionGroup>
</template> </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: { multilingual: {
visible: 'Display multilingual button' visible: 'Display multilingual button'
},
globalSearch: {
visible: 'Display GlobalSearch button'
} }
}, },
tab: { tab: {
@ -165,6 +168,20 @@ const local: App.I18n.Schema = {
visible: 'Watermark Full Screen Visible', visible: 'Watermark Full Screen Visible',
text: 'Watermark Text' 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', themeDrawerTitle: 'Theme Configuration',
pageFunTitle: 'Page Function', pageFunTitle: 'Page Function',
resetCacheStrategy: { resetCacheStrategy: {
@ -222,6 +239,13 @@ const local: App.I18n.Schema = {
'workflow_process-instance': 'Process Instance', 'workflow_process-instance': 'Process Instance',
workflow_leave: 'Leave Apply' workflow_leave: 'Leave Apply'
}, },
dict: {
sys_user_sex: {
male: 'Male',
female: 'Female',
unknown: 'Unknown'
}
},
page: { page: {
login: { login: {
common: { common: {
@ -579,6 +603,7 @@ const local: App.I18n.Schema = {
buttonPermissionList: 'Button Permission List', buttonPermissionList: 'Button Permission List',
emptyMenu: 'Empty Menu', emptyMenu: 'Empty Menu',
menuDetail: 'Menu Detail', 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`', iconifyTip: 'iconify address`https://icones.js.org`',
isFrameTip: 'If you choose External Link, the routing address needs to start with `http(s)://`', isFrameTip: 'If you choose External Link, the routing address needs to start with `http(s)://`',
isCacheTip: isCacheTip:
@ -603,6 +628,10 @@ const local: App.I18n.Schema = {
required: 'Please select Menu Icon', required: 'Please select Menu Icon',
invalid: 'Menu Icon cannot be empty' invalid: 'Menu Icon cannot be empty'
}, },
menuIds: {
required: 'Please select Menu',
invalid: 'Menu cannot be empty'
},
menuName: { menuName: {
required: 'Please enter Menu Name', required: 'Please enter Menu Name',
invalid: 'Menu Name cannot be empty' invalid: 'Menu Name cannot be empty'
@ -660,7 +689,8 @@ const local: App.I18n.Schema = {
button: 'Button', button: 'Button',
addMenu: 'Add Menu', addMenu: 'Add Menu',
addChildMenu: 'Add Child Menu', addChildMenu: 'Add Child Menu',
editMenu: 'Edit Menu' editMenu: 'Edit Menu',
cascadeDelete: 'Cascade Delete Menu'
}, },
notice: { notice: {
title: 'Notice List', title: 'Notice List',

View File

@ -135,6 +135,9 @@ const local: App.I18n.Schema = {
}, },
multilingual: { multilingual: {
visible: '显示多语言按钮' visible: '显示多语言按钮'
},
globalSearch: {
visible: '显示全局搜索按钮'
} }
}, },
tab: { tab: {
@ -165,6 +168,20 @@ const local: App.I18n.Schema = {
visible: '显示全屏水印', visible: '显示全屏水印',
text: '水印文本' text: '水印文本'
}, },
tablePropsTitle: '表格配置',
table: {
size: {
title: '表格大小',
small: '小',
medium: '中',
large: '大'
},
bordered: '边框',
bottomBordered: '底部边框',
singleColumn: '设定行的分割线',
singleLine: '设定列的分割线',
striped: '斑马线条纹'
},
themeDrawerTitle: '主题配置', themeDrawerTitle: '主题配置',
pageFunTitle: '页面功能', pageFunTitle: '页面功能',
resetCacheStrategy: { resetCacheStrategy: {
@ -222,6 +239,13 @@ const local: App.I18n.Schema = {
'workflow_process-instance': '流程实例', 'workflow_process-instance': '流程实例',
workflow_leave: '请假申请' workflow_leave: '请假申请'
}, },
dict: {
sys_user_sex: {
male: '男',
female: '女',
unknown: '未知'
}
},
page: { page: {
login: { login: {
common: { common: {
@ -579,6 +603,7 @@ const local: App.I18n.Schema = {
buttonPermissionList: '按钮权限列表', buttonPermissionList: '按钮权限列表',
emptyMenu: '暂无菜单', emptyMenu: '暂无菜单',
menuDetail: '菜单详情', menuDetail: '菜单详情',
cascadeDeleteContent: '级联删除菜单将删除所选中的菜单,是否继续?',
iconifyTip: 'iconify 地址https://icones.js.org', iconifyTip: 'iconify 地址https://icones.js.org',
isFrameTip: '选择是外链则路由地址需要以`http(s)://`开头', isFrameTip: '选择是外链则路由地址需要以`http(s)://`开头',
isCacheTip: '选择是则会被`keep-alive`缓存,需要匹配组件的`name`和地址保持一致', isCacheTip: '选择是则会被`keep-alive`缓存,需要匹配组件的`name`和地址保持一致',
@ -597,6 +622,10 @@ const local: App.I18n.Schema = {
required: '请选择菜单类型', required: '请选择菜单类型',
invalid: '菜单类型不能为空' invalid: '菜单类型不能为空'
}, },
menuIds: {
required: '请选择菜单',
invalid: '菜单不能为空'
},
icon: { icon: {
required: '请选择菜单图标', required: '请选择菜单图标',
invalid: '菜单图标不能为空' invalid: '菜单图标不能为空'
@ -658,7 +687,8 @@ const local: App.I18n.Schema = {
button: '按钮', button: '按钮',
addMenu: '新增菜单', addMenu: '新增菜单',
addChildMenu: '新增子菜单', addChildMenu: '新增子菜单',
editMenu: '编辑菜单' editMenu: '编辑菜单',
cascadeDelete: '级联删除菜单'
}, },
notice: { notice: {
title: '通知公告列表', title: '通知公告列表',

View File

@ -25,8 +25,8 @@ export function setupAppVersionNotification() {
const buildTime = await getHtmlBuildTime(); const buildTime = await getHtmlBuildTime();
// If build time hasn't changed, no update is needed // If failed to get build time or build time hasn't changed, no update is needed.
if (buildTime === BUILD_TIME) { if (!buildTime || buildTime === BUILD_TIME) {
return; 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 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 html = await res.text();
const match = html.match(/<meta name="buildTime" content="(.*)">/);
const buildTime = match?.[1] || ''; return match?.[1] || null;
} catch (error) {
return buildTime; console.error('getHtmlBuildTime error:', error);
return null;
}
} }

View File

@ -1,49 +0,0 @@
/* eslint-disable no-console */
/** 后台返回的路由动态生成name 解决缓存问题 感谢 @fourteendp 详见 https://github.com/vbenjs/vue-vben-admin/issues/3927 */
import type { Component } from 'vue';
import { defineComponent, h } from 'vue';
interface Options {
name?: string;
}
export type RouteComponentLoader = () => Promise<any>;
/**
* 作用相当于给组件包了一层 & 设置name 解决keepAlive问题
*
* @param loader 导入的路由
* @param options options
* @returns components
*/
export function createCustomNameComponent(
loader: RouteComponentLoader,
options: Options = {}
): () => Promise<Component> {
const { name } = options;
let component: Component | null = null;
const load = async () => {
try {
const { default: loadedComponent } = await loader();
component = loadedComponent;
} catch (error) {
console.error(`Cannot resolve component ${name}, error:`, error);
}
};
return async () => {
if (!component) {
await load();
}
return Promise.resolve(
defineComponent({
name,
render() {
return h(component as Component);
}
})
);
};
}

View File

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

View File

@ -1,4 +1,3 @@
import type { AxiosRequestConfig, GenericAbortSignal } from 'axios';
import { request } from '@/service/request'; import { request } from '@/service/request';
/** 获取文件管理列表 */ /** 获取文件管理列表 */
@ -18,36 +17,10 @@ export function fetchBatchDeleteOss(ossIds: CommonType.IdType[]) {
}); });
} }
/** Axios上传进度事件 */ // 查询OSS对象基于id串
export type AxiosProgressEvent = AxiosRequestConfig['onUploadProgress']; export function fetchGetOssListByIds(ossIds: CommonType.IdType[]) {
return request<Api.System.Oss[]>({
/** 默认上传结果 */ url: `/resource/oss/listByIds/${ossIds.join(',')}`,
export interface UploadResult { method: 'get'
url: string;
fileName: string;
ossId: string;
}
export interface UploadApiOptions {
onUploadProgress?: AxiosProgressEvent;
signal?: GenericAbortSignal;
}
/** 上传文件接口 */
export function uploadApi(file: File | Blob, options?: UploadApiOptions) {
const { onUploadProgress, signal } = options ?? {};
const formData = new FormData();
formData.append('file', file);
return request<UploadResult>({
url: '/resource/oss/upload',
method: 'post',
data: formData,
onUploadProgress,
headers: {
'Content-Type': 'multipart/form-data'
},
signal
}); });
} }

View File

@ -36,6 +36,15 @@ export function fetchUpdateRoleStatus(data: Api.System.RoleOperateParams) {
}); });
} }
/** 修改角色数据权限 */
export function fetchUpdateRoleDataScope(data: Api.System.RoleOperateParams) {
return request<boolean>({
url: '/system/role/dataScope',
method: 'put',
data
});
}
/** 批量删除角色信息 */ /** 批量删除角色信息 */
export function fetchBatchDeleteRole(roleIds: CommonType.IdType[]) { export function fetchBatchDeleteRole(roleIds: CommonType.IdType[]) {
return request<boolean>({ return request<boolean>({

View File

@ -12,6 +12,7 @@ import { clearAuthStorage, getToken } from './shared';
export const useAuthStore = defineStore(SetupStoreId.Auth, () => { export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
const route = useRoute(); const route = useRoute();
const authStore = useAuthStore();
const routeStore = useRouteStore(); const routeStore = useRouteStore();
const tabStore = useTabStore(); const tabStore = useTabStore();
const { toLogin, redirectFromLogin } = useRouterPush(false); const { toLogin, redirectFromLogin } = useRouterPush(false);
@ -37,8 +38,6 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
/** Reset auth store */ /** Reset auth store */
async function resetStore() { async function resetStore() {
const authStore = useAuthStore();
recordUserId(); recordUserId();
clearAuthStorage(); clearAuthStorage();

View File

@ -1,11 +1,17 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { $t } from '@/locales';
export const useDictStore = defineStore('dict', () => { export const useDictStore = defineStore('dict', () => {
const dictData = ref<{ [key: string]: Api.System.DictData[] }>({}); const dictData = ref<{ [key: string]: Api.System.DictData[] }>({});
const getDict = (key: string) => { const getDict = (key: string) => {
return dictData.value[key]; return dictData.value[key]?.map(item => ({
...item,
dictLabel: item.dictLabel?.startsWith(`dict.${item.dictType}.`)
? $t(item.dictLabel as App.I18n.I18nKey)
: item.dictLabel
}));
}; };
const setDict = (key: string, dict: Api.System.DictData[]) => { const setDict = (key: string, dict: Api.System.DictData[]) => {

View File

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

View File

@ -103,6 +103,9 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
// eslint-disable-next-line complexity // eslint-disable-next-line complexity
function parseRouter(route: ElegantConstRoute, parent?: ElegantConstRoute) { function parseRouter(route: ElegantConstRoute, parent?: ElegantConstRoute) {
route.meta = route.meta ? route.meta : { title: route.name }; route.meta = route.meta ? route.meta : { title: route.name };
if (route.meta.title.startsWith('route.')) {
route.meta.i18nKey = route.meta.title as App.I18n.I18nKey;
}
const isLayout = route.component === 'Layout'; const isLayout = route.component === 'Layout';
const isFramePage = route.component === 'FrameView'; const isFramePage = route.component === 'FrameView';
const isParentLayout = route.component === 'ParentView'; const isParentLayout = route.component === 'ParentView';

View File

@ -100,7 +100,9 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
const removedTabRouteKey = tabs.value[removeTabIndex].routeKey; const removedTabRouteKey = tabs.value[removeTabIndex].routeKey;
const isRemoveActiveTab = activeTabId.value === tabId; const isRemoveActiveTab = activeTabId.value === tabId;
const nextTab = tabs.value[removeTabIndex + 1] || homeTab.value;
// if remove the last tab, then switch to the second last tab
const nextTab = tabs.value[removeTabIndex + 1] || tabs.value[removeTabIndex - 1] || homeTab.value;
// remove tab // remove tab
tabs.value.splice(removeTabIndex, 1); tabs.value.splice(removeTabIndex, 1);

View File

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

View File

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

View File

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

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

@ -58,6 +58,10 @@ declare namespace App {
/** Whether to show the multilingual */ /** Whether to show the multilingual */
visible: boolean; visible: boolean;
}; };
globalSearch: {
/** Whether to show the GlobalSearch */
visible: boolean;
};
}; };
/** Tab */ /** Tab */
tab: { tab: {
@ -109,6 +113,20 @@ declare namespace App {
/** Watermark text */ /** Watermark text */
text: string; 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 */ /** define some theme settings tokens, will transform to css variables */
tokens: { tokens: {
light: ThemeSettingToken; light: ThemeSettingToken;
@ -401,6 +419,9 @@ declare namespace App {
multilingual: { multilingual: {
visible: string; visible: string;
}; };
globalSearch: {
visible: string;
};
}; };
tab: { tab: {
visible: string; visible: string;
@ -426,6 +447,15 @@ declare namespace App {
visible: string; visible: string;
text: 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; themeDrawerTitle: string;
pageFunTitle: string; pageFunTitle: string;
resetCacheStrategy: { title: string } & Record<UnionKey.ResetCacheStrategy, string>; resetCacheStrategy: { title: string } & Record<UnionKey.ResetCacheStrategy, string>;
@ -437,6 +467,7 @@ declare namespace App {
}; };
}; };
route: Record<I18nRouteKey, string>; route: Record<I18nRouteKey, string>;
dict: Record<string, Record<string, string>>;
page: { page: {
common: { common: {
id: string; id: string;
@ -680,6 +711,7 @@ declare namespace App {
buttonPermissionList: string; buttonPermissionList: string;
emptyMenu: string; emptyMenu: string;
menuDetail: string; menuDetail: string;
cascadeDeleteContent: string;
iconifyTip: string; iconifyTip: string;
isFrameTip: string; isFrameTip: string;
isCacheTip: string; isCacheTip: string;
@ -691,6 +723,7 @@ declare namespace App {
form: { form: {
parentId: FormMsg; parentId: FormMsg;
menuType: FormMsg; menuType: FormMsg;
menuIds: FormMsg;
icon: FormMsg; icon: FormMsg;
menuName: FormMsg; menuName: FormMsg;
orderNum: FormMsg; orderNum: FormMsg;
@ -717,6 +750,7 @@ declare namespace App {
addMenu: string; addMenu: string;
addChildMenu: string; addChildMenu: string;
editMenu: string; editMenu: string;
cascadeDelete: string;
}; };
notice: { notice: {
title: string; title: string;

View File

@ -15,6 +15,7 @@ declare module 'vue' {
copy: typeof import('./../components/custom/dept-tree-select copy.vue')['default'] copy: typeof import('./../components/custom/dept-tree-select copy.vue')['default']
CountTo: typeof import('./../components/custom/count-to.vue')['default'] CountTo: typeof import('./../components/custom/count-to.vue')['default']
DarkModeContainer: typeof import('./../components/common/dark-mode-container.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'] DeptTree: typeof import('./../components/custom/dept-tree.vue')['default']
DeptTreeSelect: typeof import('./../components/custom/dept-tree-select.vue')['default'] DeptTreeSelect: typeof import('./../components/custom/dept-tree-select.vue')['default']
DictRadio: typeof import('./../components/custom/dict-radio.vue')['default'] DictRadio: typeof import('./../components/custom/dict-radio.vue')['default']
@ -64,6 +65,7 @@ declare module 'vue' {
NA: typeof import('naive-ui')['NA'] NA: typeof import('naive-ui')['NA']
NAlert: typeof import('naive-ui')['NAlert'] NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar'] NAvatar: typeof import('naive-ui')['NAvatar']
NBadge: typeof import('naive-ui')['NBadge']
NBreadcrumb: typeof import('naive-ui')['NBreadcrumb'] NBreadcrumb: typeof import('naive-ui')['NBreadcrumb']
NBreadcrumbItem: typeof import('naive-ui')['NBreadcrumbItem'] NBreadcrumbItem: typeof import('naive-ui')['NBreadcrumbItem']
NButton: typeof import('naive-ui')['NButton'] NButton: typeof import('naive-ui')['NButton']
@ -84,6 +86,7 @@ declare module 'vue' {
NDrawerContent: typeof import('naive-ui')['NDrawerContent'] NDrawerContent: typeof import('naive-ui')['NDrawerContent']
NDropdown: typeof import('naive-ui')['NDropdown'] NDropdown: typeof import('naive-ui')['NDropdown']
NDynamicInput: typeof import('naive-ui')['NDynamicInput'] NDynamicInput: typeof import('naive-ui')['NDynamicInput']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty'] NEmpty: typeof import('naive-ui')['NEmpty']
NForm: typeof import('naive-ui')['NForm'] NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem'] NFormItem: typeof import('naive-ui')['NFormItem']
@ -130,6 +133,7 @@ declare module 'vue' {
NUpload: typeof import('naive-ui')['NUpload'] NUpload: typeof import('naive-ui')['NUpload']
NUploadDragger: typeof import('naive-ui')['NUploadDragger'] NUploadDragger: typeof import('naive-ui')['NUploadDragger']
NWatermark: typeof import('naive-ui')['NWatermark'] NWatermark: typeof import('naive-ui')['NWatermark']
OssUpload: typeof import('./../components/custom/oss-upload.vue')['default']
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default'] PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
PostSelect: typeof import('./../components/custom/post-select.vue')['default'] PostSelect: typeof import('./../components/custom/post-select.vue')['default']
ReloadButton: typeof import('./../components/common/reload-button.vue')['default'] ReloadButton: typeof import('./../components/common/reload-button.vue')['default']

View File

@ -51,6 +51,15 @@ declare namespace UnionKey {
*/ */
type ThemeTabMode = import('@sa/materials').PageTabMode; 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 */ /** Unocss animate key */
type UnoCssAnimateKey = type UnoCssAnimateKey =
| 'pulse' | 'pulse'

View File

@ -1,3 +1,4 @@
import { AcceptType } from '@/enum/business';
import { $t } from '@/locales'; import { $t } from '@/locales';
/** /**
* Transform record to option * Transform record to option
@ -87,8 +88,7 @@ export function isNull(value: any) {
/** 判断是否为图片类型 */ /** 判断是否为图片类型 */
export function isImage(suffix: string) { export function isImage(suffix: string) {
const imgSuffixList = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; return AcceptType.Image.split(',').includes(suffix.toLowerCase());
return imgSuffixList.includes(suffix.toLowerCase());
} }
/** /**

View File

@ -4,7 +4,7 @@ import { useLoading } from '@sa/hooks';
import { fetchForceLogout, fetchGetOnlineDeviceList } from '@/service/api/monitor'; import { fetchForceLogout, fetchGetOnlineDeviceList } from '@/service/api/monitor';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useTable } from '@/hooks/common/table'; 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 { $t } from '@/locales';
import ButtonIcon from '@/components/custom/button-icon.vue'; import ButtonIcon from '@/components/custom/button-icon.vue';
import SvgIcon from '@/components/custom/svg-icon.vue'; import SvgIcon from '@/components/custom/svg-icon.vue';
@ -16,12 +16,8 @@ defineOptions({
const appStore = useAppStore(); const appStore = useAppStore();
const { loading: btnLoading, startLoading: startBtnLoading, endLoading: endBtnLoading } = useLoading(false); const { loading: btnLoading, startLoading: startBtnLoading, endLoading: endBtnLoading } = useLoading(false);
const { columns, data, loading, mobilePagination, getData } = useTable({ const { columns, data, loading, getData } = useTable({
apiFn: fetchGetOnlineDeviceList, apiFn: fetchGetOnlineDeviceList,
apiParams: {
pageNum: 1,
pageSize: 15
},
columns: () => [ columns: () => [
{ title: '用户名', key: 'userName', align: 'center', minWidth: 120 }, { title: '用户名', key: 'userName', align: 'center', minWidth: 120 },
{ title: 'IP地址', key: 'ipaddr', align: 'center', minWidth: 120 }, { title: 'IP地址', key: 'ipaddr', align: 'center', minWidth: 120 },
@ -109,7 +105,6 @@ async function forceLogout(tokenId: string) {
:loading="loading" :loading="loading"
remote remote
:row-key="row => row.noticeId" :row-key="row => row.noticeId"
:pagination="mobilePagination"
class="h-full" class="h-full"
/> />
</template> </template>

View File

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

View File

@ -53,12 +53,13 @@ function createDefaultModel(): Model {
}; };
} }
type RuleKey = Extract<keyof Model, 'id' | 'deptId' | 'userId' | 'testKey' | 'value'>; type RuleKey = Extract<keyof Model, 'id' | 'deptId' | 'userId' | 'orderNum' | 'testKey' | 'value'>;
const rules: Record<RuleKey, App.Global.FormRule> = { const rules: Record<RuleKey, App.Global.FormRule> = {
id: createRequiredRule('主键不能为空'), id: createRequiredRule('主键不能为空'),
deptId: createRequiredRule('部门不能为空'), deptId: createRequiredRule('部门不能为空'),
userId: createRequiredRule('用户不能为空'), userId: createRequiredRule('用户不能为空'),
orderNum: createRequiredRule('排序号不能为空'),
testKey: createRequiredRule('key 键不能为空'), testKey: createRequiredRule('key 键不能为空'),
value: createRequiredRule('值不能为空') value: createRequiredRule('值不能为空')
}; };
@ -124,7 +125,7 @@ watch(visible, () => {
<NInput v-model:value="model.testKey" placeholder="请输入 key 键" /> <NInput v-model:value="model.testKey" placeholder="请输入 key 键" />
</NFormItem> </NFormItem>
<NFormItem label="值" path="value"> <NFormItem label="值" path="value">
<NInput v-model:value="model.value" placeholder="请输入值" /> <OssUpload v-model:value="model.value as string" upload-type="image" placeholder="请输入值" />
</NFormItem> </NFormItem>
<NFormItem label="备注" path="remark"> <NFormItem label="备注" path="remark">
<NInput v-model:value="model.remark" type="textarea" placeholder="请输入备注" /> <NInput v-model:value="model.remark" type="textarea" placeholder="请输入备注" />

View File

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

View File

@ -11,7 +11,7 @@ import { useAuth } from '@/hooks/business/auth';
import { useDownload } from '@/hooks/business/download'; import { useDownload } from '@/hooks/business/download';
import { useTable, useTableOperate } from '@/hooks/common/table'; import { useTable, useTableOperate } from '@/hooks/common/table';
import { useDict } from '@/hooks/business/dict'; 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 DictTag from '@/components/custom/dict-tag.vue';
import SvgIcon from '@/components/custom/svg-icon.vue'; import SvgIcon from '@/components/custom/svg-icon.vue';
import { $t } from '@/locales'; import { $t } from '@/locales';

View File

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

View File

@ -5,7 +5,7 @@ import { useAppStore } from '@/store/modules/app';
import { useAuth } from '@/hooks/business/auth'; import { useAuth } from '@/hooks/business/auth';
import { useTable } from '@/hooks/common/table'; import { useTable } from '@/hooks/common/table';
import { useDict } from '@/hooks/business/dict'; 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 ButtonIcon from '@/components/custom/button-icon.vue';
import DictTag from '@/components/custom/dict-tag.vue'; import DictTag from '@/components/custom/dict-tag.vue';
import SvgIcon from '@/components/custom/svg-icon.vue'; import SvgIcon from '@/components/custom/svg-icon.vue';

View File

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

View File

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

View File

@ -44,13 +44,15 @@ type Model = Api.System.DictDataOperateParams;
const model: Model = reactive(createDefaultModel()); const model: Model = reactive(createDefaultModel());
const listClassOptions = [ const listClassOptions: Record<string, string>[] = [
{ label: 'primary', value: 'primary' }, { label: 'Text', value: 'text' },
{ label: 'success', value: 'success' }, { label: 'Default', value: 'default' },
{ label: 'info', value: 'info' }, { label: 'Tertiary', value: 'tertiary' },
{ label: 'warning', value: 'warning' }, { label: 'Primary', value: 'primary' },
{ label: 'error', value: 'error' }, { label: 'Info', value: 'info' },
{ label: 'default', value: 'default' } { label: 'Success', value: 'success' },
{ label: 'Warning', value: 'warning' },
{ label: 'Error', value: 'error' }
]; ];
function createDefaultModel(): Model { function createDefaultModel(): Model {
@ -134,6 +136,9 @@ watch(visible, () => {
}); });
function renderTagLabel(option: { label: string; value: string }) { function renderTagLabel(option: { label: string; value: string }) {
if (option.value === 'text') {
return option.label;
}
return ( return (
<NTag size="small" type={option.value as any}> <NTag size="small" type={option.value as any}>
{option.label} {option.label}

View File

@ -1,5 +1,5 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { ref } from 'vue'; import { computed, ref } from 'vue';
import type { DataTableColumns, TreeInst, TreeOption } from 'naive-ui'; import type { DataTableColumns, TreeInst, TreeOption } from 'naive-ui';
import { NButton, NDivider, NIcon, NInput, NPopconfirm } from 'naive-ui'; import { NButton, NDivider, NIcon, NInput, NPopconfirm } from 'naive-ui';
import { useBoolean, useLoading } from '@sa/hooks'; 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 DictTag from '@/components/custom/dict-tag.vue';
import ButtonIcon from '@/components/custom/button-icon.vue'; import ButtonIcon from '@/components/custom/button-icon.vue';
import MenuOperateDrawer from './modules/menu-operate-drawer.vue'; import MenuOperateDrawer from './modules/menu-operate-drawer.vue';
import MenuCascadeDeleteModal from './modules/menu-cascade-delete-modal.vue';
useDict('sys_show_hide'); useDict('sys_show_hide');
useDict('sys_normal_disable'); useDict('sys_normal_disable');
@ -26,6 +27,7 @@ const editingData = ref<Api.System.Menu>();
const operateType = ref<NaiveUI.TableOperateType>('add'); const operateType = ref<NaiveUI.TableOperateType>('add');
const { loading, startLoading, endLoading } = useLoading(); const { loading, startLoading, endLoading } = useLoading();
const { bool: drawerVisible, setTrue: openDrawer } = useBoolean(); const { bool: drawerVisible, setTrue: openDrawer } = useBoolean();
const { bool: cascadeDeleteVisible, setTrue: openCascadeDeleteDrawer } = useBoolean();
const { loading: btnLoading, startLoading: startBtnLoading, endLoading: endBtnLoading } = useLoading(); const { loading: btnLoading, startLoading: startBtnLoading, endLoading: endBtnLoading } = useLoading();
/** tree pattern name , use tree search */ /** tree pattern name , use tree search */
const name = ref<string>(); const name = ref<string>();
@ -36,6 +38,18 @@ const treeData = ref<Api.System.Menu[]>([]);
const checkedKeys = ref<CommonType.IdType[]>([0]); const checkedKeys = ref<CommonType.IdType[]>([0]);
const expandedKeys = 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 menuTreeRef = ref<TreeInst>();
const btnData = ref<Api.System.MenuList>([]); const btnData = ref<Api.System.MenuList>([]);
@ -292,10 +306,18 @@ const btnColumns: DataTableColumns<Api.System.Menu> = [
v-if="hasAuth('system:menu:add')" v-if="hasAuth('system:menu:add')"
size="small" size="small"
icon="material-symbols:add-rounded" icon="material-symbols:add-rounded"
class="h-28px text-icon" class="h-28px text-icon color-primary"
:tooltip-content="$t('page.system.menu.addMenu')" :tooltip-content="$t('page.system.menu.addMenu')"
@click.stop="handleAddMenu(0)" @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 <ButtonIcon
size="small" size="small"
icon="material-symbols:refresh-rounded" icon="material-symbols:refresh-rounded"
@ -347,7 +369,7 @@ const btnColumns: DataTableColumns<Api.System.Menu> = [
<template #header-extra> <template #header-extra>
<NSpace> <NSpace>
<NButton <NButton
v-if="currentMenu.menuType === 'M' && hasAuth('system:menu:add')" v-if="isCatalog && hasAuth('system:menu:add')"
size="small" size="small"
ghost ghost
type="primary" type="primary"
@ -391,30 +413,30 @@ const btnColumns: DataTableColumns<Api.System.Menu> = [
label-class="w-20% min-w-88px" label-class="w-20% min-w-88px"
content-class="w-100px" 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> <NTag class="m-1" size="small" type="primary">{{ menuTypeRecord[currentMenu.menuType!] }}</NTag>
</NDescriptionsItem> </NDescriptionsItem>
<NDescriptionsItem :label="$t('page.system.menu.status')"> <NDescriptionsItem :label="$t('page.system.menu.status')">
<DictTag size="small" :value="currentMenu.status" dict-code="sys_normal_disable" /> <DictTag size="small" :value="currentMenu.status" dict-code="sys_normal_disable" />
</NDescriptionsItem> </NDescriptionsItem>
<NDescriptionsItem :label="$t('page.system.menu.addChildMenu')"> <NDescriptionsItem :label="$t('page.system.menu.menuName')">
{{ currentMenu.menuName }} {{ currentMenu.menuName }}
</NDescriptionsItem> </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 }} {{ currentMenu.component }}
</NDescriptionsItem> </NDescriptionsItem>
<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 }} {{ currentMenu.path }}
</NDescriptionsItem> </NDescriptionsItem>
<NDescriptionsItem <NDescriptionsItem
v-if="currentMenu.menuType === 'C'" v-if="isMenu && !isExternalType"
:label="currentMenu.isFrame !== '2' ? $t('page.system.menu.query') : $t('page.system.menu.iframeQuery')" :label="!isIframeType ? $t('page.system.menu.query') : $t('page.system.menu.iframeQuery')"
> >
{{ currentMenu.queryParam }} {{ currentMenu.queryParam }}
</NDescriptionsItem> </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 }} {{ currentMenu.perms }}
</NDescriptionsItem> </NDescriptionsItem>
<NDescriptionsItem :label="$t('page.system.menu.isFrame')"> <NDescriptionsItem :label="$t('page.system.menu.isFrame')">
@ -425,7 +447,7 @@ const btnColumns: DataTableColumns<Api.System.Menu> = [
<NDescriptionsItem :label="$t('page.system.menu.visible')"> <NDescriptionsItem :label="$t('page.system.menu.visible')">
<DictTag size="small" :value="currentMenu.visible" dict-code="sys_show_hide" /> <DictTag size="small" :value="currentMenu.visible" dict-code="sys_show_hide" />
</NDescriptionsItem> </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]"> <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') }} {{ currentMenu.isCache === '0' ? $t('page.system.menu.cache') : $t('page.system.menu.noCache') }}
</NTag> </NTag>
@ -465,6 +487,7 @@ const btnColumns: DataTableColumns<Api.System.Menu> = [
:menu-type="createType" :menu-type="createType"
@submitted="handleSubmitted" @submitted="handleSubmitted"
/> />
<MenuCascadeDeleteModal v-model:visible="cascadeDeleteVisible" @submitted="handleSubmitted" />
</TableSiderLayout> </TableSiderLayout>
</template> </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')) 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 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 localIcons = getLocalMenuIcons();
const localIconOptions = localIcons.map<SelectOption>(item => ({ const localIconOptions = localIcons.map<SelectOption>(item => ({
label: () => ( label: () => (
@ -102,7 +122,7 @@ function handleInitModel() {
if (props.operateType === 'edit' && props.rowData) { if (props.operateType === 'edit' && props.rowData) {
Object.assign(model, 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); model.component = model.component?.slice(0, -6);
} }
iconType.value = model.icon?.startsWith('local-icon-') ? '2' : '1'; iconType.value = model.icon?.startsWith('local-icon-') ? '2' : '1';
@ -118,6 +138,44 @@ function closeDrawer() {
visible.value = false; 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() { async function handleSubmit() {
await validate(); await validate();
@ -133,77 +191,36 @@ async function handleSubmit() {
visible: menuVisible, visible: menuVisible,
status, status,
perms, perms,
remark remark,
component,
queryParam
} = model; } = model;
let queryParam = model.queryParam; const payload = {
if (isFrame === '0') { menuName,
queryParam = ''; path: processPath(model.path),
} else if (isFrame === '1' && queryList.value.length) { parentId,
const queryObj: { [key: string]: string } = {}; orderNum,
queryList.value.forEach(item => (queryObj[item.key] = item.value)); queryParam: processQueryParam(queryParam),
queryParam = JSON.stringify(queryObj); isFrame,
} isCache,
menuType,
const path = model.path?.startsWith('/') ? model.path?.substring(1) : model.path; visible: menuVisible,
status,
let component = model.component; perms,
if (isFrame === '1' && menuType === 'M') { icon,
component = 'Layout'; component: processComponent(component),
} else if (isFrame === '2') { remark
component = 'FrameView'; };
} else if (isMenu.value && model.isFrame === '1') {
component = component?.endsWith('/index') ? component : `${component}/index`; const { error } =
} props.operateType === 'add' ? await fetchCreateMenu(payload) : await fetchUpdateMenu({ ...payload, menuId });
// request if (error) {
if (props.operateType === 'add') { return;
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'));
} }
window.$message?.success($t(props.operateType === 'add' ? 'common.addSuccess' : 'common.updateSuccess'));
closeDrawer(); closeDrawer();
emit('submitted', menuType!); emit('submitted', menuType!);
} }
@ -224,7 +241,7 @@ function onCreate() {
</script> </script>
<template> <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> <NDrawerContent :title="drawerTitle" :native-scrollbar="false" closable>
<NForm ref="formRef" :model="model" :rules="rules"> <NForm ref="formRef" :model="model" :rules="rules">
<NGrid responsive="screen" item-responsive> <NGrid responsive="screen" item-responsive>
@ -238,12 +255,7 @@ function onCreate() {
:placeholder="$t('page.system.menu.form.parentId.required')" :placeholder="$t('page.system.menu.form.parentId.required')"
/> />
</NFormItemGi> </NFormItemGi>
<NFormItemGi <NFormItemGi v-if="!isBtn" :span="24" :label="$t('page.system.menu.menuType')" path="menuType">
v-if="model.menuType !== 'F'"
:span="24"
:label="$t('page.system.menu.menuType')"
path="menuType"
>
<NRadioGroup v-model:value="model.menuType"> <NRadioGroup v-model:value="model.menuType">
<NRadioButton <NRadioButton
v-for="item in menuTypeOptions.filter(item => item.value !== 'F')" v-for="item in menuTypeOptions.filter(item => item.value !== 'F')"
@ -253,22 +265,30 @@ function onCreate() {
/> />
</NRadioGroup> </NRadioGroup>
</NFormItemGi> </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')" /> <NInput v-model:value="model.menuName" :placeholder="$t('page.system.menu.form.menuName.required')" />
</NFormItemGi> </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"> <NRadioGroup v-model:value="iconType">
<NRadio v-for="item in menuIconTypeOptions" :key="item.value" :value="item.value" :label="item.label" /> <NRadio v-for="item in menuIconTypeOptions" :key="item.value" :value="item.value" :label="item.label" />
</NRadioGroup> </NRadioGroup>
</NFormItemGi> </NFormItemGi>
<NFormItemGi v-if="!isBtn" span="24" path="icon"> <NFormItemGi v-if="!isBtn" span="12" path="icon">
<template #label> <template #label>
<div class="flex-center"> <div class="flex-center">
<FormTip :content="$t('page.system.menu.iconifyTip')" /> <FormTip :content="$t('page.system.menu.iconifyTip')" />
<span class="pl-3px">{{ $t('page.system.menu.icon') }}</span> <span class="pl-3px">{{ $t('page.system.menu.icon') }}</span>
</div> </div>
</template> </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 <NInput
v-model:value="model.icon" v-model:value="model.icon"
:placeholder="$t('page.system.menu.placeholder.iconifyIconPlaceholder')" :placeholder="$t('page.system.menu.placeholder.iconifyIconPlaceholder')"
@ -279,27 +299,51 @@ function onCreate() {
</template> </template>
</NInput> </NInput>
</template> </template>
<template v-if="iconType === '2'"> </NFormItemGi>
<NSelect <NFormItemGi v-if="!isBtn" :span="12" path="isFrame">
v-model:value="model.icon" <template #label>
:placeholder="$t('page.system.menu.placeholder.localIconPlaceholder')" <div class="flex-center">
filterable <FormTip :content="$t('page.system.menu.isFrameTip')" />
:options="localIconOptions" <span>{{ $t('page.system.menu.isFrame') }}</span>
/> </div>
</template> </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>
<NFormItemGi v-if="!isBtn" :span="24" path="path"> <NFormItemGi v-if="!isBtn" :span="24" path="path">
<template #label> <template #label>
<div class="flex-center"> <div class="flex-center">
<FormTip :content="$t('page.system.menu.pathTip')" /> <FormTip :content="$t('page.system.menu.pathTip')" />
<span> <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> </span>
</div> </div>
</template> </template>
<NInput v-model:value="model.path" :placeholder="$t('page.system.menu.form.path.required')" /> <NInput v-model:value="model.path" :placeholder="$t('page.system.menu.form.path.required')" />
</NFormItemGi> </NFormItemGi>
<NFormItemGi v-if="isMenu && model.isFrame === '1'" :span="24" path="component"> <NFormItemGi v-if="isMenu && isInternalType" :span="24" path="component">
<template #label> <template #label>
<div class="flex-center"> <div class="flex-center">
<FormTip :content="$t('page.system.menu.componentTip')" /> <FormTip :content="$t('page.system.menu.componentTip')" />
@ -313,13 +357,13 @@ function onCreate() {
</NInputGroup> </NInputGroup>
</NFormItemGi> </NFormItemGi>
<NFormItemGi <NFormItemGi
v-if="isMenu && model.isFrame !== '0'" v-if="isMenu && !isExternalType"
span="24" span="24"
:show-feedback="!queryList.length" :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 <NDynamicInput
v-if="model.isFrame !== '2'" v-if="isInternalType"
v-model:value="queryList" v-model:value="queryList"
item-style="margin-bottom: 0" item-style="margin-bottom: 0"
:on-create="onCreate" :on-create="onCreate"
@ -369,38 +413,7 @@ function onCreate() {
</template> </template>
<NInput v-model:value="model.perms" :placeholder="$t('page.system.menu.form.perms.required')" /> <NInput v-model:value="model.perms" :placeholder="$t('page.system.menu.form.perms.required')" />
</NFormItemGi> </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"> <NFormItemGi v-if="!isBtn" :span="12" :label="$t('page.system.menu.visible')" path="visible">
<template #label> <template #label>
<div class="flex-center"> <div class="flex-center">

View File

@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import type { UploadFileInfo } from 'naive-ui';
import FileUpload from '@/components/custom/file-upload.vue'; import FileUpload from '@/components/custom/file-upload.vue';
import { AcceptType } from '@/enum/business';
defineOptions({ defineOptions({
name: 'OssUploadModal' name: 'OssUploadModal'
}); });
const fileUploadRef = ref<InstanceType<typeof FileUpload> | null>(null);
interface Props { interface Props {
uploadType: 'file' | 'image'; uploadType: 'file' | 'image';
} }
@ -22,9 +24,9 @@ const visible = defineModel<boolean>('visible', {
default: false default: false
}); });
const accept = computed(() => { const accept = computed(() => (props.uploadType === 'file' ? AcceptType.File : AcceptType.Image));
return props.uploadType === 'file' ? '.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.pdf' : '.jpg,.jpeg,.png,.gif,.bmp,.webp';
}); const fileList = ref<UploadFileInfo[]>([]);
function handleUpdateModelWhenUpload() {} function handleUpdateModelWhenUpload() {}
@ -34,7 +36,7 @@ function closeDrawer() {
function handleClose() { function handleClose() {
closeDrawer(); closeDrawer();
if (fileUploadRef.value?.refreshList) { if (fileList.value?.length > 0) {
emit('close'); emit('close');
} }
} }
@ -56,7 +58,7 @@ watch(visible, () => {
:bordered="false" :bordered="false"
@after-leave="handleClose" @after-leave="handleClose"
> >
<FileUpload ref="fileUploadRef" :upload-type="uploadType" :accept="accept" /> <FileUpload v-model:file-list="fileList" :upload-type="uploadType" :accept="accept" />
</NModal> </NModal>
</template> </template>

View File

@ -82,7 +82,7 @@ const {
key: 'dataScope', key: 'dataScope',
title: '数据范围', title: '数据范围',
align: 'center', align: 'center',
minWidth: 120, minWidth: 180,
render: row => { render: row => {
return <NTag type="info">{dataScopeRecord[row.dataScope]}</NTag>; return <NTag type="info">{dataScopeRecord[row.dataScope]}</NTag>;
} }
@ -113,7 +113,7 @@ const {
key: 'operate', key: 'operate',
title: $t('common.operate'), title: $t('common.operate'),
align: 'center', align: 'center',
width: 220, width: 230,
render: row => { render: row => {
if (row.roleId === 1) return null; if (row.roleId === 1) return null;

View File

@ -2,7 +2,7 @@
import { computed, reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import { useLoading } from '@sa/hooks'; import { useLoading } from '@sa/hooks';
import { dataScopeOptions } from '@/constants/business'; import { dataScopeOptions } from '@/constants/business';
import { fetchGetRoleDeptTreeSelect, fetchUpdateRole } from '@/service/api/system/role'; import { fetchGetRoleDeptTreeSelect, fetchUpdateRoleDataScope } from '@/service/api/system/role';
import { useFormRules, useNaiveForm } from '@/hooks/common/form'; import { useFormRules, useNaiveForm } from '@/hooks/common/form';
import { $t } from '@/locales'; import { $t } from '@/locales';
import DeptTree from '@/components/custom/dept-tree.vue'; import DeptTree from '@/components/custom/dept-tree.vue';
@ -86,7 +86,7 @@ async function handleSubmit() {
const { roleId, roleName, roleKey, roleSort, dataScope, deptIds, menuIds } = model; const { roleId, roleName, roleKey, roleSort, dataScope, deptIds, menuIds } = model;
const { error } = await fetchUpdateRole({ const { error } = await fetchUpdateRoleDataScope({
roleId, roleId,
roleName, roleName,
roleKey, roleKey,