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,161 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
|
4
|
+
|
|
5
|
+
/** 已打开的页签项 */
|
|
6
|
+
export interface TagView {
|
|
7
|
+
/** 路由 path(不含 query) */
|
|
8
|
+
path: string
|
|
9
|
+
/** 完整路径(含 query) */
|
|
10
|
+
fullPath: string
|
|
11
|
+
/** 展示标题 */
|
|
12
|
+
title: string
|
|
13
|
+
/** 路由 name(与组件 name / keep-alive include 对齐) */
|
|
14
|
+
name?: string | symbol | null
|
|
15
|
+
/** 固定页签(如首页),不可关闭 */
|
|
16
|
+
affix?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const HOME_PATH = '/index'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 多页签:记录已打开页面,支持关闭(首页固定不可关)。
|
|
23
|
+
*/
|
|
24
|
+
export const useTagsViewStore = defineStore('tagsView', () => {
|
|
25
|
+
const visited = ref<TagView[]>([
|
|
26
|
+
{ path: HOME_PATH, fullPath: HOME_PATH, title: '首页', name: 'Home', affix: true },
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* keep-alive 缓存的组件名列表(独立 ref,仅在集合变化时更新,避免无意义的新数组触发重挂载)。
|
|
31
|
+
*/
|
|
32
|
+
const cachedNames = ref<string[]>(['Home'])
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 按当前 visited 同步缓存名(内容不变则不写 ref)。
|
|
36
|
+
*/
|
|
37
|
+
function syncCachedNames() {
|
|
38
|
+
const next = visited.value
|
|
39
|
+
.map((t) => (typeof t.name === 'string' ? t.name : ''))
|
|
40
|
+
.filter(Boolean)
|
|
41
|
+
const prev = cachedNames.value
|
|
42
|
+
if (prev.length === next.length && prev.every((n, i) => n === next[i])) {
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
cachedNames.value = next
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 根据当前路由登记页签(已存在且内容相同则跳过,避免触发 keep-alive 重挂载)。
|
|
50
|
+
* @param route 当前路由
|
|
51
|
+
*/
|
|
52
|
+
function addView(route: RouteLocationNormalizedLoaded) {
|
|
53
|
+
if (route.meta?.public) return
|
|
54
|
+
if (route.name === 'NotFound' || route.path.startsWith('/login')) return
|
|
55
|
+
|
|
56
|
+
const path = route.path
|
|
57
|
+
const title = (route.meta?.title as string) || (typeof route.name === 'string' ? route.name : path)
|
|
58
|
+
const affix = Boolean(route.meta?.affix) || path === HOME_PATH
|
|
59
|
+
const name = route.name
|
|
60
|
+
const existing = visited.value.find((t) => t.path === path)
|
|
61
|
+
if (existing) {
|
|
62
|
+
if (
|
|
63
|
+
existing.fullPath === route.fullPath &&
|
|
64
|
+
existing.title === title &&
|
|
65
|
+
existing.name === name &&
|
|
66
|
+
existing.affix === affix
|
|
67
|
+
) {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
existing.fullPath = route.fullPath
|
|
71
|
+
existing.title = title
|
|
72
|
+
existing.name = name
|
|
73
|
+
existing.affix = affix
|
|
74
|
+
syncCachedNames()
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
visited.value.push({
|
|
79
|
+
path,
|
|
80
|
+
fullPath: route.fullPath,
|
|
81
|
+
title,
|
|
82
|
+
name,
|
|
83
|
+
affix,
|
|
84
|
+
})
|
|
85
|
+
syncCachedNames()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 关闭指定 path 的页签。
|
|
90
|
+
* @param path 路由 path
|
|
91
|
+
* @returns 关闭后应跳转的 fullPath;若无需跳转返回 null
|
|
92
|
+
*/
|
|
93
|
+
function closeView(path: string): string | null {
|
|
94
|
+
const idx = visited.value.findIndex((t) => t.path === path)
|
|
95
|
+
if (idx < 0) return null
|
|
96
|
+
const tag = visited.value[idx]
|
|
97
|
+
if (tag.affix) return null
|
|
98
|
+
|
|
99
|
+
visited.value.splice(idx, 1)
|
|
100
|
+
if (visited.value.length === 0) {
|
|
101
|
+
visited.value.push({
|
|
102
|
+
path: HOME_PATH,
|
|
103
|
+
fullPath: HOME_PATH,
|
|
104
|
+
title: '首页',
|
|
105
|
+
name: 'Home',
|
|
106
|
+
affix: true,
|
|
107
|
+
})
|
|
108
|
+
syncCachedNames()
|
|
109
|
+
return HOME_PATH
|
|
110
|
+
}
|
|
111
|
+
syncCachedNames()
|
|
112
|
+
const next = visited.value[idx] || visited.value[idx - 1]
|
|
113
|
+
return next?.fullPath ?? HOME_PATH
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 关闭除固定页与当前页外的全部页签。
|
|
118
|
+
* @param currentPath 保留的当前 path
|
|
119
|
+
*/
|
|
120
|
+
function closeOthers(currentPath: string) {
|
|
121
|
+
visited.value = visited.value.filter((t) => t.affix || t.path === currentPath)
|
|
122
|
+
syncCachedNames()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 关闭全部非固定页签。
|
|
127
|
+
* @returns 应跳转的 fullPath
|
|
128
|
+
*/
|
|
129
|
+
function closeAll(): string {
|
|
130
|
+
visited.value = visited.value.filter((t) => t.affix)
|
|
131
|
+
if (!visited.value.length) {
|
|
132
|
+
visited.value.push({
|
|
133
|
+
path: HOME_PATH,
|
|
134
|
+
fullPath: HOME_PATH,
|
|
135
|
+
title: '首页',
|
|
136
|
+
name: 'Home',
|
|
137
|
+
affix: true,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
syncCachedNames()
|
|
141
|
+
return visited.value[0]?.fullPath || HOME_PATH
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 登出等场景重置为仅首页 */
|
|
145
|
+
function reset() {
|
|
146
|
+
visited.value = [
|
|
147
|
+
{ path: HOME_PATH, fullPath: HOME_PATH, title: '首页', name: 'Home', affix: true },
|
|
148
|
+
]
|
|
149
|
+
cachedNames.value = ['Home']
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
visited,
|
|
154
|
+
cachedNames,
|
|
155
|
+
addView,
|
|
156
|
+
closeView,
|
|
157
|
+
closeOthers,
|
|
158
|
+
closeAll,
|
|
159
|
+
reset,
|
|
160
|
+
}
|
|
161
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
/** 可选主题标识 */
|
|
5
|
+
export type ThemeId = 'teal' | 'ocean' | 'forest' | 'amber' | 'slate' | 'dark'
|
|
6
|
+
|
|
7
|
+
/** 主题选项(用于右上角切换) */
|
|
8
|
+
export interface ThemeOption {
|
|
9
|
+
id: ThemeId
|
|
10
|
+
label: string
|
|
11
|
+
/** 色板预览色 */
|
|
12
|
+
swatch: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const STORAGE_KEY = 'sa_theme'
|
|
16
|
+
|
|
17
|
+
/** 内置主题列表 */
|
|
18
|
+
export const THEME_OPTIONS: ThemeOption[] = [
|
|
19
|
+
{ id: 'teal', label: '青绿', swatch: '#0f766e' },
|
|
20
|
+
{ id: 'ocean', label: '海蓝', swatch: '#0369a1' },
|
|
21
|
+
{ id: 'forest', label: '森绿', swatch: '#15803d' },
|
|
22
|
+
{ id: 'amber', label: '琥珀', swatch: '#b45309' },
|
|
23
|
+
{ id: 'slate', label: '岩灰', swatch: '#475569' },
|
|
24
|
+
{ id: 'dark', label: '暗色', swatch: '#0f172a' },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 将主题应用到 document(CSS 变量由 html[data-theme] 驱动)。
|
|
29
|
+
* @param id 主题标识
|
|
30
|
+
*/
|
|
31
|
+
export function applyTheme(id: ThemeId) {
|
|
32
|
+
document.documentElement.setAttribute('data-theme', id)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 从本地存储读取主题,非法值回退青绿。
|
|
37
|
+
*/
|
|
38
|
+
function readStoredTheme(): ThemeId {
|
|
39
|
+
const raw = localStorage.getItem(STORAGE_KEY) as ThemeId | null
|
|
40
|
+
if (raw && THEME_OPTIONS.some((t) => t.id === raw)) return raw
|
|
41
|
+
return 'teal'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 界面主题状态。
|
|
46
|
+
*/
|
|
47
|
+
export const useThemeStore = defineStore('theme', () => {
|
|
48
|
+
const themeId = ref<ThemeId>(readStoredTheme())
|
|
49
|
+
|
|
50
|
+
const current = computed(() => THEME_OPTIONS.find((t) => t.id === themeId.value) || THEME_OPTIONS[0])
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 切换并持久化主题。
|
|
54
|
+
* @param id 目标主题
|
|
55
|
+
*/
|
|
56
|
+
function setTheme(id: ThemeId) {
|
|
57
|
+
themeId.value = id
|
|
58
|
+
localStorage.setItem(STORAGE_KEY, id)
|
|
59
|
+
applyTheme(id)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 应用当前主题(应用启动时调用)。 */
|
|
63
|
+
function init() {
|
|
64
|
+
applyTheme(themeId.value)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { themeId, current, setTheme, init }
|
|
68
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { get, post } from '@/utils/request'
|
|
4
|
+
|
|
5
|
+
export interface AuthUserInfo {
|
|
6
|
+
userId: number
|
|
7
|
+
userName: string
|
|
8
|
+
nickName: string
|
|
9
|
+
avatar?: string
|
|
10
|
+
permissions: string[]
|
|
11
|
+
roles: string[]
|
|
12
|
+
/** 上次成功登录时间(不含本次) */
|
|
13
|
+
lastLoginTime?: string | null
|
|
14
|
+
/** 上次登录 IP */
|
|
15
|
+
lastLoginIp?: string | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SidebarRouter {
|
|
19
|
+
name: string
|
|
20
|
+
path: string
|
|
21
|
+
component?: string | null
|
|
22
|
+
meta: { title: string; icon?: string; hidden?: boolean }
|
|
23
|
+
children?: SidebarRouter[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CaptchaResult {
|
|
27
|
+
captchaId: string
|
|
28
|
+
imageBase64: string
|
|
29
|
+
expireSeconds: number
|
|
30
|
+
devCode?: string | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SendSmsCodeResult {
|
|
34
|
+
expireSeconds: number
|
|
35
|
+
intervalSeconds: number
|
|
36
|
+
devCode?: string | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LoginOptions {
|
|
40
|
+
/** 是否启用手机号短信登录 */
|
|
41
|
+
enableSmsLogin: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TokenPairResult {
|
|
45
|
+
accessToken: string
|
|
46
|
+
expiresIn: number
|
|
47
|
+
refreshToken: string
|
|
48
|
+
refreshExpiresIn: number
|
|
49
|
+
user: AuthUserInfo
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PasswordLoginPayload {
|
|
53
|
+
loginType: 'password'
|
|
54
|
+
userName: string
|
|
55
|
+
password: string
|
|
56
|
+
captchaId: string
|
|
57
|
+
captchaCode: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SmsLoginPayload {
|
|
61
|
+
loginType: 'sms'
|
|
62
|
+
phone: string
|
|
63
|
+
smsCode: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type LoginPayload = PasswordLoginPayload | SmsLoginPayload
|
|
67
|
+
|
|
68
|
+
const TOKEN_KEY = 'sa_token'
|
|
69
|
+
const REFRESH_KEY = 'sa_refresh_token'
|
|
70
|
+
|
|
71
|
+
export const useUserStore = defineStore('user', () => {
|
|
72
|
+
const token = ref<string>(localStorage.getItem(TOKEN_KEY) || '')
|
|
73
|
+
const refreshToken = ref<string>(localStorage.getItem(REFRESH_KEY) || '')
|
|
74
|
+
const userInfo = ref<AuthUserInfo | null>(null)
|
|
75
|
+
const routersLoaded = ref(false)
|
|
76
|
+
/** 侧栏菜单树(来自 /auth/routers) */
|
|
77
|
+
const menus = ref<SidebarRouter[]>([])
|
|
78
|
+
|
|
79
|
+
function setToken(value: string) {
|
|
80
|
+
token.value = value
|
|
81
|
+
if (value) localStorage.setItem(TOKEN_KEY, value)
|
|
82
|
+
else localStorage.removeItem(TOKEN_KEY)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function setRefreshToken(value: string) {
|
|
86
|
+
refreshToken.value = value
|
|
87
|
+
if (value) localStorage.setItem(REFRESH_KEY, value)
|
|
88
|
+
else localStorage.removeItem(REFRESH_KEY)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function applyTokenPair(data: TokenPairResult) {
|
|
92
|
+
setToken(data.accessToken)
|
|
93
|
+
setRefreshToken(data.refreshToken || '')
|
|
94
|
+
if (data.user) userInfo.value = data.user
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function setMenus(list: SidebarRouter[]) {
|
|
98
|
+
menus.value = list || []
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function logoutLocal() {
|
|
102
|
+
token.value = ''
|
|
103
|
+
refreshToken.value = ''
|
|
104
|
+
userInfo.value = null
|
|
105
|
+
routersLoaded.value = false
|
|
106
|
+
menus.value = []
|
|
107
|
+
localStorage.removeItem(TOKEN_KEY)
|
|
108
|
+
localStorage.removeItem(REFRESH_KEY)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function fetchCaptcha() {
|
|
112
|
+
return get<CaptchaResult>('/auth/captcha')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function fetchLoginOptions() {
|
|
116
|
+
return get<LoginOptions>('/auth/login-options')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function sendSmsCode(phone: string) {
|
|
120
|
+
return post<SendSmsCodeResult>('/auth/sms-code', { phone })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function login(payload: LoginPayload) {
|
|
124
|
+
const data = await post<TokenPairResult>('/auth/login', payload)
|
|
125
|
+
if (!data?.accessToken) {
|
|
126
|
+
throw new Error('登录响应缺少 accessToken')
|
|
127
|
+
}
|
|
128
|
+
applyTokenPair(data)
|
|
129
|
+
return data
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 用 refreshToken 换新令牌对(供请求拦截器单飞调用)。
|
|
134
|
+
*/
|
|
135
|
+
async function refreshSession() {
|
|
136
|
+
const current = refreshToken.value
|
|
137
|
+
if (!current) {
|
|
138
|
+
throw new Error('无刷新令牌')
|
|
139
|
+
}
|
|
140
|
+
const data = await post<TokenPairResult>(
|
|
141
|
+
'/auth/refresh',
|
|
142
|
+
{ refreshToken: current },
|
|
143
|
+
{ silent: true, skipAuthRefresh: true },
|
|
144
|
+
)
|
|
145
|
+
if (!data?.accessToken || !data.refreshToken) {
|
|
146
|
+
throw new Error('刷新响应缺少令牌')
|
|
147
|
+
}
|
|
148
|
+
applyTokenPair(data)
|
|
149
|
+
return data
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function fetchInfo() {
|
|
153
|
+
userInfo.value = await get<AuthUserInfo>('/auth/info')
|
|
154
|
+
return userInfo.value
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function logout() {
|
|
158
|
+
try {
|
|
159
|
+
await post('/auth/logout')
|
|
160
|
+
} finally {
|
|
161
|
+
logoutLocal()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function hasPermission(code?: string) {
|
|
166
|
+
if (!code) return true
|
|
167
|
+
const perms = userInfo.value?.permissions || []
|
|
168
|
+
if (userInfo.value?.roles?.includes('super_admin')) return true
|
|
169
|
+
return perms.includes(code) || perms.includes('*')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
token,
|
|
174
|
+
refreshToken,
|
|
175
|
+
userInfo,
|
|
176
|
+
routersLoaded,
|
|
177
|
+
menus,
|
|
178
|
+
setToken,
|
|
179
|
+
setRefreshToken,
|
|
180
|
+
setMenus,
|
|
181
|
+
logoutLocal,
|
|
182
|
+
fetchCaptcha,
|
|
183
|
+
fetchLoginOptions,
|
|
184
|
+
sendSmsCode,
|
|
185
|
+
login,
|
|
186
|
+
refreshSession,
|
|
187
|
+
fetchInfo,
|
|
188
|
+
logout,
|
|
189
|
+
hasPermission,
|
|
190
|
+
}
|
|
191
|
+
})
|