chore: initial commit
这个提交包含在:
当前提交
aaed19de05
10
.gitignore
vendored
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
3
ops-platform/src/App.vue
普通文件
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
24
ops-platform/src/api/client.ts
普通文件
24
ops-platform/src/api/client.ts
普通文件
@ -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
普通文件
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
普通文件
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')
|
||||
25
ops-platform/src/router/index.ts
普通文件
25
ops-platform/src/router/index.ts
普通文件
@ -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>
|
||||
12
ops-platform/tsconfig.app.json
普通文件
12
ops-platform/tsconfig.app.json
普通文件
@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
7
ops-platform/tsconfig.json
普通文件
7
ops-platform/tsconfig.json
普通文件
@ -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
普通文件
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
普通文件
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
普通文件
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>
|
||||
27
tenant-platform/package.json
普通文件
27
tenant-platform/package.json
普通文件
@ -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"
|
||||
}
|
||||
}
|
||||
3
tenant-platform/src/App.vue
普通文件
3
tenant-platform/src/App.vue
普通文件
@ -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'),
|
||||
}
|
||||
53
tenant-platform/src/api/app.ts
普通文件
53
tenant-platform/src/api/app.ts
普通文件
@ -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`),
|
||||
}
|
||||
35
tenant-platform/src/api/auth.ts
普通文件
35
tenant-platform/src/api/auth.ts
普通文件
@ -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
普通文件
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
普通文件
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"
|
||||
}
|
||||
}
|
||||
32
tenant-platform/vite.config.ts
普通文件
32
tenant-platform/vite.config.ts
普通文件
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
正在加载...
在新工单中引用
屏蔽一个用户