Browse Source

Initial commit

yumeng 4 days ago
commit
ea2497f5c6

+ 3 - 0
.env.dev

@@ -0,0 +1,3 @@
+VITE_APP_ENV=dev
+VITE_API_BASE_URL=/api
+VITE_API_PROXY_TARGET=http://localhost:9028

+ 3 - 0
.env.prod

@@ -0,0 +1,3 @@
+VITE_APP_ENV=prod
+VITE_API_BASE_URL=/api
+VITE_API_PROXY_TARGET=http://localhost:9028

+ 3 - 0
.env.test

@@ -0,0 +1,3 @@
+VITE_APP_ENV=test
+VITE_API_BASE_URL=/api
+VITE_API_PROXY_TARGET=http://localhost:9028

+ 11 - 0
.gitignore

@@ -0,0 +1,11 @@
+node_modules/
+dist/
+.idea/
+
+.DS_Store
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+*.local

+ 12 - 0
index.html

@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Nuojing Data Admin</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

File diff suppressed because it is too large
+ 1747 - 0
package-lock.json


+ 26 - 0
package.json

@@ -0,0 +1,26 @@
+{
+  "name": "nuojing-data-admin",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite --host 0.0.0.0 --mode dev",
+    "test": "vite --host 0.0.0.0 --mode test",
+    "prod": "vite --host 0.0.0.0 --mode prod",
+    "build": "vite build --mode prod",
+    "build:dev": "vite build --mode dev",
+    "build:test": "vite build --mode test",
+    "build:prod": "vite build --mode prod",
+    "preview": "vite preview --host 0.0.0.0"
+  },
+  "dependencies": {
+    "@arco-design/web-vue": "^2.58.0",
+    "@vitejs/plugin-vue": "^5.1.4",
+    "axios": "^1.7.7",
+    "lucide-vue-next": "^0.468.0",
+    "pinia": "^2.2.6",
+    "vite": "^5.4.11",
+    "vue": "^3.5.13",
+    "vue-router": "^4.4.5"
+  }
+}

+ 3 - 0
src/App.vue

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

+ 17 - 0
src/api/auth.js

@@ -0,0 +1,17 @@
+import request from './request'
+
+export function login(data) {
+  return request.post('/auth/login', data)
+}
+
+export function logout() {
+  return request.post('/auth/logout')
+}
+
+export function getMenus() {
+  return request.get('/auth/menus')
+}
+
+export function getProfile() {
+  return request.get('/auth/profile')
+}

+ 50 - 0
src/api/request.js

@@ -0,0 +1,50 @@
+import axios from 'axios'
+import router from '../router'
+import { useAuthStore } from '../stores/auth'
+
+const request = axios.create({
+  baseURL: import.meta.env.VITE_API_BASE_URL,
+  timeout: 10000
+})
+
+request.interceptors.request.use((config) => {
+  const token = localStorage.getItem('token')
+  if (token) {
+    config.headers.Authorization = `Bearer ${token}`
+  }
+  return config
+})
+
+request.interceptors.response.use(
+  (response) => {
+    const body = response.data
+    if (body?.code && body.code !== 200) {
+      if (body.code === 401) {
+        const auth = useAuthStore()
+        auth.clear()
+        router.replace('/login')
+      }
+      return Promise.reject(new Error(formatErrorMessage(body.message)))
+    }
+    return body?.data
+  },
+  (error) => Promise.reject(new Error(formatErrorMessage(error.response?.data?.message || error.message)))
+)
+
+function formatErrorMessage(message) {
+  if (!message) {
+    return '请求失败,请稍后重试'
+  }
+  if (message.includes("doesn't exist") || message.includes('Table')) {
+    return '数据库表不存在,请先初始化数据库'
+  }
+  if (message.includes('SQL') || message.includes('Mapper.xml') || message.includes('java.sql')) {
+    return '数据库访问异常,请联系管理员处理'
+  }
+  if (message.includes('Network Error')) {
+    return '无法连接后端服务,请确认服务已启动'
+  }
+  return message
+}
+
+export default request

+ 34 - 0
src/api/system.js

@@ -0,0 +1,34 @@
+import request from './request'
+
+export const roleApi = {
+  list: () => request.get('/roles'),
+  detail: (id) => request.get(`/roles/${id}`),
+  save: (data) => request.post('/roles', data),
+  remove: (id) => request.delete(`/roles/${id}`),
+  menuIds: (id) => request.get(`/roles/${id}/menus`)
+}
+
+export const deptApi = {
+  tree: () => request.get('/depts/tree'),
+  save: (data) => request.post('/depts', data),
+  remove: (id) => request.delete(`/depts/${id}`)
+}
+
+export const userApi = {
+  list: () => request.get('/users'),
+  detail: (id) => request.get(`/users/${id}`),
+  save: (data) => request.post('/users', data),
+  remove: (id) => request.delete(`/users/${id}`),
+  roleIds: (id) => request.get(`/users/${id}/roles`)
+}
+
+export const menuApi = {
+  tree: () => request.get('/menus/tree'),
+  save: (data) => request.post('/menus', data),
+  remove: (id) => request.delete(`/menus/${id}`)
+}
+
+export const mediaAuthApi = {
+  configs: () => request.get('/media/auth/configs'),
+  authUrl: (params) => request.get('/media/auth/url', { params })
+}

+ 43 - 0
src/components/ErrorDialog.vue

@@ -0,0 +1,43 @@
+<template>
+  <teleport to="body">
+    <div v-if="modelValue" class="dialog-mask" @click.self="close">
+      <section class="dialog-panel" role="alertdialog" aria-modal="true" aria-labelledby="error-dialog-title">
+        <div class="dialog-icon danger">
+          <CircleAlert />
+        </div>
+        <div class="dialog-body">
+          <h3 id="error-dialog-title">{{ title }}</h3>
+          <p>{{ message }}</p>
+        </div>
+        <button class="primary-button dialog-button" type="button" @click="close">
+          确定
+        </button>
+      </section>
+    </div>
+  </teleport>
+</template>
+
+<script setup>
+import { CircleAlert } from 'lucide-vue-next'
+
+defineProps({
+  modelValue: {
+    type: Boolean,
+    default: false
+  },
+  title: {
+    type: String,
+    default: '操作失败'
+  },
+  message: {
+    type: String,
+    default: '请求处理失败,请稍后重试'
+  }
+})
+
+const emit = defineEmits(['update:modelValue'])
+
+function close() {
+  emit('update:modelValue', false)
+}
+</script>

+ 44 - 0
src/components/FormDialog.vue

@@ -0,0 +1,44 @@
+<template>
+  <a-drawer
+    :visible="modelValue"
+    :width="width"
+    :title="title"
+    class="app-form-drawer"
+    unmount-on-close
+    @cancel="close"
+  >
+    <div class="drawer-form">
+      <slot />
+    </div>
+
+    <template #footer>
+      <div class="drawer-footer-actions">
+        <a-button type="primary" @click="$emit('submit')">保存</a-button>
+        <a-button @click="close">取消</a-button>
+      </div>
+    </template>
+  </a-drawer>
+</template>
+
+<script setup>
+defineProps({
+  modelValue: {
+    type: Boolean,
+    default: false
+  },
+  title: {
+    type: String,
+    required: true
+  },
+  width: {
+    type: Number,
+    default: 680
+  }
+})
+
+const emit = defineEmits(['update:modelValue', 'submit'])
+
+function close() {
+  emit('update:modelValue', false)
+}
+</script>

+ 137 - 0
src/layouts/AdminLayout.vue

@@ -0,0 +1,137 @@
+<template>
+  <div class="admin-shell">
+    <header class="global-header">
+      <div class="brand">
+        <span class="brand-mark">N</span>
+        <span>万象台</span>
+      </div>
+      <nav class="top-menu">
+        <router-link
+          v-for="item in visibleMenus"
+          :key="item.id"
+          class="top-menu-link"
+          :class="{ active: item.id === activeTop?.id }"
+          :to="firstLeafPath(item)"
+        >
+          {{ item.title }}
+        </router-link>
+      </nav>
+      <div class="header-tools">
+        <button class="user-button" @click="confirmLogout">
+          <LogOut class="button-icon" />
+        </button>
+        <span class="user-name">{{ auth.user?.nickname || '管理员' }}</span>
+      </div>
+    </header>
+    <aside class="sidebar">
+      <nav class="menu-list">
+        <template v-for="item in sideMenus" :key="item.id">
+          <div class="menu-group">
+            <button
+              v-if="visibleChildren(item).length"
+              type="button"
+              class="menu-title menu-toggle"
+              :class="{ active: isMenuActive(item), collapsed: !isExpanded(item) }"
+              @click="toggleMenu(item)"
+            >
+              <span>{{ item.title }}</span>
+              <ChevronUp class="collapse-icon" />
+            </button>
+            <router-link
+              v-else
+              class="menu-title"
+              :class="{ active: isMenuActive(item), leaf: true }"
+              :to="item.path"
+            >
+              <span>{{ item.title }}</span>
+            </router-link>
+            <template v-if="isExpanded(item)">
+              <router-link
+                v-for="child in visibleChildren(item)"
+                :key="child.id"
+                class="menu-link"
+                :to="child.path"
+              >
+                <span>{{ child.title }}</span>
+              </router-link>
+            </template>
+          </div>
+        </template>
+      </nav>
+    </aside>
+    <main class="main-panel">
+      <section class="content-panel">
+        <router-view />
+      </section>
+    </main>
+  </div>
+</template>
+
+<script setup>
+import { computed, ref, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { Modal } from '@arco-design/web-vue'
+import * as Icons from 'lucide-vue-next'
+import { useAuthStore } from '../stores/auth'
+
+const auth = useAuthStore()
+const route = useRoute()
+const router = useRouter()
+const { ChevronUp, LogOut } = Icons
+
+const visibleMenus = computed(() => (auth.menus || []).filter((item) => !item.hidden))
+const activeTop = computed(() => visibleMenus.value.find((item) => route.path === item.path || route.path.startsWith(`${item.path}/`)) || visibleMenus.value[0])
+const sideMenus = computed(() => visibleChildren(activeTop.value || {}))
+const expandedMenuIds = ref(new Set())
+
+watch(
+  () => [route.path, sideMenus.value.map((item) => item.id).join(',')],
+  () => {
+    const active = sideMenus.value.find((item) => isMenuActive(item) && visibleChildren(item).length)
+    if (active) {
+      expandedMenuIds.value = new Set([...expandedMenuIds.value, active.id])
+    }
+  },
+  { immediate: true }
+)
+
+function visibleChildren(item) {
+  return (item.children || []).filter((child) => !child.hidden && child.menuType !== 3)
+}
+
+function firstLeafPath(item) {
+  const children = visibleChildren(item)
+  if (!children.length) {
+    return item.path
+  }
+  return firstLeafPath(children[0])
+}
+
+function isMenuActive(item) {
+  return route.path === item.path || route.path.startsWith(`${item.path}/`)
+}
+
+function isExpanded(item) {
+  return expandedMenuIds.value.has(item.id)
+}
+
+function toggleMenu(item) {
+  const next = new Set(expandedMenuIds.value)
+  if (next.has(item.id)) {
+    next.delete(item.id)
+  } else {
+    next.add(item.id)
+  }
+  expandedMenuIds.value = next
+}
+
+function confirmLogout() {
+  Modal.confirm({
+    title: '确认退出登录吗?',
+    content: '退出后需要重新登录才能继续使用系统。',
+    okText: '确认退出',
+    cancelText: '取消',
+    onOk: () => auth.logout(router)
+  })
+}
+</script>

+ 3 - 0
src/layouts/ParentView.vue

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

+ 9 - 0
src/main.js

@@ -0,0 +1,9 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import ArcoVue from '@arco-design/web-vue'
+import '@arco-design/web-vue/dist/arco.css'
+import App from './App.vue'
+import router from './router'
+import './styles.css'
+
+createApp(App).use(createPinia()).use(router).use(ArcoVue).mount('#app')

+ 19 - 0
src/router/componentMap.js

@@ -0,0 +1,19 @@
+import Layout from '../layouts/AdminLayout.vue'
+import ParentView from '../layouts/ParentView.vue'
+import UserList from '../views/system/UserList.vue'
+import RoleList from '../views/system/RoleList.vue'
+import DeptList from '../views/system/DeptList.vue'
+import MenuList from '../views/system/MenuList.vue'
+import MediaAuthConfig from '../views/media/MediaAuthConfig.vue'
+import MediaAuthCallback from '../views/media/MediaAuthCallback.vue'
+
+export const componentMap = {
+  Layout,
+  ParentView,
+  'system/UserList': UserList,
+  'system/RoleList': RoleList,
+  'system/DeptList': DeptList,
+  'system/MenuList': MenuList,
+  'media/MediaAuthConfig': MediaAuthConfig,
+  'media/MediaAuthCallback': MediaAuthCallback
+}

+ 81 - 0
src/router/dynamicRoutes.js

@@ -0,0 +1,81 @@
+import { componentMap } from './componentMap'
+import NotFound from '../views/NotFound.vue'
+
+export const dynamicRouteNames = new Set()
+const notFoundRouteName = 'NotFound'
+
+export function buildRoutesFromMenus(menus = [], parentPath = '') {
+  return menus
+    .filter((menu) => menu.menuType !== 3)
+    .map((menu) => {
+      const component = componentMap[menu.component]
+      if (!component) {
+        console.warn(`未注册的后端组件标识: ${menu.component}`)
+      }
+      const route = {
+        path: menu.path,
+        name: menu.name,
+        component: component || componentMap.Layout,
+        meta: {
+          title: menu.title,
+          icon: menu.icon,
+          hidden: menu.hidden,
+          permission: menu.permission
+        }
+      }
+      const children = buildRoutesFromMenus(menu.children || [], menu.path)
+      if (children.length > 0) {
+        route.redirect = firstLeafPath(menu)
+        route.children = children.map((child) => ({
+          ...child,
+          path: stripParentPath(child.path, menu.path)
+        }))
+      }
+      if (route.name) {
+        dynamicRouteNames.add(route.name)
+      }
+      return route
+    })
+}
+
+export function firstLeafPath(menu) {
+  const children = (menu.children || []).filter((child) => !child.hidden && child.menuType !== 3)
+  if (children.length === 0) {
+    return menu.path
+  }
+  return firstLeafPath(children[0])
+}
+
+function stripParentPath(path, parentPath) {
+  if (!path.startsWith('/')) {
+    return path
+  }
+  const prefix = parentPath.endsWith('/') ? parentPath.slice(0, -1) : parentPath
+  if (prefix && path.startsWith(`${prefix}/`)) {
+    return path.slice(prefix.length + 1)
+  }
+  return path.slice(1)
+}
+
+export function resetDynamicRoutes(router) {
+  if (router.hasRoute(notFoundRouteName)) {
+    router.removeRoute(notFoundRouteName)
+  }
+  dynamicRouteNames.forEach((name) => {
+    if (router.hasRoute(name)) {
+      router.removeRoute(name)
+    }
+  })
+  dynamicRouteNames.clear()
+}
+
+export function appendNotFoundRoute(router) {
+  if (router.hasRoute(notFoundRouteName)) {
+    router.removeRoute(notFoundRouteName)
+  }
+  router.addRoute({
+    path: '/:pathMatch(.*)*',
+    name: notFoundRouteName,
+    component: NotFound
+  })
+}

+ 36 - 0
src/router/index.js

@@ -0,0 +1,36 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import LoginView from '../views/LoginView.vue'
+import { useAuthStore } from '../stores/auth'
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes: [
+    {
+      path: '/',
+      redirect: '/login'
+    },
+    {
+      path: '/login',
+      name: 'Login',
+      component: LoginView,
+      meta: { public: true }
+    }
+  ]
+})
+
+router.beforeEach(async (to) => {
+  const auth = useAuthStore()
+  if (to.meta.public) {
+    return true
+  }
+  if (!auth.token) {
+    return '/login'
+  }
+  if (!auth.routesReady) {
+    await auth.loadMenus(router)
+    return { ...to, replace: true }
+  }
+  return true
+})
+
+export default router

+ 63 - 0
src/stores/auth.js

@@ -0,0 +1,63 @@
+import { defineStore } from 'pinia'
+import { getMenus, getProfile, login as loginApi, logout as logoutApi } from '../api/auth'
+import { appendNotFoundRoute, buildRoutesFromMenus, firstLeafPath, resetDynamicRoutes } from '../router/dynamicRoutes'
+
+export const useAuthStore = defineStore('auth', {
+  state: () => ({
+    token: localStorage.getItem('token') || '',
+    user: JSON.parse(localStorage.getItem('user') || 'null'),
+    menus: JSON.parse(localStorage.getItem('menus') || '[]'),
+    routesReady: false
+  }),
+  actions: {
+    async login(payload, router) {
+      const data = await loginApi(payload)
+      this.token = data.token
+      this.user = data.user
+      this.menus = data.menus || []
+      localStorage.setItem('token', this.token)
+      localStorage.setItem('user', JSON.stringify(this.user))
+      localStorage.setItem('menus', JSON.stringify(this.menus))
+      await this.loadMenus(router, this.menus)
+      return this.homePath()
+    },
+    async loadMenus(router, cachedMenus) {
+      const menus = cachedMenus || await getMenus()
+      this.menus = menus || []
+      localStorage.setItem('menus', JSON.stringify(this.menus))
+      resetDynamicRoutes(router)
+      buildRoutesFromMenus(this.menus).forEach((route) => router.addRoute(route))
+      appendNotFoundRoute(router)
+      this.routesReady = true
+      if (!this.user) {
+        this.user = await getProfile()
+        localStorage.setItem('user', JSON.stringify(this.user))
+      }
+    },
+    homePath() {
+      const menus = (this.menus || []).filter((menu) => !menu.hidden && menu.menuType !== 3)
+      if (!menus.length) {
+        return '/login'
+      }
+      return firstLeafPath(menus[0])
+    },
+    async logout(router) {
+      try {
+        await logoutApi()
+      } finally {
+        this.clear()
+        resetDynamicRoutes(router)
+        router.replace('/login')
+      }
+    },
+    clear() {
+      this.token = ''
+      this.user = null
+      this.menus = []
+      this.routesReady = false
+      localStorage.removeItem('token')
+      localStorage.removeItem('user')
+      localStorage.removeItem('menus')
+    }
+  }
+})

+ 886 - 0
src/styles.css

@@ -0,0 +1,886 @@
+:root {
+  font-family: Inter, "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
+  color: #202938;
+  background: #f3f5f8;
+  font-synthesis: none;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  min-width: 320px;
+  min-height: 100vh;
+}
+
+button,
+input,
+select {
+  font: inherit;
+}
+
+button {
+  cursor: pointer;
+}
+
+a {
+  color: inherit;
+  text-decoration: none;
+}
+
+.admin-shell {
+  display: grid;
+  grid-template-columns: 208px minmax(0, 1fr);
+  grid-template-rows: 56px minmax(0, 1fr);
+  min-height: 100vh;
+}
+
+.global-header {
+  grid-column: 1 / -1;
+  height: 56px;
+  background: #001526;
+  color: #d7e3ef;
+  display: grid;
+  grid-template-columns: 170px minmax(0, 1fr) auto;
+  align-items: center;
+  gap: 18px;
+  padding: 0 16px 0 18px;
+  box-shadow: 0 1px 0 rgba(0, 0, 0, 0.16);
+  position: sticky;
+  top: 0;
+  z-index: 20;
+}
+
+.sidebar {
+  grid-column: 1;
+  grid-row: 2;
+  background: #ffffff;
+  color: #5f6f84;
+  display: flex;
+  flex-direction: column;
+  min-height: calc(100vh - 56px);
+  border-right: 1px solid #e8edf3;
+  box-shadow: 2px 0 8px rgba(31, 41, 55, 0.04);
+}
+
+.brand {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  height: 56px;
+  padding: 0;
+  font-weight: 700;
+  letter-spacing: 0;
+  color: #ffffff;
+  white-space: nowrap;
+}
+
+.brand-mark {
+  display: inline-grid;
+  place-items: center;
+  width: 26px;
+  height: 26px;
+  border-radius: 7px;
+  color: #ffffff;
+  background: #1f6fff;
+  flex: 0 0 auto;
+  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
+}
+
+.top-menu {
+  display: flex;
+  align-items: stretch;
+  gap: 22px;
+  height: 56px;
+  overflow-x: auto;
+}
+
+.top-menu-link {
+  display: inline-flex;
+  align-items: center;
+  height: 56px;
+  color: #a7b5c4;
+  font-size: 15px;
+  font-weight: 650;
+  white-space: nowrap;
+  border-bottom: 3px solid transparent;
+}
+
+.top-menu-link.active,
+.top-menu-link.router-link-active {
+  color: #ffffff;
+  border-bottom-color: #236bff;
+}
+
+.header-tools {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  white-space: nowrap;
+}
+
+.user-button {
+  height: 34px;
+  border: 1px solid rgba(145, 166, 190, 0.24);
+  border-radius: 8px;
+  background: #122437;
+  color: #d7e3ef;
+}
+
+.user-button {
+  width: 34px;
+  display: inline-grid;
+  place-items: center;
+}
+
+.user-button svg {
+  width: 18px;
+  height: 18px;
+}
+
+.user-button {
+  border-radius: 50%;
+  background: #e7f0ff;
+  color: #236bff;
+}
+
+.user-name {
+  color: #ffffff;
+  font-weight: 700;
+}
+
+.menu-list {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  padding: 8px 0;
+}
+
+.menu-group {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.menu-title,
+.menu-link {
+  display: flex;
+  align-items: center;
+  min-height: 47px;
+  border-radius: 0;
+  padding: 0 16px;
+  color: #657489;
+  font-size: 15px;
+}
+
+.menu-title {
+  justify-content: space-between;
+  color: #657489;
+  font-weight: 600;
+}
+
+.menu-toggle {
+  width: 100%;
+  border: 0;
+  background: transparent;
+  text-align: left;
+}
+
+.menu-title.active {
+  color: #236bff;
+  background: #f0f5ff;
+}
+
+.menu-title.leaf {
+  min-height: 46px;
+}
+
+.menu-link {
+  margin-left: 0;
+  padding-left: 40px;
+  min-height: 46px;
+}
+
+.menu-link-root {
+  padding-left: 16px;
+  margin-left: 0;
+}
+
+.menu-link.router-link-active {
+  color: #236bff;
+  background: #f0f5ff;
+  font-weight: 700;
+  border-left: 3px solid #236bff;
+  padding-left: 37px;
+}
+
+.menu-link-root.router-link-active {
+  padding-left: 13px;
+}
+
+.collapse-icon {
+  width: 15px;
+  height: 15px;
+  color: #91a0b3;
+  transition: transform 0.18s ease;
+}
+
+.menu-title.collapsed .collapse-icon {
+  transform: rotate(180deg);
+}
+
+.menu-icon,
+.button-icon {
+  width: 18px;
+  height: 18px;
+  flex: 0 0 auto;
+}
+
+.main-panel {
+  grid-column: 2;
+  grid-row: 2;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.topbar {
+  height: 72px;
+  padding: 0 28px;
+  background: #ffffff;
+  border-bottom: 1px solid #d9e0ea;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+}
+
+.topbar h1 {
+  margin: 4px 0 0;
+  font-size: 22px;
+  line-height: 1.2;
+}
+
+.eyebrow {
+  margin: 0;
+  color: #64748b;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.content-panel {
+  padding: 24px 28px;
+  min-width: 0;
+  background: #f3f5f8;
+}
+
+.page-stack {
+  display: flex;
+  flex-direction: column;
+  gap: 18px;
+}
+
+.toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+}
+
+.toolbar h2,
+.side-form h3,
+.empty-state h2 {
+  margin: 0;
+}
+
+.data-layout {
+  display: block;
+  min-width: 0;
+}
+
+.dashboard-page {
+  display: flex;
+  flex-direction: column;
+  gap: 22px;
+}
+
+.section-card {
+  background: #ffffff;
+  border-radius: 6px;
+  padding: 18px 20px;
+  box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
+}
+
+.filter-card {
+  min-height: 174px;
+}
+
+.section-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 18px;
+}
+
+.section-actions {
+  display: inline-flex;
+  align-items: center;
+  gap: 18px;
+}
+
+.section-add-button {
+  min-width: 96px;
+  height: 36px;
+  border-radius: 6px;
+  font-weight: 600;
+}
+
+.section-title {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  color: #111827;
+  font-size: 18px;
+  font-weight: 750;
+  line-height: 1.3;
+}
+
+.section-title::before {
+  content: "";
+  width: 4px;
+  height: 18px;
+  background: #1f6fff;
+}
+
+.filter-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(180px, 1fr));
+  gap: 18px 22px;
+  margin-top: 24px;
+}
+
+.filter-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+  margin-top: 32px;
+}
+
+.section-card .arco-table-th {
+  background: #f4f5f7;
+  color: #1f2937;
+  font-weight: 700;
+}
+
+.section-card .arco-table-td,
+.section-card .arco-table-th {
+  border-bottom-color: #e5e7eb;
+}
+
+.section-card .arco-table-cell {
+  font-size: 14px;
+}
+
+.filter-grid .arco-input-wrapper,
+.filter-grid .arco-select,
+.filter-grid .arco-select-view,
+.drawer-form-inner .arco-input-wrapper,
+.drawer-form-inner .arco-input-number,
+.drawer-form-inner .arco-select,
+.drawer-form-inner .arco-select-view {
+  width: 100%;
+  min-height: 40px;
+  background: #f3f4f6;
+  border-color: transparent;
+  border-radius: 2px;
+}
+
+.filter-grid label {
+  gap: 10px;
+}
+
+.filter-grid label span {
+  color: #64748b;
+  font-size: 14px;
+}
+
+.data-table {
+  width: 100%;
+  border-collapse: collapse;
+  background: #ffffff;
+  border: 1px solid #e7ecf2;
+  border-radius: 8px;
+  overflow: hidden;
+  box-shadow: 0 1px 4px rgba(31, 41, 55, 0.04);
+}
+
+.data-table th,
+.data-table td {
+  height: 48px;
+  padding: 0 16px;
+  text-align: left;
+  border-bottom: 1px solid #edf1f5;
+  white-space: nowrap;
+}
+
+.data-table th {
+  background: #f7f9fc;
+  color: #5f6f84;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.data-table tr:last-child td {
+  border-bottom: 0;
+}
+
+.table-actions {
+  display: flex;
+  gap: 8px;
+}
+
+.side-form,
+.login-panel {
+  background: #ffffff;
+  border: 1px solid #e7ecf2;
+  border-radius: 8px;
+  padding: 18px;
+  box-shadow: 0 1px 4px rgba(31, 41, 55, 0.04);
+}
+
+.side-form {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  position: sticky;
+  top: 18px;
+}
+
+.login-panel label,
+.side-form label {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  color: #475569;
+  font-size: 13px;
+  font-weight: 650;
+}
+
+.login-panel input,
+.side-form input,
+.side-form select {
+  width: 100%;
+  height: 38px;
+  border: 1px solid #cbd5e1;
+  border-radius: 8px;
+  padding: 0 10px;
+  color: #1f2937;
+  background: #ffffff;
+}
+
+.login-panel input:focus,
+.side-form input:focus,
+.side-form select:focus {
+  outline: 2px solid rgba(35, 107, 255, 0.16);
+  border-color: #236bff;
+}
+
+.check-line {
+  flex-direction: row;
+  align-items: center;
+}
+
+.check-line input,
+.check-tree input {
+  width: 16px;
+  height: 16px;
+}
+
+.check-tree {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  max-height: 240px;
+  overflow: auto;
+  border: 1px solid #d9e0ea;
+  border-radius: 8px;
+  padding: 10px;
+}
+
+.check-tree p {
+  margin: 0 0 4px;
+  color: #334155;
+  font-weight: 700;
+}
+
+.check-tree label {
+  flex-direction: row;
+  align-items: center;
+  font-weight: 500;
+}
+
+.primary-button,
+.ghost-button,
+.icon-button {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 8px;
+  border: 0;
+}
+
+.primary-button {
+  min-height: 38px;
+  gap: 8px;
+  padding: 0 14px;
+  color: #ffffff;
+  background: #236bff;
+  font-weight: 700;
+}
+
+.primary-button:disabled {
+  opacity: 0.65;
+  cursor: default;
+}
+
+.ghost-button {
+  height: 38px;
+  gap: 8px;
+  padding: 0 12px;
+  color: #334155;
+  background: #eef2f6;
+  font-weight: 650;
+}
+
+.icon-button {
+  width: 34px;
+  height: 34px;
+  color: #334155;
+  background: #eef2f6;
+}
+
+.icon-button svg {
+  width: 17px;
+  height: 17px;
+}
+
+.icon-button.danger {
+  color: #b42318;
+  background: #fff1f1;
+}
+
+.login-page {
+  min-height: 100vh;
+  display: grid;
+  place-items: center;
+  padding: 24px;
+  background:
+    linear-gradient(135deg, rgba(35, 107, 255, 0.12), transparent 34%),
+    #f3f5f8;
+}
+
+.login-panel {
+  width: min(420px, 100%);
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  box-shadow: 0 18px 45px rgba(30, 41, 59, 0.12);
+}
+
+.login-brand {
+  height: auto;
+  padding: 0 0 8px;
+  color: #1f2937;
+}
+
+.empty-state {
+  min-height: 360px;
+  display: grid;
+  place-items: center;
+  align-content: center;
+  gap: 16px;
+}
+
+.dialog-mask {
+  position: fixed;
+  inset: 0;
+  z-index: 1000;
+  display: grid;
+  place-items: center;
+  padding: 24px;
+  background: rgba(15, 23, 42, 0.36);
+}
+
+.dialog-panel {
+  width: min(380px, 100%);
+  display: grid;
+  grid-template-columns: 42px minmax(0, 1fr);
+  gap: 14px;
+  padding: 20px;
+  border-radius: 8px;
+  background: #ffffff;
+  box-shadow: 0 24px 60px rgba(15, 23, 42, 0.22);
+}
+
+.dialog-icon {
+  width: 42px;
+  height: 42px;
+  display: grid;
+  place-items: center;
+  border-radius: 50%;
+}
+
+.dialog-icon svg {
+  width: 22px;
+  height: 22px;
+}
+
+.dialog-icon.danger {
+  color: #b42318;
+  background: #fff1f1;
+}
+
+.dialog-body {
+  min-width: 0;
+}
+
+.dialog-body h3 {
+  margin: 0 0 8px;
+  font-size: 18px;
+  line-height: 1.3;
+}
+
+.dialog-body p {
+  margin: 0;
+  color: #5f6f84;
+  font-size: 14px;
+  line-height: 1.6;
+  word-break: break-word;
+}
+
+.dialog-button {
+  grid-column: 1 / -1;
+  width: 100%;
+  margin-top: 4px;
+}
+
+.drawer-form {
+  margin: 0;
+}
+
+.drawer-form-inner {
+  padding: 0 4px;
+}
+
+.drawer-footer-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+}
+
+.app-form-drawer .arco-drawer-header {
+  height: 58px;
+  border-bottom: 1px solid #e5e7eb;
+}
+
+.app-form-drawer .arco-drawer-title {
+  font-size: 18px;
+  font-weight: 700;
+}
+
+.app-form-drawer .arco-drawer-body {
+  padding: 22px 28px;
+}
+
+.tree-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 18px;
+  margin-bottom: 12px;
+  flex-wrap: wrap;
+  width: 100%;
+}
+
+.drawer-tree {
+  display: block;
+  width: 100%;
+  max-height: 330px;
+  overflow: auto;
+  border: 1px solid #e5e7eb;
+  border-radius: 6px;
+  padding: 12px;
+}
+
+.menu-permission-item .arco-form-item-content {
+  display: block;
+}
+
+.menu-permission-item .arco-form-item-content-flex {
+  display: block;
+  width: 100%;
+}
+
+.text-action {
+  height: 24px;
+  padding: 0;
+  border: 0;
+  background: transparent;
+  color: #1f6fff;
+  font: inherit;
+  cursor: pointer;
+}
+
+.text-action.danger {
+  color: #f53f3f;
+}
+
+.menu-name-cell,
+.dept-name-cell {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-height: 24px;
+}
+
+.tree-toggle,
+.tree-toggle-placeholder {
+  width: 18px;
+  height: 18px;
+  flex: 0 0 auto;
+}
+
+.tree-toggle {
+  display: inline-grid;
+  place-items: center;
+  padding: 0;
+  border: 0;
+  border-radius: 3px;
+  background: #eef1f5;
+  color: #5f6f84;
+  font-size: 16px;
+  line-height: 1;
+}
+
+.inline-button {
+  width: fit-content;
+}
+
+.auth-tip {
+  width: fit-content;
+  min-width: 360px;
+}
+
+.auth-callback-card {
+  min-height: 420px;
+}
+
+.callback-detail {
+  width: min(680px, 100%);
+  margin: 0 auto;
+  border: 1px solid #e5e7eb;
+  border-radius: 6px;
+  overflow: hidden;
+}
+
+.callback-detail-row {
+  display: grid;
+  grid-template-columns: 160px minmax(0, 1fr);
+  gap: 16px;
+  padding: 12px 16px;
+  border-bottom: 1px solid #e5e7eb;
+}
+
+.callback-detail-row:last-child {
+  border-bottom: 0;
+}
+
+.callback-detail-row span {
+  color: #6b7280;
+}
+
+.callback-detail-row strong {
+  min-width: 0;
+  overflow-wrap: anywhere;
+  color: #111827;
+  font-weight: 600;
+}
+
+@media (max-width: 900px) {
+  .admin-shell {
+    grid-template-columns: 1fr;
+    grid-template-rows: auto auto minmax(0, 1fr);
+  }
+
+  .global-header {
+    grid-template-columns: 1fr;
+    height: auto;
+    gap: 10px;
+    padding: 10px 14px;
+  }
+
+  .sidebar {
+    grid-column: 1;
+    grid-row: 2;
+    min-height: auto;
+  }
+
+  .main-panel {
+    grid-column: 1;
+    grid-row: 3;
+  }
+
+  .top-menu {
+    height: 42px;
+    gap: 18px;
+  }
+
+  .top-menu-link {
+    height: 42px;
+  }
+
+  .header-tools {
+    overflow-x: auto;
+  }
+
+  .menu-list {
+    overflow-x: auto;
+  }
+
+  .side-form {
+    position: static;
+  }
+}
+
+@media (max-width: 640px) {
+  .header-select {
+    min-width: 120px;
+    gap: 18px;
+  }
+
+  .global-search {
+    width: 118px;
+  }
+
+  .content-panel {
+    padding: 16px;
+  }
+
+  .filter-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .toolbar {
+    align-items: flex-start;
+    flex-direction: column;
+  }
+
+  .data-table {
+    display: block;
+    overflow-x: auto;
+  }
+}

+ 56 - 0
src/views/LoginView.vue

@@ -0,0 +1,56 @@
+<template>
+  <main class="login-page">
+    <form class="login-panel" @submit.prevent="handleLogin">
+      <div class="brand login-brand">
+        <span class="brand-mark">N</span>
+        <span>Nuojing Data Admin</span>
+      </div>
+      <label>
+        <span>用户名</span>
+        <input v-model="form.username" autocomplete="username" />
+      </label>
+      <label>
+        <span>密码</span>
+        <input v-model="form.password" type="password" autocomplete="current-password" />
+      </label>
+      <button class="primary-button" :disabled="loading">
+        <LogIn class="button-icon" />
+        {{ loading ? '登录中' : '登录' }}
+      </button>
+    </form>
+    <ErrorDialog v-model="errorVisible" title="登录失败" :message="errorMessage" />
+  </main>
+</template>
+
+<script setup>
+import { reactive, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { LogIn } from 'lucide-vue-next'
+import { useAuthStore } from '../stores/auth'
+import ErrorDialog from '../components/ErrorDialog.vue'
+
+const router = useRouter()
+const auth = useAuthStore()
+const loading = ref(false)
+const errorVisible = ref(false)
+const errorMessage = ref('')
+const form = reactive({
+  username: 'admin',
+  password: '123456'
+})
+
+async function handleLogin() {
+  loading.value = true
+  errorVisible.value = false
+  errorMessage.value = ''
+  try {
+    const redirectPath = await auth.login(form, router)
+    router.replace(redirectPath)
+  } catch (err) {
+    errorMessage.value = err.message || '登录失败,请稍后重试'
+    errorVisible.value = true
+  } finally {
+    loading.value = false
+  }
+}
+</script>

+ 6 - 0
src/views/NotFound.vue

@@ -0,0 +1,6 @@
+<template>
+  <div class="empty-state">
+    <h2>页面不存在</h2>
+    <router-link class="primary-button inline-button" to="/">返回首页</router-link>
+  </div>
+</template>

+ 49 - 0
src/views/media/MediaAuthCallback.vue

@@ -0,0 +1,49 @@
+<template>
+  <div class="dashboard-page">
+    <section class="section-card auth-callback-card">
+      <a-result
+        :status="success ? 'success' : 'error'"
+        :title="success ? '授权成功' : '授权失败'"
+        :subtitle="message"
+      >
+        <template #extra>
+          <a-button type="primary" @click="backToConfig">返回授权配置</a-button>
+        </template>
+      </a-result>
+      <div class="callback-detail">
+        <div v-for="item in details" :key="item.label" class="callback-detail-row">
+          <span>{{ item.label }}</span>
+          <strong>{{ item.value }}</strong>
+        </div>
+      </div>
+    </section>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+
+const route = useRoute()
+const router = useRouter()
+const query = computed(() => route.query || {})
+const success = computed(() => ['0', '200', 'ok', 'success'].includes(String(query.value.code || '').toLowerCase()))
+const message = computed(() => String(query.value.message || query.value.msg || (success.value ? '账号已完成授权。' : '授权流程未完成。')))
+const details = computed(() => [
+  { label: '媒体类型', value: query.value.mediaType },
+  { label: '管家账号 ID', value: query.value.adminAdvertiserId },
+  { label: '请求标识', value: query.value.state },
+  { label: '返回码', value: query.value.code },
+  { label: '回调时间', value: query.value.time }
+].filter((item) => item.value !== undefined && item.value !== null && String(item.value) !== ''))
+
+function backToConfig() {
+  const mediaType = Number(query.value.mediaType || 1)
+  const pathMap = {
+    1: '/authorization/authorization/authorizationConfigDy',
+    2: '/authorization/authorization/authorizationConfigKs',
+    3: '/authorization/authorization/authorizationConfigJl'
+  }
+  router.replace(pathMap[mediaType] || pathMap[1])
+}
+</script>

+ 102 - 0
src/views/media/MediaAuthConfig.vue

@@ -0,0 +1,102 @@
+<template>
+  <div class="dashboard-page">
+    <section class="section-card">
+      <a-tabs :active-key="activeTab" @change="switchTab">
+        <a-tab-pane key="advertiser" title="广告账户" />
+        <a-tab-pane key="admin" title="管家账户" />
+      </a-tabs>
+    </section>
+
+    <section class="section-card filter-card">
+      <div class="section-title">筛选条件</div>
+      <div class="filter-grid">
+        <label>
+          <span>管家账号</span>
+          <a-input allow-clear placeholder="请选择管家账号" />
+        </label>
+        <label>
+          <span>创建时间</span>
+          <a-range-picker />
+        </label>
+      </div>
+      <div class="filter-actions">
+        <a-button type="primary">查询</a-button>
+        <a-button>重置</a-button>
+      </div>
+    </section>
+
+    <section class="section-card">
+      <div class="section-head">
+        <div class="section-actions">
+          <a-button type="primary" :loading="authLoading" @click="openAuthUrl">
+            管家账户授权
+          </a-button>
+          <a-alert
+            class="auth-tip"
+            type="success"
+            show-icon
+            banner
+            message="请授权「业务单元」/「服务商」,新增广告账号自动同步"
+          />
+        </div>
+        <a-link>下载报表</a-link>
+      </div>
+
+      <a-table :data="[]" :pagination="false" :bordered="false">
+        <template #columns>
+          <a-table-column title="管家ID" data-index="adminAdvertiserId" />
+          <a-table-column title="管家名称" data-index="adminAdvertiserName" />
+          <a-table-column title="开户代理" data-index="agentName" />
+          <a-table-column title="首次绑定时间" data-index="firstBindTime" />
+          <a-table-column title="更新者" data-index="updater" />
+          <a-table-column title="已绑定账户数" data-index="boundCount" />
+          <a-table-column title="未绑定账户数" data-index="unboundCount" />
+        </template>
+      </a-table>
+    </section>
+  </div>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { Message } from '@arco-design/web-vue'
+import { mediaAuthApi } from '../../api/system'
+
+const route = useRoute()
+const router = useRouter()
+const authLoading = ref(false)
+const activeTab = ref(route.query.tabKey === 'advertiser' ? 'advertiser' : 'admin')
+
+const mediaMap = {
+  '/authorization/authorization/authorizationConfigDy': 1,
+  '/authorization/authorization/authorizationConfigKs': 2,
+  '/authorization/authorization/authorizationConfigJl': 3
+}
+
+const mediaType = computed(() => mediaMap[route.path] || 1)
+
+function switchTab(key) {
+  activeTab.value = key
+  router.replace({ path: route.path, query: { ...route.query, tabKey: key } })
+}
+
+async function openAuthUrl() {
+  authLoading.value = true
+  try {
+    const result = await mediaAuthApi.authUrl({
+      mediaId: mediaType.value
+    })
+    if (!result?.authUrl) {
+      Message.error('获取授权链接失败')
+      return
+    }
+    window.open(result.authUrl, '_blank', 'noopener,noreferrer')
+    Message.success('正在跳转到授权页面')
+  } catch (error) {
+    Message.error(error.message || '获取授权链接失败')
+  } finally {
+    authLoading.value = false
+  }
+}
+</script>

+ 296 - 0
src/views/system/DeptList.vue

@@ -0,0 +1,296 @@
+<template>
+  <div class="dashboard-page">
+    <section class="section-card filter-card">
+      <div class="section-title">筛选条件</div>
+      <div class="filter-grid">
+        <label>
+          <span>部门名称</span>
+          <a-input v-model="filters.deptName" allow-clear placeholder="请输入部门名称" />
+        </label>
+        <label>
+          <span>状态</span>
+          <a-select v-model="filters.status" allow-clear placeholder="请选择状态">
+            <a-option :value="1">正常</a-option>
+            <a-option :value="0">停用</a-option>
+          </a-select>
+        </label>
+      </div>
+      <div class="filter-actions">
+        <a-button type="primary" @click="applyFilters">查询</a-button>
+        <a-button @click="resetFilters">重置</a-button>
+      </div>
+    </section>
+
+    <section class="section-card">
+      <div class="section-head">
+        <div class="section-title">部门列表</div>
+        <div class="section-actions">
+          <a-link @click="toggleAllDepts">{{ allExpanded ? '折叠' : '展开' }}</a-link>
+          <a-button type="primary" class="section-add-button" @click="openCreate">
+            <template #icon>+</template>
+            新增部门
+          </a-button>
+        </div>
+      </div>
+      <a-table :data="filteredDepts" :pagination="false" row-key="id" :bordered="false">
+        <template #columns>
+          <a-table-column title="部门名称">
+            <template #cell="{ record }">
+              <div class="dept-name-cell" :style="{ paddingLeft: `${record.level * 22}px` }">
+                <button
+                  v-if="hasChildren(record)"
+                  class="tree-toggle"
+                  type="button"
+                  @click="toggleDept(record.id)"
+                >
+                  {{ expandedIds.has(record.id) ? '-' : '+' }}
+                </button>
+                <span v-else class="tree-toggle-placeholder"></span>
+                <span>{{ record.deptName || '-' }}</span>
+              </div>
+            </template>
+          </a-table-column>
+          <a-table-column title="上级部门" :width="160" align="center">
+            <template #cell="{ record }">{{ record.parentName }}</template>
+          </a-table-column>
+          <a-table-column title="状态" :width="120" align="center">
+            <template #cell="{ record }">
+              <a-switch
+                :model-value="record.status === 1"
+                checked-text="开"
+                unchecked-text="关"
+                @change="(checked) => changeStatus(record, checked)"
+              />
+            </template>
+          </a-table-column>
+          <a-table-column title="创建时间" data-index="createTime" :width="190" align="center">
+            <template #cell="{ record }">{{ formatTime(record.createTime) }}</template>
+          </a-table-column>
+          <a-table-column title="操作" :width="190" align="center">
+            <template #cell="{ record }">
+              <button class="text-action" type="button" @click="edit(record)">修改</button>
+              <a-divider direction="vertical" />
+              <button class="text-action" type="button" @click="openCreate(record)">新增</button>
+              <a-divider direction="vertical" />
+              <button class="text-action danger" type="button" @click="remove(record.id)">删除</button>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </section>
+
+    <FormDialog v-model="dialogVisible" :title="form.id ? '修改部门' : '新增部门'" @submit="save">
+      <a-form :model="form" layout="vertical" class="drawer-form-inner">
+        <a-form-item label="部门名称" required>
+          <a-input v-model="form.deptName" placeholder="请输入部门名称" />
+        </a-form-item>
+        <a-form-item label="上级部门">
+          <a-input :model-value="form.parentName" readonly />
+        </a-form-item>
+        <a-form-item label="排序" required>
+          <a-input-number v-model="form.sort" :min="0" :precision="0" />
+        </a-form-item>
+        <a-form-item label="状态" required>
+          <a-radio-group v-model="form.status" type="button">
+            <a-radio :value="1">正常</a-radio>
+            <a-radio :value="0">停用</a-radio>
+          </a-radio-group>
+        </a-form-item>
+      </a-form>
+    </FormDialog>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, reactive, ref } from 'vue'
+import { Message, Modal } from '@arco-design/web-vue'
+import FormDialog from '../../components/FormDialog.vue'
+import { deptApi } from '../../api/system'
+
+const depts = ref([])
+const expandedIds = ref(new Set())
+const dialogVisible = ref(false)
+const filters = reactive({ deptName: '', status: undefined })
+const activeFilters = reactive({ deptName: '', status: undefined })
+const form = reactive({ id: null, parentId: 0, parentName: '顶级部门', deptName: '', sort: 0, status: 1 })
+const hasActiveFilter = computed(() => Boolean(
+  activeFilters.deptName.trim() ||
+  activeFilters.status !== undefined
+))
+const allDeptIdsWithChildren = computed(() => collectExpandableIds(depts.value))
+const allExpanded = computed(() => allDeptIdsWithChildren.value.length > 0 && allDeptIdsWithChildren.value.every((id) => expandedIds.value.has(id)))
+const filteredDepts = computed(() => {
+  if (hasActiveFilter.value) {
+    return flattenFiltered(depts.value)
+  }
+  return flattenVisible(depts.value)
+})
+
+onMounted(load)
+
+async function load() {
+  depts.value = await deptApi.tree()
+  if (!expandedIds.value.size) {
+    expandedIds.value = new Set(collectExpandableIds(depts.value))
+  }
+}
+
+function applyFilters() {
+  Object.assign(activeFilters, filters)
+}
+
+function resetFilters() {
+  Object.assign(filters, { deptName: '', status: undefined })
+  applyFilters()
+}
+
+function openCreate(parentDept = null) {
+  Object.assign(form, {
+    id: null,
+    parentId: parentDept?.id ?? 0,
+    parentName: parentDept?.deptName ?? '顶级部门',
+    deptName: '',
+    sort: filteredDepts.value.length + 1,
+    status: 1
+  })
+  dialogVisible.value = true
+}
+
+function edit(dept) {
+  Object.assign(form, {
+    id: dept.id,
+    parentId: dept.parentId,
+    parentName: getParentDeptName(dept.parentId),
+    deptName: dept.deptName,
+    sort: dept.sort,
+    status: dept.status
+  })
+  dialogVisible.value = true
+}
+
+async function save() {
+  if (!form.deptName) {
+    Message.warning('请填写部门名称')
+    return
+  }
+  const { parentName, ...payload } = form
+  await deptApi.save(payload)
+  Message.success(form.id ? '修改成功' : '新增成功')
+  await load()
+  dialogVisible.value = false
+}
+
+function changeStatus(dept, checked) {
+  dept.status = checked ? 1 : 0
+  const { parentName, level, hasChildren, ...payload } = dept
+  deptApi.save(payload).then(() => {
+    Message.success('状态修改成功')
+  }).catch(async () => {
+    Message.error('状态修改失败')
+    await load()
+  })
+}
+
+function remove(id) {
+  Modal.confirm({
+    title: '确认删除该部门吗?',
+    content: '删除后不可恢复。',
+    onOk: async () => {
+      await deptApi.remove(id)
+      Message.success('删除成功')
+      await load()
+    }
+  })
+}
+
+function hasChildren(dept) {
+  return dept.hasChildren
+}
+
+function toggleDept(id) {
+  const nextIds = new Set(expandedIds.value)
+  if (nextIds.has(id)) {
+    nextIds.delete(id)
+  } else {
+    nextIds.add(id)
+  }
+  expandedIds.value = nextIds
+}
+
+function toggleAllDepts() {
+  expandedIds.value = allExpanded.value ? new Set() : new Set(allDeptIdsWithChildren.value)
+}
+
+function flattenVisible(nodes, level = 0) {
+  return nodes.flatMap((node) => {
+    const current = toTableRow(node, level)
+    if (!expandedIds.value.has(node.id)) {
+      return [current]
+    }
+    return [current, ...flattenVisible(node.children || [], level + 1)]
+  })
+}
+
+function flattenFiltered(nodes, level = 0) {
+  return nodes.flatMap((node) => {
+    const children = node.children || []
+    const childRows = flattenFiltered(children, level + 1)
+    if (!matchesFilters(node) && !childRows.length) {
+      return []
+    }
+    return [toTableRow(node, level), ...childRows]
+  })
+}
+
+function toTableRow(node, level) {
+  const { children, ...row } = node
+  return {
+    ...row,
+    level,
+    parentName: getParentDeptName(row.parentId),
+    hasChildren: Array.isArray(children) && children.length > 0
+  }
+}
+
+function matchesFilters(dept) {
+  const deptName = activeFilters.deptName.trim()
+  const matchName = !deptName || dept.deptName?.includes(deptName)
+  const matchStatus = activeFilters.status === undefined || dept.status === activeFilters.status
+  return matchName && matchStatus
+}
+
+function getParentDeptName(parentId) {
+  if (!parentId) {
+    return '-'
+  }
+  const parent = findDeptById(depts.value, parentId)
+  return parent?.deptName ?? `部门ID:${parentId}`
+}
+
+function findDeptById(nodes, id) {
+  for (const node of nodes) {
+    if (node.id === id) {
+      return node
+    }
+    const match = findDeptById(node.children || [], id)
+    if (match) {
+      return match
+    }
+  }
+  return null
+}
+
+function collectExpandableIds(nodes) {
+  return nodes.flatMap((node) => {
+    const children = node.children || []
+    if (!children.length) {
+      return []
+    }
+    return [node.id, ...collectExpandableIds(children)]
+  })
+}
+
+function formatTime(value) {
+  return value ? String(value).replace('T', ' ') : '-'
+}
+</script>

+ 339 - 0
src/views/system/MenuList.vue

@@ -0,0 +1,339 @@
+<template>
+  <div class="dashboard-page">
+    <section class="section-card filter-card">
+      <div class="section-title">筛选条件</div>
+      <div class="filter-grid">
+        <label>
+          <span>菜单名称</span>
+          <a-input v-model="filters.menuName" allow-clear placeholder="请输入菜单名称" />
+        </label>
+        <label>
+          <span>权限标识</span>
+          <a-input v-model="filters.permission" allow-clear placeholder="请输入权限标识" />
+        </label>
+        <label>
+          <span>状态</span>
+          <a-select v-model="filters.status" allow-clear placeholder="请选择状态">
+            <a-option :value="1">正常</a-option>
+            <a-option :value="0">停用</a-option>
+          </a-select>
+        </label>
+      </div>
+      <div class="filter-actions">
+        <a-button type="primary" @click="applyFilters">查询</a-button>
+        <a-button @click="resetFilters">重置</a-button>
+      </div>
+    </section>
+
+    <section class="section-card">
+      <div class="section-head">
+        <div class="section-title">菜单列表</div>
+        <div class="section-actions">
+          <a-link @click="toggleAllMenus">{{ allExpanded ? '折叠' : '展开' }}</a-link>
+          <a-button type="primary" class="section-add-button" @click="openCreate">
+            <template #icon>+</template>
+            新增菜单
+          </a-button>
+        </div>
+      </div>
+      <a-table :data="filteredMenus" :pagination="false" row-key="id" :bordered="false">
+        <template #columns>
+          <a-table-column title="菜单名称" :width="240">
+            <template #cell="{ record }">
+              <div class="menu-name-cell" :style="{ paddingLeft: `${record.level * 22}px` }">
+                <button
+                  v-if="hasChildren(record)"
+                  class="tree-toggle"
+                  type="button"
+                  @click="toggleMenu(record.id)"
+                >
+                  {{ expandedIds.has(record.id) ? '-' : '+' }}
+                </button>
+                <span v-else class="tree-toggle-placeholder"></span>
+                <span>{{ record.title }}</span>
+              </div>
+            </template>
+          </a-table-column>
+          <a-table-column title="路径" data-index="path" />
+          <a-table-column title="组件" data-index="component" />
+          <a-table-column title="权限标识" data-index="permission" />
+          <a-table-column title="操作" :width="190" align="center">
+            <template #cell="{ record }">
+              <button class="text-action" type="button" @click="edit(record)">修改</button>
+              <a-divider direction="vertical" />
+              <button
+                v-if="canCreateChild(record)"
+                class="text-action"
+                type="button"
+                @click="openCreate(record)"
+              >
+                新增
+              </button>
+              <a-divider v-if="canCreateChild(record)" direction="vertical" />
+              <button class="text-action danger" type="button" @click="remove(record.id)">删除</button>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </section>
+
+    <FormDialog v-model="dialogVisible" :title="form.id ? '修改菜单' : '新增菜单'" @submit="save">
+      <a-form :model="form" layout="vertical" class="drawer-form-inner">
+        <a-form-item label="菜单名称" required>
+          <a-input v-model="form.menuName" placeholder="请输入菜单名称" />
+        </a-form-item>
+        <a-form-item label="上级菜单">
+          <a-input :model-value="form.parentName" readonly />
+        </a-form-item>
+        <a-form-item label="路由路径" required>
+          <a-input v-model="form.path" placeholder="/system/example" />
+        </a-form-item>
+        <a-form-item label="组件标识">
+          <a-input v-model="form.component" placeholder="system/ExampleList" />
+        </a-form-item>
+        <a-form-item label="路由名称">
+          <a-input v-model="form.routeName" placeholder="ExampleList" />
+        </a-form-item>
+        <a-form-item label="权限标识">
+          <a-input v-model="form.permission" placeholder="system:example:list" />
+        </a-form-item>
+        <a-form-item label="类型" required>
+          <a-select v-model="form.menuType">
+            <a-option :value="1">目录</a-option>
+            <a-option :value="2">菜单</a-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item label="排序" required>
+          <a-input-number v-model="form.sort" :min="0" :precision="0" />
+        </a-form-item>
+        <a-form-item label="状态">
+          <a-radio-group v-model="form.status" type="button">
+            <a-radio :value="1">正常</a-radio>
+            <a-radio :value="0">停用</a-radio>
+          </a-radio-group>
+        </a-form-item>
+        <a-form-item label="显示">
+          <a-switch v-model="visible" checked-text="显示" unchecked-text="隐藏" />
+        </a-form-item>
+      </a-form>
+    </FormDialog>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, reactive, ref } from 'vue'
+import { Message, Modal } from '@arco-design/web-vue'
+import FormDialog from '../../components/FormDialog.vue'
+import { menuApi } from '../../api/system'
+
+const menus = ref([])
+const expandedIds = ref(new Set())
+const dialogVisible = ref(false)
+const filters = reactive({ menuName: '', permission: '', status: undefined })
+const activeFilters = reactive({ menuName: '', permission: '', status: undefined })
+const form = reactive({
+  id: null,
+  parentId: 0,
+  parentName: '顶级菜单',
+  menuName: '',
+  path: '',
+  component: '',
+  routeName: '',
+  icon: 'Menu',
+  permission: '',
+  menuType: 2,
+  sort: 0,
+  hidden: 0,
+  status: 1
+})
+const visible = computed({
+  get: () => form.hidden === 0,
+  set: (value) => { form.hidden = value ? 0 : 1 }
+})
+const hasActiveFilter = computed(() => Boolean(
+  activeFilters.menuName.trim() ||
+  activeFilters.permission.trim() ||
+  activeFilters.status !== undefined
+))
+const allMenuIdsWithChildren = computed(() => collectExpandableIds(menus.value))
+const allExpanded = computed(() => allMenuIdsWithChildren.value.length > 0 && allMenuIdsWithChildren.value.every((id) => expandedIds.value.has(id)))
+const filteredMenus = computed(() => {
+  if (hasActiveFilter.value) {
+    return flattenFiltered(menus.value)
+  }
+  return flattenVisible(menus.value)
+})
+
+onMounted(load)
+
+async function load() {
+  menus.value = await menuApi.tree()
+  if (!expandedIds.value.size) {
+    expandedIds.value = new Set(collectExpandableIds(menus.value))
+  }
+}
+
+function applyFilters() {
+  Object.assign(activeFilters, filters)
+}
+
+function resetFilters() {
+  Object.assign(filters, { menuName: '', permission: '', status: undefined })
+  applyFilters()
+}
+
+function openCreate(parentMenu = null) {
+  const isChildOfFirstLevel = parentMenu?.level === 0
+  Object.assign(form, {
+    id: null,
+    parentId: parentMenu?.id ?? 0,
+    parentName: parentMenu?.title ?? '顶级菜单',
+    menuName: '',
+    path: '',
+    component: '',
+    routeName: '',
+    icon: 'Menu',
+    permission: '',
+    menuType: parentMenu ? (isChildOfFirstLevel ? 1 : 2) : 1,
+    sort: filteredMenus.value.length + 1,
+    hidden: 0,
+    status: 1
+  })
+  dialogVisible.value = true
+}
+
+function canCreateChild(menu) {
+  return menu.level < 2
+}
+
+function hasChildren(menu) {
+  return menu.hasChildren
+}
+
+function toggleMenu(id) {
+  const nextIds = new Set(expandedIds.value)
+  if (nextIds.has(id)) {
+    nextIds.delete(id)
+  } else {
+    nextIds.add(id)
+  }
+  expandedIds.value = nextIds
+}
+
+function toggleAllMenus() {
+  expandedIds.value = allExpanded.value ? new Set() : new Set(allMenuIdsWithChildren.value)
+}
+
+function edit(menu) {
+  Object.assign(form, {
+    id: menu.id,
+    parentId: menu.parentId,
+    parentName: getParentMenuName(menu.parentId),
+    menuName: menu.title,
+    path: menu.path,
+    component: menu.component,
+    routeName: menu.name,
+    icon: menu.icon,
+    permission: menu.permission,
+    menuType: menu.menuType,
+    sort: menu.sort,
+    hidden: menu.hidden ? 1 : 0,
+    status: menu.status ?? 1
+  })
+  dialogVisible.value = true
+}
+
+async function save() {
+  if (!form.menuName || !form.path) {
+    Message.warning('请填写菜单名称和路由路径')
+    return
+  }
+  const { parentName, ...payload } = form
+  await menuApi.save({ ...payload })
+  Message.success(form.id ? '修改成功' : '新增成功')
+  await load()
+  dialogVisible.value = false
+}
+
+function remove(id) {
+  Modal.confirm({
+    title: '确认删除该菜单吗?',
+    content: '删除后不可恢复。',
+    onOk: async () => {
+      await menuApi.remove(id)
+      Message.success('删除成功')
+      await load()
+    }
+  })
+}
+
+function flattenVisible(nodes, level = 0) {
+  return nodes.flatMap((node) => {
+    const current = toTableRow(node, level)
+    if (!expandedIds.value.has(node.id)) {
+      return [current]
+    }
+    return [current, ...flattenVisible(node.children || [], level + 1)]
+  })
+}
+
+function flattenFiltered(nodes, level = 0) {
+  return nodes.flatMap((node) => {
+    const children = node.children || []
+    const childRows = flattenFiltered(children, level + 1)
+    if (!matchesFilters(node) && !childRows.length) {
+      return []
+    }
+    return [toTableRow(node, level), ...childRows]
+  })
+}
+
+function toTableRow(node, level) {
+  const { children, ...row } = node
+  return {
+    ...row,
+    level,
+    hasChildren: Array.isArray(children) && children.length > 0
+  }
+}
+
+function matchesFilters(menu) {
+  const menuName = activeFilters.menuName.trim()
+  const permission = activeFilters.permission.trim()
+  const matchName = !menuName || menu.title?.includes(menuName)
+  const matchPermission = !permission || menu.permission?.includes(permission)
+  const matchStatus = activeFilters.status === undefined || menu.status === activeFilters.status
+  return matchName && matchPermission && matchStatus
+}
+
+function getParentMenuName(parentId) {
+  if (!parentId) {
+    return '顶级菜单'
+  }
+  const parent = findMenuById(menus.value, parentId)
+  return parent?.title ?? `菜单ID:${parentId}`
+}
+
+function findMenuById(nodes, id) {
+  for (const node of nodes) {
+    if (node.id === id) {
+      return node
+    }
+    const match = findMenuById(node.children || [], id)
+    if (match) {
+      return match
+    }
+  }
+  return null
+}
+
+function collectExpandableIds(nodes) {
+  return nodes.flatMap((node) => {
+    const children = node.children || []
+    if (!children.length) {
+      return []
+    }
+    return [node.id, ...collectExpandableIds(children)]
+  })
+}
+</script>

+ 237 - 0
src/views/system/RoleList.vue

@@ -0,0 +1,237 @@
+<template>
+  <div class="dashboard-page">
+    <section class="section-card filter-card">
+      <div class="section-title">筛选条件</div>
+      <div class="filter-grid">
+        <label>
+          <span>角色名</span>
+          <a-input v-model="filters.roleName" allow-clear placeholder="请输入角色名" />
+        </label>
+        <label>
+          <span>权限字符</span>
+          <a-input v-model="filters.roleCode" allow-clear placeholder="请输入权限字符" />
+        </label>
+        <label>
+          <span>状态</span>
+          <a-select v-model="filters.status" allow-clear placeholder="请选择状态">
+            <a-option :value="1">正常</a-option>
+            <a-option :value="0">停用</a-option>
+          </a-select>
+        </label>
+      </div>
+      <div class="filter-actions">
+        <a-button type="primary" @click="applyFilters">查询</a-button>
+        <a-button @click="resetFilters">重置</a-button>
+      </div>
+    </section>
+
+    <section class="section-card">
+      <div class="section-head">
+        <div class="section-title">角色列表</div>
+        <a-button type="primary" class="section-add-button" @click="openCreate">
+          <template #icon>+</template>
+          新增角色
+        </a-button>
+      </div>
+      <a-table :data="filteredRoles" :pagination="false" row-key="id" :bordered="false">
+        <template #columns>
+          <a-table-column title="角色名" data-index="roleName" />
+          <a-table-column title="权限标识" data-index="roleCode" />
+          <a-table-column title="状态" :width="120" align="center">
+            <template #cell="{ record }">
+              <a-switch
+                :model-value="record.status === 1"
+                checked-text="开"
+                unchecked-text="关"
+                @change="(checked) => changeStatus(record, checked)"
+              />
+            </template>
+          </a-table-column>
+          <a-table-column title="创建时间" data-index="createTime" :width="190" align="center">
+            <template #cell="{ record }">{{ formatTime(record.createTime) }}</template>
+          </a-table-column>
+          <a-table-column title="操作" :width="150" align="center">
+            <template #cell="{ record }">
+              <button class="text-action" type="button" @click="edit(record)">修改</button>
+              <a-divider direction="vertical" />
+              <button class="text-action danger" type="button" @click="remove(record.id)">删除</button>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </section>
+
+    <FormDialog v-model="dialogVisible" :title="form.id ? '修改角色' : '新增角色'" @submit="save">
+      <a-form :model="form" layout="vertical" class="drawer-form-inner">
+        <a-form-item label="角色名称" required>
+          <a-input v-model="form.roleName" placeholder="请输入角色名称" />
+        </a-form-item>
+        <a-form-item label="权限字符" required>
+          <a-input v-model="form.roleCode" placeholder="请输入权限字符" />
+        </a-form-item>
+        <a-form-item label="排序" required>
+          <a-input-number v-model="form.sort" :min="0" :precision="0" />
+        </a-form-item>
+        <a-form-item label="状态" required>
+          <a-radio-group v-model="form.status" type="button">
+            <a-radio :value="1">正常</a-radio>
+            <a-radio :value="0">停用</a-radio>
+          </a-radio-group>
+        </a-form-item>
+        <a-form-item label="菜单权限" class="menu-permission-item">
+          <div class="tree-toolbar">
+            <a-checkbox v-model="menuExpanded">展开/折叠</a-checkbox>
+            <a-checkbox v-model="menuCheckedAll">全选/全不选</a-checkbox>
+            <a-checkbox :model-value="true" disabled>父子联动</a-checkbox>
+          </div>
+          <div class="drawer-tree">
+            <a-tree
+              checkable
+              :data="menuTree"
+              :checked-keys="form.menuIds"
+              :expanded-keys="expandedKeys"
+              @check="handleMenuCheck"
+              @expand="(keys) => { expandedKeys = keys }"
+            />
+          </div>
+        </a-form-item>
+      </a-form>
+    </FormDialog>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, reactive, ref, watch } from 'vue'
+import { Message, Modal } from '@arco-design/web-vue'
+import FormDialog from '../../components/FormDialog.vue'
+import { menuApi, roleApi } from '../../api/system'
+
+const roles = ref([])
+const menus = ref([])
+const dialogVisible = ref(false)
+const filters = reactive({ roleName: '', roleCode: '', status: undefined })
+const activeFilters = reactive({ roleName: '', roleCode: '', status: undefined })
+const form = reactive({ id: null, roleName: '', roleCode: '', sort: 0, status: 1, menuIds: [] })
+const menuExpanded = ref(false)
+const menuCheckedAll = ref(false)
+const expandedKeys = ref([])
+
+const menuTree = computed(() => mapTree(menus.value))
+const allMenuKeys = computed(() => collectKeys(menuTree.value))
+const filteredRoles = computed(() => {
+  const roleName = activeFilters.roleName.trim()
+  const roleCode = activeFilters.roleCode.trim()
+  return roles.value.filter((role) => {
+    const matchName = !roleName || role.roleName?.includes(roleName)
+    const matchCode = !roleCode || role.roleCode?.includes(roleCode)
+    const matchStatus = activeFilters.status === undefined || role.status === activeFilters.status
+    return matchName && matchCode && matchStatus
+  })
+})
+
+watch(menuExpanded, (checked) => {
+  expandedKeys.value = checked ? allMenuKeys.value : []
+})
+
+watch(menuCheckedAll, (checked) => {
+  form.menuIds = checked ? allMenuKeys.value : []
+})
+
+onMounted(load)
+
+async function load() {
+  roles.value = await roleApi.list()
+  menus.value = await menuApi.tree()
+}
+
+function applyFilters() {
+  Object.assign(activeFilters, filters)
+}
+
+function resetFilters() {
+  Object.assign(filters, { roleName: '', roleCode: '', status: undefined })
+  applyFilters()
+}
+
+function openCreate() {
+  Object.assign(form, { id: null, roleName: '', roleCode: '', sort: roles.value.length + 1, status: 1, menuIds: [] })
+  menuExpanded.value = false
+  menuCheckedAll.value = false
+  expandedKeys.value = []
+  dialogVisible.value = true
+}
+
+async function edit(role) {
+  Object.assign(form, role, { menuIds: [] })
+  expandedKeys.value = allMenuKeys.value
+  menuExpanded.value = true
+  menuCheckedAll.value = false
+  dialogVisible.value = true
+
+  if (role.roleCode === 'admin') {
+    form.menuIds = allMenuKeys.value
+    menuCheckedAll.value = true
+    return
+  }
+
+  try {
+    form.menuIds = await roleApi.menuIds(role.id)
+    menuCheckedAll.value = form.menuIds.length > 0 && form.menuIds.length === allMenuKeys.value.length
+  } catch {
+    Message.error('获取角色菜单权限失败')
+  }
+}
+
+async function save() {
+  if (!form.roleName || !form.roleCode) {
+    Message.warning('请填写角色名称和权限字符')
+    return
+  }
+  await roleApi.save({ ...form })
+  Message.success(form.id ? '修改成功' : '新增成功')
+  await load()
+  dialogVisible.value = false
+}
+
+function changeStatus(role, checked) {
+  role.status = checked ? 1 : 0
+  roleApi.save({ ...role, menuIds: role.menuIds || [] }).then(() => {
+    Message.success('状态修改成功')
+  }).catch(async () => {
+    Message.error('状态修改失败')
+    await load()
+  })
+}
+
+function remove(id) {
+  Modal.confirm({
+    title: '确认删除该角色吗?',
+    content: '删除后不可恢复。',
+    onOk: async () => {
+      await roleApi.remove(id)
+      Message.success('删除成功')
+      await load()
+    }
+  })
+}
+
+function handleMenuCheck(keys) {
+  form.menuIds = Array.isArray(keys) ? keys : keys?.checked || []
+}
+
+function mapTree(nodes) {
+  return (nodes || []).map((node) => ({
+    key: node.id,
+    title: node.title,
+    children: mapTree(node.children || [])
+  }))
+}
+
+function collectKeys(nodes) {
+  return nodes.flatMap((node) => [node.key, ...collectKeys(node.children || [])])
+}
+
+function formatTime(value) {
+  return value ? String(value).replace('T', ' ') : '-'
+}
+</script>

+ 262 - 0
src/views/system/UserList.vue

@@ -0,0 +1,262 @@
+<template>
+  <div class="dashboard-page">
+    <section class="section-card filter-card">
+      <div class="section-title">筛选条件</div>
+      <div class="filter-grid">
+        <label>
+          <span>用户名</span>
+          <a-input v-model="filters.username" allow-clear placeholder="请输入用户名" />
+        </label>
+        <label>
+          <span>昵称</span>
+          <a-input v-model="filters.nickname" allow-clear placeholder="请输入昵称" />
+        </label>
+        <label>
+          <span>部门</span>
+          <a-select v-model="filters.deptId" allow-clear placeholder="请选择部门">
+            <a-option v-for="dept in deptOptions" :key="dept.id" :value="dept.id">
+              {{ dept.label }}
+            </a-option>
+          </a-select>
+        </label>
+        <label>
+          <span>状态</span>
+          <a-select v-model="filters.status" allow-clear placeholder="请选择状态">
+            <a-option :value="1">正常</a-option>
+            <a-option :value="0">停用</a-option>
+          </a-select>
+        </label>
+      </div>
+      <div class="filter-actions">
+        <a-button type="primary" @click="applyFilters">查询</a-button>
+        <a-button @click="resetFilters">重置</a-button>
+      </div>
+    </section>
+
+    <section class="section-card">
+      <div class="section-head">
+        <div class="section-title">用户列表</div>
+        <a-button type="primary" class="section-add-button" @click="openCreate">
+          <template #icon>+</template>
+          新增用户
+        </a-button>
+      </div>
+      <a-table :data="filteredUsers" :pagination="false" row-key="id" :bordered="false">
+        <template #columns>
+          <a-table-column title="用户名" data-index="username" />
+          <a-table-column title="昵称" data-index="nickname" />
+          <a-table-column title="部门" :width="160">
+            <template #cell="{ record }">{{ record.deptName || '-' }}</template>
+          </a-table-column>
+          <a-table-column title="角色" :width="220">
+            <template #cell="{ record }">
+              <a-space wrap>
+                <a-tag v-for="roleName in record.roleNames || []" :key="roleName" color="blue">
+                  {{ roleName }}
+                </a-tag>
+                <span v-if="!record.roleNames?.length">-</span>
+              </a-space>
+            </template>
+          </a-table-column>
+          <a-table-column title="状态" :width="120" align="center">
+            <template #cell="{ record }">
+              <a-switch
+                :model-value="record.status === 1"
+                checked-text="开"
+                unchecked-text="关"
+                @change="(checked) => changeStatus(record, checked)"
+              />
+            </template>
+          </a-table-column>
+          <a-table-column title="创建时间" data-index="createTime" :width="190" align="center">
+            <template #cell="{ record }">{{ formatTime(record.createTime) }}</template>
+          </a-table-column>
+          <a-table-column title="操作" :width="150" align="center">
+            <template #cell="{ record }">
+              <button class="text-action" type="button" @click="edit(record)">修改</button>
+              <a-divider direction="vertical" />
+              <button class="text-action danger" type="button" @click="remove(record.id)">删除</button>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </section>
+
+    <FormDialog v-model="dialogVisible" :title="form.id ? '修改用户' : '新增用户'" @submit="save">
+      <a-form :model="form" layout="vertical" class="drawer-form-inner">
+        <a-form-item label="用户名" required>
+          <a-input v-model="form.username" placeholder="请输入用户名" />
+        </a-form-item>
+        <a-form-item label="昵称" required>
+          <a-input v-model="form.nickname" placeholder="请输入昵称" />
+        </a-form-item>
+        <a-form-item label="密码" :required="!form.id">
+          <a-input-password v-model="form.password" :placeholder="form.id ? '不填写则不修改密码' : '请输入密码'" />
+        </a-form-item>
+        <a-form-item label="所属部门">
+          <a-select v-model="form.deptId" allow-clear placeholder="请选择部门">
+            <a-option v-for="dept in deptOptions" :key="dept.id" :value="dept.id">
+              {{ dept.label }}
+            </a-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item label="角色">
+          <a-select v-model="form.roleIds" multiple allow-clear placeholder="请选择角色">
+            <a-option v-for="role in roles" :key="role.id" :value="role.id">
+              {{ role.roleName }}
+            </a-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item label="状态" required>
+          <a-radio-group v-model="form.status" type="button">
+            <a-radio :value="1">正常</a-radio>
+            <a-radio :value="0">停用</a-radio>
+          </a-radio-group>
+        </a-form-item>
+      </a-form>
+    </FormDialog>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, reactive, ref } from 'vue'
+import { Message, Modal } from '@arco-design/web-vue'
+import FormDialog from '../../components/FormDialog.vue'
+import { deptApi, roleApi, userApi } from '../../api/system'
+
+const users = ref([])
+const roles = ref([])
+const depts = ref([])
+const dialogVisible = ref(false)
+const filters = reactive({ username: '', nickname: '', deptId: undefined, status: undefined })
+const activeFilters = reactive({ username: '', nickname: '', deptId: undefined, status: undefined })
+const form = reactive({
+  id: null,
+  username: '',
+  password: '',
+  nickname: '',
+  deptId: undefined,
+  status: 1,
+  roleIds: []
+})
+
+const deptOptions = computed(() => flattenDepts(depts.value))
+const filteredUsers = computed(() => {
+  const username = activeFilters.username.trim()
+  const nickname = activeFilters.nickname.trim()
+  return users.value.filter((user) => {
+    const matchUsername = !username || user.username?.includes(username)
+    const matchNickname = !nickname || user.nickname?.includes(nickname)
+    const matchDept = activeFilters.deptId === undefined || user.deptId === activeFilters.deptId
+    const matchStatus = activeFilters.status === undefined || user.status === activeFilters.status
+    return matchUsername && matchNickname && matchDept && matchStatus
+  })
+})
+
+onMounted(load)
+
+async function load() {
+  const [userList, roleList, deptTree] = await Promise.all([
+    userApi.list(),
+    roleApi.list(),
+    deptApi.tree()
+  ])
+  users.value = userList
+  roles.value = roleList
+  depts.value = deptTree
+}
+
+function applyFilters() {
+  Object.assign(activeFilters, filters)
+}
+
+function resetFilters() {
+  Object.assign(filters, { username: '', nickname: '', deptId: undefined, status: undefined })
+  applyFilters()
+}
+
+function openCreate() {
+  Object.assign(form, {
+    id: null,
+    username: '',
+    password: '',
+    nickname: '',
+    deptId: undefined,
+    status: 1,
+    roleIds: []
+  })
+  dialogVisible.value = true
+}
+
+function edit(user) {
+  Object.assign(form, {
+    id: user.id,
+    username: user.username,
+    password: '',
+    nickname: user.nickname,
+    deptId: user.deptId,
+    status: user.status,
+    roleIds: user.roleIds || []
+  })
+  dialogVisible.value = true
+}
+
+async function save() {
+  if (!form.username || !form.nickname) {
+    Message.warning('请填写用户名和昵称')
+    return
+  }
+  if (!form.id && !form.password) {
+    Message.warning('请填写密码')
+    return
+  }
+  await userApi.save({ ...form })
+  Message.success(form.id ? '修改成功' : '新增成功')
+  await load()
+  dialogVisible.value = false
+}
+
+function changeStatus(user, checked) {
+  user.status = checked ? 1 : 0
+  const payload = {
+    id: user.id,
+    username: user.username,
+    nickname: user.nickname,
+    deptId: user.deptId,
+    status: user.status,
+    roleIds: user.roleIds || []
+  }
+  userApi.save(payload).then(() => {
+    Message.success('状态修改成功')
+  }).catch(async () => {
+    Message.error('状态修改失败')
+    await load()
+  })
+}
+
+function remove(id) {
+  Modal.confirm({
+    title: '确认删除该用户吗?',
+    content: '删除后不可恢复。',
+    onOk: async () => {
+      await userApi.remove(id)
+      Message.success('删除成功')
+      await load()
+    }
+  })
+}
+
+function flattenDepts(nodes, level = 0) {
+  return (nodes || []).flatMap((dept) => [
+    {
+      id: dept.id,
+      label: `${' '.repeat(level)}${dept.deptName}`
+    },
+    ...flattenDepts(dept.children || [], level + 1)
+  ])
+}
+
+function formatTime(value) {
+  return value ? String(value).replace('T', ' ') : '-'
+}
+</script>

+ 21 - 0
vite.config.js

@@ -0,0 +1,21 @@
+import { defineConfig, loadEnv } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+export default defineConfig(({ mode }) => {
+  const env = loadEnv(mode, process.cwd())
+  const apiBaseUrl = env.VITE_API_BASE_URL || '/api'
+  const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:9028'
+
+  return {
+    plugins: [vue()],
+    server: {
+      port: 5173,
+      proxy: {
+        [apiBaseUrl]: {
+          target: apiProxyTarget,
+          changeOrigin: true
+        }
+      }
+    }
+  }
+})