项目初始化

This commit is contained in:
Stev_Wang
2026-01-04 17:19:04 +08:00
commit 93aae460af
41 changed files with 6922 additions and 0 deletions

57
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,57 @@
<template>
<n-config-provider :theme="theme" :theme-overrides="themeOverrides" :locale="zhCN" :date-locale="dateZhCN">
<n-message-provider>
<router-view />
</n-message-provider>
</n-config-provider>
</template>
<script setup lang="ts">
import { NConfigProvider, NMessageProvider, darkTheme, zhCN, dateZhCN } from 'naive-ui'
import { ref } from 'vue'
// 主题配置,默认使用亮色主题
const theme = ref(null)
// 可以根据需要切换为暗色主题
// const theme = ref(darkTheme)
// 主题覆盖配置
const themeOverrides = {
common: {
primaryColor: '#18a058',
primaryColorHover: '#36ad6a',
primaryColorPressed: '#0c7a43',
primaryColorSuppl: '#36ad6a',
borderRadius: '4px'
},
Button: {
borderRadius: '4px'
},
Input: {
borderRadius: '4px'
},
Card: {
borderRadius: '8px'
},
Modal: {
borderRadius: '8px'
}
}
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
#app {
width: 100%;
height: 100vh;
}
</style>

16
frontend/src/api/admin.ts Normal file
View File

@@ -0,0 +1,16 @@
import request from '@/utils/request'
export const login = (username: string, password: string) => {
return request({
url: '/api/admin/login',
method: 'post',
data: { username, password }
})
}
export const getCurrentUser = () => {
return request({
url: '/api/admin/me',
method: 'get'
})
}

View File

@@ -0,0 +1,23 @@
import request from '@/utils/request'
export const login = (username: string, password: string) => {
return request({
url: '/api/player/login',
method: 'post',
data: { username, password }
})
}
export const logout = () => {
return request({
url: '/api/player/logout',
method: 'post'
})
}
export const getAccountInfo = () => {
return request({
url: '/api/player/account',
method: 'get'
})
}

View File

@@ -0,0 +1,119 @@
<template>
<n-layout style="height: 100vh" has-sider>
<n-layout-sider
bordered
show-trigger
collapse-mode="width"
:collapsed-width="64"
:width="240"
:native-scrollbar="false"
>
<div style="height: 60px; display: flex; align-items: center; justify-content: center; border-bottom: 1px solid #e8e8e8;">
<h3 style="margin: 0; color: #18a058;">运营管理</h3>
</div>
<n-menu
:collapsed-width="64"
:collapsed-icon-size="22"
:options="menuOptions"
:value="activeKey"
@update:value="handleMenuSelect"
/>
</n-layout-sider>
<n-layout>
<n-layout-header bordered style="height: 60px; padding: 0 24px; display: flex; align-items: center; justify-content: space-between;">
<div class="header-left">
<h2 style="margin: 0;">梦幻西游运营管理系统</h2>
</div>
<div class="header-right" v-if="adminStore.userInfo">
<n-dropdown :options="userMenuOptions" @select="handleUserMenuSelect">
<div class="user-dropdown-trigger">
<span>{{ adminStore.userInfo.username }}</span>
<RiArrowDownSLine style="margin-left: 4px;" />
</div>
</n-dropdown>
</div>
</n-layout-header>
<n-layout-content style="padding: 24px;">
<router-view />
</n-layout-content>
<n-layout-footer bordered style="padding: 16px; text-align: center;">
<p style="margin: 0;">© 2026 梦幻西游一站式运营管理平台 - 运营管理系统</p>
</n-layout-footer>
</n-layout>
</n-layout>
</template>
<script setup lang="ts">
import { ref, h } from 'vue'
import { NLayout, NLayoutSider, NLayoutHeader, NLayoutContent, NLayoutFooter, NMenu, NDropdown } from 'naive-ui'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useAdminStore } from '@/stores/admin'
import { RiDashboardLine, RiArrowDownSLine, RiUserLine, RiLogoutBoxRLine } from '@remixicon/vue'
const route = useRoute()
const router = useRouter()
const adminStore = useAdminStore()
const activeKey = ref(String(route.name))
const menuOptions = [
{
label: () => h(RouterLink, { to: '/admin/dashboard' }, { default: () => '工作台' }),
key: 'AdminDashboard',
icon: () => h(RiDashboardLine, { size: '20px' })
}
]
const userMenuOptions = [
{
label: '用户信息',
key: 'userInfo',
icon: () => h(RiUserLine, { size: '18px' })
},
{
type: 'divider',
key: 'd1'
},
{
label: '退出登录',
key: 'logout',
icon: () => h(RiLogoutBoxRLine, { size: '18px' })
}
]
const handleMenuSelect = (key: string) => {
activeKey.value = key
}
const handleUserMenuSelect = (key: string) => {
if (key === 'logout') {
handleLogout()
} else if (key === 'userInfo') {
console.log('用户信息')
}
}
const handleLogout = () => {
adminStore.logout()
router.push('/admin/login')
}
</script>
<style scoped>
.header-left h2 {
color: #18a058;
}
.user-dropdown-trigger {
display: flex;
align-items: center;
cursor: pointer;
padding: 8px 16px;
border-radius: 4px;
transition: background-color 0.3s;
}
.user-dropdown-trigger:hover {
background-color: #f0f0f0;
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<n-layout style="height: 100vh">
<n-layout-header bordered style="height: 60px; padding: 0 24px; display: flex; align-items: center; justify-content: space-between;">
<div class="header-left">
<h2 style="margin: 0;">梦幻西游玩家服务中心</h2>
</div>
<div class="header-right" v-if="playerStore.userInfo">
<span style="margin-right: 16px;">欢迎, {{ playerStore.userInfo.username }}</span>
<n-button type="error" @click="handleLogout">退出登录</n-button>
</div>
</n-layout-header>
<n-layout-content style="padding: 24px;">
<router-view />
</n-layout-content>
<n-layout-footer bordered style="padding: 16px; text-align: center;">
<p style="margin: 0;">© 2026 梦幻西游一站式运营管理平台 - 玩家服务中心</p>
</n-layout-footer>
</n-layout>
</template>
<script setup lang="ts">
import { NLayout, NLayoutHeader, NLayoutContent, NLayoutFooter, NButton } from 'naive-ui'
import { usePlayerStore } from '@/stores/player'
import { useRouter } from 'vue-router'
const playerStore = usePlayerStore()
const router = useRouter()
const handleLogout = async () => {
await playerStore.logout()
router.push('/player/login')
}
</script>
<style scoped>
.header-left h2 {
color: #18a058;
}
</style>

11
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,81 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
redirect: '/player'
},
{
path: '/player/login',
name: 'PlayerLogin',
component: () => import('@/views/player/Login.vue'),
meta: { title: '玩家登录' }
},
{
path: '/player',
component: () => import('@/layouts/PlayerLayout.vue'),
children: [
{
path: 'dashboard',
name: 'PlayerDashboard',
component: () => import('@/views/player/Dashboard.vue'),
meta: { title: '玩家控制台', requiresAuth: true }
}
]
},
{
path: '/admin/login',
name: 'AdminLogin',
component: () => import('@/views/admin/Login.vue'),
meta: { title: '管理员登录' }
},
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),
children: [
{
path: 'dashboard',
name: 'AdminDashboard',
component: () => import('@/views/admin/Dashboard.vue'),
meta: { title: '管理控制台', requiresAdminAuth: true }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to, from, next) => {
document.title = (to.meta.title as string) || '梦幻西游一站式运营管理平台'
if (to.path.startsWith('/player')) {
if (to.path === '/player/login') {
next()
} else {
const playerToken = sessionStorage.getItem('player_token')
if (playerToken) {
next()
} else {
next('/player/login')
}
}
} else if (to.path.startsWith('/admin')) {
if (to.path === '/admin/login') {
next()
} else {
const adminToken = localStorage.getItem('admin_token')
if (adminToken) {
next()
} else {
next('/admin/login')
}
}
} else {
next()
}
})
export default router

View File

@@ -0,0 +1,60 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { login as loginApi } from '@/api/admin'
export const useAdminStore = defineStore('admin', () => {
const token = ref<string | null>(localStorage.getItem('admin_token'))
const userInfo = ref<any>(JSON.parse(localStorage.getItem('admin_userInfo') || 'null'))
const setToken = (newToken: string) => {
token.value = newToken
localStorage.setItem('admin_token', newToken)
}
const setUserInfo = (info: any) => {
userInfo.value = info
localStorage.setItem('admin_userInfo', JSON.stringify(info))
}
const clearToken = () => {
token.value = null
localStorage.removeItem('admin_token')
}
const clearUserInfo = () => {
userInfo.value = null
localStorage.removeItem('admin_userInfo')
}
const login = async (username: string, password: string) => {
try {
const response = await loginApi(username, password)
const data = response.data
if (data.success && data.data) {
setToken(data.data.token)
setUserInfo(data.data.user)
return { success: true, message: data.message || '登录成功' }
}
return { success: false, message: data.message || '登录失败' }
} catch (error: any) {
console.error('登录失败:', error)
return { success: false, message: error.response?.data?.message || '网络错误,请稍后重试' }
}
}
const logout = () => {
clearToken()
clearUserInfo()
}
return {
token,
userInfo,
setToken,
setUserInfo,
clearToken,
clearUserInfo,
login,
logout
}
})

View File

@@ -0,0 +1,61 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { login as loginApi, logout as logoutApi, getAccountInfo } from '@/api/player'
export const usePlayerStore = defineStore('player', () => {
const token = ref<string | null>(sessionStorage.getItem('player_token'))
const userInfo = ref<any>(null)
const setToken = (newToken: string) => {
token.value = newToken
sessionStorage.setItem('player_token', newToken)
}
const clearToken = () => {
token.value = null
sessionStorage.removeItem('player_token')
}
const login = async (username: string, password: string) => {
const response = await loginApi(username, password)
if (response.success && response.data) {
setToken(response.data)
return true
}
return false
}
const logout = async () => {
try {
await logoutApi()
} catch (error) {
console.error('退出登录失败:', error)
} finally {
clearToken()
userInfo.value = null
}
}
const fetchUserInfo = async () => {
try {
const response = await getAccountInfo()
if (response.success && response.data) {
userInfo.value = response.data
return response.data
}
} catch (error) {
console.error('获取用户信息失败:', error)
}
return null
}
return {
token,
userInfo,
setToken,
clearToken,
login,
logout,
fetchUserInfo
}
})

View File

@@ -0,0 +1,38 @@
import axios from 'axios'
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000
})
request.interceptors.request.use(
(config) => {
if (config.url?.startsWith('/api/player/')) {
const playerToken = sessionStorage.getItem('player_token')
if (playerToken) {
config.headers.Authorization = playerToken
}
} else if (config.url?.startsWith('/api/admin/')) {
const adminToken = localStorage.getItem('admin_token')
if (adminToken) {
config.headers.Authorization = `Bearer ${adminToken}`
}
}
return config
},
(error) => {
return Promise.reject(error)
}
)
request.interceptors.response.use(
(response) => {
return response.data
},
(error) => {
console.error('请求错误:', error)
return Promise.reject(error)
}
)
export default request

View File

@@ -0,0 +1,35 @@
<template>
<div class="dashboard-container">
<n-card title="欢迎来到运营管理系统">
<n-space vertical size="large">
<n-alert type="success" title="欢迎回来!">
感谢您使用梦幻西游一站式运营管理平台
</n-alert>
<n-statistic label="系统状态" value="正常运行">
<template #suffix>
<RiCheckboxCircleLine size="24px" color="#18a058" />
</template>
</n-statistic>
<n-card title="快速导航" size="small">
<n-space>
<n-button type="primary" disabled>用户管理</n-button>
<n-button type="primary" disabled>工单管理</n-button>
<n-button type="primary" disabled>公告管理</n-button>
<n-button type="primary" disabled>数据看板</n-button>
</n-space>
</n-card>
</n-space>
</n-card>
</div>
</template>
<script setup lang="ts">
import { NCard, NSpace, NAlert, NStatistic, NButton } from 'naive-ui'
import { RiCheckboxCircleLine } from '@remixicon/vue'
</script>
<style scoped>
.dashboard-container {
max-width: 800px;
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<div class="login-container">
<n-card title="管理员登录" style="width: 400px;">
<n-form ref="formRef" :model="formValue" :rules="rules" size="large">
<n-form-item path="username" label="用户名">
<n-input v-model:value="formValue.username" placeholder="请输入用户名" />
</n-form-item>
<n-form-item path="password" label="密码">
<n-input
v-model:value="formValue.password"
type="password"
show-password-on="click"
placeholder="请输入密码"
/>
</n-form-item>
<n-form-item>
<n-button type="primary" block @click="handleLogin" :loading="loading">
登录
</n-button>
</n-form-item>
</n-form>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { NCard, NForm, NFormItem, NInput, NButton, useMessage } from 'naive-ui'
import { useAdminStore } from '@/stores/admin'
const router = useRouter()
const message = useMessage()
const adminStore = useAdminStore()
const formRef = ref()
const loading = ref(false)
const formValue = ref({
username: '',
password: ''
})
const rules = {
username: {
required: true,
message: '请输入用户名',
trigger: 'blur'
},
password: {
required: true,
message: '请输入密码',
trigger: 'blur'
}
}
const handleLogin = async () => {
try {
await formRef.value?.validate()
loading.value = true
const result = await adminStore.login(formValue.value.username, formValue.value.password)
if (result.success) {
message.success(result.message)
router.push('/admin/dashboard')
} else {
message.error(result.message)
}
} catch (error) {
console.error('登录错误:', error)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<div class="dashboard-container">
<n-card title="账号信息" v-if="playerStore.userInfo">
<n-descriptions bordered :column="1">
<n-descriptions-item label="用户名">
{{ playerStore.userInfo.username }}
</n-descriptions-item>
<n-descriptions-item label="账号ID">
{{ playerStore.userInfo.accountId }}
</n-descriptions-item>
<n-descriptions-item label="创建时间">
{{ playerStore.userInfo.createdAt }}
</n-descriptions-item>
</n-descriptions>
</n-card>
<n-card title="欢迎来到玩家控制台" v-else>
<p>正在加载账号信息...</p>
</n-card>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { NCard, NDescriptions, NDescriptionsItem } from 'naive-ui'
import { usePlayerStore } from '@/stores/player'
const playerStore = usePlayerStore()
onMounted(async () => {
await playerStore.fetchUserInfo()
})
</script>
<style scoped>
.dashboard-container {
max-width: 800px;
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<div class="login-container">
<n-card title="玩家登录" style="width: 400px;">
<n-form ref="formRef" :model="formValue" :rules="rules" size="large">
<n-form-item path="username" label="用户名">
<n-input v-model:value="formValue.username" placeholder="请输入用户名" />
</n-form-item>
<n-form-item path="password" label="密码">
<n-input
v-model:value="formValue.password"
type="password"
show-password-on="click"
placeholder="请输入密码"
/>
</n-form-item>
<n-form-item>
<n-button type="primary" block @click="handleLogin" :loading="loading">
登录
</n-button>
</n-form-item>
</n-form>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { NCard, NForm, NFormItem, NInput, NButton, useMessage } from 'naive-ui'
import { usePlayerStore } from '@/stores/player'
const router = useRouter()
const message = useMessage()
const playerStore = usePlayerStore()
const formRef = ref()
const loading = ref(false)
const formValue = ref({
username: '',
password: ''
})
const rules = {
username: {
required: true,
message: '请输入用户名',
trigger: 'blur'
},
password: {
required: true,
message: '请输入密码',
trigger: 'blur'
}
}
const handleLogin = async () => {
try {
await formRef.value?.validate()
loading.value = true
const success = await playerStore.login(formValue.value.username, formValue.value.password)
if (success) {
message.success('登录成功')
router.push('/player/dashboard')
} else {
message.error('登录失败,请检查用户名和密码')
}
} catch (error) {
console.error('登录错误:', error)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
</style>