create-simpleadmin-ui 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/bin/create-simpleadmin-ui.mjs +2 -0
- package/package.json +35 -0
- package/src/index.mjs +557 -0
- package/src/sync-template.mjs +64 -0
- package/template/.env.development +4 -0
- package/template/.env.production.example +4 -0
- package/template/.vscode/extensions.json +3 -0
- package/template/README.md +26 -0
- package/template/index.html +19 -0
- package/template/package.json +30 -0
- package/template/public/vite.svg +1 -0
- package/template/src/App.vue +3 -0
- package/template/src/components/AppBreadcrumb.vue +132 -0
- package/template/src/components/AppPage.vue +23 -0
- package/template/src/components/ChangePasswordDialog.vue +97 -0
- package/template/src/components/ProfileEditDialog.vue +142 -0
- package/template/src/components/RichTextEditor.vue +121 -0
- package/template/src/components/SidebarMenuItem.vue +56 -0
- package/template/src/components/TagsView.vue +172 -0
- package/template/src/constants/menuIcons.ts +205 -0
- package/template/src/directives/permission.ts +13 -0
- package/template/src/env.d.ts +22 -0
- package/template/src/layouts/MainLayout.vue +410 -0
- package/template/src/main.ts +27 -0
- package/template/src/router/index.ts +198 -0
- package/template/src/stores/tagsView.ts +161 -0
- package/template/src/stores/theme.ts +68 -0
- package/template/src/stores/user.ts +191 -0
- package/template/src/styles/global.css +314 -0
- package/template/src/utils/datetime.ts +34 -0
- package/template/src/utils/request.ts +324 -0
- package/template/src/utils/requestSign.ts +162 -0
- package/template/src/views/error/NotFound.vue +49 -0
- package/template/src/views/home/HomeView.vue +1177 -0
- package/template/src/views/login/LoginView.vue +576 -0
- package/template/src/views/monitor/loginlog/index.vue +366 -0
- package/template/src/views/monitor/online/index.vue +298 -0
- package/template/src/views/monitor/operlog/index.vue +492 -0
- package/template/src/views/system/config/index.vue +407 -0
- package/template/src/views/system/dept/index.vue +517 -0
- package/template/src/views/system/dict/index.vue +747 -0
- package/template/src/views/system/file/index.vue +1031 -0
- package/template/src/views/system/menu/index.vue +822 -0
- package/template/src/views/system/message/index.vue +422 -0
- package/template/src/views/system/notice/index.vue +578 -0
- package/template/src/views/system/openApp/index.vue +596 -0
- package/template/src/views/system/post/index.vue +495 -0
- package/template/src/views/system/role/index.vue +546 -0
- package/template/src/views/system/user/index.vue +373 -0
- package/template/src/vite-env.d.ts +1 -0
- package/template/tsconfig.app.json +30 -0
- package/template/tsconfig.json +7 -0
- package/template/tsconfig.node.json +24 -0
- package/template/vite.config.ts +22 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# SimpleAdmin 前端(Vue3)
|
|
2
|
+
|
|
3
|
+
完整说明(启动、签名、生产、配置)见 **[Api README](../Temp.SimpleAdmin.Api/README.md)**。
|
|
4
|
+
|
|
5
|
+
## 本仓库直接运行
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install && npm run dev # http://localhost:5173
|
|
9
|
+
npm run build # dist/;生产 env 由 CI 注入
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
环境变量:`VITE_API_BASE_URL`、`VITE_SIGN_APP_ID`、`VITE_SIGN_APP_SECRET`(须与 `SysOpenApp` 一致)。
|
|
13
|
+
|
|
14
|
+
## 对外交互式创建
|
|
15
|
+
|
|
16
|
+
使用同级 CLI 包 [`create-simpleadmin-ui`](../create-simpleadmin-ui):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm create simpleadmin-ui@latest
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
本地试跑(无需发布):
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
cd ../create-simpleadmin-ui && npm install && npm start
|
|
26
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>SimpleAdmin</title>
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
10
|
+
<link
|
|
11
|
+
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap"
|
|
12
|
+
rel="stylesheet"
|
|
13
|
+
/>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<div id="app"></div>
|
|
17
|
+
<script type="module" src="/src/main.ts"></script>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "temp-simpleadmin-ui",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vue-tsc -b && vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@element-plus/icons-vue": "^2.3.2",
|
|
13
|
+
"@wangeditor/editor": "^5.1.23",
|
|
14
|
+
"@wangeditor/editor-for-vue": "^5.1.12",
|
|
15
|
+
"axios": "^1.20.0",
|
|
16
|
+
"element-plus": "^2.14.5",
|
|
17
|
+
"marked": "^18.0.11",
|
|
18
|
+
"pinia": "^4.0.3",
|
|
19
|
+
"vue": "^3.5.12",
|
|
20
|
+
"vue-router": "^4.6.4"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^20.17.0",
|
|
24
|
+
"@vitejs/plugin-vue": "^5.1.4",
|
|
25
|
+
"sass": "^1.77.8",
|
|
26
|
+
"typescript": "~5.6.2",
|
|
27
|
+
"vite": "^5.4.10",
|
|
28
|
+
"vue-tsc": "^2.1.8"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
4
|
+
import { useUserStore, type SidebarRouter } from '@/stores/user'
|
|
5
|
+
import { ArrowRight } from '@element-plus/icons-vue'
|
|
6
|
+
|
|
7
|
+
interface Crumb {
|
|
8
|
+
title: string
|
|
9
|
+
path?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const route = useRoute()
|
|
13
|
+
const router = useRouter()
|
|
14
|
+
const userStore = useUserStore()
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 拼接菜单绝对路径。
|
|
18
|
+
*/
|
|
19
|
+
function joinPath(parent: string | undefined, path: string): string {
|
|
20
|
+
if (!path) return parent || '/'
|
|
21
|
+
if (path.startsWith('/')) return path
|
|
22
|
+
const base = (parent || '').replace(/\/$/, '')
|
|
23
|
+
if (!base || base === '/') return `/${path}`
|
|
24
|
+
return `${base}/${path}`.replace(/\/{2,}/g, '/')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 在菜单树中查找通往目标 path 的标题链。
|
|
29
|
+
*/
|
|
30
|
+
function findMenuTrail(
|
|
31
|
+
nodes: SidebarRouter[],
|
|
32
|
+
target: string,
|
|
33
|
+
parentPath = '',
|
|
34
|
+
trail: Crumb[] = [],
|
|
35
|
+
): Crumb[] | null {
|
|
36
|
+
for (const node of nodes) {
|
|
37
|
+
const abs = joinPath(parentPath, node.path)
|
|
38
|
+
const title = node.meta?.title || node.name
|
|
39
|
+
const nextTrail = [...trail, { title, path: node.component ? abs : undefined }]
|
|
40
|
+
if (abs === target && node.component) {
|
|
41
|
+
return nextTrail
|
|
42
|
+
}
|
|
43
|
+
const kids = (node.children || []).filter((c) => !c.meta?.hidden)
|
|
44
|
+
if (kids.length) {
|
|
45
|
+
const found = findMenuTrail(kids, target, abs, nextTrail)
|
|
46
|
+
if (found) return found
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const crumbs = computed<Crumb[]>(() => {
|
|
53
|
+
if (route.path === '/index' || route.path === '/') {
|
|
54
|
+
return [{ title: '首页', path: '/index' }]
|
|
55
|
+
}
|
|
56
|
+
const fromMenu = findMenuTrail(userStore.menus || [], route.path)
|
|
57
|
+
if (fromMenu?.length) {
|
|
58
|
+
return [{ title: '首页', path: '/index' }, ...fromMenu]
|
|
59
|
+
}
|
|
60
|
+
const title = (route.meta?.title as string) || '页面'
|
|
61
|
+
return [
|
|
62
|
+
{ title: '首页', path: '/index' },
|
|
63
|
+
{ title },
|
|
64
|
+
]
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
function onCrumbClick(item: Crumb) {
|
|
68
|
+
if (item.path && item.path !== route.path) {
|
|
69
|
+
router.push(item.path)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<template>
|
|
75
|
+
<nav class="sa-breadcrumb" aria-label="面包屑">
|
|
76
|
+
<template v-for="(item, i) in crumbs" :key="`${item.title}-${i}`">
|
|
77
|
+
<el-icon v-if="i > 0" class="sa-breadcrumb__sep"><ArrowRight /></el-icon>
|
|
78
|
+
<button
|
|
79
|
+
v-if="item.path && i < crumbs.length - 1"
|
|
80
|
+
type="button"
|
|
81
|
+
class="sa-breadcrumb__link"
|
|
82
|
+
@click="onCrumbClick(item)"
|
|
83
|
+
>
|
|
84
|
+
{{ item.title }}
|
|
85
|
+
</button>
|
|
86
|
+
<span v-else class="sa-breadcrumb__current">{{ item.title }}</span>
|
|
87
|
+
</template>
|
|
88
|
+
</nav>
|
|
89
|
+
</template>
|
|
90
|
+
|
|
91
|
+
<style scoped lang="scss">
|
|
92
|
+
.sa-breadcrumb {
|
|
93
|
+
display: flex;
|
|
94
|
+
flex-wrap: wrap;
|
|
95
|
+
align-items: center;
|
|
96
|
+
gap: 2px 4px;
|
|
97
|
+
min-width: 0;
|
|
98
|
+
font-size: 13px;
|
|
99
|
+
line-height: 1.3;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.sa-breadcrumb__sep {
|
|
103
|
+
font-size: 12px;
|
|
104
|
+
color: var(--sa-ink-muted);
|
|
105
|
+
flex-shrink: 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.sa-breadcrumb__link {
|
|
109
|
+
border: none;
|
|
110
|
+
background: transparent;
|
|
111
|
+
padding: 0 2px;
|
|
112
|
+
cursor: pointer;
|
|
113
|
+
color: var(--sa-ink-secondary);
|
|
114
|
+
font: inherit;
|
|
115
|
+
font-weight: 500;
|
|
116
|
+
border-radius: 4px;
|
|
117
|
+
transition: color 0.15s;
|
|
118
|
+
&:hover {
|
|
119
|
+
color: var(--sa-accent);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.sa-breadcrumb__current {
|
|
124
|
+
padding: 0 2px;
|
|
125
|
+
color: var(--sa-ink);
|
|
126
|
+
font-weight: 650;
|
|
127
|
+
max-width: 220px;
|
|
128
|
+
overflow: hidden;
|
|
129
|
+
text-overflow: ellipsis;
|
|
130
|
+
white-space: nowrap;
|
|
131
|
+
}
|
|
132
|
+
</style>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
title: string
|
|
4
|
+
description?: string
|
|
5
|
+
}>()
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<div class="sa-page">
|
|
10
|
+
<div class="sa-page__head">
|
|
11
|
+
<div>
|
|
12
|
+
<h1 class="sa-page__title">{{ title }}</h1>
|
|
13
|
+
<p v-if="description" class="sa-page__desc">{{ description }}</p>
|
|
14
|
+
</div>
|
|
15
|
+
<div v-if="$slots.actions" class="sa-page__actions">
|
|
16
|
+
<slot name="actions" />
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="sa-panel">
|
|
20
|
+
<slot />
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
</template>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { reactive, ref, watch } from 'vue'
|
|
3
|
+
import { ElMessage } from 'element-plus'
|
|
4
|
+
import { put } from '@/utils/request'
|
|
5
|
+
import { useUserStore } from '@/stores/user'
|
|
6
|
+
import { resetDynamicRoutes } from '@/router'
|
|
7
|
+
import { useRouter } from 'vue-router'
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
modelValue: boolean
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const emit = defineEmits<{
|
|
14
|
+
'update:modelValue': [value: boolean]
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const router = useRouter()
|
|
18
|
+
const userStore = useUserStore()
|
|
19
|
+
const submitting = ref(false)
|
|
20
|
+
const form = reactive({
|
|
21
|
+
oldPassword: '',
|
|
22
|
+
newPassword: '',
|
|
23
|
+
confirmPassword: '',
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
watch(
|
|
27
|
+
() => props.modelValue,
|
|
28
|
+
(open) => {
|
|
29
|
+
if (open) {
|
|
30
|
+
form.oldPassword = ''
|
|
31
|
+
form.newPassword = ''
|
|
32
|
+
form.confirmPassword = ''
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
function close() {
|
|
38
|
+
emit('update:modelValue', false)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function submit() {
|
|
42
|
+
if (!form.oldPassword || !form.newPassword) {
|
|
43
|
+
ElMessage.warning('请填写原密码与新密码')
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
if (form.newPassword.length < 6) {
|
|
47
|
+
ElMessage.warning('新密码至少 6 位')
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
if (form.newPassword !== form.confirmPassword) {
|
|
51
|
+
ElMessage.warning('两次输入的新密码不一致')
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
submitting.value = true
|
|
55
|
+
try {
|
|
56
|
+
await put('/auth/password', {
|
|
57
|
+
oldPassword: form.oldPassword,
|
|
58
|
+
newPassword: form.newPassword,
|
|
59
|
+
})
|
|
60
|
+
ElMessage.success('密码已修改,请重新登录')
|
|
61
|
+
close()
|
|
62
|
+
userStore.logoutLocal()
|
|
63
|
+
resetDynamicRoutes()
|
|
64
|
+
router.push('/login')
|
|
65
|
+
} finally {
|
|
66
|
+
submitting.value = false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<template>
|
|
72
|
+
<el-dialog
|
|
73
|
+
:model-value="modelValue"
|
|
74
|
+
title="修改密码"
|
|
75
|
+
width="420px"
|
|
76
|
+
append-to-body
|
|
77
|
+
align-center
|
|
78
|
+
destroy-on-close
|
|
79
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
80
|
+
>
|
|
81
|
+
<el-form label-width="88px" @submit.prevent>
|
|
82
|
+
<el-form-item label="原密码">
|
|
83
|
+
<el-input v-model="form.oldPassword" type="password" show-password autocomplete="current-password" />
|
|
84
|
+
</el-form-item>
|
|
85
|
+
<el-form-item label="新密码">
|
|
86
|
+
<el-input v-model="form.newPassword" type="password" show-password autocomplete="new-password" />
|
|
87
|
+
</el-form-item>
|
|
88
|
+
<el-form-item label="确认密码">
|
|
89
|
+
<el-input v-model="form.confirmPassword" type="password" show-password autocomplete="new-password" />
|
|
90
|
+
</el-form-item>
|
|
91
|
+
</el-form>
|
|
92
|
+
<template #footer>
|
|
93
|
+
<el-button @click="close">取消</el-button>
|
|
94
|
+
<el-button type="primary" :loading="submitting" @click="submit">确定</el-button>
|
|
95
|
+
</template>
|
|
96
|
+
</el-dialog>
|
|
97
|
+
</template>
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { reactive, ref, watch } from 'vue'
|
|
3
|
+
import { ElMessage } from 'element-plus'
|
|
4
|
+
import { get, put } from '@/utils/request'
|
|
5
|
+
import { useUserStore, type AuthUserInfo } from '@/stores/user'
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
modelValue: boolean
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const emit = defineEmits<{
|
|
12
|
+
'update:modelValue': [value: boolean]
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
interface ProfileForm {
|
|
16
|
+
userId: number
|
|
17
|
+
userName: string
|
|
18
|
+
nickName: string
|
|
19
|
+
realName: string
|
|
20
|
+
email: string
|
|
21
|
+
phone: string
|
|
22
|
+
sex: number
|
|
23
|
+
avatar: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const userStore = useUserStore()
|
|
27
|
+
const loading = ref(false)
|
|
28
|
+
const submitting = ref(false)
|
|
29
|
+
const form = reactive<ProfileForm>({
|
|
30
|
+
userId: 0,
|
|
31
|
+
userName: '',
|
|
32
|
+
nickName: '',
|
|
33
|
+
realName: '',
|
|
34
|
+
email: '',
|
|
35
|
+
phone: '',
|
|
36
|
+
sex: 0,
|
|
37
|
+
avatar: '',
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
watch(
|
|
41
|
+
() => props.modelValue,
|
|
42
|
+
async (open) => {
|
|
43
|
+
if (!open) return
|
|
44
|
+
loading.value = true
|
|
45
|
+
try {
|
|
46
|
+
const data = await get<{
|
|
47
|
+
userId: number
|
|
48
|
+
userName: string
|
|
49
|
+
nickName: string
|
|
50
|
+
realName?: string
|
|
51
|
+
email?: string
|
|
52
|
+
phone?: string
|
|
53
|
+
sex: number
|
|
54
|
+
avatar?: string
|
|
55
|
+
}>('/auth/profile')
|
|
56
|
+
form.userId = data.userId
|
|
57
|
+
form.userName = data.userName
|
|
58
|
+
form.nickName = data.nickName || ''
|
|
59
|
+
form.realName = data.realName || ''
|
|
60
|
+
form.email = data.email || ''
|
|
61
|
+
form.phone = data.phone || ''
|
|
62
|
+
form.sex = data.sex ?? 0
|
|
63
|
+
form.avatar = data.avatar || ''
|
|
64
|
+
} finally {
|
|
65
|
+
loading.value = false
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
function close() {
|
|
71
|
+
emit('update:modelValue', false)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function submit() {
|
|
75
|
+
if (!form.nickName.trim()) {
|
|
76
|
+
ElMessage.warning('请填写昵称')
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
submitting.value = true
|
|
80
|
+
try {
|
|
81
|
+
const info = await put<AuthUserInfo>('/auth/profile', {
|
|
82
|
+
nickName: form.nickName.trim(),
|
|
83
|
+
realName: form.realName.trim() || null,
|
|
84
|
+
email: form.email.trim() || null,
|
|
85
|
+
phone: form.phone.trim() || null,
|
|
86
|
+
sex: form.sex,
|
|
87
|
+
avatar: form.avatar.trim() || null,
|
|
88
|
+
})
|
|
89
|
+
userStore.userInfo = info
|
|
90
|
+
ElMessage.success('个人资料已保存')
|
|
91
|
+
close()
|
|
92
|
+
} finally {
|
|
93
|
+
submitting.value = false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
<template>
|
|
99
|
+
<el-dialog
|
|
100
|
+
:model-value="modelValue"
|
|
101
|
+
title="修改个人信息"
|
|
102
|
+
width="480px"
|
|
103
|
+
append-to-body
|
|
104
|
+
align-center
|
|
105
|
+
destroy-on-close
|
|
106
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
107
|
+
>
|
|
108
|
+
<div v-loading="loading">
|
|
109
|
+
<el-form label-width="88px" @submit.prevent>
|
|
110
|
+
<el-form-item label="登录账号">
|
|
111
|
+
<el-input :model-value="form.userName" disabled />
|
|
112
|
+
</el-form-item>
|
|
113
|
+
<el-form-item label="昵称" required>
|
|
114
|
+
<el-input v-model="form.nickName" maxlength="64" show-word-limit placeholder="显示名称" />
|
|
115
|
+
</el-form-item>
|
|
116
|
+
<el-form-item label="真实姓名">
|
|
117
|
+
<el-input v-model="form.realName" maxlength="64" placeholder="可选" />
|
|
118
|
+
</el-form-item>
|
|
119
|
+
<el-form-item label="性别">
|
|
120
|
+
<el-radio-group v-model="form.sex">
|
|
121
|
+
<el-radio :value="0">未知</el-radio>
|
|
122
|
+
<el-radio :value="1">男</el-radio>
|
|
123
|
+
<el-radio :value="2">女</el-radio>
|
|
124
|
+
</el-radio-group>
|
|
125
|
+
</el-form-item>
|
|
126
|
+
<el-form-item label="手机号">
|
|
127
|
+
<el-input v-model="form.phone" maxlength="20" placeholder="可选" />
|
|
128
|
+
</el-form-item>
|
|
129
|
+
<el-form-item label="邮箱">
|
|
130
|
+
<el-input v-model="form.email" maxlength="128" placeholder="可选" />
|
|
131
|
+
</el-form-item>
|
|
132
|
+
<el-form-item label="头像地址">
|
|
133
|
+
<el-input v-model="form.avatar" maxlength="256" placeholder="图片 URL,可选" />
|
|
134
|
+
</el-form-item>
|
|
135
|
+
</el-form>
|
|
136
|
+
</div>
|
|
137
|
+
<template #footer>
|
|
138
|
+
<el-button @click="close">取消</el-button>
|
|
139
|
+
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
|
|
140
|
+
</template>
|
|
141
|
+
</el-dialog>
|
|
142
|
+
</template>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onBeforeUnmount, shallowRef, watch } from 'vue'
|
|
3
|
+
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
|
4
|
+
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'
|
|
5
|
+
import '@wangeditor/editor/dist/css/style.css'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 富文本编辑器:基于 wangEditor,v-model 绑定 HTML 字符串。
|
|
9
|
+
*/
|
|
10
|
+
const props = withDefaults(
|
|
11
|
+
defineProps<{
|
|
12
|
+
/** HTML 内容 */
|
|
13
|
+
modelValue?: string
|
|
14
|
+
/** 占位文案 */
|
|
15
|
+
placeholder?: string
|
|
16
|
+
/** 编辑区高度(像素) */
|
|
17
|
+
height?: number
|
|
18
|
+
/** 是否只读 */
|
|
19
|
+
readonly?: boolean
|
|
20
|
+
}>(),
|
|
21
|
+
{
|
|
22
|
+
modelValue: '',
|
|
23
|
+
placeholder: '请输入内容…',
|
|
24
|
+
height: 320,
|
|
25
|
+
readonly: false,
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const emit = defineEmits<{
|
|
30
|
+
'update:modelValue': [value: string]
|
|
31
|
+
}>()
|
|
32
|
+
|
|
33
|
+
const editorRef = shallowRef<IDomEditor>()
|
|
34
|
+
|
|
35
|
+
const toolbarConfig: Partial<IToolbarConfig> = {
|
|
36
|
+
excludeKeys: ['fullScreen', 'group-video', 'insertVideo', 'uploadVideo'],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const editorConfig: Partial<IEditorConfig> = {
|
|
40
|
+
placeholder: props.placeholder,
|
|
41
|
+
readOnly: props.readonly,
|
|
42
|
+
MENU_CONF: {
|
|
43
|
+
// 小图转 base64,避免依赖需鉴权的文件下载地址
|
|
44
|
+
uploadImage: {
|
|
45
|
+
base64LimitSize: 2 * 1024 * 1024,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function handleCreated(editor: IDomEditor) {
|
|
51
|
+
editorRef.value = editor
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
watch(
|
|
55
|
+
() => props.readonly,
|
|
56
|
+
(v) => {
|
|
57
|
+
const editor = editorRef.value
|
|
58
|
+
if (!editor) return
|
|
59
|
+
if (v) editor.disable()
|
|
60
|
+
else editor.enable()
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
onBeforeUnmount(() => {
|
|
65
|
+
const editor = editorRef.value
|
|
66
|
+
if (!editor) return
|
|
67
|
+
editor.destroy()
|
|
68
|
+
})
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<template>
|
|
72
|
+
<div class="rich-text" :class="{ 'is-readonly': readonly }">
|
|
73
|
+
<Toolbar
|
|
74
|
+
v-if="!readonly"
|
|
75
|
+
class="rich-text__toolbar"
|
|
76
|
+
:editor="editorRef"
|
|
77
|
+
:default-config="toolbarConfig"
|
|
78
|
+
mode="default"
|
|
79
|
+
/>
|
|
80
|
+
<Editor
|
|
81
|
+
class="rich-text__body"
|
|
82
|
+
:style="{ height: `${height}px` }"
|
|
83
|
+
:model-value="modelValue"
|
|
84
|
+
:default-config="editorConfig"
|
|
85
|
+
mode="default"
|
|
86
|
+
@on-created="handleCreated"
|
|
87
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
</template>
|
|
91
|
+
|
|
92
|
+
<style scoped lang="scss">
|
|
93
|
+
.rich-text {
|
|
94
|
+
width: 100%;
|
|
95
|
+
border: 1px solid var(--sa-border, #e2e8f0);
|
|
96
|
+
border-radius: 10px;
|
|
97
|
+
overflow: hidden;
|
|
98
|
+
background: #fff;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.rich-text__toolbar {
|
|
102
|
+
border-bottom: 1px solid var(--sa-border, #e2e8f0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.rich-text__body {
|
|
106
|
+
overflow-y: auto;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.rich-text.is-readonly .rich-text__body {
|
|
110
|
+
background: var(--sa-toolbar-bg, #f8fafc);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
:deep(.w-e-text-container) {
|
|
114
|
+
background: transparent !important;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
:deep(.w-e-toolbar) {
|
|
118
|
+
background: var(--sa-toolbar-bg, #f8fafc) !important;
|
|
119
|
+
border: none !important;
|
|
120
|
+
}
|
|
121
|
+
</style>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { resolveMenuIcon } from '@/constants/menuIcons'
|
|
3
|
+
import type { SidebarRouter } from '@/stores/user'
|
|
4
|
+
|
|
5
|
+
defineProps<{
|
|
6
|
+
item: SidebarRouter
|
|
7
|
+
/** 父级已拼接的绝对路径前缀,顶级传空 */
|
|
8
|
+
parentPath?: string
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 拼接侧栏菜单绝对路径。
|
|
13
|
+
* @param parent 父路径
|
|
14
|
+
* @param path 当前节点 path
|
|
15
|
+
*/
|
|
16
|
+
function joinPath(parent: string | undefined, path: string): string {
|
|
17
|
+
if (!path) return parent || '/'
|
|
18
|
+
if (path.startsWith('/')) return path
|
|
19
|
+
const base = (parent || '').replace(/\/$/, '')
|
|
20
|
+
if (!base || base === '/') return `/${path}`
|
|
21
|
+
return `${base}/${path}`.replace(/\/{2,}/g, '/')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function visibleChildren(item: SidebarRouter): SidebarRouter[] {
|
|
25
|
+
return (item.children || []).filter((c) => !c.meta?.hidden)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isDirectory(item: SidebarRouter): boolean {
|
|
29
|
+
const kids = visibleChildren(item)
|
|
30
|
+
return kids.length > 0 && !item.component
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<el-sub-menu v-if="isDirectory(item)" :index="joinPath(parentPath, item.path)">
|
|
36
|
+
<template #title>
|
|
37
|
+
<el-icon>
|
|
38
|
+
<component :is="resolveMenuIcon(item.meta?.icon, 'Folder')" />
|
|
39
|
+
</el-icon>
|
|
40
|
+
<span>{{ item.meta?.title || item.name }}</span>
|
|
41
|
+
</template>
|
|
42
|
+
<SidebarMenuItem
|
|
43
|
+
v-for="child in visibleChildren(item)"
|
|
44
|
+
:key="joinPath(joinPath(parentPath, item.path), child.path) + child.name"
|
|
45
|
+
:item="child"
|
|
46
|
+
:parent-path="joinPath(parentPath, item.path)"
|
|
47
|
+
/>
|
|
48
|
+
</el-sub-menu>
|
|
49
|
+
|
|
50
|
+
<el-menu-item v-else :index="joinPath(parentPath, item.path)">
|
|
51
|
+
<el-icon>
|
|
52
|
+
<component :is="resolveMenuIcon(item.meta?.icon, 'Document')" />
|
|
53
|
+
</el-icon>
|
|
54
|
+
<span>{{ item.meta?.title || item.name }}</span>
|
|
55
|
+
</el-menu-item>
|
|
56
|
+
</template>
|