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.
Files changed (55) hide show
  1. package/README.md +53 -0
  2. package/bin/create-simpleadmin-ui.mjs +2 -0
  3. package/package.json +35 -0
  4. package/src/index.mjs +557 -0
  5. package/src/sync-template.mjs +64 -0
  6. package/template/.env.development +4 -0
  7. package/template/.env.production.example +4 -0
  8. package/template/.vscode/extensions.json +3 -0
  9. package/template/README.md +26 -0
  10. package/template/index.html +19 -0
  11. package/template/package.json +30 -0
  12. package/template/public/vite.svg +1 -0
  13. package/template/src/App.vue +3 -0
  14. package/template/src/components/AppBreadcrumb.vue +132 -0
  15. package/template/src/components/AppPage.vue +23 -0
  16. package/template/src/components/ChangePasswordDialog.vue +97 -0
  17. package/template/src/components/ProfileEditDialog.vue +142 -0
  18. package/template/src/components/RichTextEditor.vue +121 -0
  19. package/template/src/components/SidebarMenuItem.vue +56 -0
  20. package/template/src/components/TagsView.vue +172 -0
  21. package/template/src/constants/menuIcons.ts +205 -0
  22. package/template/src/directives/permission.ts +13 -0
  23. package/template/src/env.d.ts +22 -0
  24. package/template/src/layouts/MainLayout.vue +410 -0
  25. package/template/src/main.ts +27 -0
  26. package/template/src/router/index.ts +198 -0
  27. package/template/src/stores/tagsView.ts +161 -0
  28. package/template/src/stores/theme.ts +68 -0
  29. package/template/src/stores/user.ts +191 -0
  30. package/template/src/styles/global.css +314 -0
  31. package/template/src/utils/datetime.ts +34 -0
  32. package/template/src/utils/request.ts +324 -0
  33. package/template/src/utils/requestSign.ts +162 -0
  34. package/template/src/views/error/NotFound.vue +49 -0
  35. package/template/src/views/home/HomeView.vue +1177 -0
  36. package/template/src/views/login/LoginView.vue +576 -0
  37. package/template/src/views/monitor/loginlog/index.vue +366 -0
  38. package/template/src/views/monitor/online/index.vue +298 -0
  39. package/template/src/views/monitor/operlog/index.vue +492 -0
  40. package/template/src/views/system/config/index.vue +407 -0
  41. package/template/src/views/system/dept/index.vue +517 -0
  42. package/template/src/views/system/dict/index.vue +747 -0
  43. package/template/src/views/system/file/index.vue +1031 -0
  44. package/template/src/views/system/menu/index.vue +822 -0
  45. package/template/src/views/system/message/index.vue +422 -0
  46. package/template/src/views/system/notice/index.vue +578 -0
  47. package/template/src/views/system/openApp/index.vue +596 -0
  48. package/template/src/views/system/post/index.vue +495 -0
  49. package/template/src/views/system/role/index.vue +546 -0
  50. package/template/src/views/system/user/index.vue +373 -0
  51. package/template/src/vite-env.d.ts +1 -0
  52. package/template/tsconfig.app.json +30 -0
  53. package/template/tsconfig.json +7 -0
  54. package/template/tsconfig.node.json +24 -0
  55. package/template/vite.config.ts +22 -0
@@ -0,0 +1,4 @@
1
+ VITE_API_BASE_URL=/api/v1
2
+ # 须与 SysOpenApp 中对应客户端一致;生产勿把真实密钥提交仓库
3
+ VITE_SIGN_APP_ID=simple-admin-web
4
+ VITE_SIGN_APP_SECRET=SimpleAdmin_Dev_Sign_Secret_Change_Me_32
@@ -0,0 +1,4 @@
1
+ # 生产构建示例:复制为 .env.production.local 并由 CI 注入真实值,勿提交密钥
2
+ VITE_API_BASE_URL=https://api.example.com/api/v1
3
+ VITE_SIGN_APP_ID=simple-admin-web
4
+ VITE_SIGN_APP_SECRET=REPLACE_WITH_SAME_AS_SysOpenApp_AppSecret
@@ -0,0 +1,3 @@
1
+ {
2
+ "recommendations": ["Vue.volar"]
3
+ }
@@ -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,3 @@
1
+ <template>
2
+ <router-view />
3
+ </template>
@@ -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>