first commit

This commit is contained in:
JaguarJack
2022-12-05 23:01:12 +08:00
commit 0024080c28
322 changed files with 27698 additions and 0 deletions

3
resources/admin/App.vue Normal file
View File

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

7
resources/admin/app.ts Normal file
View File

@@ -0,0 +1,7 @@
import '/admin/styles/index.scss'
import CatchAdmin from './support/catchAdmin'
const admin = new CatchAdmin()
admin.bootstrap()

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -0,0 +1,35 @@
/**
* 服务端返回码
*/
export const enum Code {
SUCCESS = 10000, // 成功
LOST_LOGIN = 10001, // 登录失效
VALIDATE_FAILED = 10002, // 验证错误
PERMISSION_FORBIDDEN = 10003, // 权限禁止
LOGIN_FAILED = 10004, // 登录失败
FAILED = 10005, // 操作失败
LOGIN_EXPIRED = 10006, // 登录失效
LOGIN_BLACKLIST = 10007, // 黑名单
USER_FORBIDDEN = 10008, // 账户被禁
WECHAT_RESPONSE_ERROR = 40000,
}
/**
* 白名单页面
*
* 不需要权限认证
*/
export const enum WhiteListPage {
LOGIN_PATH = '/login',
NOT_FOUND_PATH = '/404'
}
/**
* menu 类型
*/
export const enum MenuType {
PAGE_TYPE = 1,
Button_Type
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -0,0 +1,27 @@
<template>
<div :style="bgColor" class="flex flex-col w-full">
<img src="/admin/assets/404.png" class="w-full sm:w-3/5 m-auto" />
<div class="mr-auto w-full bottom-0 m-auto">
<div class="w-full text-center text-base text-gray-400">抱歉您访问的页面不存在</div>
<div @click="push('/')" class="text-center w-full mt-2">
<el-button type="primary">回到首页</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useAppStore } from '/admin/stores/modules/app'
import { computed } from 'vue'
const { push } = useRouter()
const dark: string = '#161d31;'
const light: string = 'rgb(241,245,249);'
const appStore = useAppStore()
const bgColor = computed(() => {
return 'background-color:' + (appStore.getIsDarkMode ? dark : light)
})
</script>

View File

@@ -0,0 +1,97 @@
<template>
<div>
<el-table :data="tableData" style="width: 100%">
<el-table-column prop="date" label="Date" width="180" />
<el-table-column prop="name" label="Name" width="180" />
<el-table-column prop="address" label="Address" />
</el-table>
<el-button text @click="visible = true">Click to open the Dialog</el-button>
<ct-dialog v-model="visible" title="show me the code" :show-footer="true">
<el-form :model="form">
<el-form-item label="Promotion name" :label-width="formLabelWidth">
<el-input v-model="form.name" autocomplete="off" />
</el-form-item>
<el-form-item label="Zones" :label-width="formLabelWidth">
<el-select v-model="form.region" placeholder="Please select a zone">
<el-option label="Zone No.1" value="shanghai" />
<el-option label="Zone No.2" value="beijing" />
</el-select>
</el-form-item>
</el-form>
</ct-dialog>
<el-tabs v-model="activeName" type="border-card" class="demo-tabs" @tab-click="handleClick">
<el-tab-pane label="User" name="first">User</el-tab-pane>
<el-tab-pane label="Config" name="second">Config</el-tab-pane>
<el-tab-pane label="Role" name="third">Role</el-tab-pane>
<el-tab-pane label="Task" name="fourth">Task</el-tab-pane>
</el-tabs>
</div>
</template>
<script lang="ts" setup>
import CtDialog from './admin/Dialog.vue'
import { ref, reactive } from 'vue'
import type { TabsPaneContext } from 'element-plus'
const visible = ref(false)
const formLabelWidth = '140px'
const activeName = ref('first')
const handleClick = (tab: TabsPaneContext, event: Event) => {
console.log(tab, event)
}
const form = reactive({
name: '',
region: '',
date1: '',
date2: '',
delivery: false,
type: [],
resource: '',
desc: ''
})
const tableData = [
{
date: '2016-05-03',
name: 'Tom',
address: 'No. 189, Grove St, Los Angeles'
},
{
date: '2016-05-02',
name: 'Tom',
address: 'No. 189, Grove St, Los Angeles'
},
{
date: '2016-05-04',
name: 'Tom',
address: 'No. 189, Grove St, Los Angeles'
},
{
date: '2016-05-01',
name: 'Tom',
address: 'No. 189, Grove St, Los Angeles'
},
{
date: '2016-05-03',
name: 'Tom',
address: 'No. 189, Grove St, Los Angeles'
},
{
date: '2016-05-02',
name: 'Tom',
address: 'No. 189, Grove St, Los Angeles'
},
{
date: '2016-05-04',
name: 'Tom',
address: 'No. 189, Grove St, Los Angeles'
}
]
</script>
<style scoped>
.dialog-footer button:first-child {
margin-right: 10px;
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<el-select v-bind="$attrs">
<el-option-group v-for="group in elOptions" :key="group.label" :label="group.label" v-if="group">
<el-option v-for="item in group.options" :key="item.value" :label="item.label" :value="item.value" />
</el-option-group>
<el-option v-for="option in elOptions" :key="option.value" :label="option.label" :value="option.value" v-else>
<slot />
</el-option>
</el-select>
</template>
<script lang="ts" setup>
import http from '/admin/support/http'
import { ref } from 'vue'
const props = defineProps({
options: {
type: Array,
default: [],
},
api: {
type: String,
default: '',
},
group: {
type: Boolean,
default: false,
},
})
interface Option {
label: string
value: string | number
}
interface GroupOption {
label: string
options: Array<Option>
}
const elOptions = props.group ? ref<Array<GroupOption>>() : ref<Array<Option>>()
if (props.api) {
http.get('options/' + props.api).then(r => {
elOptions.value = r.data.data
})
} else {
elOptions.value = props.options
}
</script>

View File

@@ -0,0 +1,16 @@
<template>
<el-button type="primary" :size="size"><Icon name="plus" class="w-4 mr-1" /> {{ text }}</el-button>
</template>
<script lang="ts" setup>
defineProps({
size: {
type: String,
default: 'default',
},
text: {
type: String,
default: '新增',
},
})
</script>

View File

@@ -0,0 +1,16 @@
<template>
<el-button type="danger" :size="size"><Icon name="trash" class="w-4 mr-1" /> {{ text }}</el-button>
</template>
<script lang="ts" setup>
defineProps({
size: {
type: String,
default: 'small',
},
text: {
type: String,
default: '删除',
},
})
</script>

View File

@@ -0,0 +1,16 @@
<template>
<el-button type="primary" :size="size"><Icon name="eye" class="w-4 mr-1" /> {{ text }}</el-button>
</template>
<script lang="ts" setup>
defineProps({
size: {
type: String,
default: 'small',
},
text: {
type: String,
default: '详情',
},
})
</script>

View File

@@ -0,0 +1,18 @@
<template>
<el-button type="success" :size="size"><Icon name="pencil-square" class="w-4 mr-1" /> {{ text }}</el-button>
</template>
<script lang="ts" setup>
defineProps({
size: {
type: String,
default: 'small',
},
text: {
type: String,
default: '更新',
},
})
</script>
<style scoped></style>

View File

@@ -0,0 +1,100 @@
<template>
<div>
<el-dialog :model-value="modelValue" :show-close="false" :fullscreen="isFullscreen" v-bind="$attrs" :width="width" :close="close" :before-close="beforeClose" draggable>
<template #header="{ titleId, titleClass }">
<div class="flex justify-between w-full">
<div>
<h4 :id="titleId" :class="titleClass">{{ title }}</h4>
</div>
<div class="flex w-14 justify-between">
<Icon :name="fullscreenIcon" @click="fullscreen" class="hover:cursor-pointer" />
<Icon name="x-mark" class="hover:cursor-pointer" @click="close" />
</div>
</div>
</template>
<slot />
<template #footer v-if="showFooter">
<span class="dialog-footer">
<el-button @click="close">{{ $t('system.cancel') }}</el-button>
<el-button type="primary" @click="close">{{ $t('system.confirm') }}</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
require: true,
},
showFooter: {
type: Boolean,
default: false,
},
width: {
type: String,
required: false,
default: '',
},
title: {
type: String,
default: '',
},
})
const emits = defineEmits(['update:modelValue'])
const isFullscreen = ref(false)
const fullscreenIcon = computed(() => {
return isFullscreen.value ? 'arrows-pointing-in' : 'arrows-pointing-out'
})
const fullscreen = () => {
isFullscreen.value = !isFullscreen.value
}
const close = () => {
emits('update:modelValue', false)
}
// 遮罩关闭调用
const beforeClose = () => {
emits('update:modelValue', false)
}
const width = ref<string>('')
onMounted(() => {
width.value = props.width ? props.width : getWidth()
})
// 窗口尺寸
const getWidth = () => {
const clientWidth = window.document.body.clientWidth
if (clientWidth <= 726) {
return '100%'
}
if (clientWidth > 726 && clientWidth < 1440) {
return '60%'
}
return '650px'
}
</script>
<style scoped lang="scss">
:deep(.el-dialog) {
border-radius: 0.5rem;
.el-dialog__header {
margin-right: 0 !important;
border-bottom: 1px solid #e2e8f0;
}
}
</style>

View File

@@ -0,0 +1,24 @@
<template>
<el-switch @change="enabled(api, id)" :active-value="Status.ENABLE" :inactive-value="Status.DISABLE" :model-value="modelValue" :loading="loading" />
</template>
<script lang="ts" setup>
import { useEnabled } from '/admin/composables/curd/useEnabled'
import { Status } from '/admin/enum/app'
import { watch } from 'vue'
const props = defineProps({
modelValue: Boolean | Number | String,
api: String,
id: Number | String,
})
const emits = defineEmits(['update:modelValue'])
const { enabled, success, loading } = useEnabled()
watch(success, function () {
emits('update:modelValue', props.modelValue === Status.ENABLE ? Status.DISABLE : Status.ENABLE)
success.value = false
})
</script>

View File

@@ -0,0 +1,59 @@
<template>
<el-breadcrumb separator="/" class="flex">
<transition-group name="breadcrumb">
<!--<el-breadcrumb-item :to="{ path: '/' }" class="text-blue=">Dashboard</el-breadcrumb-item>-->
<el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="index" class="text">{{ item }}</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</template>
<script lang="ts" setup>
import router from '/admin/router'
import { watch, onMounted, ref } from 'vue'
import { useAppStore } from '/admin/stores/modules/app'
import { RouteLocationNormalizedLoaded } from 'vue-router'
const appStore = useAppStore()
const breadcrumbs = ref<string[]>([])
// 监听当前路由的变化
watch(router.currentRoute, (newValue, oldValue) => {
// 如果是内页,则不切换激活菜单
if (newValue.meta.is_inner === undefined || !newValue.meta.is_inner) {
appStore.setActiveMenu(newValue.path)
}
getBreadcrumbs(newValue)
})
// get init breadcrumb
onMounted(() => {
if (router.currentRoute.value.path !== '/') {
appStore.setActiveMenu(router.currentRoute.value.path)
}
getBreadcrumbs(router.currentRoute.value)
})
// get breadcrums
function getBreadcrumbs(newRoute: RouteLocationNormalizedLoaded) {
breadcrumbs.value = []
breadcrumbs.value.push('首页')
newRoute.matched.forEach(m => {
if (m.meta.title !== undefined) {
breadcrumbs.value.push(m.meta?.title as string)
}
})
}
</script>
<style>
.breadcrumb-leave-active {
transition: all 1s linear;
}
.breadcrumb-leave-to {
opacity: 0;
transition: all 0.3s linear;
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<component :is="icon" class="w-5 h-5" />
</template>
<script setup>
import { computed } from 'vue'
import * as heroIcons from '@heroicons/vue/24/outline'
const props = defineProps({
name: {
type: String,
required: true,
},
})
const icon = computed(() => {
let name = ''
props.name.split('-').forEach(v => {
name += v[0].toUpperCase() + v.substr(1)
})
return heroIcons[name + 'Icon']
})
</script>

View File

@@ -0,0 +1,69 @@
import http from '/admin/support/http'
import { ref, unref } from 'vue'
import { Code } from '/admin/enum/app'
import Message from '/admin/support/message'
import { FormInstance } from 'element-plus'
import { AxiosResponse } from 'axios'
import { isFunction } from'/admin/support/helper'
// get table list
export function useCreate(path: string, id: string | number | null = null, _formData: object = {}) {
const formData = ref<object>(_formData)
const loading = ref<boolean>()
const isClose = ref<boolean>(false)
// 创建前 hook
const beforeCreate = ref()
// 更新前 hook
const beforeUpdate = ref()
// store
function store(path: string, id: string | number | null = null) {
loading.value = true
let promise: Promise<AxiosResponse> | null = null
if (id) {
if (isFunction(beforeUpdate.value)) {
beforeUpdate.value()
}
promise = http.put(path + '/' + id, unref(formData))
} else {
console.log(isFunction(beforeCreate.value), beforeCreate.value)
if (isFunction(beforeCreate.value)) {
beforeCreate.value()
}
promise = http.post(path, unref(formData))
}
promise
.then(r => {
if (r.data.code === Code.SUCCESS) {
isClose.value = true
Message.success(r.data.message)
} else {
Message.error(r.data.message)
}
})
.finally(() => {
loading.value = false
})
}
const form = ref<FormInstance>()
const submitForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl
.validate(valid => {
if (valid) {
store(path, id)
} else {
loading.value = false
}
})
.then(() => {})
}
return { formData, loading, form, submitForm, isClose, beforeCreate, beforeUpdate }
}

View File

@@ -0,0 +1,36 @@
import http from '/admin/support/http'
import { Code } from '/admin/enum/app'
import Message from '/admin/support/message'
import { ref } from 'vue'
import { isFunction } from'/admin/support/helper'
export function useDestroy(confirm: string = '确认删除吗') {
const isDeleted = ref(false)
const beforeDestroy = ref()
// fetch list
function destroy(path: string, id: string | number) {
Message.confirm(confirm + '?', function () {
// before destroy
if (isFunction(beforeDestroy.value)) {
beforeDestroy.value()
}
http
.delete(path + '/' + id)
.then(r => {
if (r.data.code === Code.SUCCESS) {
Message.success(r.data.message)
isDeleted.value = true
} else {
Message.error(r.data.message)
}
})
.finally(() => {})
})
}
return { destroy, isDeleted }
}

View File

@@ -0,0 +1,27 @@
import http from '/admin/support/http'
import { Code } from '/admin/assets/enum/app'
import Message from '/admin/support/message'
import { ref } from 'vue'
export function useEnabled() {
const success = ref(false)
const loading = ref<boolean>(false)
function enabled(path: string, id: string | number, data: object = {}) {
loading.value = true
http
.put(path + '/enable/' + id, data)
.then(r => {
if (r.data.code === Code.SUCCESS) {
success.value = true
Message.success(r.data.message)
} else {
Message.error(r.data.message)
}
})
.finally(() => {
loading.value = false
})
}
return { enabled, success, loading }
}

View File

@@ -0,0 +1,75 @@
import http from '/admin/support/http'
import { ref, unref } from 'vue'
import { Code } from '/admin/enum/app'
import Message from '/admin/support/message'
const initLimit = 10
const initPage = 1;
// get table list
export function useGetList(path: string) {
const data = ref<object>()
const page = ref(initPage)
const limit = ref(initLimit)
const query = ref<object>({
page: page.value,
limit: limit.value,
})
const loading = ref(true)
// fetch list
function getList() {
// when table's data page >= 100, it will loading
if (page.value >= 100) {
loading.value = true
}
http
.get(path, unref(query))
.then(r => {
closeLoading()
if (r.data.code === Code.SUCCESS) {
data.value = r.data
} else {
Message.error(r.data.message)
}
})
.finally(() => {
closeLoading()
})
}
// close loading
function closeLoading() {
loading.value = false
}
// search
function search() {
getList()
}
// reset
function reset() {
query.value = Object.assign({ page: page.value, limit: limit.value })
getList()
}
// change page
function changePage(p: number) {
page.value = p
// @ts-ignore
query.value.page = p
search()
}
// change limit
function changeLimit(l: number) {
limit.value = l
// @ts-ignore
query.value.page = 1
// @ts-ignore
query.value.limit = l
search()
}
return { data, query, search, reset, changePage, changeLimit, loading }
}

View File

@@ -0,0 +1,14 @@
import http from '/admin/support/http'
export function useShow(path: string, id: string | number) {
return new Promise((resolve, reject) => {
http
.get(path + '/' + id)
.then(response => {
resolve(response.data)
})
.catch(e => {
reject(e)
})
})
}

View File

@@ -0,0 +1,43 @@
/**
* 服务端返回码
*/
export const enum Code {
SUCCESS = 10000, // 成功
LOST_LOGIN = 10001, // 登录失效
VALIDATE_FAILED = 10002, // 验证错误
PERMISSION_FORBIDDEN = 10003, // 权限禁止
LOGIN_FAILED = 10004, // 登录失败
FAILED = 10005, // 操作失败
LOGIN_EXPIRED = 10006, // 登录失效
LOGIN_BLACKLIST = 10007, // 黑名单
USER_FORBIDDEN = 10008, // 账户被禁
WECHAT_RESPONSE_ERROR = 40000,
}
/**
* status
*/
export const enum Status {
ENABLE = 1,
DISABLE = 2,
}
/**
* 白名单页面
*
* 不需要权限认证
*/
export const enum WhiteListPage {
LOGIN_PATH = '/login',
NOT_FOUND_PATH = '/404',
}
/**
* menu 类型
*/
export const enum MenuType {
PAGE_TYPE = 1,
Button_Type,
}

8
resources/admin/env.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -0,0 +1,22 @@
import Cache from '/admin/support/cache'
import { createI18n } from 'vue-i18n'
import en from './languages/en'
import zh from './languages/zh'
import type { App } from 'vue'
const messages = {
en,
zh,
}
const i18n = createI18n({
locale: Cache.get('language') || 'zh',
messages,
globalInjection: true,
})
export function bootstrapI18n(app: App): void {
app.use(i18n)
}
export default i18n

View File

@@ -0,0 +1,150 @@
const en = {
system: {
name: 'CatchAdmin Dashboard',
chinese: 'Chinese',
english: 'English',
confirm: 'Confirm',
cancel: 'Cancel',
warning: 'Warning',
next: 'Next',
prev: 'Prev',
yes: 'Y',
no: 'N',
add: 'Add',
finish: 'Finish',
back: 'Back',
update: 'Update',
},
login: {
email: 'Email',
password: 'Password',
sign_in: 'Sign In',
welcome: 'Welcome Back👏',
lost_password: 'lost password?',
remember: 'Remember me',
verify: {
email: {
required: 'Please input email first',
invalid: 'Email address is invalid',
},
password: {
required: 'Please input password first',
},
},
},
register: {
sign_up: 'Sign Up',
},
generate: {
schema: {
title: 'Create Schema',
name: 'Schema Name',
name_verify: 'please input schema name',
engine: {
name: 'Search Engine',
verify: 'please select schema engine',
placeholder: 'select schema engine',
},
default_field: {
name: 'Default Field',
created_at: 'Create time',
updated_at: 'Update Time',
creator: 'Creator',
delete_at: 'SoftDelete',
},
comment: {
name: 'Schema Comment',
verify: 'please input schema comment',
},
structure: {
title: 'Create Schema Structure',
field_name: {
name: 'Field Name',
verify: 'please input field name',
},
length: 'Length',
type: {
name: 'Field Type',
placeholder: 'select field type',
verify: 'please select field type',
},
form_label: 'Form Label',
form_component: 'Component',
list: 'List',
form: 'Form',
unique: 'Unique',
search: 'Search',
search_op: {
name: 'Search Operate',
placeholder: 'select search operate',
},
nullable: 'Nullable',
default: 'Default',
rules: {
name: 'Verify Rules',
placeholder: 'select verify rules',
},
operate: 'Operate',
comment: 'Field Comment',
},
},
code: {
title: 'Code Gen',
module: {
name: 'module',
placeholder: 'please select module',
verify: 'please select module first',
},
controller: {
name: 'Controller',
placeholder: 'please input controller name',
verify: 'please input Controller name first',
},
model: {
name: 'Model',
placeholder: 'please input model name',
verify: 'please input model name first',
},
paginate: 'Paginate',
},
},
module: {
create: 'Create Module',
update: 'Update Module',
form: {
name: {
title: 'Module Name',
required: 'module name required',
},
path: {
title: 'Module Path',
required: 'module Path required',
},
desc: {
title: 'Description',
},
keywords: {
title: 'Keywords',
},
dirs: {
title: 'Default Dirs',
Controller: 'Controller',
Model: 'Model',
Database: 'Database',
Request: 'Request',
},
},
},
}
export default en

View File

@@ -0,0 +1,155 @@
const zh = {
system: {
name: 'CatchAdmin 管理系统',
chinese: '中文',
english: '英文',
confirm: '确定',
cancel: '取消',
warning: '警告',
next: '下一步',
prev: '上一步',
yes: '是',
no: '否',
add: '新增',
edit: '编辑',
finish: '完成',
back: '返回',
update: '更新',
},
login: {
email: '邮箱',
password: '密码',
sign_in: '登录',
welcome: '👏欢迎回来',
lost_password: '忘记密码?',
remember: '记住我',
verify: {
email: {
required: '请先输入邮箱',
invalid: '邮箱地址无效',
},
password: {
required: '请先输入密码',
},
},
},
register: {
sign_up: '注册',
},
generate: {
schema: {
title: '创建数据表',
name: '表名称',
name_verify: '请输入表名称',
engine: {
name: '表引擎',
verify: '请选择表引擎',
placeholder: '选择表引擎',
},
default_field: {
name: '默认字段',
created_at: '创建时间',
updated_at: '更新时间',
creator: '创建人',
delete_at: '软删除',
},
comment: {
name: '表注释',
verify: '请填写表注释/说明',
},
structure: {
title: '创建数据结构',
field_name: {
name: '字段名称',
verify: '请填写字段名称',
},
length: '长度',
type: {
name: '类型',
placeholder: '选择字段类型',
verify: '请先选择字段类型',
},
form_label: '表单 Label',
form_component: '表单组件',
list: '列表',
form: '表单',
unique: '唯一',
search: '查询',
search_op: {
name: '搜索操作符',
placeholder: '选择搜索操作符',
},
nullable: 'nullable',
default: '默认值',
rules: {
name: '验证规则',
placeholder: '选择验证规则',
},
operate: '操作',
comment: '字段注释',
},
},
code: {
title: '生成代码',
module: {
name: '模块',
placeholder: '请选择模块',
verify: '请选择模块',
},
controller: {
name: '控制器',
placeholder: '请输入控制器名称',
verify: '请输入控制器名称',
},
model: {
name: '模型',
placeholder: '请输入模型名称',
verify: '请输入模型名称',
},
paginate: '分页',
menu: {
name: '菜单名称',
placeholder: '请输入菜单名称',
verify: '请输入菜单名称',
},
},
},
module: {
create: '创建模块',
update: '更新模块',
form: {
name: {
title: '模块名称',
required: '请输入模块名称',
},
path: {
title: '模块目录',
required: '请输入模块目录',
},
desc: {
title: '模块描述',
},
keywords: {
title: '模块关键字',
},
dirs: {
title: '默认目录',
Controller: 'Controller 目录',
Model: 'Model 目录',
Database: 'Database 目录',
Request: 'Request 目录',
},
},
},
}
export default zh

View File

@@ -0,0 +1,124 @@
<script lang="ts">
import { h, defineComponent, VNode } from 'vue'
import { usePermissionsStore } from '/admin/stores/modules/user/permissions'
import MenuItem from './item.vue'
import menus from './menus.vue'
import { useUserStore } from '/admin/stores/modules/user'
import { Menu } from '/admin/types/Menu'
/**
* 递归渲染 Menu 节点
*/
function getVNodes(menus: Menu[] | undefined, _subMenuClass: string | undefined): VNode[] {
const vnodes: VNode[] = []
menus?.forEach(menu => {
if (!menu.meta?.hidden) {
let vnode: VNode
const len = menu.children?.length
if (len) {
vnode = h(
MenuItem,
{
subMenuClass: _subMenuClass,
menu,
},
{
default: () => getVNodes(menu.children, 'children-menu'),
},
)
} else {
vnode = h(MenuItem, {
subMenuClass: _subMenuClass,
menu,
})
}
vnodes.push(vnode)
}
})
return vnodes
}
/**
* filter menus
*
* @param menus
*/
function filterMenus(menus: Menu[] | undefined): Menu[] {
const newMenus: Menu[] = []
menus?.forEach(m => {
if (m.meta?.hidden) {
return false
}
if (isHasOnlyChild(m) && m.children?.length) {
newMenus.push(
Object.assign({
path: m.children[0].path,
meta: m.children[0].meta,
name: m.name,
}),
)
} else {
newMenus.push(m)
}
})
return newMenus
}
/**
* is has only child
*
* @param menu
*/
function isHasOnlyChild(menu: Menu): boolean {
if (menu.children === undefined) {
return true
}
if (menu.children.length > 1 || !menu.children.length) {
return false
}
if (menu.children[0].children?.length) {
return false
}
return true
}
export default defineComponent({
props: {
subMenuClass: {
type: String,
require: true,
},
menuClass: {
type: String,
require: true,
},
},
setup(props, ctx) {
const permissionsStore = usePermissionsStore()
const userStore = useUserStore()
// 后端的 permissions 返回 undefined则认为该后端无权限系统
const permissions = userStore.getPermissions === undefined ? [] : userStore.getPermissions
const vnodes = getVNodes(filterMenus(permissionsStore.getMenusFrom(permissions)), props.subMenuClass)
return () => {
return h(
menus,
{
class: 'border-none side-menu ' + props.menuClass,
},
{
default: () => vnodes,
},
)
}
},
})
</script>

View File

@@ -0,0 +1,58 @@
<template>
<el-sub-menu :index="menu?.path" :class="subMenuClass" v-if="menu?.children?.length">
<template #title>
<el-icon>
<Icon :name="menu?.meta?.icon" v-if="menu?.meta?.icon" class="text-sm"/>
</el-icon>
<span>{{ menu?.meta?.title }}</span>
</template>
<slot/>
</el-sub-menu>
<el-menu-item v-else class="ct-menu-item" :index="menu?.path" @click="isMiniScreen() && store.changeExpaned()">
<el-icon>
<Icon :name="menu?.meta?.icon" v-if="menu?.meta?.icon" class="text-sm"/>
</el-icon>
<span>{{ menu?.meta?.title }}</span>
</el-menu-item>
</template>
<script lang="ts" name="MenuItem" setup>
import { Menu } from '/admin/types/Menu'
import { PropType } from 'vue'
import { useAppStore } from '/admin/stores/modules/app'
import { isMiniScreen } from '/admin/support/Helper'
const store = useAppStore()
defineProps({
subMenuClass: {
type: String,
require: true,
default: '',
},
menu: {
type: Object as PropType<Menu>,
require: true,
},
})
</script>
<style scoped lang="scss">
:deep(.el-menu) {
background-color: var(--sider-sub-menu-bg-color);
}
.ct-menu-item:hover {
background-color: var(--sider-sub-menu-hover-bg-color) !important;
}
:deep(.children-menu .el-sub-menu__title) {
background-color: var(--sider-sub-menu-bg-color) !important;
}
:deep(.el-menu-item-group__title) {
padding: 0;
}
</style>

View File

@@ -0,0 +1,9 @@
<template>
<div class="block sm:hidden z-40 w-screen h-full absolute mask-bg left-0 top-0" />
</template>
<style scoped>
.mask-bg {
background-color: #00000080;
}
</style>

View File

@@ -0,0 +1,66 @@
<template>
<el-menu
:default-active="appStore.getActiveMenu"
background-color="var(--sider-menu-bg-color)"
active-text-color="var(--sider-ment-active-text-color)"
text-color="var(--sider-menu-text-color)"
:collapse="!appStore.isExpand"
:collapse-transition="false"
:router="true"
@select="selectMenu"
:unique-opened="true"
>
<slot/>
</el-menu>
</template>
<script lang="ts" setup name="menus">
import { useAppStore } from '/admin/stores/modules/app'
const appStore = useAppStore()
const selectMenu = (index: string) => {
if (index.startsWith('http') || index.startsWith('https')) {
window.open(index)
}
}
</script>
<style scoped lang="scss">
.el-menu {
border-right: none;
}
:deep(.el-menu--inline) {
@apply pt-1 pb-2;
}
:deep(.ct-menu-item) {
@apply mt-1;
}
:deep(.is-active) {
background-color: var(--side-active-menu-bg-color) !important;
}
:deep(.el-sub-menu__title) {
padding-left: calc(calc(var(--el-menu-base-level-padding) + var(--el-menu-level) * var(--el-menu-level-padding)));
color: var(--sider-menu-text-color);
}
:deep(.el-sub-menu) {
color: var(--sider-sub-menu-bg-color);
}
:deep(.el-sub-menu__title:hover) {
background-color: var(--sider-menu-bg-color);
}
:deep(.el-menu--popup .el-menu-item:hover) {
background-color: var(--sider-menu-bg-color) !important;
}
:deep(.el-menu-item:hover) {
background-color: var(--sider-sub-menu-hover-bg-color) !important;
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<div :class="'w-full h-screen flex flex-col transition-spacing duration-300 ease-linear overflow-hidden ' + mlClass">
<!-- Header -->
<Header />
<!-- Tag view -->
<!--<div class=""></div>-->
<!-- Container -->
<div class="p-1 sm:p-4 max-w-full h-screen overflow-auto sm:overflow-x-hidden">
<router-view />
<!--<div class="w-full text-center text-gray-400 h-10 leading-10 mt-2">CatchAdmin 管理系统 @copyright 2018 ~ {{ year }}</div>-->
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useAppStore } from '/admin/stores/modules/app'
const appStore = useAppStore()
const mlClass = computed(() => {
return appStore.isExpand ? 'ml-0 sm:ml-56' : 'ml-0 sm:ml-16'
})
const year = computed(() => {
const date = new Date()
return date.getFullYear()
})
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div class="flex flex-row h-16 w-full drop-shadow border-l dark:border-l-0 border-gray-200" style="background-color: var(--header-bg-color)">
<div class="flex flex-row justify-between w-full">
<div class="flex flex-row min-w-[17rem]">
<div class="h-full flex items-center w-8 ml-2 hover:cursor-pointer" @click="store.changeExpaned">
<Icon name="list-bullet" class="w-6 h-8" />
</div>
<div class="w-96 flex items-center pl-3 sm:pl-0">
<Breadcrumbs />
</div>
</div>
<div class="flex w-52 sm:min-w-[18rem] flex-row item-center pl-1 sm:pl-0 justify-end sm:justify-between mr-4">
<div class="w-3/5 hidden sm:flex">
<!-- 搜索 -->
<Search />
<!-- 多语言 -->
<Lang />
<!-- 暗黑主题 -->
<Theme />
<Notification />
</div>
<Profile />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAppStore } from '/admin/stores/modules/app'
import Notification from './notification.vue'
const store = useAppStore()
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div class="w-10 h-10 grid place-items-center rounded-full mt-3 hover:cursor-pointer">
<div class="flex hover:cursor-pointer pl-1 pr-1">
<el-dropdown size="large" class="flex items-center justify-center hover:cursor-pointer w-full" @command="selectLanguage">
<Icon name="language" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="lang in langs" :key="lang.value" :command="lang.value" :disabled="lang.value == defaultLang">
{{ $t('system.' + lang.label) }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script lang="ts" setup>
import { reactive, computed } from 'vue'
import { useAppStore } from '/admin/stores/modules/app'
const langs = reactive([
{ label: 'chinese', value: 'zh' },
{ label: 'english', value: 'en' },
])
const appStore = useAppStore()
// select default languages
const defaultLang = computed(() => {
return appStore.getLocale
})
// select language
const selectLanguage = (value: 'zh' | 'en') => {
appStore.changeLocale(value)
location.reload()
}
</script>

View File

@@ -0,0 +1,22 @@
<template>
<div class="h-16 flex items-center justify-center logo-bg">
<img :src="logo" class="h-9 w-9" />
<div class="text-md logo-text pl-3" v-if="store.isExpand">CatchAdmin</div>
</div>
</template>
<script lang="ts" setup>
import { useAppStore } from '/admin/stores/modules/app'
import logo from '/admin/assets/logo.png'
const store = useAppStore()
</script>
<style scoped lang="scss">
.logo-bg {
background-color: var(--header-logo-bg-color);
}
.logo-text {
color: var(--header-logo-text-color);
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<!-- 通知 -->
<div class="w-10 h-10 grid place-items-center rounded-full mt-3 hover:cursor-pointer" ref="messageRef" v-click-outside="onClickOutside">
<el-badge :value="3">
<Icon name="bell" />
</el-badge>
<el-popover ref="popoverRef" :virtual-ref="messageRef" trigger="hover" virtual-triggering :width="300">
<el-tabs model-value="message">
<el-tab-pane label="消息(8)" name="message">
<div>
<div class="flex flex-row w-full border-b border-b-slate-300 dark:border-b-slate-500 mt-2" v-for="(message, key) in messages" :key="key">
<div>
<el-avatar src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" class="w-2 h-2" />
</div>
<div class="ml-2 h-10 mt-2">
{{ message }}
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="通知(1)" name="notice">
<div>
<div class="flex flex-row w-full border-b border-b-slate-300 dark:border-b-slate-500 mt-2" v-for="(message, key) in messages" :key="key">
<div>
<el-avatar src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" class="w-2 h-2" />
</div>
<div class="ml-2 h-10 mt-2">
{{ message }}
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</el-popover>
</div>
</template>
<script setup lang="ts">
import { ref, unref } from 'vue'
import { ClickOutside as vClickOutside } from 'element-plus'
const messageRef = ref()
const popoverRef = ref()
const onClickOutside = () => {
unref(popoverRef).popperRef?.delayHide?.()
}
const messages = ref()
messages.value = ['你收到 catchadmin 的好友申请', '你收到 catchadmin pro 的 license 授权', '你收到 catchadmin 通知']
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div class="flex w-2/5 hover:cursor-pointer pl-1 pr-1">
<el-dropdown size="large" placement="bottom-end" class="flex items-center justify-center hover:cursor-pointer w-full">
<div class="flex lg:items-center">
<img :src="userStore.getAvatar" class="w-7 h-7 rounded-full" />
<div class="ml-2 hidden lg:block">{{ userStore.getNickname }}</div>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>Action 1</el-dropdown-item>
<el-dropdown-item>Action 2</el-dropdown-item>
<el-dropdown-item>Action 3</el-dropdown-item>
<el-dropdown-item divided @click="logout">
<Icon name="logout" class="mr-1" />
退
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script lang="ts" setup>
import { useUserStore } from '/admin/stores/modules/user'
import Message from '/admin/support/message'
const userStore = useUserStore()
const logout = () => {
Message.confirm('确定退出系统吗?', () => {
userStore.logout()
})
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div class="w-10 h-10 grid place-items-center rounded-full mt-3 hover:cursor-pointer">
<div class="flex flex-row w-96">
<Icon name="magnifying-glass" class="hidden sm:block" @click="serachMenuVisable = true" />
<Teleport to="body">
<el-dialog v-model="serachMenuVisable" width="30%" draggable>
<el-cascader :filterable="true" :options="options" @change="toWhere" placeholder="请输入菜单名称" clearable class="w-full" :show-all-levels="false" />
</el-dialog>
</Teleport>
</div>
</div>
</template>
<script lang="ts" setup>
import { usePermissionsStore } from '/admin/stores/modules/user/permissions'
import { Menu } from '/admin/types/Menu'
import router from '/admin/router'
import { ref, computed } from 'vue'
const serachMenuVisable = ref(false)
const permissionStore = usePermissionsStore()
const options = computed(() => {
return filterMenus(permissionStore.getMenus)
})
const toWhere = (value: string[]) => {
if (value.length) {
router.push({ path: value[value.length - 1] })
}
serachMenuVisable.value = false
}
/**
* filter menus
*
* @param menus
*/
function filterMenus(menus: Menu[] | undefined): Object[] {
const cascaderMenus: Object[] = []
menus?.forEach(menu => {
if (menu.meta === undefined) {
const child = menu.children?.pop()
cascaderMenus.push(Object.assign({ label: child?.meta?.title, value: child?.path }))
} else {
const cascaderMenu = Object.assign({ label: menu.meta?.title, value: menu.path, children: [] })
cascaderMenu.children = filterMenus(menu.children)
cascaderMenus.push(cascaderMenu)
}
})
return cascaderMenus
}
</script>

View File

@@ -0,0 +1,22 @@
<template>
<div class="w-10 h-10 grid place-items-center rounded-full mt-3 hover:cursor-pointer">
<Icon name="moon" @click="changeTheme()" v-if="isDark" />
<Icon name="sun" @click="changeTheme()" v-else />
</div>
</template>
<script setup lang="ts">
import { useDark, useToggle } from '@vueuse/core'
import { useAppStore } from '/admin/stores/modules/app'
import { unref } from 'vue'
const appStore = useAppStore()
const isDark = useDark()
const toggleDark = useToggle(isDark)
function changeTheme() {
appStore.setDarkMode(!unref(isDark))
toggleDark()
}
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div>
<div :class="sideClass + ' drop-shadow-md overflow-y'">
<!--logo -->
<Logo />
<!-- menu item -->
<Menu :menu-class="menuClass" />
</div>
<Mask v-if="isMobile && appStore.isExpand" @click="appStore.changeExpaned()" />
</div>
</template>
<script lang="ts" setup>
import { useAppStore } from '/admin/stores/modules/app'
import { computed, onMounted, ref, watch } from 'vue'
import { isMiniScreen } from '/admin/support/Helper'
const isMobile = ref(isMiniScreen())
const layoutSide = ' h-screen z-[1000] sm:z-0 absolute top-0 left-0 sm:fixed transition-width duration-300 ease-linear sider-bg'
const layoutSideOpenClass = 'w-56' + layoutSide
const layoutSideHiddenClass = 'w-0 sm:w-16' + layoutSide
// 是否是小屏幕
const sideClass = computed(() => {
return appStore.isExpand ? layoutSideOpenClass : layoutSideHiddenClass
})
// menu class
const menuClass = ref<string>()
// 判断展开状态
const appStore = useAppStore()
watch(appStore.$state, state => {
// 如果切换到小屏幕,并且是菜单是收缩状态
menuClass.value = isExpandWhenInMobile()
})
// 监控屏幕大小
onMounted(() => {
window.onresize = () => {
return (() => {
// 如果切换到小屏幕,并且是菜单是收缩状态,则隐藏子菜单
isMobile.value = isMiniScreen()
menuClass.value = isExpandWhenInMobile()
})()
}
// 刷新或者 go back 的时候默认展开菜单
appStore.isExpand = true
})
function isExpandWhenInMobile(): string {
return !appStore.isExpand && isMobile.value ? 'hidden' : ''
}
</script>
<style scoped>
.sider-bg {
background-color: var(--sider-menu-bg-color);
}
</style>

View File

@@ -0,0 +1,8 @@
<template>
<div class="w-full flex" ref="el">
<!--- Sider -->
<Sider />
<!--content-->
<Content />
</div>
</template>

View File

@@ -0,0 +1,9 @@
import { RouteRecordRaw } from 'vue-router'
// @ts-ignore
const modules = import.meta.glob('@/module/**/views/router.ts', { eager: true })
let moduleRoutes: RouteRecordRaw[] = []
Object.keys(modules).forEach(routePath => {
moduleRoutes = moduleRoutes.concat(modules[routePath].default)
})
export default moduleRoutes

View File

@@ -0,0 +1,68 @@
import { useUserStore } from '/admin/stores/modules/user'
import { getAuthToken, removeAuthToken, setPageTitle } from '/admin/support/Helper'
import progress from '/admin/support/progress'
import { WhiteListPage } from '/admin/enum/app'
import { Router, RouteRecordRaw } from 'vue-router'
import { usePermissionsStore } from '/admin/stores/modules/user/permissions'
import { Menu } from '/admin/types/Menu'
const guard = (router: Router) => {
// white list
const whiteList: string[] = [WhiteListPage.LOGIN_PATH, WhiteListPage.NOT_FOUND_PATH]
router.beforeEach(async (to, from, next) => {
// set page title
setPageTitle(to.meta.title as unknown as string)
// page start
progress.start()
// 获取用户的 token
const authToken = getAuthToken()
// 如果 token 存在
if (authToken) {
// 如果进入 /login 页面,重定向到首页
if (to.path === WhiteListPage.LOGIN_PATH) {
next({ path: '/' })
} else {
const userStore = useUserStore()
// 获取用户ID
if (userStore.getId) {
next()
} else {
try {
// 阻塞获取用户信息
await userStore.getUserInfo()
// 如果后端没有返回 permissions前台则只使用静态路由
if (userStore.getPermissions !== undefined) {
// 挂载路由(实际是从后端获取用户的权限)
const permissionStore = usePermissionsStore()
// 动态路由挂载
const asyncRoutes = permissionStore.getAsyncMenusFrom(userStore.getPermissions)
asyncRoutes.forEach((route: Menu) => {
router.addRoute(route as unknown as RouteRecordRaw)
})
}
next({ ...to, replace: true })
} catch (e) {
removeAuthToken()
next({ path: `${WhiteListPage.LOGIN_PATH}?redirect=${to.path}` })
}
}
}
progress.done()
} else {
// 如果不在白名单
if (whiteList.indexOf(to.path) !== -1) {
next()
} else {
next({ path: WhiteListPage.LOGIN_PATH })
}
progress.done()
}
})
router.afterEach(() => {
progress.done()
})
}
export default guard

View File

@@ -0,0 +1,65 @@
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import type { App } from 'vue'
// module routers
import moduleRoutes from './constantRoutes'
export const constantRoutes: RouteRecordRaw[] = [
{
path: '/dashboard',
component: () => import('/admin/layout/index.vue'),
children: [
{
path: '',
name: 'Dashboard',
meta: { title: 'Dashboard', icon: 'home', hidden: false },
component: () => import('/admin/views/dashboard/index.vue'),
},
],
},
]
// @ts-ignore
.concat(moduleRoutes)
// default routes, it will not change to menus
const defaultRoutes: RouteRecordRaw[] = [
{
path: '/',
name: '/',
component: () => import('/admin/layout/index.vue'),
redirect: '/dashboard',
children: [
{
path: '/404',
name: '404',
meta: { title: '404' },
component: () => import('/admin/components/404/index.vue'),
},
],
},
{
path: '/login',
name: 'login',
component: () => import('/admin/views/login/index.vue'),
},
// 未定义路有重定向到 404
{
path: '/:pathMatch(.*)*',
redirect: '/404',
},
]
const routes = constantRoutes.concat(defaultRoutes)
const router = createRouter({
history: createWebHashHistory(),
routes,
// 路由滚动
scrollBehavior(to, from, savedPosition) {
return savedPosition || { top: 0, behavior: 'smooth' }
},
})
export function bootstrapRouter(app: App) {
app.use(router)
}
export default router

View File

@@ -0,0 +1,10 @@
import { createPinia } from 'pinia'
import type { App } from 'vue'
const store = createPinia()
export function bootstrapStore(app: App) : void {
app.use(store)
}
export default store

View File

@@ -0,0 +1,76 @@
import { defineStore } from 'pinia'
import Cache from '/admin/support/cache'
/**
* app
*/
type app = {
size: 'small' | 'medium' | 'large'
isExpand: boolean
locale: 'zh' | 'en'
isMobile: boolean
isDarkMode: boolean
activeMenu: string
}
export const useAppStore = defineStore('app', {
state: (): app => ({
size: 'small',
isExpand: true,
locale: Cache.get('language'),
isMobile: false,
isDarkMode: false,
activeMenu: '/dashboard',
}),
getters: {
getSize(): string {
return this.size
},
getLocale(): string {
return this.locale
},
getIsMobile(): boolean {
return this.isMobile
},
getIsDarkMode(): boolean {
return this.isDarkMode
},
getActiveMenu(): string {
return this.activeMenu
},
},
actions: {
changeSize(size: 'small' | 'medium' | 'large'): void {
this.size = size
},
changeLocale(locale: 'zh' | 'en'): void {
Cache.set('language', locale)
this.locale = locale
},
changeExpaned(): void {
this.isExpand = !this.isExpand
},
setDarkMode(isDarkMode: boolean): void {
this.isDarkMode = isDarkMode
},
setActiveMenu(activeMenu: string): void {
this.activeMenu = activeMenu.startsWith('/') ? activeMenu : '/' + activeMenu
},
},
})

View File

@@ -0,0 +1,152 @@
import { defineStore } from 'pinia'
import { User } from '/admin/types/user'
import http from '/admin/support/http'
import { rememberAuthToken, removeAuthToken } from '/admin/support/helper'
import Message from '/admin/support/message'
import router from '/admin/router'
import { Permission } from '/admin/types/permission'
export const useUserStore = defineStore('UserStore', {
state: (): User => {
return {
id: 0,
nickname: '',
avatar: '',
email: '',
remember_token: '',
status: 0,
permissions: [] as Permission[],
roles: [] as string[],
}
},
getters: {
getId(): number {
return this.id
},
getNickname(): string {
return this.nickname
},
getAvatar(): string {
return this.avatar
},
getRoles(): string[] | undefined {
return this.roles
},
getPermissions(): Permission[] | undefined {
return this.permissions
},
},
actions: {
isSuperAdmin(): boolean {
return this.id === 1
},
setNickname(nickname: string) {
this.nickname = nickname
},
setId(id: number) {
this.id = id
},
setRememberToken(token: string) {
this.remember_token = token
},
setAvatar(avatar: string) {
this.avatar = avatar
},
setRoles(roles: string[]) {
this.roles = roles
},
setPermissions(permissions: Permission[]) {
this.permissions = permissions
},
setEmail(email: string) {
this.email = email
},
setStatus(status: number) {
this.status = status
},
/**
* login
*
* @param params
* @returns
*/
login(params: Object) {
return new Promise<void>((resolve, reject) => {
http
.post('/login', params)
.then(response => {
const { token } = response.data.data
rememberAuthToken(token)
this.setRememberToken(token)
resolve()
})
.catch(e => {
reject(e)
})
})
},
/**
* logout
*/
logout() {
http
.post('/logout')
.then(() => {
removeAuthToken()
this.$reset()
router.push({ path: '/login' })
})
.catch(e => {
Message.error(e.message)
})
},
/**
* user info
*/
getUserInfo() {
return new Promise((resolve, reject) => {
http
.get('/user/info')
.then(response => {
const { id, nickname, email, avatar, permissions, roles, rememberToken, status } = response.data.data
// set user info
this.setId(id)
this.setNickname(nickname)
this.setEmail(email)
this.setRoles(roles)
this.setRememberToken(rememberToken)
this.setStatus(status)
this.setAvatar(avatar)
this.setPermissions(permissions)
resolve(response.data.data)
})
.catch(e => {
reject(e)
})
})
},
},
})

View File

@@ -0,0 +1,193 @@
import { defineStore } from 'pinia'
import { Permission } from '/admin/types/permission'
import { MenuType } from '/admin/enum/app'
import { Menu } from '/admin/types/Menu'
import { constantRoutes } from '/admin/router'
import { RouteRecordRaw } from 'vue-router'
interface Permissions {
menus: Menu[]
asyncMenus: Menu[]
permissions: Permission[]
menuPathMap: Map<string, string>
}
export const usePermissionsStore = defineStore('PermissionsStore', {
state: (): Permissions => {
return {
menus: [],
asyncMenus: [],
permissions: [],
menuPathMap: new Map(),
}
},
/**
* get
*/
getters: {
getMenus(): Menu[] {
return this.menus
},
getAsyncMenus(): Menu[] {
return this.asyncMenus
},
getPermissions(): Permission[] {
return this.permissions
},
getMenuPathMap(): Map<string, string> {
return this.menuPathMap
},
},
/**
* actions
*/
actions: {
/**
* generate async menus
* @param permissions
* @param force
* @returns
*/
getAsyncMenusFrom(permissions: Permission[], force: boolean = false): Menu[] {
// 如果非强制获取并且 menu 有值,直接返回
if (!force && this.asyncMenus.length > 0) {
return this.asyncMenus
}
const menus: Permission[] = []
permissions.forEach(permission => {
if (permission.type === MenuType.PAGE_TYPE) {
menus.push(permission)
}
// set map
this.menuPathMap.set(permission.route, permission.title)
})
this.setAsyncMenus(this.getAsnycMenus(menus))
return this.asyncMenus
},
/**
* get menus
* @param permissions
* @param force
* @returns
*/
getMenusFrom(permissions: Permission[], force: boolean = false): Menu[] {
// 如果非强制获取并且 menu 有值,直接返回
if (!force && this.menus.length > 0) {
return this.menus
}
const asyncMenus = this.getAsyncMenusFrom(permissions, force)
this.setMenus(asyncMenus)
return this.menus
},
/**
* set menus
*
* @param menus
*/
setMenus(menus: Menu[]) {
this.menus = this.transformRoutesToMenus(constantRoutes).concat(menus)
},
setAsyncMenus(menus: Menu[]) {
this.asyncMenus = menus
},
/**
* 生成 Menus
*
* @param permissions
* @param parentId
* @param path
* @returns
*/
getAsnycMenus(permissions: Permission[], parentId: number = 0, path: string = ''): Menu[] {
const menus: Menu[] = []
permissions.forEach(permission => {
if (permission.parent_id === parentId) {
// menu
const menu: Menu = Object.assign({
path: this.resoulveRoutePath(permission.route, path),
name: permission.module + '_' + permission.permission_mark,
redirect: permission.redirect,
meta: Object.assign({ title: permission.title, icon: permission.icon, hidden: permission.hidden, is_inner: permission.is_inner }),
})
// child menu
const children = this.getAsnycMenus(permissions, permission.id, menu.path)
if (children.length > 0) {
menu.children = children
}
menus.push(menu)
}
})
return menus
},
/**
* transform routes to menus
* @param routes
* @param path
* @returns
*/
transformRoutesToMenus(routes: Menu[] | Array<RouteRecordRaw>, path: string = ''): Menu[] {
const menus: Menu[] = []
routes.forEach(route => {
if (route.meta?.hidden) {
return false
}
const menu: Menu = Object.assign({
path: this.resoulveRoutePath(route.path, path),
name: route.name,
meta: route.meta,
component: route.component,
})
if (route.children?.length) {
menu.children = this.transformRoutesToMenus(route.children, menu.path)
}
menus.push(menu)
})
return menus
},
/**
* resoulve route path
* @param route
* @param path
* @returns
*/
resoulveRoutePath(route: string, path: string): string {
if (path.length) {
return (path + (route.indexOf('/') === -1 ? '/' : '') + route).replace(/\/$/g, '')
}
// 去除尾部的 /
return route.replace(/\/$/g, '')
},
},
})

View File

@@ -0,0 +1,61 @@
@forward 'element-plus/theme-chalk/src/common/var' with (
// 基础色调
$colors:
(
'primary': (
'base': #4f46e5,
),
'success': (
'base': #059669,
),
'warning': (
'base': #fbbf24,
),
'danger': (
'base': #f43f5e,
),
'error': (
'base': #f43f5e,
),
'info': (
'base': #909399,
)
),
$input: ('border-radius': 8px)
);
@use 'element-plus/theme-chalk/src/index' as *;
.el-table {
border-radius: var(--el-table-border-radius);
.el-table__row {
@apply h-14;
}
.el-table__header {
@apply h-14 bg-black;
}
}
.el-tabs {
border-radius: var(--el-table-border-radius);
}
.el-card {
border-radius: var(--el-card-border-radius) !important;
}
.el-pagination {
button {
border-radius: var(--el-page-border-radius) !important;
}
.el-pager {
.number {
border-radius: var(--el-page-border-radius) !important;
}
.more {
border-radius: var(--el-page-border-radius) !important;
}
}
}

View File

@@ -0,0 +1,12 @@
// tailwindcss
@import 'tailwind.css';
// element style 必须在 tailwindcss 之后,不然样式会被 tailwindcss 覆盖
// issue 在这里 https://github.com/tailwindlabs/tailwindcss/discussions/5969
@import 'element';
// 后台管理定义的 css 变量
@import 'var';
// theme
@import 'theme/index';

View File

@@ -0,0 +1,22 @@
@tailwind base;
@layer base {
.layout-sider {
@apply z-0 absolute top-16 left-0 sm:static transition-width duration-300 ease-linear
}
.layout-sider-open {
@apply layout-sider w-64
}
.layout-sider-hidden {
@apply layout-sider w-0 lg:w-20
}
.layout-sider-mask {
@apply block lg:hidden z-40 w-full h-full absolute
}
}
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,36 @@
@import 'element-plus/theme-chalk/dark/css-vars.css';
html.dark {
background-color: #161d31;
// 侧边栏背景色
--sider-bg-color: #283046;
// header logo 文字颜色
--header-logo-text-color: #ffffff;
// 侧边栏菜单的文字颜色
--sider-menu-text-color: #ffffff;
// sub menu bg color
--sider-sub-menu-bg-color: #161d31;
// 侧边栏子菜单 hover 的颜色
--sider-sub-menu-hover-bg-color: #343d55;
// 激活文字颜色
--sider-ment-active-text-color: var(--el-color-primary);
// 激活时背景色
--side-active-menu-bg-color: rgba(255, 255, 255, 0.08);
/* 自定义深色背景颜色 */
--el-bg-color: var(--sider-sub-menu-hover-bg-color);
--el-fill-color-blank: var(--sider-bg-color);
--el-bg-color-overlay: var(--sider-bg-color);
--header-bg-color: var(--sider-bg-color);
// border color
--el-border-color-lighter: #3b4253;
--el-fill-color-light: #161d31;
// side sub menu margin
--sider-sub-menu-bg-margin: 0px 0.05rem;
}

View File

@@ -0,0 +1,3 @@
@import "light";
@import "dark";

View File

@@ -0,0 +1,25 @@
html {
background-color: rgb(241, 245, 249);
// 侧边颜色
--sider-bg-color: #ffffff;
// header logo 背景颜色
--header-logo-bg-color: var(--sider-bg-color);
// 侧边栏菜单的文字颜色
--sider-menu-text-color: #625f6e;
// sub menu bg color
--sider-sub-menu-bg-color: #ffffff;
// 侧边栏子菜单 hover 的颜色
--sider-sub-menu-hover-bg-color: #f6f6f6;
// 菜单激活时文字的颜色
--sider-ment-active-text-color: var(--el-color-primary);
// 激活菜单背景
--side-active-menu-bg-color: rgba(36, 153, 239, 0.06);
// header logo 文字颜色
--header-logo-text-color: #625f6e;
// header bg color
--header-bg-color: #ffffff;
// 侧边栏菜单的背景色
--sider-menu-bg-color: var(--sider-bg-color);
--el-table-tr-bg-color: black;
}

View File

@@ -0,0 +1,12 @@
:root {
--el-menu-base-level-padding: 20px;
// 后台自定义
// el-table
--el-table-border-radius: 8px;
// el-tabs
--el-tabs-border-radius: 8px;
// el-card
--el-card-border-radius: 8px;
// -el-page
--el-page-border-radius: 8px;
}

View File

@@ -0,0 +1,32 @@
export default class Cache {
private static readonly prefix:string = 'catchadmin_'
/**
* set
*
* @param key
* @param value
*/
static set (key:string, value: any) : void {
window.localStorage.setItem(Cache.prefix + key, value)
}
/**
* get
*
* @param key
* @returns
*/
static get (key: string) : any {
return window.localStorage.getItem(Cache.prefix + key)
}
/**
* delete
*
* @param key
* @returns
*/
static del (key: string) : void {
window.localStorage.removeItem(Cache.prefix + key)
}
}

View File

@@ -0,0 +1,88 @@
import { createApp } from 'vue'
import type { App as app } from 'vue'
import App from '/admin/App.vue'
import router, { bootstrapRouter } from '/admin/router'
import ElementPlus from 'element-plus'
import zh from 'element-plus/es/locale/lang/zh-cn'
import { bootstrapStore } from '/admin/stores'
import Cache from './cache'
import { bootstrapI18n } from '/admin/i18n'
import guard from '/admin/router/guard'
/**
* catchadmin
*/
export default class CatchAdmin {
protected app: app
protected element: string
/**
* construct
*
* @param ele
*/
constructor(ele: string = '#app') {
this.app = createApp(App)
this.element = ele
}
/**
* admin boot
*/
bootstrap(): void {
this.useElementPlus().usePinia().useI18n().useRouter().mount()
}
/**
* 挂载节点
*
* @returns
*/
protected mount(): void {
this.app.mount(this.element)
}
/**
* 加载路由
*
* @returns
*/
protected useRouter(): CatchAdmin {
guard(router)
bootstrapRouter(this.app)
return this
}
/**
* ui
*
* @returns
*/
protected useElementPlus(): CatchAdmin {
this.app.use(ElementPlus, {
locale: Cache.get('language') === 'zh' && zh,
})
return this
}
/**
* use pinia
*/
protected usePinia(): CatchAdmin {
bootstrapStore(this.app)
return this
}
/**
* use i18n
*/
protected useI18n(): CatchAdmin {
bootstrapI18n(this.app)
return this
}
}

View File

@@ -0,0 +1,89 @@
/**
* Helper 助教函数集合
*/
import Cache from '/admin/support/cache'
import i18n from '/admin/i18n'
const AUTH_TOKEN = 'auth_token'
/**
* env get
*
* @param key
*/
export function env(key: string): any {
const env = import.meta.env
return env[key]
}
/**
* remember token
*
* @param token
*/
export function rememberAuthToken(token: string): void {
Cache.set(AUTH_TOKEN, token)
}
/**
* remove auth token
*/
export function removeAuthToken(): void {
Cache.del(AUTH_TOKEN)
}
/**
* get auth token
*
*/
export function getAuthToken(): string | null {
return Cache.get(AUTH_TOKEN)
}
/**
* 是否是小屏幕
* @return
*/
export function isMiniScreen(): boolean {
return window.document.body.clientWidth < 500
}
/**
* translate
*
* @param translate
* @returns
*/
export function t(translate: string) {
return i18n.global.t(translate)
}
/**
* is undefined
*
* @param value
* @returns
*/
export function isUndefined(value: any): boolean {
return value === undefined
}
/**
* set page title
*
* @param title
*/
export function setPageTitle(title: string) {
document.title = title
}
/**
* is function?
*
* @param value
*/
export function isFunction(value: any) {
return typeof value === 'function'
}

View File

@@ -0,0 +1,213 @@
import { Code } from '/admin/enum/app'
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
import { env, getAuthToken, removeAuthToken } from './helper'
import Message from './message'
import router from '/admin/router'
import ResponseData from '/admin/types/responseData'
/**
* http util
*/
class Http {
/**
* axios config
* @protected
*/
protected config: AxiosRequestConfig = {}
/**
* base url
* @protected
*/
protected baseURL: string = ''
/**
* http request timeout
*
* @protected
*/
protected timeout: number = 0
/**
* http request headers
*
* @protected
*/
protected headers: { [k: string]: string } = {}
/**
* axios instance
*
* @protected
*/
protected request: AxiosInstance
/**
* instance
*/
constructor() {
this.request = axios.create(this.getConfig())
}
/**
* get request
*
* @param path
* @param params
*/
public get(path: string, params: object = {}) {
return this.request.get(this.baseURL + path, {
params,
})
}
/**
* post request
*
* @param path
* @param data
*/
public post(path: string, data: object = {}) {
return this.request.post(this.baseURL + path, data)
}
/**
* put request
*
* @param path
* @param data
*/
public put(path: string, data: object = {}) {
return this.request.put(this.baseURL + path, data)
}
/**
* delete request
*
* @param path
*/
public delete(path: string) {
return this.request.delete(this.baseURL + path)
}
/**
* set timeout
*
* @param timeout
* @returns
*/
public setTimeout(timeout: number): Http {
this.timeout = timeout
return this
}
/**
* set baseurl
*
* @param url
* @returns
*/
public setBaseUrl(url: string): Http {
this.baseURL = url
return this
}
/**
* set headers
*
* @param key
* @param value
* @returns
*/
public setHeader(key: string, value: string): Http {
this.headers.key = value
return this
}
/**
* get axios 配置
*
* @returns
*/
protected getConfig(): AxiosRequestConfig {
// set base url
this.config.baseURL = this.baseURL ? this.baseURL : env('VITE_BASE_URL')
// set timeout
this.config.timeout = this.timeout ? this.timeout : 5000
// set ajax request
this.headers['X-Requested-With'] = 'XMLHttpRequest'
this.config.headers = this.headers
return this.config
}
/**
* 添加请求拦截器
*
*/
public interceptorsOfRequest(): void {
this.request.interceptors.request.use(function (config: AxiosRequestConfig) {
const token = getAuthToken()
if (token) {
if (!config.headers) {
config.headers = {}
}
config.headers.authorization = 'Bearer ' + token
}
return config
})
}
/**
* 添加响应拦截器
*
*/
public interceptorsOfResponse(): void {
this.request.interceptors.response.use(
response => {
const r: ResponseData = response.data
const code = r.code
const message = r.message
if (code === 1e4) {
return response
}
if (code === 10004) {
Message.error(message || 'Error')
} else if (code === Code.LOST_LOGIN || code === Code.LOGIN_EXPIRED) {
// to re-login
Message.confirm(message + ',需要重新登陆', function () {
removeAuthToken()
router.push('/login')
})
} else if (code === Code.LOGIN_BLACKLIST || code === Code.USER_FORBIDDEN) {
Message.error(message || 'Error')
removeAuthToken()
// to login page
router.push('/login')
} else {
Message.error(message || 'Error')
}
return Promise.reject(new Error(message || 'Error'))
},
error => {
Message.error(error.message)
return Promise.reject(error)
},
)
}
}
const http = new Http()
http.interceptorsOfRequest()
http.interceptorsOfResponse()
export default http

View File

@@ -0,0 +1,59 @@
import { ElMessage, ElMessageBox } from 'element-plus'
import { t } from './helper'
export default class Message {
/**
* success
*
* @param message
*/
static success (message: string) : void {
this.message(message, 'success')
}
/**
* error
*
* @param message
*/
static error (message: string) : void {
this.message(message, 'error')
}
/**
* warning
*
* @param message
*/
static warning (message: string) : void {
this.message(message, 'warning')
}
/**
* confirm
*
* @param message
* @param callback
*/
static confirm (message: string, callback: any) : void {
ElMessageBox.confirm(message, t('system.warning'), {
confirmButtonText: t('system.confirm'),
cancelButtonText: t('system.cancel'),
type: 'warning'
}).then(callback)
}
/**
* message
*
* @param message
* @param type
*/
protected static message (message: string, type: any) {
ElMessage({
message,
type
})
}
}

View File

@@ -0,0 +1,14 @@
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
// 全局进度条的配置
NProgress.configure({
easing: 'ease', // 动画方式
speed: 500, // 递增进度条的速度
showSpinner: true, // 是否显示加载ico
trickleSpeed: 200, // 自动递增间隔
minimum: 0.3, // 更改启动时使用的最小百分比
parent: 'body' // 指定进度条的父容器
})
export default NProgress

View File

@@ -0,0 +1,27 @@
export interface Permission {
id: number
parent_id: number
title: string
type: number
icon: string
component: string
module: string
permission_mark: string
route: string
redirect: string
keepAlive: boolean
hidden: boolean
is_inner: boolean
}

View File

@@ -0,0 +1 @@
export {}

View File

@@ -0,0 +1,22 @@
// login user type
import { Permission } from './permission'
export interface User {
id: number,
nickname: string,
avatar: string,
email: string,
status: number,
remember_token: string,
roles?: string[],
permissions?: Permission[]
}

View File

@@ -0,0 +1,33 @@
import { Component } from 'vue'
import { RouteRecordRaw } from 'vue-router'
export interface Meta {
title: string
icon: string
roles?: string[]
cache?: boolean
hidden: boolean
keepalive?: boolean
is_inner?: boolean
}
// @ts-ignore
export interface Menu extends Omit<RouteRecordRaw, 'meta'> {
path: string
name: string
meta?: Meta
redirect?: string
component?: Component
children?: Menu[]
}

View File

@@ -0,0 +1,7 @@
export default interface ResponseData{
code: number;
message: string;
data: any
}

View File

@@ -0,0 +1,15 @@
export interface RouterMeta {
icon: string,
title: string,
roles: string[]
}
export interface RouterRecord {
name: string;
meta: RouterMeta;
component?: string;
children?: RouterRecord[];
fullPath?: string;
redirect: string
}

View File

@@ -0,0 +1,36 @@
<template>
<div class="flex flex-col bg-white dark:bg-regal-dark pl-5 pr-5 rounded">
<span class="text-lg mt-5">项目依赖</span>
<div class="flex mt-3">
<el-table :data="dependencies" border width="200">
<el-table-column prop="dependency" label="Dependency" />
<el-table-column prop="version" label="Version" />
</el-table>
<el-table :data="devDependencies" border>
<el-table-column prop="devDependency" label="DevDependency" />
<el-table-column prop="version" label="Version" />
</el-table>
</div>
</div>
</template>
<script lang="ts" setup>
import packages from '/admin/../package.json'
import { computed } from 'vue'
const dependencies = computed(() => {
const _dependencies = []
for (const dependency in packages.dependencies) {
_dependencies.push(Object.assign({ dependency, version: (packages.dependencies as any)[dependency] }))
}
return _dependencies
})
const devDependencies = computed(() => {
const _devDependencies = []
for (const devDependency in packages.devDependencies) {
_devDependencies.push(Object.assign({ devDependency, version: (packages.devDependencies as any)[devDependency] }))
}
return _devDependencies
})
</script>

View File

@@ -0,0 +1,84 @@
<template>
<div>
<div class="bg-white dark:bg-regal-dark rounded flex flex-col sm:flex-row justify-between">
<div class="w-full sm:w-[40rem] flex flex-row p1-1 sm:pl-3 items-center justify-between min-h-28 border-b pb-2 sm:pb-0 sm:border-b-0 border-gray-400">
<img :src="avatar" class="rounded-full w-16 h-16" />
<div class="flex flex-col w-[34rem] pl-4 sm:pl-0 pt-2 sm:pt-3">
<div class="text-lg text-zinc-800 dark:text-gray-200">很高兴见到你👋 {{ nickname }}{{ itsTimeDo }}</div>
<div class="text-sm text-gray-400 break-words pt-0 sm:pt-2">{{ context }}</div>
</div>
</div>
<div class="flex items-center h-28 w-full sm:w-[23rem] justify-between pl-2 pr-2 sm:pr-3">
<div class="flex flex-col text-center">
<div class="text-lg text-gray-600 dark:text-gray-400">项目数</div>
<div class="text text-gray-400 dark:text-gray-200">1000</div>
</div>
<el-divider direction="vertical" />
<div class="flex flex-col text-center">
<div class="text-lg text-gray-600 dark:text-gray-400">国内排名</div>
<div class="text text-gray-400 dark:text-gray-200">1000</div>
</div>
<el-divider direction="vertical" border-style="dashed" />
<div class="flex flex-col text-center">
<div class="text-lg text-gray-600 dark:text-gray-400">团队成员</div>
<div class="text text-gray-400 dark:text-gray-200">1000</div>
</div>
</div>
</div>
<div class="flex flex-col sm:flex-row mt-4 justify-between">
<Introduce />
<Project />
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useUserStore } from '/admin/stores/modules/user'
import Introduce from './introduce.vue'
import Project from './project.vue'
const userStore = useUserStore()
const nickname = computed(() => {
return userStore.getNickname
})
const avatar = computed(() => {
return userStore.getAvatar
})
const itsTimeDo = computed(() => {
const date = new Date()
const now = date.getHours()
if (isInRange(now, 2, 5)) {
return '凌晨了,该休息了!注意身体!😪'
} else if (isInRange(now, 5, 8)) {
return '早晨,开始全新的一天!😊'
} else if (isInRange(now, 8, 12)) {
return '上午好,开始摸鱼的一天!😄'
} else if (isInRange(now, 12, 18)) {
return '下午好,快要下班了!再坚持下💪'
} else if (isInRange(now, 18, 23)) {
return '晚上了,请点击右上角关闭!👉'
} else {
return '深夜了,为什么还在打开该系统?💢'
}
})
const context = computed(() => {
const contexts: string[] = [
'资本主义社会里的民主是一种残缺不全的,贫乏和虚伪和民主,是只供富人,只供少数人享受的民主',
'资本来到世间,从头到脚,每个毛孔都滴着血和肮脏的东西',
'既然掠夺给少数人造成了天然的权利,那么多数人就只得积聚足够的力量,来取得夺回他们被夺去的一切的天然权利',
'资本家有百分之五十的利润,就会铤而走险;有了百分之一百的利润就敢践踏人间一切法律;有了百分之三百的利润就敢冒上绞刑架的危险',
]
return contexts[Math.floor(Math.random() * contexts.length)]
})
function isInRange(compare: number, min: number, max: number) {
return compare >= min && compare < max
}
</script>

View File

@@ -0,0 +1,121 @@
<template>
<div class="w-full sm:w-[72%]">
<el-card shadow="never">
<template #header>
<div class="flex flex-row justify-between">
<span class="text-lg">项目基于</span>
</div>
</template>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-1">
<el-card shadow="hover">
<div class="flex justify-between">
<Vue class="w-10 h-10" />
<div class="text-lg">
<el-link href="https://cn.vuejs.org/" target="_blank" :underline="false"><el-button type="success" size="small">GO</el-button></el-link>
</div>
</div>
<div class="text-sm text-gray-400 pt-2">渐进式 JavaScript 框架 易学易用性能出色适用场景丰富的 Web 前端框架Go</div>
</el-card>
<el-card shadow="hover">
<div class="flex justify-between">
<Laravel class="w-10 h-10" />
<div class="text-lg">
<el-link href="https://learnku.com/docs/laravel" target="_blank" :underline="false"><el-button type="danger" size="small">GO</el-button></el-link>
</div>
</div>
<div class="text-sm text-gray-400 pt-2">Laravel 是一个具有表现力优雅语法的 Web 应用程序框架</div>
</el-card>
<el-card shadow="hover">
<div class="flex justify-between">
<Element class="w-10 h-10" />
<div class="text-lg">
<el-link href="https://element-plus.org/" target="_blank" :underline="false"><el-button type="primary" size="small">GO</el-button></el-link>
</div>
</div>
<div class="text-sm text-gray-400 pt-2">Element Plus 基于 Vue3面向设计师和开发者的组件库</div>
</el-card>
<el-card shadow="hover">
<div class="flex justify-between">
<Tailwindcss class="w-10 h-10" />
<div class="text-lg">
<el-link href="https://tailwindcss.com/" target="_blank" :underline="false"><el-button size="small">GO</el-button></el-link>
</div>
</div>
<div class="text-sm text-gray-400 pt-2">Tailwind CSS 是一个功能类优先的 CSS 框架无需离开您的HTML即可快速建立现代网站</div>
</el-card>
<el-card shadow="hover">
<div class="flex justify-between">
<Composer class="w-10 h-10" />
<div class="text-lg">
<el-link href="https://getcomposer.org/" target="_blank" :underline="false"><el-button type="info" size="small">GO</el-button></el-link>
</div>
</div>
<div class="text-sm text-gray-400 pt-2">Composer PHP 中用于依赖管理的工具它允许您声明项目所依赖的库并将为您管理安装/更新它们</div>
</el-card>
<el-card shadow="hover">
<div class="flex justify-between">
<Vite class="w-10 h-10" />
<div class="text-lg">
<el-link href="https://cn.vitejs.dev/" target="_blank" :underline="false"><el-button type="warning" size="small">Go</el-button></el-link>
</div>
</div>
<div class="text-sm text-gray-400 pt-2">Vite 下一代的前端工具链 为开发提供极速响应</div>
</el-card>
</div>
</el-card>
<div>
<el-card shadow="never" class="mt-4">
<template #header>
<div class="flex flex-row justify-between">
<span class="text-lg">项目动态</span>
</div>
</template>
<ul>
<li class="flex border-b border-b-slate-200 dark:border-b-slate-700 pt-2">
<div><el-avatar src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" /></div>
<div class="flex flex-col ml-4">
<div class="text">JaguarJack 提交一个 PR</div>
<div class="text-sm text-gray-400 mt-1 mb-2">1 小时前</div>
</div>
</li>
<li class="flex border-b border-b-slate-200 dark:border-b-slate-700 pt-2">
<div><el-avatar src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" /></div>
<div class="flex flex-col ml-4 mt-1">
<div>JaguarJack 关闭了一个 Issue</div>
<div class="text-sm text-gray-400 mt-1 mb-2">2 小时前</div>
</div>
</li>
<li class="flex border-b border-b-slate-200 dark:border-b-slate-700 pt-2">
<div><el-avatar src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" /></div>
<div class="flex flex-col ml-4 mt-1">
<div>JaguarJack 提交新的 commit</div>
<div class="text-sm text-gray-400 mt-1 mb-2">24 小时前</div>
</div>
</li>
<li class="flex border-b border-b-slate-200 dark:border-b-slate-700 pt-2">
<div><el-avatar src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" /></div>
<div class="flex flex-col ml-4 mt-1">
<div>JaguarJack 创建了一个 new project</div>
<div class="text-sm text-gray-400 mt-1 mb-2">2021.10.26</div>
</div>
</li>
</ul>
</el-card>
</div>
</div>
</template>
<script lang="ts" setup>
// @ts-ignores
import Laravel from '~icons/logos/laravel'
// @ts-ignores
import Vue from '~icons/logos/vue'
// @ts-ignores
import Vite from '~icons/logos/vitejs'
// @ts-ignores
import Element from '~icons/logos/element'
// @ts-ignores
import Composer from '~icons/logos/composer'
// @ts-ignores
import Tailwindcss from '~icons/logos/tailwindcss-icon'
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="w-full sm:w-[27%]">
<el-card shadow="never">
<template #header>
<div class="flex flex-row justify-between">
<span class="text-lg">项目信息</span>
</div>
</template>
<div class="grid grid-cols-1 sm:grid-cols-1 gap-1">
<div>
<div class="text-xl text-center">Catch-admin</div>
<div class="text-base text-gray-400">
是一个基于Vue3.0Vite ElementPlus TypeScript 的后台解决方案提供了丰富的功能组件它可以帮助你快速搭建企业级中后台产品原型
<el-link href="https://catchadmin.com/" target="_blank">更多...</el-link>
</div>
<div class="flex justify-between pl-4 pr-4 mt-2 w-full sm:w-[75%] m-auto">
<a href="https://github.com/JaguarJack/catch-admin/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/JaguarJack/catch-admin" /></a>
<a href="https://github.com/JaguarJack/catch-admin/network"><img alt="GitHub forks" src="https://img.shields.io/github/forks/JaguarJack/catch-admin" /></a>
<a href="https://github.com/JaguarJack/catch-admin/blob/master/LICENSE.txt"><img alt="GitHub license" src="https://img.shields.io/github/license/JaguarJack/catch-admin" /></a>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-1 mt-2">
<el-card shadow="hover">
<div class="text-center">易用</div>
<div class="text-sm text-gray-400 pt-2">catchadmin 是从头开始设计的模块化框架解耦化的设计让你轻松驾驭框架</div>
</el-card>
<el-card shadow="hover">
<div class="text-center">专注</div>
<div class="text-sm text-gray-400 pt-2">catchadmin 加入大量快速开发组件让你专注于后台管理的开发而无需重复无意义的工作</div>
</el-card>
<el-card shadow="hover">
<div class="text-center">强大</div>
<div class="text-sm text-gray-400 pt-2">catchadmin 基于 PHP 庞大的生态使其稳定并且强大使用 catchadmin 可以开发任意 web 应用</div>
</el-card>
</div>
</div>
</el-card>
</div>
</template>

View File

@@ -0,0 +1,74 @@
<template>
<div class="bg-gray-50 h-screen flex items-center justify-center">
<div class="flex w-full sm:w-[32rem] shadow bg-white lg:rounded-lg">
<!--<div class="w-1/2 hidden sm:block">
<img src="@/assets/login-left.png" />
</div>-->
<div class="w-full mx-auto pt-6 pb-6 pl-4 pr-4">
<div class="flex mt-2">
<img :src="logo" class="mx-auto w-8" />
</div>
<div class="w-full text-center text-2xl mt-6 mb-8 text-indigo-700">Hi, {{ $t('login.welcome') }}</div>
<el-divider>{{ $t('login.sign_in') }}</el-divider>
<div>
<el-form
ref="form"
:model="params"
status-icon
v-loading.fullscreen.lock="loading"
:rules="rules"
element-loading-background="rgba(0, 0, 0, 0.7)"
label-width="70px"
class="w-11/12 sm:w-4/5 pt-2 space-y-8 mx-auto"
>
<el-form-item prop="email">
<el-input v-model="params.email" type="email" autocomplete="off" :placeholder="$t('login.email')" size="large" :prefix-icon="Message" class="h-12 text-base" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="params.password" type="password" autocomplete="off" size="large" :placeholder="$t('login.password')" show-password :prefix-icon="Lock" class="h-12 text-base" />
</el-form-item>
</el-form>
</div>
<div class="flex justify-between w-11/12 sm:w-4/5 mx-auto mt-3">
<el-checkbox v-model="params.remember" class="top-2">
{{ $t('login.remember') }}
</el-checkbox>
<div class="text-sm pt-3 text-indigo-600 cursor-pointer">
{{ $t('login.lost_password') }}
</div>
</div>
<div class="w-11/12 sm:w-4/5 mx-auto mt-5">
<el-button type="primary" @click="submit(form)" size="large" class="w-full text-xl">
{{ $t('login.sign_in') }}
</el-button>
</div>
<div class="w-full text-center text-sm text-gray-400 mt-8 mb-10">{{ $t('system.name') }} @copyright 2018-{{ new Date().getFullYear() }}</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { Lock, Message } from '@element-plus/icons-vue'
import { onMounted } from 'vue'
import { useLogin } from './login'
import logo from '/admin/assets/logo.png'
const { params, loading, submit, form, rules } = useLogin()
// set default color-theme light
onMounted(() => {
document.querySelector('html')?.setAttribute('class', 'light')
})
</script>
<style lang="scss" scoped>
:deep(.el-form-item__content) {
margin-left: 0 !important;
}
:deep(.el-divider__text) {
@apply text-xl text-slate-400;
}
</style>

View File

@@ -0,0 +1,60 @@
import { reactive, ref } from 'vue'
import type { FormInstance } from 'element-plus'
import { useUserStore } from '/admin/stores/modules/user'
import router from '/admin/router'
import { t } from '/admin/support/helper'
export const useLogin = () => {
const params = reactive({
email: '',
password: '',
remember: false,
})
const loading = ref(false)
const rules = reactive({
email: [
{ required: true, message: t('login.verify.email.required'), trigger: 'blur' },
{ type: 'email', message: t('login.verify.email.invalid'), trigger: 'blur' },
],
password: [{ required: true, message: t('login.verify.password.required'), trigger: 'blur' }],
})
const form = ref<FormInstance>()
const submit = (loginForm: FormInstance | undefined) => {
if (!loginForm) return
loginForm.validate(valid => {
if (valid) {
loading.value = true
const store = useUserStore()
store
.login(params)
.then(() => {
loading.value = false
router.push({ path: '/' })
})
.catch(e => {
loading.value = false
})
} else {
return false
}
})
}
return {
params,
rules,
loading,
submit,
form,
}
}

View File