xiongzhu há 2 anos atrás
pai
commit
71342623b1

+ 11 - 1
src/App.vue

@@ -1,10 +1,20 @@
 <script setup>
 import { RouterView } from 'vue-router'
 import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
+import { useWindowSize } from '@vueuse/core'
+import { computed, provide } from 'vue'
+const { width } = useWindowSize()
+const size = computed(() => {
+    return width.value > 768 ? 'small' : 'medium'
+})
+const isMobile = computed(() => {
+    return width.value <= 768
+})
+provide('isMobile', isMobile)
 </script>
 
 <template>
-    <ElConfigProvider :locale="zhCn" >
+    <ElConfigProvider :locale="zhCn">
         <RouterView />
     </ElConfigProvider>
 </template>

+ 19 - 1
src/assets/logo.svg

@@ -1 +1,19 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="28px" height="28px" viewBox="0 0 28 28" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>logo</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="首页" transform="translate(-16.000000, -99.000000)" fill="#FFFFFF">
+            <g id="编组" transform="translate(0.000000, 88.000000)">
+                <g id="LOGO备份" transform="translate(16.000000, 11.000000)">
+                    <g id="编组-83" transform="translate(2.000000, 2.000000)">
+                        <path d="M12.2873658,0 C5.50124107,0 0,5.50124107 0,12.2873658 C0,19.0734906 5.50124107,24.5747317 12.2873658,24.5747317 C16.654991,24.5747317 20.6756592,22.2687245 22.8877214,18.5048014 L23,18.2749865 L21.3784725,17.3841251 L21.2791651,17.5569806 C19.4011277,20.7524439 15.9924659,22.7076469 12.2873658,22.7076469 C6.53240351,22.7076469 1.86708476,18.0423282 1.86708476,12.2873658 C1.86708476,6.53240351 6.53240351,1.86708476 12.2873658,1.86708476 C15.9068605,1.86708476 19.2475092,3.73215926 21.1531561,6.80897852 L21.269837,6.99736911 L22.8877214,6.05928988 L22.740454,5.82587583 C20.4946423,2.19983339 16.553942,0 12.2873658,0 Z" id="路径" fill-rule="nonzero"></path>
+                        <path d="M12.2088942,4.1512942 C7.63585277,4.1512942 3.92867021,7.85847676 3.92867021,12.4315182 C3.92867021,17.0045596 7.63585277,20.7117422 12.2088942,20.7117422 C15.1519195,20.7117422 17.8615887,19.1576867 19.3520559,16.6215898 L19.4843929,16.3467403 L17.795713,15.3900755 L17.6564225,15.6250639 C16.5190013,17.5604372 14.4540159,18.7449589 12.2088942,18.7449589 C8.72207716,18.7449589 5.89545346,15.9183352 5.89545346,12.4315182 C5.89545346,8.94470115 8.72207716,6.11807745 12.2088942,6.11807745 C14.4021708,6.11807745 16.4259009,7.24797072 17.5807579,9.11257974 L17.795713,9.45259814 L19.4843929,8.48632353 L19.2528143,8.07698139 C17.7396268,5.63381927 15.0838062,4.1512942 12.2088942,4.1512942 Z" id="路径" fill-rule="nonzero"></path>
+                        <polygon id="矩形备份-141" transform="translate(19.953421, 17.593844) rotate(28.000000) translate(-19.953421, -17.593844) " points="17.1026675 16.6653561 22.8041752 16.759414 22.8041752 18.5223317 17.1026675 18.4282738"></polygon>
+                        <path d="M12.4411076,7.73990028 C9.84999861,7.73990028 7.74948972,9.84040917 7.74948972,12.4315182 C7.74948972,15.0226272 9.84999861,17.1231361 12.4411076,17.1231361 C14.1083576,17.1231361 15.6439322,16.2425024 16.488198,14.8059462 L16.6004766,14.6148991 L14.9365502,13.5556816 L14.7925646,13.8094203 C14.3013443,14.6452537 13.4104532,15.1563529 12.4411076,15.1563529 C10.936223,15.1563529 9.71627297,13.9364028 9.71627297,12.4315182 C9.71627297,10.9266336 10.936223,9.70668353 12.4411076,9.70668353 C13.3881059,9.70668353 14.261128,10.1941769 14.7599098,10.9994999 L14.8765906,11.1878905 L16.5343848,10.212967 L16.4319661,9.96390153 C15.5748544,8.58002615 14.0697422,7.73990028 12.4411076,7.73990028 Z" id="路径" fill-rule="nonzero"></path>
+                        <polygon id="矩形" transform="translate(16.651762, 9.124785) rotate(-31.000000) translate(-16.651762, -9.124785) " points="14.0762884 8.21676072 19.2272361 8.2698908 19.2272361 10.0328085 14.0762884 9.97967843"></polygon>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 85 - 0
src/components/ChangePwd.vue

@@ -0,0 +1,85 @@
+<template>
+    <ElDialog
+        width="80%"
+        class="max-w-lg"
+        title="修改密码"
+        :model-value="modelValue"
+        @update:model-value="setVisible"
+        @open="onOpen"
+    >
+        <ElForm :model="model" :rules="rules" ref="formEl" label-position="top">
+            <ElFormItem label="新密码" prop="password">
+                <ElInput v-model="model.password" show-password></ElInput>
+            </ElFormItem>
+            <ElFormItem label="确认密码" prop="repeat">
+                <ElInput v-model="model.repeat" show-password></ElInput>
+            </ElFormItem>
+        </ElForm>
+        <template #footer>
+            <ElButton @click="setVisible(false)">取消</ElButton>
+            <ElButton type="primary" @click="submit" :loading="loading">确定</ElButton>
+        </template>
+    </ElDialog>
+</template>
+<script setup>
+import { ref } from 'vue'
+import { http } from '@/plugins/http'
+import { ElMessage } from 'element-plus'
+const props = defineProps({
+    modelValue: {
+        type: Boolean,
+        default: false
+    }
+})
+const emit = defineEmits(['update:modelValue'])
+const model = ref({
+    password: '',
+    repeat: ''
+})
+const formEl = ref(null)
+const rules = {
+    password: [
+        { required: true, message: '请输入密码', trigger: 'blur' },
+        { min: 6, max: 20, message: '长度在 6 到 20 个字符', trigger: 'blur' }
+    ],
+    repeat: [
+        { required: true, message: '请再次输入密码', trigger: 'blur' },
+        { min: 6, max: 20, message: '长度在 6 到 20 个字符', trigger: 'blur' },
+        {
+            validator: (rule, value, callback) => {
+                if (value !== model.value.password) {
+                    callback(new Error('两次输入密码不一致'))
+                } else {
+                    callback()
+                }
+            },
+            trigger: 'blur'
+        }
+    ]
+}
+function setVisible(val) {
+    emit('update:modelValue', val)
+}
+const loading = ref(false)
+function submit() {
+    formEl.value.validate(async (valid) => {
+        if (valid) {
+            loading.value = true
+            try {
+                await http.post('/users/updatePassword', { password: model.value.password })
+                setVisible(false)
+                ElMessage.success('修改成功')
+            } catch (e) {
+                ElMessage.error(e.message)
+            } finally {
+                loading.value = false
+            }
+        }
+    })
+}
+function onOpen() {
+    if (formEl.value) {
+        formEl.value.clearValidate()
+    }
+}
+</script>

+ 1 - 1
src/components/ListView.vue

@@ -10,7 +10,7 @@ import { useElementBounding, useWindowSize } from '@vueuse/core'
 const filterEl = ref(null)
 const { height: filterHeight } = useElementBounding(filterEl)
 const { height: windowHeight } = useWindowSize()
-const tableHeight = computed(() => windowHeight.value - 140 - filterHeight.value)
+const tableHeight = computed(() => windowHeight.value - 145 - filterHeight.value)
 provide('tableHeight', tableHeight)
 </script>
 <style lang="less" scoped>

+ 44 - 0
src/components/LogoSvg.vue

@@ -0,0 +1,44 @@
+<template>
+    <svg
+        width="28px"
+        height="28px"
+        viewBox="0 0 28 28"
+        version="1.1"
+        xmlns="http://www.w3.org/2000/svg"
+        xmlns:xlink="http://www.w3.org/1999/xlink"
+    >
+        <title>logo</title>
+        <g id="页面-1" stroke="none" stroke-width="1">
+            <g id="首页" transform="translate(-16.000000, -99.000000)">
+                <g id="编组" transform="translate(0.000000, 88.000000)">
+                    <g id="LOGO备份" transform="translate(16.000000, 11.000000)">
+                        <g id="编组-83" transform="translate(2.000000, 2.000000)">
+                            <path
+                                d="M12.2873658,0 C5.50124107,0 0,5.50124107 0,12.2873658 C0,19.0734906 5.50124107,24.5747317 12.2873658,24.5747317 C16.654991,24.5747317 20.6756592,22.2687245 22.8877214,18.5048014 L23,18.2749865 L21.3784725,17.3841251 L21.2791651,17.5569806 C19.4011277,20.7524439 15.9924659,22.7076469 12.2873658,22.7076469 C6.53240351,22.7076469 1.86708476,18.0423282 1.86708476,12.2873658 C1.86708476,6.53240351 6.53240351,1.86708476 12.2873658,1.86708476 C15.9068605,1.86708476 19.2475092,3.73215926 21.1531561,6.80897852 L21.269837,6.99736911 L22.8877214,6.05928988 L22.740454,5.82587583 C20.4946423,2.19983339 16.553942,0 12.2873658,0 Z"
+                                id="路径"
+                            ></path>
+                            <path
+                                d="M12.2088942,4.1512942 C7.63585277,4.1512942 3.92867021,7.85847676 3.92867021,12.4315182 C3.92867021,17.0045596 7.63585277,20.7117422 12.2088942,20.7117422 C15.1519195,20.7117422 17.8615887,19.1576867 19.3520559,16.6215898 L19.4843929,16.3467403 L17.795713,15.3900755 L17.6564225,15.6250639 C16.5190013,17.5604372 14.4540159,18.7449589 12.2088942,18.7449589 C8.72207716,18.7449589 5.89545346,15.9183352 5.89545346,12.4315182 C5.89545346,8.94470115 8.72207716,6.11807745 12.2088942,6.11807745 C14.4021708,6.11807745 16.4259009,7.24797072 17.5807579,9.11257974 L17.795713,9.45259814 L19.4843929,8.48632353 L19.2528143,8.07698139 C17.7396268,5.63381927 15.0838062,4.1512942 12.2088942,4.1512942 Z"
+                                id="路径"
+                            ></path>
+                            <polygon
+                                id="矩形备份-141"
+                                transform="translate(19.953421, 17.593844) rotate(28.000000) translate(-19.953421, -17.593844) "
+                                points="17.1026675 16.6653561 22.8041752 16.759414 22.8041752 18.5223317 17.1026675 18.4282738"
+                            ></polygon>
+                            <path
+                                d="M12.4411076,7.73990028 C9.84999861,7.73990028 7.74948972,9.84040917 7.74948972,12.4315182 C7.74948972,15.0226272 9.84999861,17.1231361 12.4411076,17.1231361 C14.1083576,17.1231361 15.6439322,16.2425024 16.488198,14.8059462 L16.6004766,14.6148991 L14.9365502,13.5556816 L14.7925646,13.8094203 C14.3013443,14.6452537 13.4104532,15.1563529 12.4411076,15.1563529 C10.936223,15.1563529 9.71627297,13.9364028 9.71627297,12.4315182 C9.71627297,10.9266336 10.936223,9.70668353 12.4411076,9.70668353 C13.3881059,9.70668353 14.261128,10.1941769 14.7599098,10.9994999 L14.8765906,11.1878905 L16.5343848,10.212967 L16.4319661,9.96390153 C15.5748544,8.58002615 14.0697422,7.73990028 12.4411076,7.73990028 Z"
+                                id="路径"
+                            ></path>
+                            <polygon
+                                id="矩形"
+                                transform="translate(16.651762, 9.124785) rotate(-31.000000) translate(-16.651762, -9.124785) "
+                                points="14.0762884 8.21676072 19.2272361 8.2698908 19.2272361 10.0328085 14.0762884 9.97967843"
+                            ></polygon>
+                        </g>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </svg>
+</template>

+ 0 - 67
src/components/PageTable.vue

@@ -1,67 +0,0 @@
-<template>
-    <ElTable :data="tableData" size="small" :height="tableHeight1" v-loading="loading">
-        <slot></slot>
-    </ElTable>
-    <div class="mt-4 flex justify-center">
-        <ElPagination
-            layout="total, sizes, prev, pager, next, jumper"
-            v-model:page-size="pageSize"
-            v-model:current-page="page"
-            :total="total"
-            small
-        />
-    </div>
-</template>
-<script setup>
-import { ref, onMounted, computed, watch, inject } from 'vue'
-import { http } from '@/plugins/http'
-import { useWindowSize } from '@vueuse/core'
-import { ElMessage } from 'element-plus'
-
-const props = defineProps({
-    url: {
-        type: String,
-        required: true
-    }
-})
-const { height } = useWindowSize()
-const tableHeight = computed(() => height.value - 140)
-const tableHeight1 = inject('tableHeight')
-
-const tableData = ref([])
-const page = ref(1)
-const pageSize = ref(20)
-const total = ref(0)
-const loading = ref(false)
-async function getData() {
-    try {
-        loading.value = true
-        const res = await http.post(props.url, {
-            page: {
-                page: page.value,
-                limit: pageSize.value
-            },
-            search: {
-                where: {
-                    status: 'PENDING'
-                },
-                order: {
-                    createdAt: 'DESC'
-                }
-            }
-        })
-        loading.value = false
-        tableData.value = res.items
-        total.value = res.meta.totalItems
-    } catch (e) {
-        loading.value = false
-        ElMessage.error(e.message)
-    }
-}
-onMounted(() => {
-    getData()
-})
-watch([page, pageSize], () => {
-    getData()
-})
-</script>

+ 115 - 0
src/components/PagingTable.vue

@@ -0,0 +1,115 @@
+<template>
+    <div class="filter" ref="filterEl">
+        <slot name="filter"></slot>
+    </div>
+    <ElConfigProvider :size="isMobile ? '' : 'small'">
+        <ElTable :data="tableData" :height="tableHeight" stripe v-loading="loading">
+            <slot></slot>
+        </ElTable>
+    </ElConfigProvider>
+    <div class="mt-4 flex justify-center">
+        <ElPagination
+            ref="paginEl"
+            :layout="isMobile ? 'total, prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
+            v-model:page-size="pageConfig.pageSize"
+            v-model:current-page="page"
+            :total="total"
+            :small="!isMobile"
+        />
+    </div>
+</template>
+<script setup>
+import { ref, onMounted, computed, watch, inject } from 'vue'
+import { http } from '@/plugins/http'
+import { ElMessage } from 'element-plus'
+import { useStorage, useElementBounding, useWindowSize } from '@vueuse/core'
+
+const props = defineProps({
+    url: {
+        type: String,
+        required: true
+    },
+    where: {
+        type: Object,
+        default: () => ({})
+    },
+    order: {
+        type: Object,
+        default: () => ({ createdAt: 'DESC' })
+    }
+})
+const search = computed(() => {
+    const where = { ...(props.where || {}) }
+    Object.keys(where).forEach((key) => {
+        if (where[key] === null) {
+            delete where[key]
+        }
+    })
+    return {
+        where: props.where,
+        order: props.order
+    }
+})
+
+const filterEl = ref(null)
+const paginEl = ref(null)
+const { height: filterHeight } = useElementBounding(filterEl)
+const { height: paginHeight } = useElementBounding(paginEl)
+const { height: windowHeight } = useWindowSize()
+const tableHeight = computed(() => windowHeight.value - 120 - filterHeight.value - paginHeight.value)
+const isMobile = inject('isMobile')
+
+const tableData = ref([])
+const page = ref(1)
+const pageConfig = useStorage('pageConfig', {
+    pageSize: 20
+})
+
+const total = ref(0)
+const loading = ref(false)
+async function getData() {
+    try {
+        loading.value = true
+        const res = await http.post(props.url, {
+            page: {
+                page: page.value,
+                limit: pageConfig.value.pageSize
+            },
+            search: search.value
+        })
+        loading.value = false
+        tableData.value = res.items
+        total.value = res.meta.totalItems
+    } catch (e) {
+        loading.value = false
+        ElMessage.error(e.message)
+    }
+}
+onMounted(() => {
+    getData()
+})
+watch(search, () => {
+    console.log('search changed')
+    if (page.value !== 1) {
+        page.value = 1
+    } else {
+        getData()
+    }
+})
+watch([page, pageConfig], () => {
+    console.log('page changed')
+    getData()
+})
+defineExpose({
+    refresh() {
+        getData()
+    }
+})
+</script>
+<style lang="less" scoped>
+.filter {
+    :deep(> *) {
+        margin-bottom: 10px;
+    }
+}
+</style>

+ 19 - 0
src/components/SideMenu.vue

@@ -0,0 +1,19 @@
+<template>
+    <ElMenu class="side-menu !border-0 bg-slate-100 dark:bg-zinc-800" :default-active="defaultActive" :router="true" unique-opened>
+        <SubMenus :menus="menus" />
+    </ElMenu>
+</template>
+<script setup>
+import SubMenus from './SubMenus.vue'
+
+const props = defineProps({
+    defaultActive: {
+        type: String,
+        default: 'home'
+    },
+    menus: {
+        type: Array,
+        default: () => []
+    }
+})
+</script>

+ 30 - 0
src/components/SubMenus.vue

@@ -0,0 +1,30 @@
+<template>
+    <template v-for="item in menus" :key="item.name">
+        <ElSubMenu v-if="item.children" :index="item.name" class="bg-slate-100 dark:bg-zinc-800">
+            <template #title>
+                <div class="w-5 h-5 mr-2" v-if="item.icon">
+                    <component :is="item.icon"></component>
+                </div>
+                {{ item.title }}
+            </template>
+            <SubMenus :menus="item.children"></SubMenus>
+        </ElSubMenu>
+        <ElMenuItem v-else :index="item.name" class="bg-slate-100 dark:bg-zinc-800">
+            <template #title>
+                <div class="w-5 h-5 mr-2" v-if="item.icon">
+                    <component :is="item.icon"></component>
+                </div>
+                {{ item.title }}
+            </template>
+        </ElMenuItem>
+    </template>
+</template>
+<script setup>
+import { computed } from 'vue'
+const props = defineProps({
+    menus: {
+        type: Array,
+        default: () => []
+    }
+})
+</script>

+ 11 - 0
src/components/UserAvatar.vue

@@ -0,0 +1,11 @@
+<template>
+    <img v-if="user.avatar" />
+    <User v-else class="w-8 h-8 p-1.5 rounded-3xl bg-slate-400 dark:bg-gray-600 text-white" />
+</template>
+<script setup>
+import { storeToRefs } from 'pinia'
+import { useUserStore } from '@/stores/user'
+import { User } from '@vicons/tabler'
+
+const { user } = storeToRefs(useUserStore())
+</script>

+ 26 - 0
src/plugins/http.js

@@ -75,6 +75,32 @@ const http = {
                     reject(e)
                 })
         })
+    },
+    put(url, body, options) {
+        options = options || {}
+        body = body || {}
+        return new Promise((resolve, reject) => {
+            axiosInstance
+                .put(url, body, { ...options, withCredentials: true })
+                .then((res) => {
+                    resolve(res.data)
+                })
+                .catch((e) => {
+                    reject(e)
+                })
+        })
+    },
+    delete(url, params) {
+        return new Promise((resolve, reject) => {
+            axiosInstance
+                .delete(url, { params, withCredentials: true })
+                .then((res) => {
+                    resolve(res.data)
+                })
+                .catch((e) => {
+                    reject(e)
+                })
+        })
     }
 }
 

+ 32 - 2
src/router/index.js

@@ -17,12 +17,42 @@ const router = createRouter({
                 {
                     path: 'home',
                     name: 'home',
-                    component: () => import('../views/HomeView.vue')
+                    component: () => import('../views/HomeView.vue'),
+                    meta: {
+                        title: '主页'
+                    }
+                },
+                {
+                    path: '/user',
+                    name: 'user',
+                    component: () => import('../views/UserView.vue'),
+                    meta: {
+                        title: '用户列表'
+                    }
                 },
                 {
                     path: 'withdraw',
                     name: 'withdraw',
-                    component: () => import('../views/WithdrawView.vue')
+                    component: () => import('../views/WithdrawView.vue'),
+                    meta: {
+                        title: '提现'
+                    }
+                },
+                {
+                    path: 'memberPlan',
+                    name: 'memberPlan',
+                    component: () => import('../views/MemberPlanView.vue'),
+                    meta: {
+                        title: '会员计划'
+                    }
+                },
+                {
+                    path: '404',
+                    name: '404',
+                    component: () => import('../views/NotFoundView.vue'),
+                    meta: {
+                        title: '页面不存在'
+                    }
                 }
             ]
         }

+ 5 - 0
src/styles/main.less

@@ -7,3 +7,8 @@ body,
     @apply h-full;
     @apply transition-all;
 }
+
+@font-face {
+    font-family: 'sh';
+    src: url(https://cdn.raex.vip/font/2023-03-24-10-09-25HtghnVXP.ttf);
+}

+ 1 - 1
src/views/LoginView.vue

@@ -1,5 +1,5 @@
 <template>
-    <ElContainer class="h-full">
+    <ElContainer class="h-full" @keyup.enter="login">
         <ElMain class="!flex items-center justify-center">
             <ElCard class="w-5/6 max-w-lg">
                 <ElForm :model="model" :rules="rules" label-position="top" ref="form">

+ 108 - 31
src/views/MainView.vue

@@ -1,56 +1,133 @@
 <template>
     <ElContainer class="h-full">
-        <ElAside class="bg-neutral-50 dark:bg-neutral-900">
-            <ElMenu class="!border-0" default-active="home" :router="true">
-                <ElMenuItem index="home">
-                    <template #title>
-                        <div class="w-5 h-5 mr-2"><Home /></div>
-                        主页
-                    </template>
-                </ElMenuItem>
-                <ElMenuItem index="withdraw" :route="{ name: 'withdraw' }">
-                    <template #title>
-                        <div class="w-5 h-5 mr-2"><Wallet /></div>
-                        提现申请
-                    </template>
-                </ElMenuItem>
-            </ElMenu>
+        <ElAside class="bg-slate-100 dark:bg-zinc-800" v-if="!isMobile">
+            <div class="h-16 px-4 flex items-center justify-center cursor-pointer">
+                <LogoSvg class="fill-black dark:fill-white" /><span class="pl-2 font-[sh]">CHILLGPT</span>
+            </div>
+            <SideMenu :default-active="activeMenu" :menus="menus" />
         </ElAside>
         <ElContainer>
-            <ElHeader class="border-b border-neutral-200 dark:border-neutral-800 flex items-center">
+            <ElHeader class="!h-16 border-b !box-border border-neutral-200 dark:border-neutral-800 flex items-center">
+                <div class="p-4 mr-4 cursor-pointer" @click="toggleMenu" v-if="isMobile">
+                    <Menu2 class="w-5 h-5" />
+                </div>
+
                 <el-breadcrumb separator="/">
-                    <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
-                    <el-breadcrumb-item>提现申请</el-breadcrumb-item>
+                    <el-breadcrumb-item :to="{ path: '/' }">主页</el-breadcrumb-item>
+                    <el-breadcrumb-item v-if="route.name !== 'home'">{{
+                        route.meta?.title || route.path
+                    }}</el-breadcrumb-item>
                 </el-breadcrumb>
                 <div class="grow"></div>
-                <DarkSwitch />
+                <DarkSwitch class="mr-4" />
+                <el-dropdown @command="onCommand">
+                    <UserAvatar />
+                    <template #dropdown>
+                        <el-dropdown-menu>
+                            <el-dropdown-item command="changePassword">修改密码</el-dropdown-item>
+                            <el-dropdown-item command="logout">退出登录</el-dropdown-item>
+                        </el-dropdown-menu>
+                    </template>
+                </el-dropdown>
             </ElHeader>
             <ElMain class="bg-neutral-50 dark:bg-neutral-900">
                 <RouterView></RouterView>
             </ElMain>
         </ElContainer>
+
+        <ElDrawer
+            v-model="showDrawer"
+            direction="ltr"
+            size="80%"
+            v-if="isMobile"
+            class="!bg-slate-100 dark:!bg-zinc-800"
+        >
+            <SideMenu :default-active="activeMenu" :menus="menus" />
+        </ElDrawer>
+
+        <ChangePwd v-model="showChangePwdDialog"> </ChangePwd>
     </ElContainer>
 </template>
 <script setup>
 import { ElAside, ElContainer, ElHeader, ElMain } from 'element-plus'
-import { Wallet, Home } from '@vicons/tabler'
 import DarkSwitch from '@/components/DarkSwitch.vue'
+import SideMenu from '@/components/SideMenu.vue'
+import { useRoute } from 'vue-router'
+import { ref, watch, shallowRef, inject } from 'vue'
+import LogoSvg from '@/components/LogoSvg.vue'
+import { User, MoodSmile, Wallet, Home, ExternalLink, Menu2 } from '@vicons/tabler'
+import UserAvatar from '@/components/UserAvatar.vue'
+import ChangePwd from '@/components/ChangePwd.vue'
+import { http } from '@/plugins/http'
 
-const menus = [
+const route = useRoute()
+const activeMenu = ref(route.name || 'home')
+const isMobile = inject('isMobile')
+const showDrawer = ref(false)
+const menus = ref([
+    {
+        name: 'home',
+        title: '主页',
+        icon: shallowRef(Home)
+    },
     {
-        name: 'Dashboard',
-        icon: 'el-icon-s-home',
-        path: '/home'
+        name: 'user-parent',
+        title: '用户管理',
+        icon: shallowRef(User),
+        children: [
+            {
+                name: 'user',
+                title: '用户列表'
+            }
+        ]
     },
     {
-        name: 'Users',
-        icon: 'el-icon-user',
-        path: '/users'
+        name: 'membership-parent',
+        title: '会员管理',
+        icon: shallowRef(MoodSmile),
+        children: [
+            {
+                name: 'membership',
+                title: '会员列表'
+            },
+            {
+                name: 'memberOrder',
+                title: '会员订单'
+            },
+            {
+                name: 'memberPlan',
+                title: '会员计划'
+            }
+        ]
     },
     {
-        name: 'Settings',
-        icon: 'el-icon-setting',
-        path: '/settings'
+        name: 'money',
+        title: '提现申请',
+        icon: shallowRef(Wallet),
+        children: [
+            {
+                name: 'withdraw',
+                title: '提现申请'
+            }
+        ]
+    }
+])
+function toggleMenu() {
+    showDrawer.value = !showDrawer.value
+}
+watch(route, () => {
+    activeMenu.value = route.name
+})
+const showChangePwdDialog = ref(false)
+function onCommand(cmd) {
+    if (cmd === 'logout') {
+        logout()
+    } else if (cmd === 'changePassword') {
+        showChangePwdDialog.value = true
     }
-]
+}
+function logout() {
+    http.setToken(null)
+    location.reload()
+}
 </script>

+ 104 - 0
src/views/MemberPlanView.vue

@@ -0,0 +1,104 @@
+<template>
+    <div class="flex items-center justify-center flex-wrap space-x-4">
+        <ElCard v-for="item in plans" :key="item.id" class="mb-4 !rounded-lg" :body-style="{ padding: 0 }">
+            <div class="w-40 h-60 flex flex-col items-center justify-center">
+                <div class="text-xl text-gray-500">{{ item.name }}</div>
+                <div class="text-2xl mt-8">¥{{ item.price }}</div>
+                <div class="text-sm mt-8 text-gray-500">有效期:{{ item.duration }}天</div>
+                <div class="mt-8">
+                    <ElButton type="primary" link @click="editPlan(item)">编辑</ElButton>
+                    <ElButton type="danger" link @click="deletePlan(item)">删除</ElButton>
+                </div>
+            </div>
+        </ElCard>
+    </div>
+    <div class="text-center mt-8">
+        <ElButton size="large" @click="editPlan()">添加</ElButton>
+    </div>
+    <ElDialog v-model="showEditDialog" title="编辑">
+        <ElForm :model="model" :rules="rules" label-position="top" ref="form">
+            <ElFormItem prop="name" label="名称">
+                <ElInput v-model="model.name" />
+            </ElFormItem>
+            <ElFormItem prop="price" label="价格">
+                <ElInput v-model="model.price" />
+            </ElFormItem>
+            <ElFormItem prop="duration" label="有效期">
+                <ElInput v-model="model.duration" />
+            </ElFormItem>
+            <ElFormItem prop="tokenLimit" label="Token限制">
+                <ElInput v-model="model.tokenLimit" />
+            </ElFormItem>
+        </ElForm>
+        <template #footer>
+            <ElButton :disabled="loading" @click="showEditDialog = false">取消</ElButton>
+            <ElButton :loading="loading" @click="savePlan" type="primary">保存</ElButton>
+        </template>
+    </ElDialog>
+</template>
+<script setup>
+import { http } from '@/plugins/http'
+import { onMounted, ref } from 'vue'
+import { Edit } from '@vicons/tabler'
+import { ElMessage, ElMessageBox } from 'element-plus'
+const plans = ref([])
+async function getPlans() {
+    const res = await http.get('/membership/plans')
+    plans.value = res
+}
+onMounted(() => {
+    getPlans()
+})
+
+const showEditDialog = ref(false)
+const form = ref(null)
+const model = ref({})
+const rules = {
+    name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
+    price: [{ required: true, message: '请输入价格', trigger: 'blur' }],
+    duration: [{ required: true, message: '请输入有效期', trigger: 'blur' }],
+    tokenLimit: [{ required: true, message: '请输入Token限制', trigger: 'blur' }]
+}
+const loading = ref(false)
+async function editPlan(plan) {
+    if (form.value) form.value.clearValidate()
+    if (plan) {
+        model.value = { ...plan }
+    } else {
+        model.value = {
+            name: '',
+            price: '',
+            duration: '',
+            tokenLimit: ''
+        }
+    }
+    showEditDialog.value = true
+}
+async function savePlan() {
+    form.value.validate().then(async () => {
+        try {
+            loading.value = true
+            await http.put('/admin/membership/plans', model.value)
+            ElMessage.success('保存成功')
+            showEditDialog.value = false
+            getPlans()
+        } catch (error) {
+            ElMessage.error(error.message)
+        } finally {
+            loading.value = false
+        }
+    })
+}
+async function deletePlan(plan) {
+    try {
+        await ElMessageBox.confirm('此操作将永久删除该计划, 是否继续?', '提示', {
+            type: 'warning'
+        })
+        await http.delete(`/admin/membership/plans/${plan.id}`)
+        ElMessage.success('删除成功')
+        getPlans()
+    } catch (error) {
+        if ('cancel' !== error) ElMessage.error(error.message)
+    }
+}
+</script>

+ 4 - 0
src/views/NotFoundView.vue

@@ -0,0 +1,4 @@
+<template>
+    <div></div>
+</template>
+<script setup></script>

+ 14 - 0
src/views/UserView.vue

@@ -0,0 +1,14 @@
+<template>
+    <PagingTable url="/admin/users" :where="{ roles: 'user' }">
+        <ElTableColumn prop="id" label="#" width="80" />
+        <ElTableColumn prop="username" label="用户名" />
+        <ElTableColumn prop="name" label="昵称" />
+        <ElTableColumn prop="phone" label="手机" />
+        <ElTableColumn prop="createdAt" label="注册时间" :formatter="timeFormatter" width="150" />
+    </PagingTable>
+</template>
+<script setup>
+import PagingTable from '@/components/PagingTable.vue'
+import { useTimeFormatter } from '@/utils/formatter'
+const timeFormatter = useTimeFormatter()
+</script>

+ 59 - 16
src/views/WithdrawView.vue

@@ -1,27 +1,70 @@
 <template>
-    <ListView>
+    <PagingTable url="/admin/withdraw" ref="table" :where="where">
         <template #filter>
-            <EnumSelect :enum="WithdrawStatus" v-model="status" placeholder="筛选状态" />
+            <EnumSelect :enum="WithdrawStatus" v-model="where.status" placeholder="筛选状态" />
         </template>
-        <PageTable url="/admin/withdraw">
-            <ElTableColumn prop="id" label="ID" />
-            <ElTableColumn prop="userId" label="用户ID" />
-            <ElTableColumn prop="amount" label="提现金额" />
-            <ElTableColumn prop="name" label="姓名" />
-            <ElTableColumn prop="account" label="提现账号" />
-            <ElTableColumn prop="status" label="状态" :formatter="statusFormatter" />
-            <ElTableColumn prop="createdAt" label="创建时间" width="130" :formatter="timeFormatter" />
-        </PageTable>
-    </ListView>
+        <ElTableColumn prop="id" label="ID" />
+        <ElTableColumn prop="userId" label="用户ID" />
+        <ElTableColumn prop="amount" label="提现金额" />
+        <ElTableColumn prop="name" label="姓名" />
+        <ElTableColumn prop="account" label="提现账号" />
+        <ElTableColumn prop="status" label="状态" :formatter="statusFormatter" />
+        <ElTableColumn prop="createdAt" label="创建时间" width="150" :formatter="timeFormatter" />
+        <ElTableColumn label="操作" width="180" align="center">
+            <template #default="{ row }">
+                <ElButton
+                    v-if="row.status === 'PENDING'"
+                    @click="finish(row, true)"
+                    type="success"
+                    plain
+                    :disabled="processing"
+                >
+                    完成
+                </ElButton>
+                <ElButton
+                    v-if="row.status === 'PENDING'"
+                    @click="finish(row, false)"
+                    type="danger"
+                    plain
+                    :disabled="processing"
+                >
+                    拒绝
+                </ElButton>
+            </template>
+        </ElTableColumn>
+    </PagingTable>
 </template>
 <script setup>
-import { ref } from 'vue'
-import ListView from '@/components/ListView.vue'
-import PageTable from '@/components/PageTable.vue'
+import { computed, ref } from 'vue'
+import PagingTable from '@/components/PagingTable.vue'
 import { useTimeFormatter, useEnumFormatter } from '@/utils/formatter'
 import { WithdrawStatus } from '@/enums'
 import EnumSelect from '@/components/EnumSelect.vue'
+import { ElMessageBox, ElMessage } from 'element-plus'
+import { http } from '@/plugins/http'
+
+const table = ref(null)
 const timeFormatter = useTimeFormatter()
 const statusFormatter = useEnumFormatter(WithdrawStatus)
-const status = ref(null)
+const where = ref({
+    status: 'PENDING'
+})
+
+const processing = ref(false)
+async function finish(row, success) {
+    console.log(event)
+    try {
+        processing.value = true
+        await ElMessageBox.confirm(`确认${success ? '完成' : '拒绝'}提现申请?`, '确认', {
+            type: 'warning'
+        })
+        await http.post(`/admin/withdraw/${success ? 'finish' : 'reject'}`, { id: row.id })
+        processing.value = false
+        ElMessage.success('操作成功')
+        table.value.refresh()
+    } catch (error) {
+        processing.value = false
+        if ('cancel' !== error) ElMessage.error(error.message)
+    }
+}
 </script>