1
0
mirror of https://github.com/zclzone/vue-naive-admin.git synced 2025-12-28 12:10:20 +08:00

26 Commits

Author SHA1 Message Date
张传龙
bdbe9b8483 refactor: dynamic routes 2022-09-18 15:10:22 +08:00
张传龙
30211e14ea chore: update deps 2022-09-18 15:08:06 +08:00
张传龙
e7b1896d9e chore: update deps 2022-09-15 21:44:58 +08:00
张传龙
a5c1046e67 mod: remove 2022-09-14 09:14:37 +08:00
张传龙
31670cd671 mod: base demo 2022-09-11 17:08:36 +08:00
张传龙
b0e3a94e12 style: remove extra code 2022-09-10 16:00:39 +08:00
张传龙
2b2a324a62 refactor: simplify noNeedToken judge 2022-09-10 15:56:53 +08:00
张传龙
40483e09e6 refactor: keep alive 2022-09-09 09:53:49 +08:00
张传龙
a5a3472486 refactor: setupExtend replace with defineOptions 2022-09-08 15:08:28 +08:00
张传龙
fd1752693a chore: update deps 2022-09-08 15:06:23 +08:00
张传龙
2f3a83758a mod: reload page 2022-09-08 09:00:28 +08:00
张传龙
738212c84b Merge branch 'main' of https://github.com/zclzone/vue-naive-admin 2022-09-07 16:13:50 +08:00
张传龙
a4f3e16007 style: update icon and title 2022-09-07 16:12:38 +08:00
Ronnie Zhang
5b2d1c68dd Merge pull request #26 from haichao0817/main
style: change 'loging' to 'loading'
2022-09-07 14:56:17 +08:00
wukang
7b8b50322c change 'loging' to 'loading' 2022-09-07 11:01:02 +08:00
张传龙
bb171866b6 refactor: routes and file 2022-09-04 12:18:47 +08:00
张传龙
f1bc9edbac refactor: request error tip 2022-09-04 11:35:30 +08:00
张传龙
3a38adc71e fix: mock api error 2022-09-03 22:43:57 +08:00
Ronnie Zhang
b760cc34dd Merge pull request #25 from zclzone/feature/crud-table
Feature/crud table
2022-09-03 22:34:49 +08:00
张传龙
b59e47b5dd feat: finish curd table 2022-09-03 22:28:37 +08:00
张传龙
d1dd58215d wip: crud table 2022-09-03 17:33:20 +08:00
张传龙
661aed1a94 style: add annotation 2022-09-03 17:32:30 +08:00
张传龙
f2e2fc6819 wip: crud table 2022-09-01 14:53:18 +08:00
张传龙
9ea8ffd7fd wip: crud table 2022-08-31 10:16:38 +08:00
张传龙
af983d16b9 wip: commonPage 2022-08-29 10:08:18 +08:00
张传龙
079761b6fd feat: add page components 2022-08-28 19:37:23 +08:00
56 changed files with 1728 additions and 977 deletions

View File

@@ -1,11 +1,5 @@
import vue from '@vitejs/plugin-vue'
/**
* * 扩展setup插件支持在script标签中使用name属性
* usage: <script setup name="MyComp"></script>
*/
import vueSetupExtend from 'vite-plugin-vue-setup-extend-plus'
/**
* * unocss插件原子css
* https://github.com/antfu/unocss
@@ -22,7 +16,7 @@ import { configMockPlugin } from './mock'
import unplugin from './unplugin'
export function createVitePlugins(viteEnv, isBuild) {
const plugins = [vue(), vueSetupExtend(), ...unplugin, configHtmlPlugin(viteEnv, isBuild), Unocss()]
const plugins = [vue(), ...unplugin, configHtmlPlugin(viteEnv, isBuild), Unocss()]
if (viteEnv?.VITE_USE_MOCK) {
plugins.push(configMockPlugin(isBuild))

View File

@@ -1,4 +1,5 @@
import { resolve } from 'path'
import DefineOptions from 'unplugin-vue-define-options/vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
@@ -18,6 +19,7 @@ import { getSrcPath } from '../utils'
const customIconPath = resolve(getSrcPath(), 'assets/svg')
export default [
DefineOptions(),
AutoImport({
imports: ['vue', 'vue-router'],
dts: false,

View File

@@ -1,17 +1,8 @@
export default [
const posts = [
{
url: '/api/posts',
method: 'get',
response: () => {
return {
code: 0,
message: 'ok',
data: [
{
id: 36,
title: '使用纯css优雅配置移动端rem布局',
author: 'Ronnie',
category: '移动端,Css',
author: '大脸怪',
category: 'Css',
description: '通常配置rem布局会使用js进行处理比如750的设计稿会这样...',
content: '通常配置rem布局会使用js进行处理比如750的设计稿会这样',
isRecommend: true,
@@ -20,7 +11,6 @@ export default [
updateDate: '2021-11-04T04:03:36.000Z',
},
{
id: 35,
title: 'Vue2&Vue3项目风格指南',
author: 'Ronnie',
category: 'Vue',
@@ -32,7 +22,6 @@ export default [
updateDate: '2022-02-28T04:02:39.000Z',
},
{
id: 28,
title: '如何优雅的给图片添加水印',
author: '大脸怪',
category: 'JavaScript',
@@ -45,7 +34,6 @@ export default [
},
{
id: 26,
title: '前端缓存的理解',
author: '大脸怪',
category: 'Http',
@@ -57,7 +45,6 @@ export default [
updateDate: '2021-09-17T09:33:24.000Z',
},
{
id: 18,
title: 'Promise的五个静态方法',
author: '大脸怪',
category: 'JavaScript',
@@ -68,7 +55,79 @@ export default [
createDate: '2021-02-22T22:37:06.000Z',
updateDate: '2021-09-17T09:33:24.000Z',
},
],
]
export default [
{
url: '/api/posts',
method: 'get',
response: (data = {}) => {
const { title, pageNo, pageSize } = data.query
let pageData = []
let total = 60
const filterData = posts.filter((item) => item.title.includes(title) || (!title && title !== 0))
if (filterData.length) {
if (pageSize) {
while (pageData.length < pageSize) {
pageData.push(filterData[Math.round(Math.random() * (filterData.length - 1))])
}
} else {
pageData = filterData
}
pageData = pageData.map((item, index) => ({
id: pageSize * (pageNo - 1) + index + 1,
...item,
}))
} else {
total = 0
}
return {
code: 0,
message: 'ok',
data: {
pageData,
total,
pageNo,
pageSize,
},
}
},
},
{
url: '/api/post',
method: 'post',
response: ({ body }) => {
return {
code: 0,
message: 'ok',
data: body,
}
},
},
{
url: '/api/post/:id',
method: 'put',
response: ({ query, body }) => {
return {
code: 0,
message: 'ok',
data: {
id: query.id,
body,
},
}
},
},
{
url: '/api/post/:id',
method: 'delete',
response: ({ query }) => {
return {
code: 0,
message: 'ok',
data: {
id: query.id,
},
}
},
},

View File

@@ -21,8 +21,8 @@
"md-editor-v3": "^1.11.4",
"mockjs": "^1.1.0",
"pinia": "^2.0.13",
"vue": "^3.2.31",
"vue-router": "^4.0.15"
"vue": "^3.2.39",
"vue-router": "^4.1.5"
},
"devDependencies": {
"@commitlint/cli": "^17.0.3",
@@ -44,20 +44,20 @@
"fs-extra": "^10.0.1",
"husky": "^8.0.1",
"lint-staged": "^13.0.3",
"naive-ui": "^2.32.1",
"naive-ui": "^2.33.3",
"prettier": "^2.6.1",
"rollup-plugin-visualizer": "^5.6.0",
"sass": "^1.49.10",
"unocss": "^0.43.2",
"unplugin-auto-import": "^0.9.2",
"unplugin-icons": "^0.14.1",
"unplugin-icons": "^0.14.9",
"unplugin-vue-components": "^0.17.21",
"vite": "^3.0.9",
"unplugin-vue-define-options": "^0.11.2",
"vite": "^3.1.1",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-html": "^3.2.0",
"vite-plugin-mock": "^2.9.6",
"vite-plugin-svg-icons": "^2.0.1",
"vite-plugin-vue-setup-extend-plus": "^0.1.0"
"vite-plugin-svg-icons": "^2.0.1"
},
"config": {
"commitizen": {

714
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,5 +2,5 @@ import request from '@/utils/http'
export default {
getUser: () => request.get('/user'),
refreshToken: () => request.post('/auth/refreshToken'),
refreshToken: () => request.post('/auth/refreshToken', null, { noNeedTip: true }),
}

View File

@@ -1,8 +1,7 @@
<script setup>
import { renderCustomIcon } from '@/utils/icon'
/** 自定义图标 */
const props = defineProps({
/** 图标名称(图片的文件名) */
/** 图标名称(assets/svg下的文件名) */
icon: {
type: String,
required: true,
@@ -16,10 +15,8 @@ const props = defineProps({
default: undefined,
},
})
const iconCom = computed(() => renderCustomIcon(props.icon, props))
</script>
<template>
<component :is="iconCom" />
<TheIcon type="custom" v-bind="props" />
</template>

View File

@@ -1,4 +1,4 @@
<script setup name="SvgIcon">
<script setup>
const props = defineProps({
icon: {
type: String,

View File

@@ -0,0 +1,33 @@
<script setup>
import { renderIcon, renderCustomIcon } from '@/utils/icon'
const props = defineProps({
icon: {
type: String,
required: true,
},
size: {
type: Number,
default: 14,
},
color: {
type: String,
default: undefined,
},
/** iconify | custom */
type: {
type: String,
default: 'iconify',
},
})
const iconCom = computed(() =>
props.type === 'iconify'
? renderIcon(props.icon, { size: props.size, color: props.color })
: renderCustomIcon(props.icon, { size: props.size, color: props.color })
)
</script>
<template>
<component :is="iconCom" />
</template>

View File

@@ -0,0 +1,17 @@
<template>
<transition name="fade-slide" mode="out-in" appear>
<section class="cus-scroll-y wh-full p-15 flex-col bg-[#f5f6fb]">
<slot />
<AppFooter v-if="showFooter" mt-15 />
</section>
</transition>
</template>
<script setup>
defineProps({
showFooter: {
type: Boolean,
default: false,
},
})
</script>

View File

@@ -0,0 +1,33 @@
<template>
<AppPage :show-footer="showFooter">
<header v-if="showHeader" px-15 mb-15 min-h-45 flex justify-between items-center>
<slot v-if="$slots.header" name="header" />
<template v-else>
<h2 color="#333" text-22 font-normal>{{ title || route.meta?.title }}</h2>
<slot name="action" />
</template>
</header>
<n-card rounded-10 flex-1>
<slot />
</n-card>
</AppPage>
</template>
<script setup>
defineProps({
showFooter: {
type: Boolean,
default: false,
},
showHeader: {
type: Boolean,
default: true,
},
title: {
type: String,
default: undefined,
},
})
const route = useRoute()
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div min-h-60 p-15 flex items-start justify-between b-1 bc-ccc rounded-8 bg="#fafafc">
<n-space wrap :size="[35, 15]">
<slot />
</n-space>
<div flex-shrink-0>
<n-button secondary type="primary" @click="emit('reset')">重置</n-button>
<n-button ml-20 type="primary" @click="emit('search')">搜索</n-button>
</div>
</div>
</template>
<script setup>
const emit = defineEmits(['search', 'reset'])
</script>

View File

@@ -0,0 +1,29 @@
<template>
<div flex items-center>
<label v-if="!isNullOrWhitespace(label)" w-80 flex-shrink-0 :style="{ width: labelWidth + 'px' }">
{{ label }}
</label>
<div :style="{ width: contentWidth + 'px' }" flex-shrink-0>
<slot />
</div>
</div>
</template>
<script setup>
import { isNullOrWhitespace } from '@/utils/is'
defineProps({
label: {
type: String,
default: '',
},
labelWidth: {
type: Number,
default: 80,
},
contentWidth: {
type: Number,
default: 220,
},
})
</script>

View File

@@ -0,0 +1,48 @@
<template>
<n-modal v-model:show="show" :style="{ width }" preset="card" :title="title" size="huge" :bordered="false">
<slot />
<template v-if="showFooter" #footer>
<footer flex justify-end>
<slot name="footer">
<n-button @click="show = false">取消</n-button>
<n-button :loading="loading" ml-20 type="primary" @click="emit('onSave')">保存</n-button>
</slot>
</footer>
</template>
</n-modal>
</template>
<script setup>
const props = defineProps({
width: {
type: String,
default: '600px',
},
title: {
type: String,
default: '',
},
showFooter: {
type: Boolean,
default: true,
},
visible: {
type: Boolean,
required: true,
},
loading: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:visible', 'onSave'])
const show = computed({
get() {
return props.visible
},
set(v) {
emit('update:visible', v)
},
})
</script>

View File

@@ -0,0 +1,130 @@
<template>
<QueryBar v-if="$slots.queryBar" mb-30 @search="handleSearch" @reset="handleReset">
<slot name="queryBar" />
</QueryBar>
<n-data-table
:remote="remote"
:loading="loading"
:scroll-x="scrollX"
:columns="columns"
:data="tableData"
:row-key="(row) => row[rowKey]"
:pagination="isPagination ? pagination : false"
@update:checked-row-keys="onChecked"
@update:page="onPageChange"
/>
</template>
<script setup>
const props = defineProps({
/**
* @remote true: 后端分页 false 前端分页
*/
remote: {
type: Boolean,
default: true,
},
/**
* @remote 是否分页
*/
isPagination: {
type: Boolean,
default: true,
},
scrollX: {
type: Number,
default: 1200,
},
rowKey: {
type: String,
default: 'id',
},
columns: {
type: Array,
required: true,
},
/** queryBar中的参数 */
queryItems: {
type: Object,
default() {
return {}
},
},
/** 补充参数(可选) */
extraParams: {
type: Object,
default() {
return {}
},
},
/**
* ! 约定接口入参出参
* * 分页模式需约定分页接口入参
* @pageSize 分页参数一页展示多少条默认10
* @pageNo 分页参数页码默认1
* * 需约定接口出参
* @pageData 分页模式必须,非分页模式如果没有pageData则取上一层data
* @total 分页模式必须非分页模式如果没有total则取上一层data.length
*/
getData: {
type: Function,
required: true,
},
})
const emit = defineEmits(['update:queryItems', 'onChecked'])
const loading = ref(false)
const initQuery = { ...props.queryItems }
const tableData = ref([])
const pagination = reactive({ page: 1, pageSize: 10 })
async function handleQuery() {
try {
loading.value = true
let paginationParams = {}
// 如果非分页模式或者使用前端分页,则无需传分页参数
if (props.isPagination && props.remote) {
paginationParams = { pageNo: pagination.page, pageSize: pagination.pageSize }
}
const { data } = await props.getData({ ...props.queryItems, ...props.extraParams, ...paginationParams })
tableData.value = data?.pageData || data
pagination.itemCount = data.total ?? data.length
} catch (error) {
tableData.value = []
pagination.itemCount = 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
handleQuery()
}
async function handleReset() {
const queryItems = { ...props.queryItems }
for (const key in queryItems) {
queryItems[key] = ''
}
emit('update:queryItems', { ...queryItems, ...initQuery })
await nextTick()
pagination.page = 1
handleQuery()
}
function onPageChange(currentPage) {
pagination.page = currentPage
if (props.remote) {
handleQuery()
}
}
function onChecked(rowKeys) {
if (props.columns.some((item) => item.type === 'selection')) {
emit('onChecked', rowKeys)
}
}
defineExpose({
handleSearch,
handleReset,
})
</script>

1
src/composables/index.js Normal file
View File

@@ -0,0 +1 @@
export { default as useCRUD } from './useCRUD'

103
src/composables/useCRUD.js Normal file
View File

@@ -0,0 +1,103 @@
import { isNullOrWhitespace } from '@/utils/is'
const ACTIONS = {
view: '查看',
edit: '编辑',
add: '新增',
}
export default function ({ name, initForm = {}, doCreate, doDelete, doUpdate, refresh }) {
const modalVisible = ref(false)
const modalAction = ref('')
const modalTitle = computed(() => ACTIONS[modalAction.value] + name)
const modalLoading = ref(false)
const modalFormRef = ref(null)
const modalForm = ref({ ...initForm })
/** 新增 */
function handleAdd() {
modalAction.value = 'add'
modalVisible.value = true
modalForm.value = { ...initForm }
}
/** 修改 */
function handleEdit(row) {
modalAction.value = 'edit'
modalVisible.value = true
modalForm.value = { ...row }
}
/** 查看 */
function handleView(row) {
modalAction.value = 'view'
modalVisible.value = true
modalForm.value = { ...row }
}
/** 保存 */
function handleSave() {
if (!['edit', 'add'].includes(modalAction.value)) {
modalVisible.value = false
return
}
modalFormRef.value?.validate(async (err) => {
if (err) return
const actions = {
add: {
api: () => doCreate(modalForm.value),
cb: () => $message.success('新增成功'),
},
edit: {
api: () => doUpdate(modalForm.value),
cb: () => $message.success('编辑成功'),
},
}
const action = actions[modalAction.value]
try {
modalLoading.value = true
const data = await action.api()
action.cb()
modalLoading.value = modalVisible.value = false
data && refresh(data)
} catch (error) {
modalLoading.value = false
}
})
}
/** 删除 */
function handleDelete(id, confirmOptions) {
if (isNullOrWhitespace(id)) return
$dialog.confirm({
content: '确定删除?',
async confirm() {
try {
modalLoading.value = true
const data = await doDelete(id)
$message.success('删除成功')
modalLoading.value = false
refresh(data)
} catch (error) {
modalLoading.value = false
}
},
...confirmOptions,
})
}
return {
modalVisible,
modalAction,
modalTitle,
modalLoading,
handleAdd,
handleDelete,
handleEdit,
handleView,
handleSave,
modalForm,
modalFormRef,
}
}

View File

@@ -1,18 +1,17 @@
<template>
<router-view v-slot="{ Component, route }">
<transition name="fade-slide" mode="out-in" appear>
<keep-alive :include="keepAliveRouteNames">
<component :is="Component" v-if="appStore.reloadFlag" :key="route.path" />
</keep-alive>
</transition>
<KeepAlive :include="keepAliveRouteNames">
<component :is="Component" v-if="appStore.reloadFlag" :key="route.meta?.key || route.fullPath" />
</KeepAlive>
</router-view>
</template>
<script setup>
import { useAppStore } from '@/store/modules/app'
import { useRouter } from 'vue-router'
const appStore = useAppStore()
const router = useRouter()
const allRoutes = router.getRoutes()
const keepAliveRouteNames = computed(() => {
return allRoutes.filter((route) => route.meta?.keepAlive).map((route) => route.name)

View File

@@ -1,5 +1,4 @@
<template>
<header px-15 h-full flex items-center>
<div flex items-center>
<MenuCollapse />
<BreadCrumb ml-15 />
@@ -9,7 +8,6 @@
<FullScreen />
<UserAvatar />
</div>
</header>
</template>
<script setup>

View File

@@ -6,7 +6,7 @@
:collapsed-icon-size="22"
:collapsed-width="64"
:options="menuOptions"
:value="(currentRoute.meta && currentRoute.meta.activeMenu) || currentRoute.name"
:value="curRoute.meta?.activeMenu || curRoute.name"
@update:value="handleMenuSelect"
/>
</template>
@@ -19,9 +19,9 @@ import { useAppStore } from '@/store/modules/app'
import { renderCustomIcon, renderIcon } from '@/utils/icon'
const router = useRouter()
const curRoute = useRoute()
const permissionStore = usePermissionStore()
const appStore = useAppStore()
const { currentRoute } = router
const menuOptions = computed(() => {
return permissionStore.menus.map((item) => getMenuItem(item)).sort((a, b) => a.order - b.order)
@@ -87,7 +87,7 @@ function handleMenuSelect(key, item) {
if (isExternal(item.path)) {
window.open(item.path)
} else {
if (item.path === currentRoute.value.path && !currentRoute.value.meta?.keepAlive) {
if (item.path === curRoute.path) {
appStore.reloadPage()
} else {
router.push(item.path)
@@ -106,7 +106,7 @@ function handleMenuSelect(key, item) {
&.n-menu-item-content--selected,
&:hover {
&::before {
border-left: 4px solid var(--primaryColor);
border-left: 4px solid var(--primary-color);
}
}
}

View File

@@ -72,10 +72,15 @@ const options = computed(() => [
},
])
const route = useRoute()
const actionMap = new Map([
[
'reload',
() => {
if (route.meta?.keepAlive) {
// 重置keepAlive
route.meta.key = +new Date()
}
appStore.reloadPage()
},
],

View File

@@ -22,7 +22,7 @@
</ScrollX>
</template>
<script setup name="Tags">
<script setup>
import ContextMenu from './ContextMenu.vue'
import { useTagsStore } from '@/store/modules/tags'
import ScrollX from '@/components/common/ScrollX.vue'

View File

@@ -1,5 +1,5 @@
<template>
<n-layout has-sider h-full>
<n-layout has-sider wh-full>
<n-layout-sider
bordered
collapse-mode="width"
@@ -10,19 +10,18 @@
>
<SideBar />
</n-layout-sider>
<n-layout>
<n-layout-header bg-white border-b bc-eee :style="`height: ${header.height ?? 60}px`">
<AppHeader />
</n-layout-header>
<n-layout bg="#f5f6fb" :style="`height: calc(100% - ${header.height ?? 60}px)`">
<AppTags v-if="tags.visible" :style="`height: ${tags.height ?? 50}px`" />
<AppMain
class="cus-scroll border-t bc-eee overflow-auto"
:style="{ height: `calc(100% - ${tags.visible ? tags.height ?? 50 : 0}px)` }"
/>
</n-layout>
</n-layout>
<article flex-1 flex-col overflow-hidden>
<header bg-white px-15 border-b bc-eee flex items-center :style="`height: ${header.height}px`">
<AppHeader />
</header>
<section v-if="tags.visible" border-b bc-eee>
<AppTags :style="{ height: `${tags.height}px` }" />
</section>
<section flex-1 overflow-hidden>
<AppMain />
</section>
</article>
</n-layout>
</template>

View File

@@ -8,12 +8,12 @@ import { setupRouter } from '@/router'
import { setupStore } from '@/store'
import App from './App.vue'
function setupApp() {
async function setupApp() {
const app = createApp(App)
setupStore(app)
setupRouter(app)
await setupRouter(app)
app.mount('#app')
}

View File

@@ -1,14 +1,8 @@
import { useUserStore } from '@/store/modules/user'
import { usePermissionStore } from '@/store/modules/permission'
import { NOT_FOUND_ROUTE } from '@/router/routes'
import { getToken, refreshAccessToken, removeToken } from '@/utils/token'
import { toLogin } from '@/utils/auth'
import { getToken, refreshAccessToken } from '@/utils/token'
import { isNullOrWhitespace } from '@/utils/is'
const WHITE_LIST = ['/login']
const WHITE_LIST = ['/login', '/404']
export function createPermissionGuard(router) {
const userStore = useUserStore()
const permissionStore = usePermissionStore()
router.beforeEach(async (to) => {
const token = getToken()
@@ -19,24 +13,9 @@ export function createPermissionGuard(router) {
}
/** 有token的情况 */
if (to.path === '/login') return { path: '/' }
if (to.path === '/login') return { path: '/', replace: true }
// 已经拿到用户信息
if (userStore.userId) {
refreshAccessToken()
return true
}
await userStore.getUserInfo().catch((error) => {
removeToken()
toLogin()
$message.error(error.message || '获取用户信息失败!')
})
const accessRoutes = permissionStore.generateRoutes(userStore.role)
accessRoutes.forEach((route) => {
!router.hasRoute(route.name) && router.addRoute(route)
})
router.addRoute(NOT_FOUND_ROUTE)
return { ...to, replace: true }
})
}

View File

@@ -1,28 +1,53 @@
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
import { setupRouterGuard } from './guard'
import { basicRoutes } from './routes'
import { basicRoutes as routes, EMPTY_ROUTE, NOT_FOUND_ROUTE } from './routes'
import { getToken } from '@/utils/token'
import { isNullOrWhitespace } from '@/utils/is'
import { useUserStore } from '@/store/modules/user'
import { usePermissionStore } from '@/store/modules/permission'
const isHash = import.meta.env.VITE_USE_HASH === 'true'
export const router = createRouter({
history: isHash ? createWebHashHistory('/') : createWebHistory('/'),
routes: [],
routes,
scrollBehavior: () => ({ left: 0, top: 0 }),
})
export function resetRouter() {
export async function resetRouter() {
router.getRoutes().forEach((route) => {
const { name } = route
router.hasRoute(name) && router.removeRoute(name)
})
basicRoutes.forEach((route) => {
!router.hasRoute(route.name) && router.addRoute(route)
})
await addDynamicRoutes()
}
export function setupRouter(app) {
basicRoutes.forEach((route) => {
export async function addDynamicRoutes() {
const token = getToken()
// 没有token情况
if (isNullOrWhitespace(token)) {
router.addRoute(EMPTY_ROUTE)
return
}
// 有token的情况
try {
const userStore = useUserStore()
const permissionStore = usePermissionStore()
!userStore.userId && (await userStore.getUserInfo())
const accessRoutes = permissionStore.generateRoutes(userStore.role)
accessRoutes.forEach((route) => {
!router.hasRoute(route.name) && router.addRoute(route)
})
app.use(router)
setupRouterGuard(router)
router.hasRoute(EMPTY_ROUTE.name) && router.removeRoute(EMPTY_ROUTE.name)
router.addRoute(NOT_FOUND_ROUTE)
} catch (error) {
console.error(error)
}
}
export async function setupRouter(app) {
await addDynamicRoutes()
setupRouterGuard(router)
app.use(router)
}

View File

@@ -25,7 +25,7 @@ export const basicRoutes = [
meta: {
title: '外部链接',
icon: 'mdi:link-variant',
order: 2,
order: 4,
},
children: [
{
@@ -63,6 +63,12 @@ export const NOT_FOUND_ROUTE = {
isHidden: true,
}
export const EMPTY_ROUTE = {
name: 'Empty',
path: '/:pathMatch(.*)*',
component: null,
}
const modules = import.meta.glob('@/views/**/route.js', { eager: true })
const asyncRoutes = []
Object.keys(modules).forEach((key) => {

View File

@@ -27,13 +27,9 @@ export const useUserStore = defineStore('user', {
async getUserInfo() {
try {
const res = await api.getUser()
if (res.code === 0) {
const { id, name, avatar, role } = res.data
this.userInfo = { id, name, avatar, role }
return Promise.resolve(res.data)
} else {
return Promise.reject(res)
}
} catch (error) {
return Promise.reject(error)
}

View File

@@ -32,25 +32,42 @@ body {
transform: translateX(30px);
}
/* 自定义滚动条样式 */
/* 自定义滚动条样式 */
.cus-scroll {
overflow: auto;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
}
.cus-scroll-x {
overflow-x: auto;
&::-webkit-scrollbar {
width: 0;
height: 8px;
}
}
.cus-scroll-y {
overflow-y: auto;
&::-webkit-scrollbar {
width: 8px;
height: 0;
}
}
.cus-scroll,
.cus-scroll-x,
.cus-scroll-y {
&::-webkit-scrollbar-thumb {
background-color: transparent;
border-radius: 3px;
}
&::-webkit-scrollbar-corner {
background: #f6f6f6;
border-radius: 4px;
}
&:hover {
&::-webkit-scrollbar-thumb {
background: #bfbfbf;
}
&::-webkit-scrollbar-thumb:hover {
background: #999999;
background: var(--primary-color);
}
}
}

View File

@@ -6,3 +6,9 @@ export function toLogin() {
query: { ...router.currentRoute.value.query, redirect: router.currentRoute.value.path },
})
}
export function toFourZeroFour() {
router.replace({
path: '/404',
})
}

View File

@@ -9,7 +9,7 @@ export function setupMessage(NMessage) {
* * loading message不会自动清除除非被替换成非loading message非loading message默认2秒后自动清除
*/
removeMessage(message, duration = 2000) {
removeMessage(message = loadingMessage, duration = 2000) {
setTimeout(() => {
if (message) {
message.destroy()

View File

@@ -1,13 +1,43 @@
import { useUserStore } from '@/store/modules/user'
const WITHOUT_TOKEN_API = [{ url: '/auth/login', method: 'POST' }]
export function isWithoutToken({ url, method = '' }) {
return WITHOUT_TOKEN_API.some((item) => item.url === url && item.method === method.toUpperCase())
}
import { isNullOrUndef } from '@/utils/is'
import { removeToken } from '@/utils/token'
import { toLogin } from '@/utils/auth'
export function addBaseParams(params) {
if (!params.userId) {
params.userId = useUserStore().userId
}
}
export function resolveResError(error = {}) {
let { code, message } = error
if (isNullOrUndef(code)) {
// 未知错误
code = -1
message = message ?? '接口未知异常!'
} else {
switch (code) {
case 400:
message = message ?? '请求参数错误'
break
case 401:
message = message ?? '登录已过期'
removeToken()
toLogin()
break
case 403:
message = message ?? '没有权限'
break
case 404:
message = message ?? '资源或接口不存在'
break
case 500:
message = message ?? '服务器异常'
break
default:
message = message ?? '操作异常!'
break
}
}
return { code, message }
}

View File

@@ -1,16 +1,10 @@
import { getToken } from '@/utils/token'
import { toLogin } from '@/utils/auth'
import { isNullOrUndef } from '@/utils/is'
import { isWithoutToken } from './helpers'
import { resolveResError } from './helpers'
export function reqResolve(config) {
// 防止缓存给get请求加上时间戳
if (config.method === 'get') {
config.params = { ...config.params, t: new Date().getTime() }
}
// 处理不需要token的请求
if (isWithoutToken(config)) {
if (config.noNeedToken) {
return config
}
@@ -35,37 +29,18 @@ export function reqReject(error) {
}
export function repResolve(response) {
return response?.data
const { noNeedTip } = response.config
if (response.data?.code !== 0) {
const { code, message } = resolveResError(response?.data)
!noNeedTip && $message.error(message)
return Promise.reject({ code, message, error: response?.data })
}
return Promise.resolve(response?.data)
}
export function repReject(error) {
let { code, message } = error.response?.data || {}
if (isNullOrUndef(code)) {
// 未知错误
code = -1
message = '接口异常!'
} else {
/**
* TODO 此处可以根据后端返回的错误码自定义框架层面的错误处理
*/
switch (code) {
case 400:
message = message || '请求参数错误'
break
case 401:
message = message || '登录已过期'
break
case 403:
message = message || '没有权限'
break
case 404:
message = message || '资源或接口不存在'
break
default:
message = message || '未知异常'
break
}
}
console.error(`${code}${error}`)
return Promise.resolve({ code, message, error })
const { noNeedTip } = error.response?.config || error.config
const { code, message } = resolveResError(error.response?.data)
!noNeedTip && $message.error(message)
return Promise.reject({ code, message, error })
}

View File

@@ -1,7 +1,7 @@
import { h } from 'vue'
import { Icon } from '@iconify/vue'
import { NIcon } from 'naive-ui'
import SvgIcon from '@/components/custom/SvgIcon.vue'
import SvgIcon from '@/components/icon/SvgIcon.vue'
export function renderIcon(icon, props = { size: 12 }) {
return () => h(NIcon, props, { default: () => h(Icon, { icon }) })

View File

@@ -72,6 +72,7 @@ export function isNullOrWhitespace(val) {
return isNullOrUndef(val) || isWhitespace(val)
}
/** 空数组 | 空字符串 | 空对象 | 空Map | 空Set */
export function isEmpty(val) {
if (isArray(val) || isString(val)) {
return val.length === 0

View File

@@ -26,8 +26,6 @@ export async function refreshAccessToken() {
if (new Date().getTime() - time <= 1000 * 60 * 30) return
try {
const res = await api.refreshToken()
if (res.code === 0) {
setToken(res.data.token)
}
} catch (error) {}
}

View File

@@ -0,0 +1,89 @@
<template>
<CommonPage show-footer>
<n-space size="large">
<n-card title="按钮 Button">
<n-space>
<n-button>Default</n-button>
<n-button type="tertiary"> Tertiary </n-button>
<n-button type="primary"> Primary </n-button>
<n-button type="info"> Info </n-button>
<n-button type="success"> Success </n-button>
<n-button type="warning"> Warning </n-button>
<n-button type="error"> Error </n-button>
</n-space>
</n-card>
<n-card title="带 Icon 的按钮">
<n-space>
<n-button type="info"> <TheIcon icon="material-symbols:add" :size="18" class="mr-5" /> 新增 </n-button>
<n-button type="error">
<TheIcon icon="material-symbols:delete-outline" :size="18" class="mr-5" /> 删除
</n-button>
<n-button type="warning">
<TheIcon icon="material-symbols:edit-outline" :size="18" class="mr-5" /> 编辑
</n-button>
<n-button type="primary"> <TheIcon icon="majesticons:eye-line" :size="18" class="mr-5" /> 查看 </n-button>
</n-space>
</n-card>
</n-space>
<n-space size="large" mt-30>
<n-card min-w-340 title="通知 Notification">
<n-space>
<n-button @click="notify('info')"> 信息 </n-button>
<n-button @click="notify('success')"> 成功 </n-button>
<n-button @click="notify('warning')"> 警告 </n-button>
<n-button @click="notify('error')"> 错误 </n-button>
</n-space>
</n-card>
<n-card min-w-340 title="确认弹窗 Dialog">
<n-button type="error" @click="handleDelete"> <icon-mi:delete mr-5 />删除</n-button>
</n-card>
<n-card min-w-340 title="消息提醒 Message">
<n-button :loading="loading" type="primary" @click="handleLogin">
<icon-mdi:login v-show="!loading" mr-5 />
登陆
</n-button>
</n-card>
</n-space>
</CommonPage>
</template>
<script setup>
const handleDelete = function () {
$dialog.confirm({
content: '确认删除?',
confirm() {
$message.success('删除成功')
},
cancel() {
$message.warning('已取消')
},
})
}
const loading = ref(false)
function handleLogin() {
loading.value = true
$message.loading('登陆中...')
setTimeout(() => {
$message.error('登陆失败')
$message.loading('正在尝试重新登陆...')
setTimeout(() => {
$message.success('登陆成功')
loading.value = false
}, 2000)
}, 2000)
}
function notify(type) {
$notification[type]({
content: '说点啥呢',
meta: '想不出来',
duration: 2500,
keepAliveOnHover: true,
})
}
</script>

View File

@@ -0,0 +1,31 @@
<template>
<CommonPage show-footer>
<div w-350>
<n-input v-model:value="inputVal" />
<n-input-number v-model:value="number" mt-30 />
<p mt-20 text-center color-gray text-14>右击标签重新加载可重置keep-alive</p>
</div>
</CommonPage>
</template>
<script setup>
defineOptions({ name: 'KeepAlive' })
const inputVal = ref('')
const number = ref(0)
onMounted(() => {
$message.success('onMounted')
})
onUnmounted(() => {
$message.error('onUnmounted')
})
onActivated(() => {
$message.info('onActivated')
})
onDeactivated(() => {
$message.warning('onDeactivated')
})
</script>

View File

@@ -2,50 +2,41 @@ const Layout = () => import('@/layout/index.vue')
export default {
name: 'Test',
path: '/test',
path: '/base',
component: Layout,
redirect: '/test/unocss',
redirect: '/base/index',
meta: {
title: '基础功能测试',
customIcon: 'logo',
title: '基础功能',
icon: 'majesticons:compass-line',
order: 1,
},
children: [
{
name: 'BaseComponents',
path: 'idnex',
component: () => import('./index.vue'),
meta: {
title: '基础组件',
icon: 'material-symbols:auto-awesome-outline-rounded',
},
},
{
name: 'Unocss',
path: 'unocss',
component: () => import('./unocss/index.vue'),
meta: {
title: '测试unocss',
title: 'Unocss',
icon: 'material-symbols:auto-awesome-outline-rounded',
},
},
{
name: 'Message',
path: 'message',
component: () => import('./message/index.vue'),
meta: {
title: '测试Message',
icon: 'material-symbols:auto-awesome-outline-rounded',
},
},
{
name: 'Dialog',
path: 'dialog',
component: () => import('./dialog/index.vue'),
meta: {
title: '测试Dialog',
icon: 'material-symbols:auto-awesome-outline-rounded',
},
},
{
name: 'TestKeepAlive',
name: 'KeepAlive',
path: 'keep-alive',
component: () => import('./keep-alive/index.vue'),
meta: {
title: '测试Keep-Alive',
keepAlive: true,
title: 'KeepAlive',
icon: 'material-symbols:auto-awesome-outline-rounded',
keepAlive: true,
},
},
],

View File

@@ -0,0 +1,66 @@
<template>
<CommonPage show-footer>
<p>
文档<a hover-decoration-underline c-blue href="https://uno.antfu.me/" target="_blank">https://uno.antfu.me/</a>
</p>
<p>
playground
<a c-blue hover-decoration-underline href="https://unocss.antfu.me/play/" target="_blank">
https://unocss.antfu.me/play/
</a>
</p>
<div f-c-c mt-20 w-350 rounded-10 b-1 bc-ccc>
<div flex w-360 flex-wrap justify-around p-10>
<div w-50 h-50 b-1 rounded-5 f-c-c p-10 m-20>
<span w-6 h-6 rounded-3 bg-black></span>
</div>
<div w-50 h-50 b-1 rounded-5 flex justify-between p-10 m-20>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black self-end></span>
</div>
<div w-50 h-50 b-1 rounded-5 flex justify-between p-10 m-20>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black self-center></span>
<span w-6 h-6 rounded-3 bg-black self-end></span>
</div>
<div w-50 h-50 b-1 rounded-5 flex justify-between p-10 m-20>
<div flex-col justify-between>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black></span>
</div>
<div flex-col justify-between>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black></span>
</div>
</div>
<div w-50 h-50 b-1 rounded-5 f-c-c flex-col p-10 m-20>
<div flex w-full justify-between>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black></span>
</div>
<span w-6 h-6 rounded-3 bg-black></span>
<div flex w-full justify-between>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black></span>
</div>
</div>
<div w-50 h-50 b-1 rounded-5 flex-col justify-between p-10 m-20>
<div flex w-full justify-between>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black></span>
</div>
<div flex w-full justify-between>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black></span>
</div>
<div flex w-full justify-between>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black></span>
</div>
</div>
</div>
</div>
<h2 font-normal text-14 text-center w-350 mt-10 color-gray>Flex 骰子</h2>
</CommonPage>
</template>

View File

@@ -1,5 +1,5 @@
<template>
<div p-24>
<CommonPage :show-header="false" title="32323">
<div h-60 pl-20 pr-20 flex items-center bg-white>
<input
v-model="post.title"
@@ -7,17 +7,19 @@
type="text"
placeholder="输入文章标题..."
/>
<n-button type="primary" style="width: 80px" :loading="btnLoading" @click="handleSavePost">保存</n-button>
</div>
<MdEditor v-model="post.content" style="height: calc(100vh - 220px)" />
<n-button type="primary" style="width: 80px" :loading="btnLoading" @click="handleSavePost">
<TheIcon v-if="!btnLoading" icon="line-md:confirm-circle" class="mr-5" :size="18" /> 保存
</n-button>
</div>
<MdEditor v-model="post.content" style="height: calc(100vh - 250px)" />
</CommonPage>
</template>
<script setup>
import MdEditor from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
const router = useRouter()
defineOptions({ name: 'MDEditor' })
// refs
let post = ref({})
@@ -29,7 +31,6 @@ function handleSavePost(e) {
setTimeout(() => {
$message.success('保存成功')
btnLoading.value = false
router.push('/example/table/post')
}, 2000)
}
</script>

41
src/views/demo/route.js Normal file
View File

@@ -0,0 +1,41 @@
const Layout = () => import('@/layout/index.vue')
export default {
name: 'Demo',
path: '/demo',
component: Layout,
redirect: '/demo/crud-table',
meta: {
title: '示例页面',
customIcon: 'logo',
role: ['admin'],
requireAuth: true,
order: 3,
},
children: [
{
name: 'CrudTable',
path: 'crud-table',
component: () => import('./table/index.vue'),
meta: {
title: 'CRUD表格',
icon: 'ic:baseline-table-view',
role: ['admin'],
requireAuth: true,
keepAlive: true,
},
},
{
name: 'MDEditor',
path: 'md-editor',
component: () => import('./editor/md-editor.vue'),
meta: {
title: 'MD编辑器',
icon: 'ri:markdown-line',
role: ['admin'],
requireAuth: true,
keepAlive: true,
},
},
],
}

View File

@@ -3,11 +3,7 @@ import request from '@/utils/http'
export default {
getPosts: (params = {}) => request.get('posts', { params }),
getPostById: (id) => request.get(`/post/${id}`),
savePost: (id, data = {}) => {
if (id) {
return request.put(`/post/${id}`, data)
}
return request.post('/post', data)
},
addPost: (data) => request.post('/post', data),
updatePost: (data) => request.put(`/post/${data.id}`, data),
deletePost: (id) => request.delete(`/post/${id}`),
}

View File

@@ -0,0 +1,221 @@
<template>
<CommonPage show-footer title="文章">
<template #action>
<n-button type="primary" @click="handleAdd">
<TheIcon icon="material-symbols:add" :size="18" class="mr-5" /> 新建文章
</n-button>
</template>
<CrudTable
ref="$table"
v-model:query-items="queryItems"
:extra-params="extraParams"
:scroll-x="1200"
:columns="columns"
:get-data="api.getPosts"
@on-checked="onChecked"
>
<template #queryBar>
<QueryBarItem label="标题" :label-width="50">
<n-input
v-model:value="queryItems.title"
type="text"
placeholder="请输入标题"
@keydown.enter="$table?.handleSearch"
/>
</QueryBarItem>
</template>
</CrudTable>
<!-- 新增/编辑/查看 -->
<CrudModal
v-model:visible="modalVisible"
:title="modalTitle"
:loading="modalLoading"
:show-footer="modalAction !== 'view'"
@on-save="handleSave"
>
<n-form
ref="modalFormRef"
label-placement="left"
label-align="left"
:label-width="80"
:model="modalForm"
:disabled="modalAction === 'view'"
>
<n-form-item label="作者" path="author">
<n-input v-model:value="modalForm.author" disabled />
</n-form-item>
<n-form-item
label="文章标题"
path="title"
:rule="{
required: true,
message: '请输入文章标题',
trigger: ['input', 'blur'],
}"
>
<n-input v-model:value="modalForm.title" placeholder="请输入文章标题" />
</n-form-item>
<n-form-item
label="文章内容"
path="content"
:rule="{
required: true,
message: '请输入文章内容',
trigger: ['input', 'blur'],
}"
>
<n-input
v-model:value="modalForm.content"
placeholder="请输入文章内容"
type="textarea"
:autosize="{
minRows: 3,
maxRows: 5,
}"
/>
</n-form-item>
</n-form>
</CrudModal>
</CommonPage>
</template>
<script setup>
import { NButton, NSwitch } from 'naive-ui'
import { formatDateTime } from '@/utils'
import { renderIcon } from '@/utils/icon'
import { useCRUD } from '@/composables'
import api from './api'
import { isNullOrUndef } from '@/utils/is'
defineOptions({ name: 'CrudTable' })
const $table = ref(null)
/** QueryBar筛选参数可选 */
const queryItems = ref({})
/** 补充参数(可选) */
const extraParams = ref({})
onMounted(() => {
$table.value?.handleSearch()
})
const columns = [
{ type: 'selection', fixed: 'left' },
{
title: '发布',
key: 'isPublish',
width: 60,
align: 'center',
fixed: 'left',
render(row) {
return h(NSwitch, {
size: 'small',
rubberBand: false,
value: row['isPublish'],
loading: !!row.publishing,
onUpdateValue: () => handlePublish(row),
})
},
},
{ title: '标题', key: 'title', width: 150, ellipsis: { tooltip: true } },
{ title: '分类', key: 'category', width: 80, ellipsis: { tooltip: true } },
{ title: '创建人', key: 'author', width: 80 },
{
title: '创建时间',
key: 'createDate',
width: 150,
render(row) {
return h('span', formatDateTime(row['createDate']))
},
},
{
title: '最后更新时间',
key: 'updateDate',
width: 150,
render(row) {
return h('span', formatDateTime(row['updateDate']))
},
},
{
title: '操作',
key: 'actions',
width: 240,
align: 'center',
fixed: 'right',
render(row) {
return [
h(
NButton,
{
size: 'small',
type: 'primary',
secondary: true,
onClick: () => handleView(row),
},
{ default: () => '查看', icon: renderIcon('majesticons:eye-line', { size: 14 }) }
),
h(
NButton,
{
size: 'small',
type: 'primary',
style: 'margin-left: 15px;',
onClick: () => handleEdit(row),
},
{ default: () => '编辑', icon: renderIcon('material-symbols:edit-outline', { size: 14 }) }
),
h(
NButton,
{
size: 'small',
type: 'error',
style: 'margin-left: 15px;',
onClick: () => handleDelete(row.id),
},
{ default: () => '删除', icon: renderIcon('material-symbols:delete-outline', { size: 14 }) }
),
]
},
},
]
// 选中事件
function onChecked(rowKeys) {
if (rowKeys.length) $message.info(`选中${rowKeys.join(' ')}`)
}
// 发布
function handlePublish(row) {
if (isNullOrUndef(row.id)) return
row.publishing = true
setTimeout(() => {
row.isPublish = !row.isPublish
row.publishing = false
$message?.success(row.isPublish ? '已发布' : '已取消发布')
}, 1000)
}
const {
modalVisible,
modalAction,
modalTitle,
modalLoading,
handleAdd,
handleDelete,
handleEdit,
handleView,
handleSave,
modalForm,
modalFormRef,
} = useCRUD({
name: '文章',
initForm: { author: '大脸怪' },
doCreate: api.addPost,
doDelete: api.deletePost,
doUpdate: api.updatePost,
refresh: () => $table.value?.handleSearch(),
})
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div h-full flex>
<AppPage>
<n-result m-auto status="404" description="抱歉,您访问的页面不存在。">
<template #icon>
<img src="@/assets/images/404.webp" width="500" />
@@ -8,7 +8,7 @@
<n-button @click="replace('/')">返回首页</n-button>
</template>
</n-result>
</div>
</AppPage>
</template>
<script setup>

View File

@@ -1,3 +0,0 @@
<template>
<router-view></router-view>
</template>

View File

@@ -1,39 +0,0 @@
<template>
<div p-24>
<div flex>
<n-button size="small" type="primary" @click="handleCreate">新建文章</n-button>
</div>
<n-data-table
mt-20
:loading="loading"
:scroll-x="1600"
:data="tableData"
:columns="columns"
:pagination="pagination"
:row-key="(row) => row.id"
@update:checked-row-keys="handleCheck"
/>
</div>
</template>
<script setup>
import { usePostTable } from './usePostTable'
const router = useRouter()
const pagination = ref({ pageSize: 10 })
const { loading, columns, tableData, initColumns, initTableData } = usePostTable()
onBeforeMount(() => {
initColumns()
initTableData()
})
function handleCreate() {
router.push('/example/table/post-create')
}
function handleCheck(rowKeys) {
if (rowKeys.length) $message.info(`选中${rowKeys.join(' ')}`)
}
</script>

View File

@@ -1,161 +0,0 @@
import { h } from 'vue'
import { NButton, NSwitch } from 'naive-ui'
import { formatDateTime } from '@/utils'
import api from './api'
import { renderIcon } from '@/utils/icon'
export const usePostTable = () => {
// refs
const loading = ref(false)
const tableData = ref([])
const columns = ref([])
async function initTableData() {
loading.value = true
tableData.value = await getTableData()
loading.value = false
}
function handleDelete(row) {
if (row && row.id) {
$dialog.confirm({
content: '确定删除?',
confirm() {
$message.success('删除成功')
initTableData()
},
cancel() {
$message.success('已取消')
},
})
}
}
async function handleRecommend(row) {
if (row && row.id) {
row.recommending = true
setTimeout(() => {
row.isRecommend = !row.isRecommend
row.recommending = false
$message.success(row.isRecommend ? '已推荐' : '已取消推荐')
}, 800)
}
}
async function handlePublish(row) {
if (row && row.id) {
row.publishing = true
setTimeout(() => {
row.isPublish = !row.isPublish
row.publishing = false
$message.success(row.isPublish ? '已发布' : '已取消发布')
}, 800)
}
}
function initColumns() {
columns.value = [
{ type: 'selection' },
{ title: '标题', key: 'title', width: 150, ellipsis: { tooltip: true } },
{ title: '分类', key: 'category', width: 80, ellipsis: { tooltip: true } },
{
title: '描述',
key: 'description',
width: 200,
ellipsis: { tooltip: true },
},
{ title: '创建人', key: 'author', width: 80 },
{
title: '创建时间',
key: 'createDate',
width: 150,
render(row) {
return h('span', formatDateTime(row['createDate']))
},
},
{
title: '最后更新时间',
key: 'updateDate',
width: 150,
render(row) {
return h('span', formatDateTime(row['updateDate']))
},
},
{
title: '推荐',
key: 'isRecommend',
width: 100,
align: 'center',
fixed: 'right',
render(row) {
return h(NSwitch, {
size: 'small',
value: row['isRecommend'],
rubberBand: false,
loading: !!row.recommending,
onUpdateValue: () => handleRecommend(row),
})
},
},
{
title: '发布',
key: 'isPublish',
width: 100,
align: 'center',
fixed: 'right',
render(row) {
return h(NSwitch, {
size: 'small',
rubberBand: false,
value: row['isPublish'],
loading: !!row.publishing,
onUpdateValue: () => handlePublish(row),
})
},
},
{
title: '操作',
key: 'actions',
width: 120,
align: 'center',
fixed: 'right',
render(row) {
return [
h(
NButton,
{
size: 'small',
type: 'error',
style: 'margin-left: 15px;',
onClick: () => handleDelete(row),
},
{ default: () => '删除', icon: renderIcon('material-symbols:delete-outline', { size: 14 }) }
),
]
},
},
]
}
async function getTableData() {
try {
const res = await api.getPosts()
if (res.code === 0) {
return res.data
}
console.warn(res.message)
return []
} catch (error) {
console.error(error)
return []
}
}
return {
loading,
columns,
tableData,
initColumns,
initTableData,
}
}

View File

@@ -1,53 +0,0 @@
const Layout = () => import('@/layout/index.vue')
export default {
name: 'Example',
path: '/example',
component: Layout,
redirect: '/example/table',
meta: {
title: '组件示例',
icon: 'mdi:menu',
role: ['admin'],
requireAuth: true,
order: 3,
},
children: [
{
name: 'Table',
path: 'table',
component: () => import('./index.vue'),
redirect: '/example/table/post',
meta: {
title: '表格',
icon: 'mdi:table',
role: ['admin'],
requireAuth: true,
},
children: [
{
name: 'PostList',
path: 'post',
component: () => import('./post/index.vue'),
meta: {
title: '文章列表',
icon: 'material-symbols:auto-awesome-outline-rounded',
role: ['admin'],
requireAuth: true,
},
},
{
name: 'PostCreate',
path: 'post-create',
component: () => import('./post/PostCreate.vue'),
meta: {
title: '创建文章',
icon: 'material-symbols:auto-awesome-outline-rounded',
role: ['admin'],
requireAuth: true,
},
},
],
},
],
}

View File

@@ -1,5 +1,5 @@
import request from '@/utils/http'
export default {
login: (data) => request.post('/auth/login', data),
login: (data) => request.post('/auth/login', data, { noNeedToken: true }),
}

View File

@@ -1,12 +1,15 @@
<template>
<div class="cus-scroll h-full py-15 flex-col overflow-auto bg-cover" :style="{ backgroundImage: `url(${bgImg})` }">
<div m-auto p-15 f-c-c min-w-345 rounded-10 card-shadow bg-white dark:bg-dark bg-opacity-60>
<AppPage :show-footer="true" bg-cover :style="{ backgroundImage: `url(${bgImg})` }">
<div
style="transform: translateY(25px)"
class="m-auto p-15 f-c-c min-w-345 max-w-700 rounded-10 card-shadow bg-white bg-opacity-60"
>
<div w-380 hidden md:block px-20 py-35>
<img src="@/assets/images/login_banner.webp" w-full alt="login_banner" />
</div>
<div w-320 flex-col px-20 py-35>
<h5 f-c-c text-24 font-normal color="#6a6a6a"><icon-custom-logo mr-30 text-50 color-primary />{{ title }}</h5>
<h5 f-c-c text-24 font-normal color="#6a6a6a"><icon-custom-logo mr-10 text-50 color-primary />{{ title }}</h5>
<div mt-30>
<n-input
v-model:value="loginInfo.name"
@@ -33,14 +36,13 @@
</div>
<div mt-20>
<n-button w-full h-50 rounded-5 text-16 type="primary" :loading="loging" @click="handleLogin">
<n-button w-full h-50 rounded-5 text-16 type="primary" :loading="loading" @click="handleLogin">
登录
</n-button>
</div>
</div>
</div>
<AppFooter />
</div>
</AppPage>
</template>
<script setup>
@@ -49,6 +51,7 @@ import { setToken } from '@/utils/token'
import { useStorage } from '@vueuse/core'
import bgImg from '@/assets/images/login_bg.webp'
import api from './api'
import { addDynamicRoutes } from '@/router'
const title = import.meta.env.VITE_TITLE
@@ -71,7 +74,7 @@ function initLoginInfo() {
}
const isRemember = useStorage('isRemember', false)
const loging = ref(false)
const loading = ref(false)
async function handleLogin() {
const { name, password } = loginInfo.value
if (!name || !password) {
@@ -79,10 +82,9 @@ async function handleLogin() {
return
}
try {
loading.value = true
$message.loading('正在验证...')
loging.value = true
const res = await api.login({ name, password: password.toString() })
if (res.code === 0) {
$message.success('登录成功')
setToken(res.data.token)
if (isRemember.value) {
@@ -90,6 +92,7 @@ async function handleLogin() {
} else {
lStorage.remove('loginInfo')
}
await addDynamicRoutes()
if (query.redirect) {
const path = query.redirect
Reflect.deleteProperty(query, 'redirect')
@@ -97,12 +100,10 @@ async function handleLogin() {
} else {
router.push('/')
}
} else {
$message.warning(res.message)
}
} catch (error) {
$message.error(error.message)
console.error(error)
$message.removeMessage()
}
loging.value = false
loading.value = false
}
</script>

View File

@@ -1,19 +0,0 @@
<template>
<div p24>
<n-button type="error" @click="handleDelete"> <icon-mi:delete mr-5 />删除</n-button>
</div>
</template>
<script setup name="TestDialog">
const handleDelete = function () {
$dialog.confirm({
content: '确认删除?',
confirm() {
$message.success('删除成功')
},
cancel() {
$message.warning('已取消')
},
})
}
</script>

View File

@@ -1,20 +0,0 @@
<template>
<div p24>
<n-gradient-text gradient="linear-gradient(90deg, red 0%, green 50%, blue 100%)"> 注意查看提示语 </n-gradient-text>
</div>
</template>
<!--使用keep-alive须设置name注意请与对应的路由的name保持一致方便统一处理-->
<script setup name="TestKeepAlive">
onMounted(() => {
$message.success('触发onMounted')
})
onActivated(() => {
$message.success('触发onActivated')
})
onDeactivated(() => {
$message.success('触发onDeactivated')
})
</script>

View File

@@ -1,21 +0,0 @@
<template>
<div p24>
<n-button type="primary" @click="handleLogin">
<icon-mdi:login mr-5 />
登陆
</n-button>
</div>
</template>
<script setup>
function handleLogin() {
$message.loading('登陆中...')
setTimeout(() => {
$message.error('登陆失败')
$message.loading('正在尝试重新登陆...')
setTimeout(() => {
$message.success('登陆成功')
}, 2000)
}, 2000)
}
</script>

View File

@@ -1,68 +0,0 @@
<template>
<div p-24>
<p>
文档<a hover-decoration-underline c-blue href="https://uno.antfu.me/" target="_blank">https://uno.antfu.me/</a>
</p>
<p>
playground
<a c-blue hover-decoration-underline href="https://unocss.antfu.me/play/" target="_blank">
https://unocss.antfu.me/play/
</a>
</p>
<div flex mt-20>
<div flex p-20 rounded-5 bg="#fff">
<div text-20 font-600>Flex布局</div>
<div flex w-360 flex-wrap justify-around ml-15 p-10>
<div w-50 h-50 b-1 rounded-5 f-c-c p-10 m-20>
<span w-6 h-6 rounded-3 bg-black></span>
</div>
<div w-50 h-50 b-1 rounded-5 flex justify-between p-10 m-20>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black self-end></span>
</div>
<div w-50 h-50 b-1 rounded-5 flex justify-between p-10 m-20>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black self-center></span>
<span w-6 h-6 rounded-3 bg-black self-end></span>
</div>
<div w-50 h-50 b-1 rounded-5 flex justify-between p-10 m-20>
<div flex-col justify-between>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black></span>
</div>
<div flex-col justify-between>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black></span>
</div>
</div>
<div w-50 h-50 b-1 rounded-5 f-c-c flex-col p-10 m-20>
<div flex w-full justify-between>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black></span>
</div>
<span w-6 h-6 rounded-3 bg-black></span>
<div flex w-full justify-between>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black></span>
</div>
</div>
<div w-50 h-50 b-1 rounded-5 flex-col justify-between p-10 m-20>
<div flex w-full justify-between>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black></span>
</div>
<div flex w-full justify-between>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black></span>
</div>
<div flex w-full justify-between>
<span w-6 h-6 rounded-3 bg-black></span>
<span w-6 h-6 rounded-3 bg-black></span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,5 +1,6 @@
<template>
<div p-15>
<AppPage :show-footer="true">
<div flex-1>
<n-card rounded-10>
<div flex items-center>
<img rounded-full width="60" :src="userStore.avatar" />
@@ -47,6 +48,7 @@
</div>
</n-card>
</div>
</AppPage>
</template>
<script setup>