ソースを参照

# feat:管理后台适配手机端;完成网站基础信息管理

yangyi 6 日 前
コミット
c227900685

+ 13 - 2
src/api/meta-controller.ts

@@ -5,18 +5,29 @@
  * Serve应用接口文档
  * OpenAPI spec version: 0.0.1-SNAPSHOT
  */
-import type { Response } from "./models";
+import type { Response, WebsiteMetaDto } from "./models";
 import { customAxiosInstance } from "../util/axios-instance";
 
 export const getMetaController = () => {
+  const updateWebsiteMeta = (websiteMetaDto: WebsiteMetaDto) => {
+    return customAxiosInstance<Response>({
+      url: `/meta/updateWebsiteMeta`,
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      data: websiteMetaDto,
+    });
+  };
   const getWebsiteMeta = () => {
     return customAxiosInstance<Response>({
       url: `/meta/getWebsiteMeta`,
       method: "GET",
     });
   };
-  return { getWebsiteMeta };
+  return { updateWebsiteMeta, getWebsiteMeta };
 };
+export type UpdateWebsiteMetaResult = NonNullable<
+  Awaited<ReturnType<ReturnType<typeof getMetaController>["updateWebsiteMeta"]>>
+>;
 export type GetWebsiteMetaResult = NonNullable<
   Awaited<ReturnType<ReturnType<typeof getMetaController>["getWebsiteMeta"]>>
 >;

+ 1 - 0
src/api/models/index.ts

@@ -24,3 +24,4 @@ export * from "./updateUserPasswordDto";
 export * from "./updateUserStatusDto";
 export * from "./userDto";
 export * from "./userVo";
+export * from "./websiteMetaDto";

+ 37 - 0
src/api/models/websiteMetaDto.ts

@@ -0,0 +1,37 @@
+/**
+ * Generated by orval v7.0.1 🍺
+ * Do not edit manually.
+ * Serve API
+ * Serve应用接口文档
+ * OpenAPI spec version: 0.0.1-SNAPSHOT
+ */
+
+/**
+ * 网站元数据DTO,用于更新网站配置信息
+ */
+export interface WebsiteMetaDto {
+  /**
+   * 网站公告
+   * @minLength 0
+   * @maxLength 500
+   */
+  announcement?: string;
+  /**
+   * 网站logo URL
+   * @minLength 0
+   * @maxLength 500
+   */
+  logo?: string;
+  /**
+   * 网站声明
+   * @minLength 0
+   * @maxLength 500
+   */
+  statement?: string;
+  /**
+   * 网站标题
+   * @minLength 0
+   * @maxLength 200
+   */
+  title?: string;
+}

+ 88 - 10
src/layout/AdminLayout.vue

@@ -8,15 +8,17 @@ const { websiteMeta,  fetchMeta } = useMetaStore()
 const loginUserStore = useLoginUserStore()
 const login = computed(()=>loginUserStore.loginUser.isLogin)
 const loginHandler = () => {
-
+  router.push('/login')
 }
 const logoutHandler = () => {
-
+  loginUserStore.logoutUser()
+  router.push('/')
 }
 const menus:ComputedRef<RouteRecordRaw[] | undefined> = computed(()=>{
   return adminRoutes[0].children;
 })
 const isCollapse = ref(false)
+const mobileMenuVisible = ref(false)
 
 onMounted(() => {
   fetchMeta()
@@ -47,11 +49,14 @@ const handleClose = (key: string, keyPath: string[]) => {
 <template>
   <el-container>
     <el-header class="header">
-      <div class="header-left" @click="router.push('/')">
-        <img :src="websiteMeta.logo" alt="logo" class="logo">
-        <h2 class="title">{{ websiteMeta.title }}</h2>
+      <div class="header-left">
+        <el-button class="menu-toggle" @click="mobileMenuVisible = true">☰</el-button>
+        <div class="brand" @click="router.push('/')">
+          <img :src="websiteMeta.logo" alt="logo" class="logo">
+          <h2 class="title">{{ websiteMeta.title }}</h2>
+        </div>
       </div>
-      <h1>后台管理</h1>
+      <h1 class="page-title">后台管理</h1>
       <div class="header-right">
         <template v-if="login">
           <el-dropdown placement="bottom">
@@ -84,12 +89,49 @@ const handleClose = (key: string, keyPath: string[]) => {
             @open="handleOpen"
             @close="handleClose"
         >
-          <el-menu-item :index="menu.path" v-for="menu in menus" :key="menu.path">
-            <el-icon><Component :is="menu.meta?.icon"/></el-icon>
-            <template #title>{{menu.meta?.title}}</template>
-          </el-menu-item>
+          <template v-for="menu in menus" :key="menu.path">
+            <el-sub-menu v-if="menu.children?.length" :index="menu.path">
+              <template #title>
+                <el-icon><Component :is="menu.meta?.icon"/></el-icon>
+                <span>{{menu.meta?.title}}</span>
+              </template>
+              <el-menu-item v-for="child in menu.children" :key="child.path" :index="child.path">
+                <template #title>{{child.meta?.title}}</template>
+              </el-menu-item>
+            </el-sub-menu>
+            <el-menu-item v-else :index="menu.path">
+              <el-icon><Component :is="menu.meta?.icon"/></el-icon>
+              <template #title>{{menu.meta?.title}}</template>
+            </el-menu-item>
+          </template>
         </el-menu>
       </el-aside>
+
+      <el-drawer
+          v-model="mobileMenuVisible"
+          direction="ltr"
+          size="auto"
+          :with-header="false"
+      >
+        <el-menu router @select="mobileMenuVisible = false">
+          <template v-for="menu in menus" :key="menu.path">
+            <el-sub-menu v-if="menu.children?.length" :index="menu.path">
+              <template #title>
+                <el-icon><Component :is="menu.meta?.icon"/></el-icon>
+                <span>{{menu.meta?.title}}</span>
+              </template>
+              <el-menu-item v-for="child in menu.children" :key="child.path" :index="child.path">
+                <template #title>{{child.meta?.title}}</template>
+              </el-menu-item>
+            </el-sub-menu>
+            <el-menu-item v-else :index="menu.path">
+              <el-icon><Component :is="menu.meta?.icon"/></el-icon>
+              <template #title>{{menu.meta?.title}}</template>
+            </el-menu-item>
+          </template>
+        </el-menu>
+      </el-drawer>
+
       <el-main>
         <router-view/>
       </el-main>
@@ -114,6 +156,12 @@ const handleClose = (key: string, keyPath: string[]) => {
   align-items: center;
   gap: 10px;
 }
+.brand {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  cursor: pointer;
+}
 .logo {
   width: 40px;
   height: 40px;
@@ -139,4 +187,34 @@ const handleClose = (key: string, keyPath: string[]) => {
   width: 200px;
   min-height: 400px;
 }
+
+.menu-toggle {
+  display: none;
+}
+
+.page-title {
+  font-size: 20px;
+}
+
+@media (max-width: 768px) {
+  .menu {
+    display: none;
+  }
+  .menu-toggle {
+    display: inline-flex;
+  }
+  .header {
+    padding: 0 10px;
+  }
+  .header-left .title {
+    font-size: 16px;
+  }
+  .logo {
+    width: 32px;
+    height: 32px;
+  }
+  .page-title {
+    font-size: 16px;
+  }
+}
 </style>

+ 8 - 0
src/router/index.ts

@@ -12,12 +12,14 @@ import LoginView from '../view/LoginView.vue'
 import UserLayout from '../layout/UserLayout.vue'
 import AdminLayout from "../layout/AdminLayout.vue"
 import UserView from "../view/UserView.vue"
+import SiteSettingsView from "../view/SiteSettingsView.vue"
 import {
   HomeFilled,
   Menu,
   User,
   ShoppingCart,
   Bell,
+  Setting,
 } from '@element-plus/icons-vue'
 import { useLoginUserStore } from "../store"
 import { ElMessage } from "element-plus"
@@ -97,6 +99,12 @@ export const adminRoutes: RouteRecordRaw[] = [
         name: 'other',
         component: AdminLayout,
         meta: { title: '其他', icon: Menu, requiresAdmin: true }
+      },
+      {
+        path: '/admin/settings',
+        name: 'settings',
+        component: SiteSettingsView,
+        meta: { title: '网站设置', icon: Setting, requiresAdmin: true }
       }
     ],
     meta: { requiresAdmin: true }

+ 129 - 0
src/view/SiteSettingsView.vue

@@ -0,0 +1,129 @@
+<script setup lang="ts">
+import { onMounted, reactive, ref } from 'vue'
+import { useMetaStore } from '../store'
+import { getMetaController } from '../api/meta-controller'
+import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
+
+const activeTab = ref('basic')
+const formRef = ref<FormInstance>()
+
+const { websiteMeta } = useMetaStore()
+
+const form = reactive({
+  title: '',
+  logo: '',
+  statement: '',
+  announcement: '',
+})
+
+const rules: FormRules = {
+  title: [
+    { max: 200, message: '网站标题不能超过 200 个字符', trigger: 'blur' },
+  ],
+  logo: [
+    { max: 500, message: 'Logo URL 不能超过 500 个字符', trigger: 'blur' },
+    {
+      pattern: /^(https?):\/\/[^\s$.?#].[^\s]*$/,
+      message: '请输入正确的 URL 格式',
+      trigger: 'blur',
+    },
+  ],
+  statement: [
+    { max: 500, message: '网站声明不能超过 500 个字符', trigger: 'blur' },
+  ],
+  announcement: [
+    { max: 500, message: '网站公告不能超过 500 个字符', trigger: 'blur' },
+  ],
+}
+
+onMounted(() => {
+  form.title = websiteMeta.title
+  form.logo = websiteMeta.logo
+  form.statement = websiteMeta.statement
+  form.announcement = websiteMeta.announcement
+})
+
+const handleUpload = () => {
+  ElMessage.info('图片上传功能待实现')
+}
+
+const handleSubmit = async () => {
+  const valid = await formRef.value?.validate().catch(() => false)
+  if (!valid) return
+  try {
+    await getMetaController().updateWebsiteMeta({
+      title: form.title,
+      logo: form.logo,
+      announcement: form.announcement,
+      statement: form.statement,
+    })
+    websiteMeta.title = form.title
+    websiteMeta.logo = form.logo
+    websiteMeta.statement = form.statement
+    websiteMeta.announcement = form.announcement
+    ElMessage.success('保存成功')
+  } catch {
+    ElMessage.error('保存失败')
+  }
+}
+</script>
+
+<template>
+  <div class="site-settings">
+<!--    <h2>网站设置</h2>-->
+    <el-tabs v-model="activeTab" class="tabs">
+      <el-tab-pane label="网站基础信息" name="basic">
+        <el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="form">
+          <el-form-item label="网站标题" prop="title">
+            <el-input v-model="form.title" placeholder="请输入网站标题" maxlength="200" show-word-limit />
+          </el-form-item>
+          <el-form-item label="网站Logo" prop="logo">
+            <el-input v-model="form.logo" placeholder="Logo URL" maxlength="500">
+              <template #append>
+                <el-button @click="handleUpload">上传</el-button>
+              </template>
+            </el-input>
+            <div v-if="form.logo" class="logo-preview">
+              <img :src="form.logo" alt="logo preview">
+            </div>
+          </el-form-item>
+          <el-form-item label="网站声明" prop="statement">
+            <el-input v-model="form.statement" type="textarea" :rows="3" placeholder="请输入网站声明" maxlength="500" show-word-limit />
+          </el-form-item>
+          <el-form-item label="网站公告" prop="announcement">
+            <el-input v-model="form.announcement" type="textarea" :rows="3" placeholder="请输入网站公告" maxlength="500" show-word-limit />
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" @click="handleSubmit">保存</el-button>
+          </el-form-item>
+        </el-form>
+      </el-tab-pane>
+    </el-tabs>
+  </div>
+</template>
+
+<style scoped>
+.site-settings {
+  padding: 20px;
+}
+.site-settings h2 {
+  margin: 0 0 20px;
+  font-size: 18px;
+}
+.tabs {
+  max-width: 700px;
+}
+.form {
+  margin-top: 20px;
+  max-width: 600px;
+}
+.logo-preview {
+  margin-top: 8px;
+}
+.logo-preview img {
+  max-width: 200px;
+  max-height: 80px;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+}
+</style>

+ 9 - 3
src/view/UserView.vue

@@ -247,19 +247,25 @@ const handleBatchDelete = () => {
 
 // 切换启用状态
 const handleToggleEnable = async (row: UserVo, newEnable: number) => {
+  const label = newEnable === 1 ? '启用' : '禁用'
+  try {
+    await ElMessageBox.confirm(`确认${label}用户「${row.username}」吗?`, '提示', { type: 'warning' })
+  } catch {
+    return
+  }
   const oldEnable = row.enable
   row.enable = newEnable
   try {
     const res = await api.updateUserStatus({ id: row.id!, enable: newEnable })
     if (res?.code === 0 || res?.code === 200) {
-      ElMessage.success('状态更新成功')
+      ElMessage.success(`${label}成功`)
     } else {
       row.enable = oldEnable
-      ElMessage.error(res?.message || '状态更新失败')
+      ElMessage.error(res?.message || `${label}失败`)
     }
   } catch {
     row.enable = oldEnable
-    ElMessage.error('状态更新失败')
+    ElMessage.error(`${label}失败`)
   }
 }