chore: initial commit

这个提交包含在:
XuqmGroup 2026-04-21 22:07:29 +08:00
当前提交 aaed19de05
共有 41 个文件被更改,包括 1679 次插入0 次删除

10
.gitignore vendored 普通文件
查看文件

@ -0,0 +1,10 @@
node_modules/
dist/
.DS_Store
*.class
target/
build/
.gradle/
*.iml
.idea/
*.log

9
ops-platform/env.d.ts vendored 普通文件
查看文件

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

12
ops-platform/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>XuqmGroup 运营平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

27
ops-platform/package.json 普通文件
查看文件

@ -0,0 +1,27 @@
{
"name": "ops-platform",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5174",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"element-plus": "^2.9.1",
"@element-plus/icons-vue": "^2.3.1",
"pinia": "^3.0.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"typescript": "^5.8.2",
"vite": "^6.2.2",
"vue-tsc": "^2.2.8",
"unplugin-auto-import": "^0.18.2",
"unplugin-vue-components": "^0.27.4"
}
}

3
ops-platform/src/App.vue 普通文件
查看文件

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

查看文件

@ -0,0 +1,24 @@
import axios from 'axios'
const client = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
})
client.interceptors.request.use((config) => {
const token = localStorage.getItem('ops_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
client.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('ops_token')
window.location.href = '/login'
}
return Promise.reject(err)
}
)
export default client

37
ops-platform/src/api/ops.ts 普通文件
查看文件

@ -0,0 +1,37 @@
import client from './client'
export interface TenantItem {
id: string
username: string
nickname: string
email: string
phone?: string
type: 'MAIN' | 'SUB'
status: 'ACTIVE' | 'DISABLED' | 'PENDING_EMAIL'
parentId?: string
createdAt: string
}
export interface TenantPage {
content: TenantItem[]
total: number
totalPages: number
}
export interface Statistics {
totalTenants: number
todayNew: number
activeApps: number
onlineUsers: number
}
export const opsApi = {
listTenants: (keyword = '', page = 0, size = 20) =>
client.get<{ data: TenantPage }>('/ops/tenants', { params: { keyword, page, size } }),
toggleStatus: (id: string) =>
client.post(`/ops/tenants/${id}/toggle-status`),
statistics: () =>
client.get<{ data: Statistics }>('/ops/statistics'),
}

13
ops-platform/src/main.ts 普通文件
查看文件

@ -0,0 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia()).use(router).use(ElementPlus).mount('#app')

查看文件

@ -0,0 +1,25 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: () => import('@/views/auth/LoginView.vue') },
{
path: '/',
component: () => import('@/views/layout/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', redirect: '/tenants' },
{ path: 'tenants', component: () => import('@/views/tenants/TenantListView.vue') },
{ path: 'statistics', component: () => import('@/views/statistics/StatisticsView.vue') },
],
},
],
})
router.beforeEach((to) => {
const token = localStorage.getItem('ops_token')
if (to.meta.requiresAuth && !token) return '/login'
})
export default router

查看文件

@ -0,0 +1,40 @@
<template>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f0f2f5">
<el-card style="width:360px">
<h2 style="text-align:center;margin-bottom:24px">运营平台登录</h2>
<el-form :model="form" @submit.prevent="login">
<el-form-item>
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
</el-form-item>
<el-form-item>
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
</el-form-item>
<el-button type="primary" native-type="submit" style="width:100%" :loading="loading">登录</el-button>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const router = useRouter()
const form = reactive({ username: '', password: '' })
const loading = ref(false)
async function login() {
loading.value = true
try {
const res = await axios.post('/api/auth/ops/login', form)
localStorage.setItem('ops_token', res.data.data.token)
router.push('/tenants')
} catch (e: any) {
ElMessage.error(e.response?.data?.message ?? '登录失败')
} finally {
loading.value = false
}
}
</script>

查看文件

@ -0,0 +1,28 @@
<template>
<el-container style="height:100vh">
<el-aside width="200px" style="background:#1d2129">
<div style="height:60px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:bold;font-size:16px;border-bottom:1px solid #2d3142">
XuqmGroup 运营平台
</div>
<el-menu router :default-active="$route.path" background-color="#1d2129" text-color="#c9d1d9" active-text-color="#409eff">
<el-menu-item index="/tenants"><el-icon><Avatar /></el-icon></el-menu-item>
<el-menu-item index="/statistics"><el-icon><TrendCharts /></el-icon></el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header style="background:#fff;border-bottom:1px solid #e8e8e8;display:flex;align-items:center;justify-content:flex-end">
<el-button link @click="logout">退出登录</el-button>
</el-header>
<el-main><router-view /></el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
function logout() {
localStorage.removeItem('ops_token')
router.push('/login')
}
</script>

查看文件

@ -0,0 +1,42 @@
<template>
<div>
<h2 style="margin-bottom:24px">数据统计</h2>
<el-row :gutter="16">
<el-col :span="6" v-for="item in stats" :key="item.label">
<el-card shadow="hover">
<el-statistic :title="item.label" :value="item.value" />
</el-card>
</el-col>
</el-row>
<el-card style="margin-top:24px">
<template #header>近7天注册趋势</template>
<div style="text-align:center;padding:40px;color:#999">
图表区域 可集成 ECharts Chart.js
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { opsApi } from '@/api/ops'
const stats = ref([
{ label: '总租户数', value: 0 },
{ label: '今日新增', value: 0 },
{ label: '活跃应用', value: 0 },
{ label: '在线用户', value: 0 },
])
onMounted(async () => {
try {
const res = await opsApi.statistics()
const d = res.data.data
stats.value[0].value = d.totalTenants ?? 0
stats.value[1].value = d.todayNew ?? 0
stats.value[2].value = d.activeApps ?? 0
stats.value[3].value = d.onlineUsers ?? 0
} catch {}
})
</script>

查看文件

@ -0,0 +1,79 @@
<template>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2>租户管理</h2>
<el-input v-model="search" placeholder="搜索用户名/邮箱" style="width:240px" clearable @clear="loadTenants" @keyup.enter="loadTenants">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</div>
<el-table :data="tenants" v-loading="loading">
<el-table-column prop="username" label="用户名" />
<el-table-column prop="nickname" label="昵称" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="type" label="类型">
<template #default="{ row }">
<el-tag :type="row.type === 'MAIN' ? 'primary' : 'info'">
{{ row.type === 'MAIN' ? '主账号' : '子账号' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="row.status === 'ACTIVE' ? 'success' : 'danger'">
{{ row.status === 'ACTIVE' ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="注册时间" width="180">
<template #default="{ row }">{{ new Date(row.createdAt).toLocaleString('zh-CN') }}</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button link :type="row.status === 'ACTIVE' ? 'danger' : 'success'"
@click="toggleStatus(row)">
{{ row.status === 'ACTIVE' ? '禁用' : '启用' }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top:16px"
:current-page="page + 1" :page-size="size" :total="total"
layout="prev, pager, next" @current-change="(p: number) => { page = p - 1; loadTenants() }" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { opsApi, type TenantItem } from '@/api/ops'
const tenants = ref<TenantItem[]>([])
const loading = ref(false)
const search = ref('')
const page = ref(0)
const size = ref(20)
const total = ref(0)
async function loadTenants() {
loading.value = true
try {
const res = await opsApi.listTenants(search.value, page.value, size.value)
tenants.value = res.data.data.content
total.value = res.data.data.total
} catch {
tenants.value = []
} finally {
loading.value = false
}
}
async function toggleStatus(row: TenantItem) {
await opsApi.toggleStatus(row.id)
ElMessage.success('状态已更新')
loadTenants()
}
onMounted(loadTenants)
</script>

查看文件

@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

查看文件

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.app.json" }
]
}

查看文件

@ -0,0 +1,7 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": ["vite.config.*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}

23
ops-platform/vite.config.ts 普通文件
查看文件

@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
vue(),
AutoImport({ resolvers: [ElementPlusResolver()] }),
Components({ resolvers: [ElementPlusResolver()] }),
],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
server: {
port: 5174,
proxy: {
'/api': { target: 'http://localhost:8081', changeOrigin: true },
},
},
})

14
package.json 普通文件
查看文件

@ -0,0 +1,14 @@
{
"name": "xuqmgroup-web",
"private": true,
"packageManager": "yarn@1.22.22",
"workspaces": [
"tenant-platform",
"ops-platform"
],
"scripts": {
"dev:tenant": "yarn workspace tenant-platform dev",
"dev:ops": "yarn workspace ops-platform dev",
"build": "yarn workspaces run build"
}
}

13
tenant-platform/index.html 普通文件
查看文件

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XuqmGroup 开放平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

查看文件

@ -0,0 +1,27 @@
{
"name": "tenant-platform",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"element-plus": "^2.9.1",
"@element-plus/icons-vue": "^2.3.1",
"pinia": "^3.0.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"typescript": "^5.8.2",
"vite": "^6.2.2",
"vue-tsc": "^2.2.8",
"unplugin-auto-import": "^0.18.2",
"unplugin-vue-components": "^0.27.4"
}
}

查看文件

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

查看文件

@ -0,0 +1,29 @@
import client from './client'
export interface SubAccount {
id: string
username: string
email: string
nickname: string
phone?: string
status: 'ACTIVE' | 'DISABLED'
createdAt: string
}
export const accountApi = {
list: () => client.get<{ data: SubAccount[] }>('/sub-accounts'),
sendVerifyCode: (email: string) =>
client.post('/sub-accounts/send-verify-code', null, { params: { email } }),
verifyEmail: (email: string, code: string) =>
client.post('/sub-accounts/verify-email', null, { params: { email, code } }),
create: (data: { username: string; password: string; email?: string; nickname: string; phone?: string }) =>
client.post<{ data: SubAccount }>('/sub-accounts', data),
disable: (id: string) => client.delete(`/sub-accounts/${id}`),
generatePassword: () =>
client.get<{ data: { password: string } }>('/sub-accounts/generate-password'),
}

查看文件

@ -0,0 +1,53 @@
import client from './client'
export interface App {
id: string
tenantId: string
packageName: string
name: string
description?: string
iconUrl?: string
appKey: string
appSecret: string
createdAt: string
}
export interface CreateAppRequest {
packageName: string
name: string
description?: string
iconUrl?: string
}
export interface FeatureService {
id: string
appId: string
platform: 'ANDROID' | 'IOS' | 'HARMONY'
serviceType: 'IM' | 'PUSH' | 'UPDATE'
enabled: boolean
secretKey: string
createdAt: string
}
export const appApi = {
list: () => client.get<{ data: App[] }>('/apps'),
get: (id: string) => client.get<{ data: App }>(`/apps/${id}`),
create: (data: CreateAppRequest) => client.post<{ data: App }>('/apps', data),
update: (id: string, data: CreateAppRequest) => client.put<{ data: App }>(`/apps/${id}`, data),
delete: (id: string) => client.delete(`/apps/${id}`),
getServices: (appId: string) =>
client.get<{ data: FeatureService[] }>(`/apps/${appId}/services`),
toggleService: (appId: string, platform: string, serviceType: string, enable: boolean) =>
client.post<{ data: FeatureService }>(`/apps/${appId}/services/toggle`, null, {
params: { platform, serviceType, enable },
}),
regenerateKey: (appId: string, serviceId: string) =>
client.post<{ data: FeatureService }>(`/apps/${appId}/services/${serviceId}/regenerate-key`),
}

查看文件

@ -0,0 +1,35 @@
import client from './client'
export interface LoginRequest {
account: string
password: string
captchaKey: string
captchaCode: string
}
export interface RegisterRequest {
username: string
password: string
email: string
nickname: string
phone?: string
emailCode: string
}
export const authApi = {
getCaptcha: () => client.get<{ data: { key: string; image: string } }>('/auth/captcha'),
sendEmailCode: (email: string, purpose: string) =>
client.post('/auth/send-email-code', null, { params: { email, purpose } }),
register: (data: RegisterRequest) => client.post('/auth/register', data),
login: (data: LoginRequest) =>
client.post<{ data: { token: string } }>('/auth/login', data),
forgotPassword: (email: string) =>
client.post('/auth/forgot-password', null, { params: { email } }),
resetPassword: (email: string, code: string, newPassword: string) =>
client.post('/auth/reset-password', null, { params: { email, code, newPassword } }),
}

查看文件

@ -0,0 +1,32 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const client = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
timeout: 15000,
})
client.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
client.interceptors.response.use(
(res) => res,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
router.push('/login')
} else {
const msg = error.response?.data?.message ?? '请求失败'
ElMessage.error(msg)
}
return Promise.reject(error)
},
)
export default client

9
tenant-platform/src/env.d.ts vendored 普通文件
查看文件

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

18
tenant-platform/src/main.ts 普通文件
查看文件

@ -0,0 +1,18 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')

查看文件

@ -0,0 +1,59 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
component: () => import('@/views/auth/LoginView.vue'),
},
{
path: '/register',
component: () => import('@/views/auth/RegisterView.vue'),
},
{
path: '/forgot-password',
component: () => import('@/views/auth/ForgotPasswordView.vue'),
},
{
path: '/',
component: () => import('@/views/layout/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
redirect: '/dashboard',
},
{
path: 'dashboard',
component: () => import('@/views/dashboard/DashboardView.vue'),
},
{
path: 'apps',
component: () => import('@/views/apps/AppListView.vue'),
},
{
path: 'apps/:id',
component: () => import('@/views/apps/AppDetailView.vue'),
},
{
path: 'accounts',
component: () => import('@/views/accounts/SubAccountView.vue'),
},
],
},
],
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.token) {
return '/login'
}
if ((to.path === '/login' || to.path === '/register') && auth.token) {
return '/dashboard'
}
})
export default router

查看文件

@ -0,0 +1,46 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface UserInfo {
id: string
username: string
nickname: string
type: 'MAIN' | 'SUB'
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('token'))
const user = ref<UserInfo | null>(null)
function setToken(t: string) {
token.value = t
localStorage.setItem('token', t)
parseUser(t)
}
function parseUser(t: string) {
try {
const payload = JSON.parse(atob(t.split('.')[1]))
user.value = {
id: payload.sub,
username: payload.username,
nickname: payload.nickname,
type: payload.type,
}
} catch {
user.value = null
}
}
function logout() {
token.value = null
user.value = null
localStorage.removeItem('token')
}
if (token.value) {
parseUser(token.value)
}
return { token, user, setToken, logout }
})

查看文件

@ -0,0 +1,157 @@
<template>
<div>
<div class="page-header">
<h2>子账号管理</h2>
<el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon>
</el-button>
</div>
<el-table :data="accounts" v-loading="loading">
<el-table-column prop="username" label="用户名" />
<el-table-column prop="nickname" label="昵称" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="row.status === 'ACTIVE' ? 'success' : 'danger'">
{{ row.status === 'ACTIVE' ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button link type="danger" @click="handleDisable(row.id)">禁用</el-button>
</template>
</el-table-column>
</el-table>
<!-- 创建子账号先验证邮箱 -->
<el-dialog v-model="showDialog" :title="step === 0 ? '验证邮箱' : '创建子账号'" width="480px">
<template v-if="step === 0">
<p style="color:#666;margin-bottom:16px">首次创建子账号需验证主账号邮箱24小时内有效</p>
<el-form label-position="top">
<el-form-item label="主账号邮箱">
<div class="code-row">
<el-input v-model="verifyEmail" placeholder="邮箱" style="flex:1" />
<el-button :disabled="countdown > 0" @click="sendVerifyCode">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
<el-form-item label="验证码">
<el-input v-model="verifyCode" placeholder="6位验证码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" @click="handleVerify">确认验证</el-button>
</template>
</template>
<template v-else>
<el-form ref="createFormRef" :model="createForm" label-position="top">
<el-form-item label="用户名" :rules="[{required:true, min:3}]">
<el-input v-model="createForm.username" />
</el-form-item>
<el-form-item label="昵称" :rules="[{required:true}]">
<el-input v-model="createForm.nickname" />
</el-form-item>
<el-form-item label="密码">
<div class="code-row">
<el-input v-model="createForm.password" show-password style="flex:1" />
<el-button @click="genPassword">随机生成</el-button>
</div>
</el-form-item>
<el-form-item label="邮箱(选填)">
<el-input v-model="createForm.email" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="handleCreate">创建</el-button>
</template>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { accountApi, type SubAccount } from '@/api/account'
const accounts = ref<SubAccount[]>([])
const loading = ref(false)
const showDialog = ref(false)
const step = ref(0)
const verifyEmail = ref('')
const verifyCode = ref('')
const countdown = ref(0)
const creating = ref(false)
const createForm = reactive({ username: '', nickname: '', password: '', email: '' })
async function loadAccounts() {
loading.value = true
try {
const res = await accountApi.list()
accounts.value = res.data.data
} finally {
loading.value = false
}
}
async function openCreate() {
step.value = 0
showDialog.value = true
}
async function sendVerifyCode() {
if (!verifyEmail.value) { ElMessage.warning('请输入邮箱'); return }
await accountApi.sendVerifyCode(verifyEmail.value)
ElMessage.success('验证码已发送')
countdown.value = 60
const t = setInterval(() => { if (--countdown.value <= 0) clearInterval(t) }, 1000)
}
async function handleVerify() {
await accountApi.verifyEmail(verifyEmail.value, verifyCode.value)
ElMessage.success('验证成功')
step.value = 1
}
async function genPassword() {
const res = await accountApi.generatePassword()
createForm.password = res.data.data.password
}
async function handleCreate() {
creating.value = true
try {
await accountApi.create({
username: createForm.username,
nickname: createForm.nickname,
password: createForm.password,
email: createForm.email || undefined,
})
showDialog.value = false
ElMessage.success('子账号创建成功')
loadAccounts()
} finally {
creating.value = false
}
}
async function handleDisable(id: string) {
await ElMessageBox.confirm('确定禁用此子账号?', '警告', { type: 'warning' })
await accountApi.disable(id)
ElMessage.success('已禁用')
loadAccounts()
}
onMounted(loadAccounts)
</script>
<style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.code-row { display: flex; gap: 8px; width: 100%; }
</style>

查看文件

@ -0,0 +1,122 @@
<template>
<div v-if="app">
<el-page-header @back="$router.back()" :content="app.name" style="margin-bottom:24px" />
<el-card style="margin-bottom:16px">
<el-descriptions :column="2" border>
<el-descriptions-item label="应用名称">{{ app.name }}</el-descriptions-item>
<el-descriptions-item label="包名">{{ app.packageName }}</el-descriptions-item>
<el-descriptions-item label="AppKey">
<el-text class="mono">{{ app.appKey }}</el-text>
<el-button link @click="copy(app.appKey)"><el-icon><CopyDocument /></el-icon></el-button>
</el-descriptions-item>
<el-descriptions-item label="AppSecret">
<el-text class="mono">{{ app.appSecret }}</el-text>
<el-button link @click="copy(app.appSecret)"><el-icon><CopyDocument /></el-icon></el-button>
</el-descriptions-item>
<el-descriptions-item label="简述" :span="2">{{ app.description ?? '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card>
<template #header>功能服务配置</template>
<el-tabs v-model="activePlatform">
<el-tab-pane label="Android" name="ANDROID" />
<el-tab-pane label="iOS" name="IOS" />
<el-tab-pane label="鸿蒙" name="HARMONY" />
</el-tabs>
<div class="service-grid">
<el-card v-for="svcType in ['IM', 'PUSH', 'UPDATE']" :key="svcType" class="service-card">
<div class="service-header">
<span class="service-name">{{ serviceLabel(svcType) }}</span>
<el-switch
:model-value="isEnabled(activePlatform, svcType)"
@change="(val: boolean) => toggleService(activePlatform, svcType, val)"
/>
</div>
<template v-if="isEnabled(activePlatform, svcType)">
<div class="key-row">
<span class="key-label">SecretKey</span>
<el-text class="mono key-value" size="small">
{{ getService(activePlatform, svcType)?.secretKey }}
</el-text>
<el-button link size="small" @click="copy(getService(activePlatform, svcType)?.secretKey ?? '')">
<el-icon><CopyDocument /></el-icon>
</el-button>
<el-button link size="small" type="warning"
@click="regenerate(getService(activePlatform, svcType)?.id ?? '')">
重新生成
</el-button>
</div>
</template>
</el-card>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { appApi, type App, type FeatureService } from '@/api/app'
const route = useRoute()
const app = ref<App | null>(null)
const services = ref<FeatureService[]>([])
const activePlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
function isEnabled(platform: string, svcType: string) {
return services.value.some(
s => s.platform === platform && s.serviceType === svcType && s.enabled
)
}
function getService(platform: string, svcType: string) {
return services.value.find(s => s.platform === platform && s.serviceType === svcType)
}
function serviceLabel(type: string) {
return { IM: '即时通讯 (IM)', PUSH: '离线推送', UPDATE: '版本管理' }[type] ?? type
}
async function loadData() {
const id = route.params.id as string
const [appRes, svcRes] = await Promise.all([
appApi.get(id), appApi.getServices(id),
])
app.value = appRes.data.data
services.value = svcRes.data.data
}
async function toggleService(platform: string, svcType: string, enable: boolean) {
await appApi.toggleService(route.params.id as string, platform, svcType, enable)
ElMessage.success(enable ? '已开启' : '已关闭')
loadData()
}
async function regenerate(serviceId: string) {
await appApi.regenerateKey(route.params.id as string, serviceId)
ElMessage.success('密钥已重新生成')
loadData()
}
function copy(text: string) {
navigator.clipboard.writeText(text)
ElMessage.success('已复制')
}
onMounted(loadData)
</script>
<style scoped>
.mono { font-family: monospace; font-size: 12px; }
.service-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 16px; }
.service-card { border: 1px solid #e8e8e8; }
.service-header { display: flex; justify-content: space-between; align-items: center; font-weight: 500; margin-bottom: 12px; }
.service-name { font-size: 15px; }
.key-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.key-label { font-size: 12px; color: #888; }
.key-value { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>

查看文件

@ -0,0 +1,102 @@
<template>
<div>
<div class="page-header">
<h2>我的应用</h2>
<el-button type="primary" @click="showCreate = true">
<el-icon><Plus /></el-icon>
</el-button>
</div>
<el-table :data="apps" v-loading="loading" style="width:100%">
<el-table-column prop="name" label="应用名称" />
<el-table-column prop="packageName" label="包名" />
<el-table-column prop="appKey" label="AppKey" show-overflow-tooltip />
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button link type="primary" @click="$router.push(`/apps/${row.id}`)">详情</el-button>
<el-button link type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="showCreate" title="创建应用" width="480px">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-position="top">
<el-form-item label="包名" prop="packageName">
<el-input v-model="createForm.packageName" placeholder="com.example.app" />
</el-form-item>
<el-form-item label="应用名称" prop="name">
<el-input v-model="createForm.name" />
</el-form-item>
<el-form-item label="简述">
<el-input v-model="createForm.description" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="handleCreate">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { appApi, type App } from '@/api/app'
const apps = ref<App[]>([])
const loading = ref(false)
const showCreate = ref(false)
const creating = ref(false)
const createFormRef = ref<FormInstance>()
const createForm = reactive({ packageName: '', name: '', description: '' })
const createRules: FormRules = {
packageName: [{ required: true, message: '请输入包名' }],
name: [{ required: true, message: '请输入应用名' }],
}
async function loadApps() {
loading.value = true
try {
const res = await appApi.list()
apps.value = res.data.data
} finally {
loading.value = false
}
}
async function handleCreate() {
await createFormRef.value?.validate()
creating.value = true
try {
await appApi.create(createForm)
showCreate.value = false
ElMessage.success('应用创建成功')
loadApps()
} finally {
creating.value = false
}
}
async function handleDelete(id: string) {
await ElMessageBox.confirm('确定删除此应用?删除后不可恢复。', '警告', { type: 'warning' })
await appApi.delete(id)
ElMessage.success('已删除')
loadApps()
}
function formatDate(d: string) {
return d ? new Date(d).toLocaleString('zh-CN') : '-'
}
onMounted(loadApps)
</script>
<style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
</style>

查看文件

@ -0,0 +1,87 @@
<template>
<div class="page">
<el-card class="card">
<h2 class="title">找回密码</h2>
<el-steps :active="step" finish-status="success" style="margin-bottom:24px">
<el-step title="验证邮箱" />
<el-step title="重置密码" />
</el-steps>
<template v-if="step === 0">
<el-form ref="step0Ref" :model="form">
<el-form-item prop="email" :rules="[{required:true, type:'email', message:'邮箱格式错误'}]">
<el-input v-model="form.email" placeholder="注册邮箱" prefix-icon="Message" />
</el-form-item>
<el-form-item>
<div class="code-row">
<el-input v-model="form.code" placeholder="验证码" style="flex:1" />
<el-button :disabled="countdown > 0" @click="sendCode">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
<el-button type="primary" style="width:100%" @click="nextStep">下一步</el-button>
</el-form>
</template>
<template v-else>
<el-form ref="step1Ref" :model="form">
<el-form-item prop="newPassword" :rules="[{required:true, min:6}]">
<el-input v-model="form.newPassword" type="password" placeholder="新密码" show-password />
</el-form-item>
<el-button type="primary" style="width:100%" :loading="loading" @click="handleReset">
确认重置
</el-button>
</el-form>
</template>
<div style="text-align:center;margin-top:16px">
<router-link to="/login">返回登录</router-link>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { authApi } from '@/api/auth'
const router = useRouter()
const step = ref(0)
const loading = ref(false)
const countdown = ref(0)
const form = reactive({ email: '', code: '', newPassword: '' })
async function sendCode() {
if (!form.email) { ElMessage.warning('请输入邮箱'); return }
await authApi.forgotPassword(form.email)
ElMessage.success('验证码已发送')
countdown.value = 60
const t = setInterval(() => { if (--countdown.value <= 0) clearInterval(t) }, 1000)
}
function nextStep() {
if (!form.code) { ElMessage.warning('请输入验证码'); return }
step.value = 1
}
async function handleReset() {
loading.value = true
try {
await authApi.resetPassword(form.email, form.code, form.newPassword)
ElMessage.success('密码重置成功')
router.push('/login')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f0f2f5; }
.card { width: 420px; }
.title { text-align: center; margin-bottom: 20px; }
.code-row { display: flex; gap: 8px; width: 100%; }
</style>

查看文件

@ -0,0 +1,120 @@
<template>
<div class="login-page">
<el-card class="login-card">
<h2 class="title">XuqmGroup 开放平台</h2>
<el-form ref="formRef" :model="form" :rules="rules" @submit.prevent="handleLogin">
<el-form-item prop="account">
<el-input v-model="form.account" placeholder="用户名 / 邮箱" prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码"
prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item prop="captchaCode">
<div class="captcha-row">
<el-input v-model="form.captchaCode" placeholder="验证码" style="flex:1" />
<img :src="captchaImage" class="captcha-img" @click="loadCaptcha" alt="captcha" />
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" style="width:100%">
</el-button>
</el-form-item>
</el-form>
<div class="links">
<router-link to="/register">注册账号</router-link>
<router-link to="/forgot-password">忘记密码</router-link>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const captchaKey = ref('')
const captchaImage = ref('')
const form = reactive({ account: '', password: '', captchaCode: '' })
const rules: FormRules = {
account: [{ required: true, message: '请输入账号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
captchaCode: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
}
async function loadCaptcha() {
try {
const res = await authApi.getCaptcha()
captchaKey.value = res.data.data.key
captchaImage.value = res.data.data.image
} catch {}
}
async function handleLogin() {
await formRef.value?.validate()
loading.value = true
try {
const res = await authApi.login({
account: form.account,
password: form.password,
captchaKey: captchaKey.value,
captchaCode: form.captchaCode,
})
auth.setToken(res.data.data.token)
router.push('/dashboard')
} finally {
loading.value = false
loadCaptcha()
}
}
onMounted(loadCaptcha)
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
}
.title {
text-align: center;
margin-bottom: 24px;
color: #333;
}
.captcha-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.captcha-img {
height: 36px;
cursor: pointer;
border: 1px solid #dcdfe6;
border-radius: 4px;
}
.links {
display: flex;
justify-content: space-between;
font-size: 14px;
}
</style>

查看文件

@ -0,0 +1,119 @@
<template>
<div class="page">
<el-card class="card">
<h2 class="title">注册账号</h2>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="3-32位字符" />
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="form.nickname" placeholder="显示名称" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="用于验证和找回密码" />
</el-form-item>
<el-form-item label="邮箱验证码" prop="emailCode">
<div class="code-row">
<el-input v-model="form.emailCode" placeholder="6位验证码" style="flex:1" />
<el-button :loading="codeSending" :disabled="countdown > 0"
@click="sendCode">
{{ countdown > 0 ? `${countdown}s 后重发` : '发送验证码' }}
</el-button>
</div>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" show-password />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password />
</el-form-item>
<el-form-item label="手机号(选填)">
<el-input v-model="form.phone" placeholder="选填" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" style="width:100%" @click="handleRegister">
</el-button>
</el-form-item>
</el-form>
<div class="link">
已有账号<router-link to="/login">立即登录</router-link>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { authApi } from '@/api/auth'
const router = useRouter()
const formRef = ref<FormInstance>()
const loading = ref(false)
const codeSending = ref(false)
const countdown = ref(0)
const form = reactive({
username: '', nickname: '', email: '',
emailCode: '', password: '', confirmPassword: '', phone: '',
})
const rules: FormRules = {
username: [{ required: true, min: 3, max: 32, message: '用户名3-32位', trigger: 'blur' }],
nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
email: [{ required: true, type: 'email', message: '请输入正确邮箱', trigger: 'blur' }],
emailCode: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
password: [{ required: true, min: 6, message: '密码至少6位', trigger: 'blur' }],
confirmPassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{
validator: (rule: unknown, value: string, callback: (e?: Error) => void) => {
if (value !== form.password) callback(new Error('两次密码不一致'))
else callback()
},
},
],
}
async function sendCode() {
if (!form.email) { ElMessage.warning('请先填写邮箱'); return }
codeSending.value = true
try {
await authApi.sendEmailCode(form.email, 'REGISTER')
ElMessage.success('验证码已发送')
countdown.value = 60
const timer = setInterval(() => {
if (--countdown.value <= 0) clearInterval(timer)
}, 1000)
} finally {
codeSending.value = false
}
}
async function handleRegister() {
await formRef.value?.validate()
loading.value = true
try {
await authApi.register({
username: form.username, password: form.password,
email: form.email, nickname: form.nickname,
phone: form.phone || undefined, emailCode: form.emailCode,
})
ElMessage.success('注册成功,请登录')
router.push('/login')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f0f2f5; }
.card { width: 480px; }
.title { text-align: center; margin-bottom: 20px; }
.code-row { display: flex; gap: 8px; width: 100%; }
.link { text-align: center; font-size: 14px; }
</style>

查看文件

@ -0,0 +1,69 @@
<template>
<div>
<h2 style="margin-bottom:24px">控制台</h2>
<el-row :gutter="16">
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="我的应用" :value="stats.appCount">
<template #prefix><el-icon><Grid /></el-icon></template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="已开启服务" :value="stats.serviceCount">
<template #prefix><el-icon><Setting /></el-icon></template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="子账号" :value="stats.subAccountCount">
<template #prefix><el-icon><User /></el-icon></template>
</el-statistic>
</el-card>
</el-col>
</el-row>
<el-card style="margin-top:24px">
<template #header>快速开始</template>
<el-timeline>
<el-timeline-item timestamp="Step 1" placement="top">
创建应用填写基本信息和包名
</el-timeline-item>
<el-timeline-item timestamp="Step 2" placement="top">
在应用详情中选择平台Android / iOS / 鸿蒙开启所需服务
</el-timeline-item>
<el-timeline-item timestamp="Step 3" placement="top">
获取 AppKey ServiceKey集成 SDK
</el-timeline-item>
</el-timeline>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { appApi } from '@/api/app'
import { accountApi } from '@/api/account'
const stats = ref({ appCount: 0, serviceCount: 0, subAccountCount: 0 })
onMounted(async () => {
try {
const [appsRes, accountsRes] = await Promise.all([
appApi.list(), accountApi.list(),
])
stats.value.appCount = appsRes.data.data.length
stats.value.subAccountCount = accountsRes.data.data.length
let serviceCount = 0
for (const app of appsRes.data.data) {
const svcRes = await appApi.getServices(app.id)
serviceCount += svcRes.data.data.filter(s => s.enabled).length
}
stats.value.serviceCount = serviceCount
} catch {}
})
</script>

查看文件

@ -0,0 +1,109 @@
<template>
<el-container class="layout-container">
<el-aside width="220px" class="sidebar">
<div class="logo">
<span>XuqmGroup</span>
</div>
<el-menu
:default-active="$route.path"
router
background-color="#1d2129"
text-color="#c9d1d9"
active-text-color="#409eff"
>
<el-menu-item index="/dashboard">
<el-icon><Odometer /></el-icon>
<span>控制台</span>
</el-menu-item>
<el-menu-item index="/apps">
<el-icon><Grid /></el-icon>
<span>我的应用</span>
</el-menu-item>
<el-menu-item index="/accounts" v-if="auth.user?.type === 'MAIN'">
<el-icon><User /></el-icon>
<span>子账号管理</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="user-info">
<el-avatar :size="32" style="background:#409eff">
{{ auth.user?.nickname?.charAt(0) ?? 'U' }}
</el-avatar>
<span class="nickname">{{ auth.user?.nickname }}</span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const router = useRouter()
function handleCommand(cmd: string) {
if (cmd === 'logout') {
auth.logout()
router.push('/login')
}
}
</script>
<style scoped>
.layout-container {
height: 100vh;
}
.sidebar {
background: #1d2129;
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
font-weight: bold;
border-bottom: 1px solid #2d3142;
}
.header {
background: #fff;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
justify-content: flex-end;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.nickname {
font-size: 14px;
color: #333;
}
</style>

查看文件

@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

查看文件

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.app.json" }
]
}

查看文件

@ -0,0 +1,7 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": ["vite.config.*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}

查看文件

@ -0,0 +1,32 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8081',
changeOrigin: true,
},
},
},
})