create-librex 1.0.1
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/index.mjs +110 -0
- package/package.json +22 -0
- package/template/.env.development +1 -0
- package/template/_gitignore +23 -0
- package/template/env.d.ts +1 -0
- package/template/index.html +12 -0
- package/template/package.json +26 -0
- package/template/scripts/create-page.mjs +453 -0
- package/template/src/App.vue +19 -0
- package/template/src/main.ts +60 -0
- package/template/src/pages/auth/LoginPage.vue +92 -0
- package/template/src/pages/notFound/NotFound.vue +29 -0
- package/template/src/router/index.ts +64 -0
- package/template/src/stores/asyncRoute.ts +24 -0
- package/template/src/stores/user.ts +100 -0
- package/template/src/vite-env.d.ts +12 -0
- package/template/tsconfig.app.json +4 -0
- package/template/tsconfig.json +24 -0
- package/template/tsconfig.node.json +4 -0
- package/template/vite-plugin-librex.ts +293 -0
- package/template/vite.config.ts +35 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createApp } from 'vue'
|
|
2
|
+
import { createPinia } from 'pinia'
|
|
3
|
+
import { computed } from 'vue'
|
|
4
|
+
import router, { allConfigs, setAuthGuard } from './router'
|
|
5
|
+
import App from './App.vue'
|
|
6
|
+
import 'librex/styles/breathing.css'
|
|
7
|
+
import { Librex, useConfig } from 'librex'
|
|
8
|
+
import { flushPendingBricks } from 'librex'
|
|
9
|
+
import { useUserStore } from './stores/user'
|
|
10
|
+
import { useAsyncRouteStore } from './stores/asyncRoute'
|
|
11
|
+
|
|
12
|
+
async function bootstrap() {
|
|
13
|
+
const app = createApp(App)
|
|
14
|
+
const pinia = createPinia()
|
|
15
|
+
app.use(pinia)
|
|
16
|
+
|
|
17
|
+
// Librex framework
|
|
18
|
+
app.use(Librex, {
|
|
19
|
+
provideUser: () => {
|
|
20
|
+
const userStore = useUserStore()
|
|
21
|
+
return {
|
|
22
|
+
displayName: computed(() => userStore.currentUser?.name || 'Not logged in'),
|
|
23
|
+
roleLabel: computed(() => userStore.roleLabel),
|
|
24
|
+
avatarUrl: computed(() => userStore.currentUser?.avatar || null),
|
|
25
|
+
logout: () => userStore.logout(),
|
|
26
|
+
changePassword: (_old: string, _new: string) => userStore.changePassword(_old, _new),
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
provideRoute: () => {
|
|
30
|
+
const asyncRouteStore = useAsyncRouteStore()
|
|
31
|
+
return { accessibleConfigs: computed(() => asyncRouteStore.accessibleConfigs) }
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
app.use(router)
|
|
36
|
+
|
|
37
|
+
// Register page configs
|
|
38
|
+
const configStore = useConfig()
|
|
39
|
+
flushPendingBricks()
|
|
40
|
+
for (const config of allConfigs) {
|
|
41
|
+
configStore.registerPageConfig(config)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Auth guard
|
|
45
|
+
const userStore = useUserStore()
|
|
46
|
+
const asyncRouteStore = useAsyncRouteStore()
|
|
47
|
+
setAuthGuard({
|
|
48
|
+
isAuthenticated: () => userStore.isAuthenticated,
|
|
49
|
+
canAccess: (pageId: string) => asyncRouteStore.canAccess(pageId),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
app.config.errorHandler = (err, _vm, info) => {
|
|
53
|
+
console.error('[LibreX Error]', err, info)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
app.mount('#app')
|
|
57
|
+
userStore.tryRestoreSession()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
bootstrap()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="login-page">
|
|
3
|
+
<div class="login-card">
|
|
4
|
+
<h1 class="login-title">LibreX</h1>
|
|
5
|
+
<p class="login-desc">积木式后台管理系统</p>
|
|
6
|
+
<form class="login-form" @submit.prevent="handleLogin">
|
|
7
|
+
<div class="field">
|
|
8
|
+
<input v-model="form.username" type="text" placeholder="用户名" autocomplete="username" />
|
|
9
|
+
</div>
|
|
10
|
+
<div class="field">
|
|
11
|
+
<input v-model="form.password" type="password" placeholder="密码" autocomplete="current-password" />
|
|
12
|
+
</div>
|
|
13
|
+
<Transition name="fade">
|
|
14
|
+
<div v-if="error" class="login-error">{{ error }}</div>
|
|
15
|
+
</Transition>
|
|
16
|
+
<button type="submit" :disabled="loading">
|
|
17
|
+
{{ loading ? '登录中...' : '登录' }}
|
|
18
|
+
</button>
|
|
19
|
+
</form>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</template>
|
|
23
|
+
|
|
24
|
+
<script setup lang="ts">
|
|
25
|
+
import { ref, reactive, onMounted } from 'vue'
|
|
26
|
+
import { useRouter, useRoute } from 'vue-router'
|
|
27
|
+
import { useUserStore } from '@/stores/user'
|
|
28
|
+
|
|
29
|
+
const router = useRouter()
|
|
30
|
+
const route = useRoute()
|
|
31
|
+
const userStore = useUserStore()
|
|
32
|
+
|
|
33
|
+
const form = reactive({ username: '', password: '' })
|
|
34
|
+
const loading = ref(false)
|
|
35
|
+
const error = ref('')
|
|
36
|
+
|
|
37
|
+
onMounted(() => {
|
|
38
|
+
if (userStore.isAuthenticated) {
|
|
39
|
+
router.replace((route.query.redirect as string) || '/')
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
async function handleLogin() {
|
|
44
|
+
error.value = ''
|
|
45
|
+
if (!form.username || !form.password) { error.value = '请输入用户名和密码'; return }
|
|
46
|
+
loading.value = true
|
|
47
|
+
try {
|
|
48
|
+
const ok = await userStore.login({ username: form.username, password: form.password })
|
|
49
|
+
if (ok) router.replace((route.query.redirect as string) || '/')
|
|
50
|
+
else error.value = '用户名或密码错误'
|
|
51
|
+
} catch { error.value = '登录失败' }
|
|
52
|
+
finally { loading.value = false }
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<style scoped>
|
|
57
|
+
.login-page {
|
|
58
|
+
display: flex; align-items: center; justify-content: center;
|
|
59
|
+
width: 100vw; height: 100vh;
|
|
60
|
+
background: var(--color-bg-viewport);
|
|
61
|
+
}
|
|
62
|
+
.login-card {
|
|
63
|
+
width: 360px; padding: 40px;
|
|
64
|
+
background: var(--color-bg-surface); border-radius: 16px;
|
|
65
|
+
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
|
66
|
+
}
|
|
67
|
+
.login-title { margin: 0 0 4px; font-size: 28px; font-weight: 700; text-align: center; }
|
|
68
|
+
.login-desc { margin: 0 0 32px; font-size: 14px; color: var(--color-text-tertiary); text-align: center; }
|
|
69
|
+
.login-form { display: flex; flex-direction: column; gap: 16px; }
|
|
70
|
+
.field input {
|
|
71
|
+
width: 100%; padding: 12px 14px;
|
|
72
|
+
border: 1px solid var(--color-border); border-radius: 8px;
|
|
73
|
+
background: var(--color-bg-input); color: var(--color-text-primary);
|
|
74
|
+
font-size: 14px; outline: none;
|
|
75
|
+
box-sizing: border-box;
|
|
76
|
+
}
|
|
77
|
+
.field input:focus { border-color: var(--color-accent); }
|
|
78
|
+
.login-error {
|
|
79
|
+
padding: 10px 14px; border-radius: 8px;
|
|
80
|
+
background: color-mix(in srgb, var(--color-accent-danger, #e53e3e) 10%, transparent);
|
|
81
|
+
color: var(--color-accent-danger, #e53e3e); font-size: 13px; text-align: center;
|
|
82
|
+
}
|
|
83
|
+
button[type="submit"] {
|
|
84
|
+
padding: 12px; border: none; border-radius: 8px;
|
|
85
|
+
background: var(--color-accent); color: #fff;
|
|
86
|
+
font-size: 15px; font-weight: 600; cursor: pointer;
|
|
87
|
+
}
|
|
88
|
+
button[type="submit"]:hover { opacity: 0.9; }
|
|
89
|
+
button[type="submit"]:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
90
|
+
.fade-enter-active, .fade-leave-active { transition: all .2s ease; }
|
|
91
|
+
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(-4px); }
|
|
92
|
+
</style>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="not-found-page">
|
|
3
|
+
<div class="not-found-content">
|
|
4
|
+
<div class="not-found-code">404</div>
|
|
5
|
+
<h1>页面未找到</h1>
|
|
6
|
+
<p>你访问的页面不存在或已被移除</p>
|
|
7
|
+
<button class="nf-btn" @click="$router.push('/')">返回首页</button>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
</template>
|
|
11
|
+
|
|
12
|
+
<style scoped>
|
|
13
|
+
.not-found-page {
|
|
14
|
+
display: flex; align-items: center; justify-content: center;
|
|
15
|
+
min-height: calc(100vh - 44px); padding: 40px;
|
|
16
|
+
}
|
|
17
|
+
.not-found-content { text-align: center; }
|
|
18
|
+
.not-found-code {
|
|
19
|
+
font-size: 96px; font-weight: 800; line-height: 1;
|
|
20
|
+
background: linear-gradient(135deg, var(--color-accent) 0%, var(--color-accent-purple) 100%);
|
|
21
|
+
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
|
22
|
+
}
|
|
23
|
+
h1 { margin: 12px 0 8px; font-size: 24px; }
|
|
24
|
+
p { margin: 0 0 24px; color: var(--color-text-tertiary); }
|
|
25
|
+
.nf-btn {
|
|
26
|
+
padding: 10px 24px; border-radius: 10px; border: 1px solid var(--color-border);
|
|
27
|
+
background: var(--color-bg-surface); font-size: 14px; cursor: pointer;
|
|
28
|
+
}
|
|
29
|
+
</style>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Librex Router — 编译期路由自动发现 + 权限守卫
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
|
6
|
+
import type { PageConfig } from 'librex'
|
|
7
|
+
import NotFound from '@/pages/notFound/NotFound.vue'
|
|
8
|
+
import LoginPage from '@/pages/auth/LoginPage.vue'
|
|
9
|
+
import { brickRoutes } from 'virtual:librex-routes'
|
|
10
|
+
|
|
11
|
+
const allConfigs: PageConfig[] = brickRoutes
|
|
12
|
+
.map(r => r.meta?.pageConfig as PageConfig | undefined)
|
|
13
|
+
.filter(Boolean) as PageConfig[]
|
|
14
|
+
|
|
15
|
+
const routes: RouteRecordRaw[] = [
|
|
16
|
+
{ path: '/', redirect: '/' },
|
|
17
|
+
{ path: '/login', name: 'login', component: LoginPage },
|
|
18
|
+
...brickRoutes,
|
|
19
|
+
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFound },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
const router = createRouter({ history: createWebHistory(), routes })
|
|
23
|
+
|
|
24
|
+
let _canAccess: ((pageId: string) => boolean) | null = null
|
|
25
|
+
let _isAuthenticated: (() => boolean) | null = null
|
|
26
|
+
|
|
27
|
+
export function setAuthGuard(guards: {
|
|
28
|
+
isAuthenticated: () => boolean
|
|
29
|
+
canAccess: (pageId: string) => boolean
|
|
30
|
+
}): void {
|
|
31
|
+
_isAuthenticated = guards.isAuthenticated
|
|
32
|
+
_canAccess = guards.canAccess
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PUBLIC_PATHS = new Set(['/login'])
|
|
36
|
+
|
|
37
|
+
router.beforeEach((to, _from) => {
|
|
38
|
+
if (PUBLIC_PATHS.has(to.path) || to.name === 'not-found' || to.name === 'login') {
|
|
39
|
+
return true
|
|
40
|
+
}
|
|
41
|
+
if (!_isAuthenticated || !_canAccess) {
|
|
42
|
+
console.error('[Router Guard] 守卫未初始化')
|
|
43
|
+
return { path: '/login' }
|
|
44
|
+
}
|
|
45
|
+
if (!_isAuthenticated()) {
|
|
46
|
+
return { path: '/login', query: { redirect: to.fullPath } }
|
|
47
|
+
}
|
|
48
|
+
const pageConfig = to.meta?.pageConfig as PageConfig | undefined
|
|
49
|
+
if (!pageConfig) return { name: 'not-found' }
|
|
50
|
+
if (!_canAccess(pageConfig.id)) return { name: 'not-found' }
|
|
51
|
+
return true
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
declare module 'vue-router' {
|
|
55
|
+
interface RouteMeta {
|
|
56
|
+
title?: string
|
|
57
|
+
icon?: string
|
|
58
|
+
pageConfig?: PageConfig
|
|
59
|
+
pageDir?: string
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default router
|
|
64
|
+
export { allConfigs }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { useConfig } from 'librex'
|
|
4
|
+
import { useUserStore } from './user'
|
|
5
|
+
import type { PageConfig } from 'librex'
|
|
6
|
+
|
|
7
|
+
export const useAsyncRouteStore = defineStore('async-route', () => {
|
|
8
|
+
const configStore = useConfig()
|
|
9
|
+
const userStore = useUserStore()
|
|
10
|
+
|
|
11
|
+
const accessibleConfigs = computed<PageConfig[]>(() => {
|
|
12
|
+
const all = Array.from(configStore.allPageConfigs.values())
|
|
13
|
+
return all.filter((cfg) => {
|
|
14
|
+
if (!cfg.meta?.permissions || cfg.meta.permissions.length === 0) return true
|
|
15
|
+
return cfg.meta.permissions.some(p => userStore.roleLabel === p)
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
function canAccess(pageId: string): boolean {
|
|
20
|
+
return accessibleConfigs.value.some(c => c.id === pageId)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return { accessibleConfigs, canAccess }
|
|
24
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
|
|
4
|
+
export interface UserInfo {
|
|
5
|
+
id: number
|
|
6
|
+
name: string
|
|
7
|
+
avatar?: string
|
|
8
|
+
role: 'admin' | 'editor' | 'viewer'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const TOKEN_KEY = 'librex-token'
|
|
12
|
+
const USER_KEY = 'librex-user'
|
|
13
|
+
|
|
14
|
+
export const useUserStore = defineStore('user', () => {
|
|
15
|
+
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
|
|
16
|
+
const currentUser = ref<UserInfo | null>(loadUser())
|
|
17
|
+
const loading = ref(false)
|
|
18
|
+
|
|
19
|
+
const isAuthenticated = computed(() => !!token.value && !!currentUser.value)
|
|
20
|
+
const roleLabel = computed(() => currentUser.value?.role ?? '')
|
|
21
|
+
|
|
22
|
+
async function login(credentials: { username: string; password: string }): Promise<boolean> {
|
|
23
|
+
loading.value = true
|
|
24
|
+
try {
|
|
25
|
+
// TODO: 替换为实际登录 API
|
|
26
|
+
const res = await fetch('/api/admin/login', {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
body: JSON.stringify({ account: credentials.username, password: credentials.password }),
|
|
30
|
+
})
|
|
31
|
+
const data = await res.json()
|
|
32
|
+
if (data.code !== 1 || !data.data) return false
|
|
33
|
+
|
|
34
|
+
token.value = data.data.token || data.data.session_id
|
|
35
|
+
localStorage.setItem(TOKEN_KEY, token.value || '')
|
|
36
|
+
|
|
37
|
+
// 获取用户信息
|
|
38
|
+
const user = await fetchUserInfo()
|
|
39
|
+
if (user) {
|
|
40
|
+
currentUser.value = user
|
|
41
|
+
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
|
42
|
+
}
|
|
43
|
+
return true
|
|
44
|
+
} catch {
|
|
45
|
+
return false
|
|
46
|
+
} finally {
|
|
47
|
+
loading.value = false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function fetchUserInfo(): Promise<UserInfo | null> {
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch('/api/admin/info')
|
|
54
|
+
const data = await res.json()
|
|
55
|
+
if (data.code !== 1 || !data.data) return null
|
|
56
|
+
return {
|
|
57
|
+
id: data.data.id,
|
|
58
|
+
name: data.data.name,
|
|
59
|
+
role: data.data.type === 1 ? 'admin' : 'editor',
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function logout() {
|
|
67
|
+
token.value = null
|
|
68
|
+
currentUser.value = null
|
|
69
|
+
localStorage.removeItem(TOKEN_KEY)
|
|
70
|
+
localStorage.removeItem(USER_KEY)
|
|
71
|
+
import('@/router').then(m => m.default.replace('/login'))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function changePassword(_oldPass: string, _newPass: string): Promise<{ ok: boolean; message: string }> {
|
|
75
|
+
return { ok: false, message: '请实现修改密码 API' }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function tryRestoreSession(): Promise<boolean> {
|
|
79
|
+
const savedToken = localStorage.getItem(TOKEN_KEY)
|
|
80
|
+
if (!savedToken) return false
|
|
81
|
+
token.value = savedToken
|
|
82
|
+
const user = await fetchUserInfo()
|
|
83
|
+
if (user) {
|
|
84
|
+
currentUser.value = user
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
const saved = loadUser()
|
|
88
|
+
if (saved) { currentUser.value = saved; return true }
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { token, currentUser, loading, isAuthenticated, roleLabel, login, logout, changePassword, tryRestoreSession }
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
function loadUser(): UserInfo | null {
|
|
96
|
+
try {
|
|
97
|
+
const raw = localStorage.getItem(USER_KEY)
|
|
98
|
+
return raw ? JSON.parse(raw) : null
|
|
99
|
+
} catch { return null }
|
|
100
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
declare module '*.vue' {
|
|
4
|
+
import type { DefineComponent } from 'vue'
|
|
5
|
+
const component: DefineComponent<object, object, unknown>
|
|
6
|
+
export default component
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
declare module '*.less' {
|
|
10
|
+
const content: Record<string, string>
|
|
11
|
+
export default content
|
|
12
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"jsx": "preserve",
|
|
8
|
+
"jsxImportSource": "vue",
|
|
9
|
+
"resolveJsonModule": true,
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"paths": {
|
|
16
|
+
"@/*": ["./src/*"]
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "env.d.ts"],
|
|
20
|
+
"references": [
|
|
21
|
+
{ "path": "./tsconfig.app.json" },
|
|
22
|
+
{ "path": "./tsconfig.node.json" }
|
|
23
|
+
]
|
|
24
|
+
}
|