feat: 完成 opsx-licence 许可证管理平台全栈实现
基于 opsx-web-lite 框架(Go+Gin+Vue3+Element Plus)构建许可证管理服务器,
业务域参考 Licence 服务器 Web 版。
【后端 - 4层架构】
- 认证:单 Admin 表 + JWT(HS256)
- 核心授权域:Product / Customer / License / Activation
· License 生成/验证/激活/机器绑定/心跳/续期/吊销
· 客户端契约接口:/api/v1/license/{verify,activate,deactivate,heartbeat}
- 定价订单:Plan / Order / 支付网关配置
- 防重复购买:CustomerEntitlement + 幂等支付 webhook + 手动发放
- 分析审计:Dashboard 统计 / Analytics / AuditLog / Setting 管理
【前端】
- Dashboard(ECharts 图表)、产品、客户、License、计划、订单、权益、日志、设置
- 9 个完整 CRUD 业务页面 + 登录/布局
【部署】
- SQLite + 单二进制(//go:embed 嵌入前端)
- 开发:./run.sh start(前端5181/后端9001)
- 生产:./run.sh build && ./run.sh binary start
- 默认账号:admin/admin
Author: Logan.Li
This commit is contained in:
commit
4ae318335c
88 files changed
+10562
No files matched your search
@@ -0,0 +1,2 @@
|
||||
# 开发环境
|
||||
VITE_API_BASE_URL=/api
|
||||
@@ -0,0 +1,2 @@
|
||||
# 生产环境(嵌入单二进制时,前端与后端同源,走相对路径)
|
||||
VITE_API_BASE_URL=/api
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>opsx-licence 授权管理平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2310
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "opsx-licence-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.5",
|
||||
"pinia": "^2.1.7",
|
||||
"element-plus": "^2.5.0",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.6.5",
|
||||
"echarts": "^5.4.3",
|
||||
"vue-echarts": "^6.6.8",
|
||||
"dayjs": "^1.11.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"vite": "^5.0.11",
|
||||
"sass": "^1.69.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 根组件,仅承载 router-view // Logan.Li
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
import request from './request'
|
||||
|
||||
// 数据分析与仪表板 API // Logan.Li
|
||||
|
||||
export function getDashboardStats() {
|
||||
return request({ url: '/dashboard/stats', method: 'get' })
|
||||
}
|
||||
|
||||
export function getExpiringLicenses(days = 30) {
|
||||
return request({ url: '/dashboard/expiring', method: 'get', params: { days } })
|
||||
}
|
||||
|
||||
export function getRecentActivities(limit = 10) {
|
||||
return request({ url: '/dashboard/activities', method: 'get', params: { limit } })
|
||||
}
|
||||
|
||||
export function getOnlineStats() {
|
||||
return request({ url: '/dashboard/online-stats', method: 'get' })
|
||||
}
|
||||
|
||||
export function getRevenueStats() {
|
||||
return request({ url: '/dashboard/revenue', method: 'get' })
|
||||
}
|
||||
|
||||
export function getLicenseGrowth() {
|
||||
return request({ url: '/analytics/license-growth', method: 'get' })
|
||||
}
|
||||
|
||||
export function getProductDistribution() {
|
||||
return request({ url: '/analytics/product-distribution', method: 'get' })
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import request from './request'
|
||||
|
||||
// 登录 // Logan.Li
|
||||
export function login(data) {
|
||||
return request({
|
||||
url: '/auth/login',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取当前用户信息 // Logan.Li
|
||||
export function getMe() {
|
||||
return request({
|
||||
url: '/auth/me',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
// 修改密码 // Logan.Li
|
||||
export function changePassword(data) {
|
||||
return request({
|
||||
url: '/auth/password',
|
||||
method: 'put',
|
||||
data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import request from './request'
|
||||
|
||||
// 客户管理 API // Logan.Li
|
||||
|
||||
export function getCustomers(params) {
|
||||
return request({ url: '/customers', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getAllCustomers() {
|
||||
return request({ url: '/customers/all', method: 'get' })
|
||||
}
|
||||
|
||||
export function getCustomer(id) {
|
||||
return request({ url: `/customers/${id}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function createCustomer(data) {
|
||||
return request({ url: '/customers', method: 'post', data })
|
||||
}
|
||||
|
||||
export function updateCustomer(id, data) {
|
||||
return request({ url: `/customers/${id}`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function deleteCustomer(id) {
|
||||
return request({ url: `/customers/${id}`, method: 'delete' })
|
||||
}
|
||||
|
||||
export function updateCustomerStatus(id, status) {
|
||||
return request({ url: `/customers/${id}/status`, method: 'patch', data: { status } })
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import request from './request'
|
||||
|
||||
// 客户权益 API // Logan.Li
|
||||
|
||||
export function getEntitlements(params) {
|
||||
return request({ url: '/entitlements', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getEntitlement(id) {
|
||||
return request({ url: `/entitlements/${id}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function revokeEntitlement(id) {
|
||||
return request({ url: `/entitlements/${id}/revoke`, method: 'post' })
|
||||
}
|
||||
|
||||
export function renewEntitlement(id, planId) {
|
||||
return request({ url: `/entitlements/${id}/renew`, method: 'post', data: { plan_id: planId } })
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import request from './request'
|
||||
|
||||
// License 管理 API // Logan.Li
|
||||
|
||||
export function getLicenses(params) {
|
||||
return request({ url: '/licenses', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getLicense(id) {
|
||||
return request({ url: `/licenses/${id}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function createLicense(data) {
|
||||
return request({ url: '/licenses', method: 'post', data })
|
||||
}
|
||||
|
||||
export function updateLicense(id, data) {
|
||||
return request({ url: `/licenses/${id}`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function deleteLicense(id) {
|
||||
return request({ url: `/licenses/${id}`, method: 'delete' })
|
||||
}
|
||||
|
||||
export function renewLicense(id, duration) {
|
||||
return request({ url: `/licenses/${id}/renew`, method: 'post', data: { duration } })
|
||||
}
|
||||
|
||||
export function revokeLicense(id, reason) {
|
||||
return request({ url: `/licenses/${id}/revoke`, method: 'post', data: { reason } })
|
||||
}
|
||||
|
||||
export function suspendLicense(id) {
|
||||
return request({ url: `/licenses/${id}/suspend`, method: 'post' })
|
||||
}
|
||||
|
||||
export function activateLicense(id) {
|
||||
return request({ url: `/licenses/${id}/activate`, method: 'post' })
|
||||
}
|
||||
|
||||
export function getActivations(id) {
|
||||
return request({ url: `/licenses/${id}/activations`, method: 'get' })
|
||||
}
|
||||
|
||||
export function forceOffline(licenseId, activationId) {
|
||||
return request({ url: `/licenses/${licenseId}/activations/${activationId}/offline`, method: 'post' })
|
||||
}
|
||||
|
||||
export function exportLicenses(params) {
|
||||
return request({ url: '/licenses/export', method: 'get', params, responseType: 'blob' })
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import request from './request'
|
||||
|
||||
// 订单管理 API // Logan.Li
|
||||
|
||||
export function getOrders(params) {
|
||||
return request({ url: '/orders', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getOrder(id) {
|
||||
return request({ url: `/orders/${id}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function manualGrant(data) {
|
||||
return request({ url: '/orders/manual-grant', method: 'post', data })
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import request from './request'
|
||||
|
||||
// 定价计划 API // Logan.Li
|
||||
|
||||
export function getPlans(params) {
|
||||
return request({ url: '/plans', method: 'get', params })
|
||||
}
|
||||
|
||||
export function createPlan(data) {
|
||||
return request({ url: '/plans', method: 'post', data })
|
||||
}
|
||||
|
||||
export function updatePlan(id, data) {
|
||||
return request({ url: `/plans/${id}`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function deletePlan(id) {
|
||||
return request({ url: `/plans/${id}`, method: 'delete' })
|
||||
}
|
||||
|
||||
export function togglePlanStatus(id, status) {
|
||||
return request({ url: `/plans/${id}/status`, method: 'patch', data: { status } })
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import request from './request'
|
||||
|
||||
// 产品管理 API // Logan.Li
|
||||
|
||||
export function getProducts(params) {
|
||||
return request({ url: '/products', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getAllProducts() {
|
||||
return request({ url: '/products/all', method: 'get' })
|
||||
}
|
||||
|
||||
export function getProduct(id) {
|
||||
return request({ url: `/products/${id}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function createProduct(data) {
|
||||
return request({ url: '/products', method: 'post', data })
|
||||
}
|
||||
|
||||
export function updateProduct(id, data) {
|
||||
return request({ url: `/products/${id}`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function deleteProduct(id) {
|
||||
return request({ url: `/products/${id}`, method: 'delete' })
|
||||
}
|
||||
|
||||
export function updateProductStatus(id, status) {
|
||||
return request({ url: `/products/${id}/status`, method: 'patch', data: { status } })
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// 创建 axios 实例 // Logan.Li
|
||||
const request = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 请求拦截器:注入 Bearer token // Logan.Li
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
const userStore = useUserStore()
|
||||
if (userStore.token) {
|
||||
config.headers.Authorization = `Bearer ${userStore.token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// 响应拦截器:统一处理 code 与 HTTP 错误 // Logan.Li
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
const res = response.data
|
||||
|
||||
// code 为 0 或 200 视为成功 // Logan.Li
|
||||
if (res.code === 0 || res.code === 200) {
|
||||
return res
|
||||
}
|
||||
|
||||
// 401:登录态失效,引导重新登录 // Logan.Li
|
||||
if (res.code === 401) {
|
||||
ElMessageBox.confirm('登录状态已过期,请重新登录', '系统提示', {
|
||||
confirmButtonText: '重新登录',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}).then(() => {
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
window.location.href = '/login'
|
||||
})
|
||||
return Promise.reject(new Error(res.message || '未授权'))
|
||||
}
|
||||
|
||||
// 其他业务错误:仅错误用 ElMessage 提示(常规操作不打扰) // Logan.Li
|
||||
ElMessage.error(res.message || '请求失败')
|
||||
return Promise.reject(new Error(res.message || 'Error'))
|
||||
},
|
||||
(error) => {
|
||||
let message = '网络错误'
|
||||
|
||||
if (error.response) {
|
||||
switch (error.response.status) {
|
||||
case 400:
|
||||
message = error.response.data?.message || '请求参数错误'
|
||||
break
|
||||
case 401:
|
||||
message = '未授权,请重新登录'
|
||||
{
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login'
|
||||
}, 1500)
|
||||
}
|
||||
break
|
||||
case 403:
|
||||
message = '拒绝访问'
|
||||
break
|
||||
case 404:
|
||||
message = '请求的资源不存在'
|
||||
break
|
||||
case 500:
|
||||
message = '服务器内部错误'
|
||||
break
|
||||
default:
|
||||
message = `连接错误 ${error.response.status}`
|
||||
}
|
||||
} else if (error.request) {
|
||||
message = '网络连接失败,请检查网络'
|
||||
} else {
|
||||
message = error.message
|
||||
}
|
||||
|
||||
ElMessage.error(message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
@@ -0,0 +1,31 @@
|
||||
import request from './request'
|
||||
|
||||
// 系统设置与日志 API // Logan.Li
|
||||
|
||||
export function getSetting() {
|
||||
return request({ url: '/settings', method: 'get' })
|
||||
}
|
||||
|
||||
export function updateSetting(data) {
|
||||
return request({ url: '/settings', method: 'put', data })
|
||||
}
|
||||
|
||||
export function getLicenseTemplates() {
|
||||
return request({ url: '/settings/license-templates', method: 'get' })
|
||||
}
|
||||
|
||||
export function saveLicenseTemplate(data) {
|
||||
return request({ url: '/settings/license-templates', method: 'post', data })
|
||||
}
|
||||
|
||||
export function deleteLicenseTemplate(id) {
|
||||
return request({ url: `/settings/license-templates/${id}`, method: 'delete' })
|
||||
}
|
||||
|
||||
export function getAuditLogs(params) {
|
||||
return request({ url: '/logs', method: 'get', params })
|
||||
}
|
||||
|
||||
export function clearAuditLogs() {
|
||||
return request({ url: '/logs/clear', method: 'delete' })
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<!-- 侧边栏 -->
|
||||
<el-aside :width="isCollapse ? '64px' : '220px'" class="layout-aside">
|
||||
<div class="logo">
|
||||
<el-icon size="24"><Key /></el-icon>
|
||||
<span v-show="!isCollapse" class="logo-text">opsx-licence</span>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="isCollapse"
|
||||
:router="true"
|
||||
background-color="#001529"
|
||||
text-color="rgba(255,255,255,0.65)"
|
||||
active-text-color="#fff"
|
||||
>
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><Odometer /></el-icon>
|
||||
<template #title>仪表板</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu index="licence">
|
||||
<template #title>
|
||||
<el-icon><Key /></el-icon>
|
||||
<span>授权管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/products">
|
||||
<el-icon><Box /></el-icon>产品管理
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/customers">
|
||||
<el-icon><User /></el-icon>客户管理
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/licenses">
|
||||
<el-icon><Key /></el-icon>License 管理
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-sub-menu index="commerce">
|
||||
<template #title>
|
||||
<el-icon><ShoppingCart /></el-icon>
|
||||
<span>定价与订单</span>
|
||||
</template>
|
||||
<el-menu-item index="/plans">
|
||||
<el-icon><PriceTag /></el-icon>定价计划
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/orders">
|
||||
<el-icon><ShoppingCart /></el-icon>订单管理
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/entitlements">
|
||||
<el-icon><Medal /></el-icon>客户权益
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-sub-menu index="system">
|
||||
<template #title>
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
<span>审计与设置</span>
|
||||
</template>
|
||||
<el-menu-item index="/logs">
|
||||
<el-icon><Document /></el-icon>操作日志
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/settings">
|
||||
<el-icon><Setting /></el-icon>系统设置
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<!-- 顶栏 -->
|
||||
<el-header class="layout-header">
|
||||
<div class="header-left">
|
||||
<el-icon class="collapse-btn" @click="isCollapse = !isCollapse">
|
||||
<Fold v-if="!isCollapse" />
|
||||
<Expand v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-dropdown @command="handleCommand">
|
||||
<span class="user-info">
|
||||
<el-avatar :size="32" :icon="UserFilled" />
|
||||
<span class="username">{{ userInfo?.username || 'admin' }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="changePassword">修改密码</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<el-main class="layout-main">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
|
||||
<!-- 修改密码对话框 -->
|
||||
<el-dialog v-model="pwdDialogVisible" title="修改密码" width="420px">
|
||||
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="80px">
|
||||
<el-form-item label="原密码" prop="old_password">
|
||||
<el-input v-model="pwdForm.old_password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop="new_password">
|
||||
<el-input v-model="pwdForm.new_password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirm_password">
|
||||
<el-input v-model="pwdForm.confirm_password" type="password" show-password />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="pwdDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitChangePassword">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { UserFilled } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { changePassword } from '@/api/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const isCollapse = ref(false)
|
||||
const userInfo = ref(userStore.userInfo)
|
||||
|
||||
// 首次进入拉取用户信息 // Logan.Li
|
||||
if (userStore.token && !userInfo.value) {
|
||||
userStore.fetchUserInfo().then((info) => {
|
||||
userInfo.value = info
|
||||
})
|
||||
}
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
|
||||
// 退出登录 / 修改密码 // Logan.Li
|
||||
async function handleCommand(command) {
|
||||
if (command === 'logout') {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要退出登录吗?', '提示', { type: 'warning' })
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
} catch { /* 用户取消 */ }
|
||||
} else if (command === 'changePassword') {
|
||||
pwdDialogVisible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 修改密码 // Logan.Li
|
||||
const pwdDialogVisible = ref(false)
|
||||
const pwdFormRef = ref(null)
|
||||
const pwdForm = reactive({ old_password: '', new_password: '', confirm_password: '' })
|
||||
const pwdRules = {
|
||||
old_password: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
|
||||
new_password: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度至少 6 位', trigger: 'blur' },
|
||||
],
|
||||
confirm_password: [
|
||||
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (value !== pwdForm.new_password) callback(new Error('两次输入的密码不一致'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
async function submitChangePassword() {
|
||||
if (!pwdFormRef.value) return
|
||||
await pwdFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
await changePassword({ old_password: pwdForm.old_password, new_password: pwdForm.new_password })
|
||||
ElMessage.success('密码修改成功,请重新登录')
|
||||
pwdDialogVisible.value = false
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
} catch { /* 拦截器已处理 */ }
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container { height: 100vh; }
|
||||
.layout-aside { background-color: #001529; transition: width 0.3s; overflow-x: hidden; }
|
||||
.logo { height: 60px; display: flex; align-items: center; justify-content: center; gap: 8px; color: #fff; font-size: 18px; font-weight: 600; }
|
||||
.logo-text { white-space: nowrap; }
|
||||
.layout-aside :deep(.el-menu) { border-right: none; }
|
||||
.layout-header { display: flex; align-items: center; justify-content: space-between; background-color: #fff; border-bottom: 1px solid #f0f0f0; }
|
||||
.collapse-btn { font-size: 20px; cursor: pointer; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||
.username { font-size: 14px; }
|
||||
.layout-main { background-color: #f5f7fa; padding: 20px; }
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
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'
|
||||
import { useUserStore } from './stores/user'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册 Element Plus 图标 // Logan.Li
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: undefined }) // 默认中文
|
||||
|
||||
// 从 localStorage 恢复登录状态 // Logan.Li
|
||||
const userStore = useUserStore()
|
||||
userStore.restore()
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,109 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { public: true, title: '登录' },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
meta: { title: '仪表板', icon: 'Odometer' },
|
||||
},
|
||||
{
|
||||
path: 'products',
|
||||
name: 'Products',
|
||||
component: () => import('@/views/Products.vue'),
|
||||
meta: { title: '产品管理', icon: 'Box' },
|
||||
},
|
||||
{
|
||||
path: 'customers',
|
||||
name: 'Customers',
|
||||
component: () => import('@/views/Customers.vue'),
|
||||
meta: { title: '客户管理', icon: 'User' },
|
||||
},
|
||||
{
|
||||
path: 'licenses',
|
||||
name: 'Licenses',
|
||||
component: () => import('@/views/Licenses.vue'),
|
||||
meta: { title: 'License 管理', icon: 'Key' },
|
||||
},
|
||||
{
|
||||
path: 'plans',
|
||||
name: 'Plans',
|
||||
component: () => import('@/views/Plans.vue'),
|
||||
meta: { title: '定价计划', icon: 'PriceTag' },
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'Orders',
|
||||
component: () => import('@/views/Orders.vue'),
|
||||
meta: { title: '订单管理', icon: 'ShoppingCart' },
|
||||
},
|
||||
{
|
||||
path: 'entitlements',
|
||||
name: 'Entitlements',
|
||||
component: () => import('@/views/Entitlements.vue'),
|
||||
meta: { title: '客户权益', icon: 'Medal' },
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'Logs',
|
||||
component: () => import('@/views/Logs.vue'),
|
||||
meta: { title: '操作日志', icon: 'Document' },
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/Settings.vue'),
|
||||
meta: { title: '系统设置', icon: 'Setting' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/NotFound.vue'),
|
||||
meta: { public: true, title: '页面不存在' },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// 全局前置守卫:未登录访问受保护页面时重定向到登录页 // Logan.Li
|
||||
router.beforeEach((to, from, next) => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} - opsx-licence`
|
||||
}
|
||||
|
||||
if (to.meta.public) {
|
||||
if (to.path === '/login' && userStore.token) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!userStore.token) {
|
||||
next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,51 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { login as loginApi, getMe } from '@/api/auth'
|
||||
|
||||
const TOKEN_KEY = 'opsx_licence_token'
|
||||
|
||||
// 用户状态 store(setup 语法) // Logan.Li
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref('')
|
||||
const userInfo = ref(null)
|
||||
|
||||
// 从 localStorage 恢复登录状态 // Logan.Li
|
||||
function restore() {
|
||||
const saved = localStorage.getItem(TOKEN_KEY)
|
||||
if (saved) {
|
||||
token.value = saved
|
||||
}
|
||||
}
|
||||
|
||||
// 登录:保存 token 并拉取用户信息 // Logan.Li
|
||||
async function login(loginForm) {
|
||||
const res = await loginApi(loginForm)
|
||||
token.value = res.data.token
|
||||
userInfo.value = res.data.user_info
|
||||
localStorage.setItem(TOKEN_KEY, token.value)
|
||||
return res
|
||||
}
|
||||
|
||||
// 拉取当前用户信息 // Logan.Li
|
||||
async function fetchUserInfo() {
|
||||
const res = await getMe()
|
||||
userInfo.value = res.data
|
||||
return res.data
|
||||
}
|
||||
|
||||
// 登出:清空状态 // Logan.Li
|
||||
function logout() {
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
userInfo,
|
||||
restore,
|
||||
login,
|
||||
fetchUserInfo,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="keyword" placeholder="搜索客户/邮箱/公司" clearable style="width: 260px" @keyup.enter="loadData" @clear="loadData" />
|
||||
<el-button type="primary" @click="openCreate">新增客户</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe>
|
||||
<el-table-column prop="name" label="客户名称" min-width="120" />
|
||||
<el-table-column prop="company" label="公司" min-width="140" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="180" />
|
||||
<el-table-column prop="phone" label="电话" width="130" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.status === 1" @change="(v) => toggleStatus(row, v)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="edit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page" v-model:page-size="pageSize"
|
||||
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑客户' : '新增客户'" width="520px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
|
||||
<el-form-item label="名称" prop="name"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="公司"><el-input v-model="form.company" /></el-form-item>
|
||||
<el-form-item label="邮箱" prop="email"><el-input v-model="form.email" /></el-form-item>
|
||||
<el-form-item label="电话"><el-input v-model="form.phone" /></el-form-item>
|
||||
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submit">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { getCustomers, createCustomer, updateCustomer, deleteCustomer, updateCustomerStatus } from '@/api/customer'
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const formRef = ref(null)
|
||||
const form = reactive({ id: null, name: '', company: '', email: '', phone: '', address: '', notes: '' })
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getCustomers({ page: page.value, page_size: pageSize.value, keyword: keyword.value })
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, { id: null, name: '', company: '', email: '', phone: '', address: '', notes: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function edit(row) {
|
||||
Object.assign(form, row)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
if (form.id) await updateCustomer(form.id, form)
|
||||
else await createCustomer(form)
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch { /* */ }
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
await ElMessageBox.confirm(`确定删除客户「${row.name}」?`, '提示', { type: 'warning' })
|
||||
await deleteCustomer(row.id)
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function toggleStatus(row, val) {
|
||||
await updateCustomerStatus(row.id, val ? 1 : 0)
|
||||
loadData()
|
||||
}
|
||||
|
||||
loadData()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6" v-for="card in statCards" :key="card.title">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<el-icon :size="36" :color="card.color">
|
||||
<component :is="card.icon" />
|
||||
</el-icon>
|
||||
<div class="stat-info">
|
||||
<div class="stat-title">{{ card.title }}</div>
|
||||
<div class="stat-value">{{ card.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<!-- 收入趋势 -->
|
||||
<el-col :span="14">
|
||||
<el-card shadow="never">
|
||||
<template #header>近 6 个月收入趋势</template>
|
||||
<v-chart class="chart" :option="revenueOption" autoresize />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<!-- 产品分布 -->
|
||||
<el-col :span="10">
|
||||
<el-card shadow="never">
|
||||
<template #header>License 产品分布</template>
|
||||
<v-chart class="chart" :option="productOption" autoresize />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<!-- 即将到期 -->
|
||||
<el-col :span="14">
|
||||
<el-card shadow="never">
|
||||
<template #header>即将到期 License(30 天内)</template>
|
||||
<el-table :data="expiring" size="small" stripe>
|
||||
<el-table-column prop="license_key" label="License Key" min-width="180" />
|
||||
<el-table-column prop="customer_name" label="客户" min-width="100" />
|
||||
<el-table-column prop="product_name" label="产品" min-width="100" />
|
||||
<el-table-column prop="end_date" label="到期时间" width="120" />
|
||||
<el-table-column prop="days_left" label="剩余天数" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.days_left <= 7 ? 'danger' : 'warning'" size="small">
|
||||
{{ row.days_left }} 天
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<!-- 最近活动 -->
|
||||
<el-col :span="10">
|
||||
<el-card shadow="never">
|
||||
<template #header>最近操作</template>
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="act in activities"
|
||||
:key="act.id"
|
||||
:timestamp="act.created_at"
|
||||
placement="top"
|
||||
>
|
||||
<strong>{{ act.username }}</strong>
|
||||
<span style="margin-left: 8px; color: #909399">{{ act.action }}</span>
|
||||
<div style="color: #606266; font-size: 13px">{{ act.detail }}</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { BarChart, PieChart, LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components'
|
||||
import {
|
||||
getDashboardStats, getExpiringLicenses, getRecentActivities,
|
||||
getRevenueStats, getProductDistribution,
|
||||
} from '@/api/analytics'
|
||||
|
||||
use([CanvasRenderer, BarChart, PieChart, LineChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent])
|
||||
|
||||
const stats = ref({})
|
||||
const expiring = ref([])
|
||||
const activities = ref([])
|
||||
const revenueData = ref([])
|
||||
const productData = ref([])
|
||||
|
||||
const statCards = computed(() => [
|
||||
{ title: 'License 总数', value: stats.value.total_licenses ?? '—', icon: 'Key', color: '#409eff' },
|
||||
{ title: '活跃授权', value: stats.value.active_licenses ?? '—', icon: 'CircleCheck', color: '#67c23a' },
|
||||
{ title: '客户数', value: stats.value.total_customers ?? '—', icon: 'User', color: '#e6a23c' },
|
||||
{ title: '在线设备', value: stats.value.online_devices ?? '—', icon: 'Monitor', color: '#f56c6c' },
|
||||
])
|
||||
|
||||
const revenueOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: revenueData.value.map((d) => d.period) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ data: revenueData.value.map((d) => d.amount), type: 'line', smooth: true, areaStyle: {}, color: '#409eff' }],
|
||||
}))
|
||||
|
||||
const productOption = computed(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
series: [{
|
||||
type: 'pie', radius: ['40%', '70%'],
|
||||
data: productData.value.map((d) => ({ name: d.product_name || '未知', value: d.count })),
|
||||
}],
|
||||
}))
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [s, e, a, r, p] = await Promise.all([
|
||||
getDashboardStats(), getExpiringLicenses(30), getRecentActivities(8),
|
||||
getRevenueStats(), getProductDistribution(),
|
||||
])
|
||||
stats.value = s.data
|
||||
expiring.value = e.data
|
||||
activities.value = a.data
|
||||
revenueData.value = r.data
|
||||
productData.value = p.data
|
||||
} catch {
|
||||
// 拦截器已处理
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card { margin-bottom: 0; }
|
||||
.stat-content { display: flex; align-items: center; gap: 16px; }
|
||||
.stat-info { display: flex; flex-direction: column; }
|
||||
.stat-title { font-size: 13px; color: #909399; }
|
||||
.stat-value { font-size: 26px; font-weight: 600; color: #303133; }
|
||||
.chart { height: 280px; }
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<el-select v-model="customerFilter" placeholder="客户" clearable filterable style="width: 180px" @change="loadData">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
<el-select v-model="productFilter" placeholder="产品" clearable filterable style="width: 180px" @change="loadData">
|
||||
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe>
|
||||
<el-table-column prop="customer_name" label="客户" min-width="110" />
|
||||
<el-table-column prop="product_name" label="产品" min-width="110" />
|
||||
<el-table-column prop="plan_name" label="计划" min-width="110" />
|
||||
<el-table-column label="类型" width="90">
|
||||
<template #default="{ row }">{{ planTypeLabel(row.plan_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="license_key" label="License Key" min-width="200" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="entStatusTag(row.status)" size="small">{{ entStatusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="有效期" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.valid_from }}</div>
|
||||
<div style="color: #909399">至 {{ row.valid_until || '永久' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剩余" width="80">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.days_left <= 7 ? '#f56c6c' : '' }">{{ row.days_left >= 9999 ? '永久' : row.days_left + '天' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="设备" width="80">
|
||||
<template #default="{ row }">{{ row.current_devices }}/{{ row.max_devices }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" :disabled="!row.renewable" @click="renew(row)">续费</el-button>
|
||||
<el-button size="small" type="danger" @click="revoke(row)">吊销</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page" v-model:page-size="pageSize"
|
||||
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getEntitlements, revokeEntitlement, renewEntitlement } from '@/api/entitlement'
|
||||
import { getAllProducts } from '@/api/product'
|
||||
import { getAllCustomers } from '@/api/customer'
|
||||
|
||||
const list = ref([])
|
||||
const products = ref([])
|
||||
const customers = ref([])
|
||||
const loading = ref(false)
|
||||
const customerFilter = ref('')
|
||||
const productFilter = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
|
||||
function planTypeLabel(t) { return { perpetual: '买断', lifetime: '终身', monthly: '月订阅', yearly: '年订阅' }[t] || t }
|
||||
function entStatusLabel(s) { return { active: '有效', expired: '过期', revoked: '已吊销' }[s] || s }
|
||||
function entStatusTag(s) { return { active: 'success', expired: 'warning', revoked: 'danger' }[s] || '' }
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getEntitlements({
|
||||
page: page.value, page_size: pageSize.value,
|
||||
customer_id: customerFilter.value, product_id: productFilter.value,
|
||||
})
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function revoke(row) {
|
||||
await ElMessageBox.confirm(`确定吊销「${row.customer_name}」对「${row.product_name}」的权益?`, '提示', { type: 'warning' })
|
||||
await revokeEntitlement(row.id)
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function renew(row) {
|
||||
await ElMessageBox.confirm('将按原计划续费,是否继续?', '续费', { type: 'info' })
|
||||
try {
|
||||
await renewEntitlement(row.id, row.plan_id)
|
||||
ElMessage.success('续费成功')
|
||||
loadData()
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [p, c] = await Promise.all([getAllProducts(), getAllCustomers()])
|
||||
products.value = p.data
|
||||
customers.value = c.data
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { margin-bottom: 16px; }
|
||||
.filters { display: flex; gap: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<el-input v-model="keyword" placeholder="搜索 License Key" clearable style="width: 220px" @keyup.enter="loadData" @clear="loadData" />
|
||||
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 120px" @change="loadData">
|
||||
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
<el-select v-model="productFilter" placeholder="产品" clearable filterable style="width: 160px" @change="loadData">
|
||||
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div>
|
||||
<el-button @click="exportData">导出 CSV</el-button>
|
||||
<el-button type="primary" @click="openCreate">生成 License</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe>
|
||||
<el-table-column prop="license_key" label="License Key" min-width="220" />
|
||||
<el-table-column prop="customer_name" label="客户" min-width="100" />
|
||||
<el-table-column prop="product_name" label="产品" min-width="100" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="end_date" label="到期时间" width="110" />
|
||||
<el-table-column label="剩余" width="70">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.days_left <= 7 ? '#f56c6c' : '' }">{{ row.days_left }}天</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="激活" width="80">
|
||||
<template #default="{ row }">{{ row.current_activations }}/{{ row.max_activations }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="viewDetail(row)">详情</el-button>
|
||||
<el-button size="small" @click="renew(row)">续期</el-button>
|
||||
<el-button size="small" type="warning" @click="toggle(row, row.status === 'suspended' ? 'active' : 'suspended')">
|
||||
{{ row.status === 'suspended' ? '恢复' : '暂停' }}
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" @click="revoke(row)">吊销</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page" v-model:page-size="pageSize"
|
||||
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 生成 License -->
|
||||
<el-dialog v-model="createVisible" title="生成 License" width="520px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="客户" prop="customer_id">
|
||||
<el-select v-model="form.customer_id" filterable placeholder="选择客户" style="width: 100%">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="产品" prop="product_id">
|
||||
<el-select v-model="form.product_id" filterable placeholder="选择产品" style="width: 100%">
|
||||
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期" prop="duration">
|
||||
<el-select v-model="form.duration" style="width: 100%">
|
||||
<el-option v-for="d in durations" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大激活数"><el-input-number v-model="form.max_activations" :min="1" /></el-form-item>
|
||||
<el-form-item label="生成数量"><el-input-number v-model="form.count" :min="1" :max="100" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitCreate">生成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情/激活记录 -->
|
||||
<el-dialog v-model="detailVisible" title="License 详情" width="680px">
|
||||
<el-descriptions :column="2" border size="small" v-if="detail.license">
|
||||
<el-descriptions-item label="License Key">{{ detail.license.license_key }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ detail.license.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户">{{ detail.license.customer_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="产品">{{ detail.license.product_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="到期">{{ detail.license.end_date }}</el-descriptions-item>
|
||||
<el-descriptions-item label="剩余天数">{{ detail.license.days_left }} 天</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h4 style="margin: 16px 0 8px">激活设备</h4>
|
||||
<el-table :data="detail.activations" size="small" stripe>
|
||||
<el-table-column prop="device_id" label="设备 ID" min-width="120" />
|
||||
<el-table-column prop="device_name" label="设备名" min-width="120" />
|
||||
<el-table-column prop="ip" label="IP" width="120" />
|
||||
<el-table-column label="在线" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_online ? 'success' : 'info'" size="small">{{ row.is_online ? '在线' : '离线' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_seen_at" label="最后心跳" width="160" />
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" link @click="handleForceOffline(row)">下线</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getLicenses, createLicense, renewLicense, revokeLicense, suspendLicense,
|
||||
activateLicense, getLicense, forceOffline, exportLicenses,
|
||||
} from '@/api/license'
|
||||
import { getAllProducts } from '@/api/product'
|
||||
import { getAllCustomers } from '@/api/customer'
|
||||
|
||||
const list = ref([])
|
||||
const products = ref([])
|
||||
const customers = ref([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const statusFilter = ref('')
|
||||
const productFilter = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '未激活', value: 'inactive' }, { label: '已激活', value: 'active' },
|
||||
{ label: '已过期', value: 'expired' }, { label: '已吊销', value: 'revoked' }, { label: '已暂停', value: 'suspended' },
|
||||
]
|
||||
const durations = [
|
||||
{ label: '1 个月', value: 30 }, { label: '3 个月', value: 90 }, { label: '6 个月', value: 180 },
|
||||
{ label: '1 年', value: 365 }, { label: '2 年', value: 730 }, { label: '3 年', value: 1095 },
|
||||
{ label: '5 年', value: 1825 }, { label: '永久', value: 0 },
|
||||
]
|
||||
|
||||
function statusLabel(s) { return statusOptions.find((o) => o.value === s)?.label || s }
|
||||
function statusTag(s) {
|
||||
return { active: 'success', inactive: 'info', expired: 'warning', revoked: 'danger', suspended: 'warning' }[s] || ''
|
||||
}
|
||||
|
||||
const createVisible = ref(false)
|
||||
const formRef = ref(null)
|
||||
const form = reactive({ customer_id: null, product_id: null, duration: 365, max_activations: 1, count: 1, notes: '' })
|
||||
const rules = {
|
||||
customer_id: [{ required: true, message: '请选择客户' }],
|
||||
product_id: [{ required: true, message: '请选择产品' }],
|
||||
duration: [{ required: true, message: '请选择有效期' }],
|
||||
}
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detail = reactive({ license: null, activations: [] })
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getLicenses({
|
||||
page: page.value, page_size: pageSize.value,
|
||||
keyword: keyword.value, status: statusFilter.value, product_id: productFilter.value,
|
||||
})
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, { customer_id: null, product_id: null, duration: 365, max_activations: 1, count: 1, notes: '' })
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
const res = await createLicense(form)
|
||||
ElMessage.success(`成功生成 ${res.data.length} 个 License`)
|
||||
createVisible.value = false
|
||||
loadData()
|
||||
} catch { /* */ }
|
||||
})
|
||||
}
|
||||
|
||||
async function viewDetail(row) {
|
||||
const res = await getLicense(row.id)
|
||||
detail.license = res.data.license
|
||||
detail.activations = res.data.activations || []
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
async function renew(row) {
|
||||
const { value } = await ElMessageBox.prompt('续期天数(0=永久)', '续期', {
|
||||
inputType: 'number', inputValue: '365', inputValidator: (v) => v !== '' && v >= 0,
|
||||
})
|
||||
await renewLicense(row.id, parseInt(value))
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function revoke(row) {
|
||||
const { value } = await ElMessageBox.prompt('吊销原因', '吊销 License', { inputType: 'textarea' })
|
||||
await revokeLicense(row.id, value || '')
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function toggle(row, status) {
|
||||
if (status === 'active') await activateLicense(row.id)
|
||||
else await suspendLicense(row.id)
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function handleForceOffline(act) {
|
||||
await ElMessageBox.confirm(`确定强制下线设备「${act.device_name || act.device_id}」?`, '提示', { type: 'warning' })
|
||||
await forceOffline(detail.license.id, act.id)
|
||||
// 刷新详情 // Logan.Li
|
||||
const res = await getLicense(detail.license.id)
|
||||
detail.activations = res.data.activations || []
|
||||
}
|
||||
|
||||
async function exportData() {
|
||||
try {
|
||||
const res = await exportLicenses({ status: statusFilter.value, product_id: productFilter.value })
|
||||
const url = window.URL.createObjectURL(new Blob([res]))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'licenses.csv'
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [p, c] = await Promise.all([getAllProducts(), getAllCustomers()])
|
||||
products.value = p.data
|
||||
customers.value = c.data
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
|
||||
.filters { display: flex; gap: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<el-icon size="40" color="#409eff"><Key /></el-icon>
|
||||
<h2 class="login-title">opsx-licence</h2>
|
||||
<p class="login-subtitle">授权管理平台</p>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="loginFormRef"
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
size="large"
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="用户名"
|
||||
:prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
show-password
|
||||
:prefix-icon="Lock"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
style="width: 100%"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="login-tip">默认账号:admin / admin</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loginFormRef = ref(null)
|
||||
const loading = ref(false)
|
||||
const loginForm = reactive({
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
})
|
||||
const loginRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
// 处理登录 // Logan.Li
|
||||
async function handleLogin() {
|
||||
if (!loginFormRef.value) return
|
||||
await loginFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login(loginForm)
|
||||
// 登录成功后跳转到 redirect 或首页 // Logan.Li
|
||||
const redirect = route.query.redirect || '/'
|
||||
router.push(redirect)
|
||||
} catch {
|
||||
// 拦截器已处理错误提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.login-card {
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.login-title {
|
||||
margin: 12px 0 4px;
|
||||
font-size: 24px;
|
||||
color: #303133;
|
||||
}
|
||||
.login-subtitle {
|
||||
margin: 0;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
.login-tip {
|
||||
text-align: center;
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<el-select v-model="actionFilter" placeholder="操作类型" clearable style="width: 150px" @change="loadData">
|
||||
<el-option v-for="a in actions" :key="a" :label="a" :value="a" />
|
||||
</el-select>
|
||||
<el-select v-model="targetFilter" placeholder="目标类型" clearable style="width: 130px" @change="loadData">
|
||||
<el-option v-for="t in targets" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-popconfirm title="确定清空全部日志?" @confirm="clearLogs">
|
||||
<template #reference><el-button type="danger">清空日志</el-button></template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe>
|
||||
<el-table-column prop="username" label="操作人" width="100" />
|
||||
<el-table-column prop="action" label="操作" width="110" />
|
||||
<el-table-column prop="target_type" label="目标类型" width="100" />
|
||||
<el-table-column prop="target_name" label="目标名称" min-width="160" />
|
||||
<el-table-column prop="detail" label="详情" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="结果" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.result === 'success' ? 'success' : 'danger'" size="small">{{ row.result }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip" label="IP" width="130" />
|
||||
<el-table-column prop="created_at" label="时间" width="170" />
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page" v-model:page-size="pageSize"
|
||||
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getAuditLogs, clearAuditLogs } from '@/api/setting'
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const actionFilter = ref('')
|
||||
const targetFilter = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(15)
|
||||
const total = ref(0)
|
||||
|
||||
const actions = ['create', 'update', 'delete', 'renew', 'revoke', 'activate', 'suspend', 'force_offline', 'manual_grant', 'payment_completed', 'update_status', 'toggle_status']
|
||||
const targets = ['license', 'customer', 'product', 'plan', 'order', 'entitlement', 'activation']
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAuditLogs({ page: page.value, page_size: pageSize.value, action: actionFilter.value, target_type: targetFilter.value })
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function clearLogs() {
|
||||
await clearAuditLogs()
|
||||
ElMessage.success('日志已清空')
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
|
||||
.filters { display: flex; gap: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<el-result icon="warning" title="404" sub-title="抱歉,您访问的页面不存在">
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 404 页面 // Logan.Li
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<el-select v-model="statusFilter" placeholder="订单状态" clearable style="width: 130px" @change="loadData">
|
||||
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button type="primary" @click="grantVisible = true">手动发放</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe>
|
||||
<el-table-column prop="order_no" label="订单号" min-width="200" />
|
||||
<el-table-column prop="customer_name" label="客户" min-width="100" />
|
||||
<el-table-column prop="product_name" label="产品" min-width="100" />
|
||||
<el-table-column prop="plan_name" label="计划" min-width="100" />
|
||||
<el-table-column label="金额" width="100">
|
||||
<template #default="{ row }">{{ row.amount }} {{ row.currency }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="payment_gateway" label="渠道" width="90" />
|
||||
<el-table-column prop="created_at" label="创建时间" width="160" />
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page" v-model:page-size="pageSize"
|
||||
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 手动发放 License -->
|
||||
<el-dialog v-model="grantVisible" title="手动发放 License" width="500px">
|
||||
<el-form ref="grantFormRef" :model="grantForm" :rules="grantRules" label-width="90px">
|
||||
<el-form-item label="客户邮箱" prop="customer_email"><el-input v-model="grantForm.customer_email" /></el-form-item>
|
||||
<el-form-item label="客户姓名"><el-input v-model="grantForm.customer_name" /></el-form-item>
|
||||
<el-form-item label="产品" prop="product_id">
|
||||
<el-select v-model="grantForm.product_id" filterable style="width: 100%" @change="loadPlans">
|
||||
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="计划" prop="plan_id">
|
||||
<el-select v-model="grantForm.plan_id" filterable style="width: 100%">
|
||||
<el-option v-for="pl in plans" :key="pl.id" :label="pl.name" :value="pl.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="grantForm.notes" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="grantVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitGrant">发放</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getOrders, manualGrant } from '@/api/order'
|
||||
import { getAllProducts } from '@/api/product'
|
||||
import request from '@/api/request'
|
||||
|
||||
const list = ref([])
|
||||
const products = ref([])
|
||||
const plans = ref([])
|
||||
const loading = ref(false)
|
||||
const statusFilter = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '待支付', value: 'pending' }, { label: '已支付', value: 'paid' },
|
||||
{ label: '失败', value: 'failed' }, { label: '已退款', value: 'refunded' }, { label: '已取消', value: 'cancelled' },
|
||||
]
|
||||
function statusLabel(s) { return statusOptions.find((o) => o.value === s)?.label || s }
|
||||
function statusTag(s) { return { paid: 'success', pending: 'warning', failed: 'danger', refunded: 'info', cancelled: 'info' }[s] || '' }
|
||||
|
||||
const grantVisible = ref(false)
|
||||
const grantFormRef = ref(null)
|
||||
const grantForm = reactive({ customer_email: '', customer_name: '', product_id: null, plan_id: null, notes: '' })
|
||||
const grantRules = {
|
||||
customer_email: [{ required: true, type: 'email', message: '请输入有效邮箱', trigger: 'blur' }],
|
||||
product_id: [{ required: true, message: '请选择产品' }],
|
||||
plan_id: [{ required: true, message: '请选择计划' }],
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getOrders({ page: page.value, page_size: pageSize.value, status: statusFilter.value })
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadPlans(productID) {
|
||||
grantForm.plan_id = null
|
||||
const res = await request({ url: `/v1/products/${productID}/plans`, method: 'get' })
|
||||
plans.value = res.data || []
|
||||
}
|
||||
|
||||
async function submitGrant() {
|
||||
await grantFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
const res = await manualGrant(grantForm)
|
||||
ElMessage.success('License 发放成功:' + res.data.license_key)
|
||||
grantVisible.value = false
|
||||
loadData()
|
||||
} catch { /* */ }
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
products.value = (await getAllProducts()).data
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
|
||||
.filters { display: flex; gap: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<div class="toolbar">
|
||||
<el-select v-model="productFilter" placeholder="筛选产品" clearable filterable style="width: 200px" @change="loadData" />
|
||||
<el-button type="primary" @click="openCreate">新增计划</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe>
|
||||
<el-table-column prop="name" label="计划名称" min-width="130" />
|
||||
<el-table-column label="类型" width="90">
|
||||
<template #default="{ row }">{{ planTypeLabel(row.plan_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" width="100">
|
||||
<template #default="{ row }">{{ row.price }} {{ row.currency }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时长" width="90">
|
||||
<template #default="{ row }">{{ row.duration === 0 ? '永久' : row.duration + ' 天' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="max_devices" label="设备数" width="80" />
|
||||
<el-table-column label="功能" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="f in row.features" :key="f" size="small" style="margin: 2px">{{ f }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.status === 1" @change="(v) => toggle(row, v)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="edit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page" v-model:page-size="pageSize"
|
||||
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑计划' : '新增计划'" width="540px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="产品" prop="product_id">
|
||||
<el-select v-model="form.product_id" filterable :disabled="!!form.id" style="width: 100%">
|
||||
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" prop="name"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="类型" prop="plan_type">
|
||||
<el-select v-model="form.plan_type" style="width: 100%">
|
||||
<el-option value="perpetual" label="买断" /><el-option value="lifetime" label="终身" />
|
||||
<el-option value="monthly" label="月订阅" /><el-option value="yearly" label="年订阅" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="价格" prop="price"><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item>
|
||||
<el-form-item label="货币"><el-input v-model="form.currency" style="width: 100px" /></el-form-item>
|
||||
<el-form-item label="时长(天)"><el-input-number v-model="form.duration" :min="0" /><span style="margin-left: 8px; color: #909399">0=永久</span></el-form-item>
|
||||
<el-form-item label="设备数"><el-input-number v-model="form.max_devices" :min="1" /></el-form-item>
|
||||
<el-form-item label="功能">
|
||||
<el-select v-model="form.features" multiple filterable allow-create default-first-option style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submit">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { getPlans, createPlan, updatePlan, deletePlan, togglePlanStatus } from '@/api/plan'
|
||||
import { getAllProducts } from '@/api/product'
|
||||
|
||||
const list = ref([])
|
||||
const products = ref([])
|
||||
const loading = ref(false)
|
||||
const productFilter = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const formRef = ref(null)
|
||||
const form = reactive({ id: null, product_id: null, name: '', plan_type: 'monthly', price: 0, currency: 'USD', duration: 30, max_devices: 1, features: [] })
|
||||
const rules = {
|
||||
product_id: [{ required: true, message: '请选择产品' }],
|
||||
name: [{ required: true, message: '请输入名称' }],
|
||||
plan_type: [{ required: true, message: '请选择类型' }],
|
||||
price: [{ required: true, message: '请输入价格' }],
|
||||
}
|
||||
|
||||
function planTypeLabel(t) { return { perpetual: '买断', lifetime: '终身', monthly: '月订阅', yearly: '年订阅' }[t] || t }
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getPlans({ page: page.value, page_size: pageSize.value, product_id: productFilter.value })
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, { id: null, product_id: null, name: '', plan_type: 'monthly', price: 0, currency: 'USD', duration: 30, max_devices: 1, features: [] })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function edit(row) {
|
||||
Object.assign(form, row, { features: row.features || [], price: row.price })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
if (form.id) await updatePlan(form.id, form)
|
||||
else await createPlan(form)
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch { /* */ }
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
await ElMessageBox.confirm(`确定删除计划「${row.name}」?`, '提示', { type: 'warning' })
|
||||
await deletePlan(row.id)
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function toggle(row, val) {
|
||||
await togglePlanStatus(row.id, val ? 1 : 0)
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
products.value = (await getAllProducts()).data
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="keyword" placeholder="搜索产品名称/编码" clearable style="width: 240px" @keyup.enter="loadData" @clear="loadData" />
|
||||
<el-button type="primary" @click="dialogVisible = true">新增产品</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe>
|
||||
<el-table-column prop="name" label="产品名称" min-width="140" />
|
||||
<el-table-column prop="code" label="编码" width="120" />
|
||||
<el-table-column prop="version" label="版本" width="90" />
|
||||
<el-table-column prop="price" label="价格" width="90">
|
||||
<template #default="{ row }">{{ row.price }} {{ row.currency || '' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="price_model" label="计费模式" width="110" />
|
||||
<el-table-column label="功能" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="f in row.features" :key="f" size="small" style="margin: 2px">{{ f }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.status === 1" @change="(v) => toggleStatus(row, v)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="edit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page" v-model:page-size="pageSize"
|
||||
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑产品' : '新增产品'" width="560px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="名称" prop="name"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="编码" prop="code"><el-input v-model="form.code" :disabled="!!form.id" /></el-form-item>
|
||||
<el-form-item label="版本"><el-input v-model="form.version" /></el-form-item>
|
||||
<el-form-item label="价格"><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item>
|
||||
<el-form-item label="计费模式">
|
||||
<el-select v-model="form.price_model" style="width: 100%">
|
||||
<el-option value="subscription" label="订阅" /><el-option value="perpetual" label="买断" /><el-option value="trial" label="试用" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="功能特性">
|
||||
<el-select v-model="form.features" multiple filterable allow-create default-first-option style="width: 100%" placeholder="输入后回车添加" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submit">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getProducts, createProduct, updateProduct, deleteProduct, updateProductStatus } from '@/api/product'
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const formRef = ref(null)
|
||||
const form = reactive({ id: null, name: '', code: '', version: '', price: 0, price_model: 'subscription', features: [], description: '' })
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '请输入编码', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getProducts({ page: page.value, page_size: pageSize.value, keyword: keyword.value })
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function edit(row) {
|
||||
Object.assign(form, row, { features: row.features || [] })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
if (form.id) {
|
||||
await updateProduct(form.id, form)
|
||||
} else {
|
||||
await createProduct(form)
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch { /* 拦截器处理 */ }
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
await ElMessageBox.confirm(`确定删除产品「${row.name}」?`, '提示', { type: 'warning' })
|
||||
await deleteProduct(row.id)
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function toggleStatus(row, val) {
|
||||
await updateProductStatus(row.id, val ? 1 : 0)
|
||||
loadData()
|
||||
}
|
||||
|
||||
loadData()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-row :gutter="16">
|
||||
<!-- 系统设置 -->
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>系统设置</template>
|
||||
<el-form :model="setting" label-width="130px" v-loading="loadingSetting">
|
||||
<el-form-item label="系统名称"><el-input v-model="setting.system_name" /></el-form-item>
|
||||
<el-form-item label="默认有效期(天)"><el-input-number v-model="setting.default_duration" :min="0" /></el-form-item>
|
||||
<el-form-item label="默认最大激活数"><el-input-number v-model="setting.default_max_activations" :min="1" /></el-form-item>
|
||||
<el-form-item label="到期提醒(天)"><el-input-number v-model="setting.expire_reminder_days" :min="1" /></el-form-item>
|
||||
<el-form-item label="API 限频"><el-input-number v-model="setting.api_rate_limit" :min="0" /></el-form-item>
|
||||
<el-form-item label="日志保留(天)"><el-input-number v-model="setting.log_retention_days" :min="0" /></el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveSetting">保存设置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- License 模板 -->
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>License 模板</span>
|
||||
<el-button size="small" type="primary" @click="openTemplate">新增模板</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="templates" size="small" stripe>
|
||||
<el-table-column prop="name" label="名称" min-width="120" />
|
||||
<el-table-column label="时长" width="90">
|
||||
<template #default="{ row }">{{ row.duration === 0 ? '永久' : row.duration + '天' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="max_activations" label="激活数" width="80" />
|
||||
<el-table-column prop="description" label="说明" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link @click="editTemplate(row)">编辑</el-button>
|
||||
<el-button size="small" link type="danger" @click="removeTemplate(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 模板对话框 -->
|
||||
<el-dialog v-model="tmplVisible" :title="tmplForm.id ? '编辑模板' : '新增模板'" width="440px">
|
||||
<el-form :model="tmplForm" label-width="90px">
|
||||
<el-form-item label="名称"><el-input v-model="tmplForm.name" /></el-form-item>
|
||||
<el-form-item label="时长(天)"><el-input-number v-model="tmplForm.duration" :min="0" /><span style="margin-left:8px;color:#909399">0=永久</span></el-form-item>
|
||||
<el-form-item label="激活数"><el-input-number v-model="tmplForm.max_activations" :min="1" /></el-form-item>
|
||||
<el-form-item label="说明"><el-input v-model="tmplForm.description" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="tmplVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveTemplate">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getSetting, updateSetting, getLicenseTemplates, saveLicenseTemplate, deleteLicenseTemplate } from '@/api/setting'
|
||||
|
||||
const setting = reactive({})
|
||||
const loadingSetting = ref(false)
|
||||
const templates = ref([])
|
||||
const tmplVisible = ref(false)
|
||||
const tmplForm = reactive({ id: null, name: '', duration: 365, max_activations: 1, description: '' })
|
||||
|
||||
async function loadSetting() {
|
||||
loadingSetting.value = true
|
||||
try {
|
||||
const res = await getSetting()
|
||||
Object.assign(setting, res.data)
|
||||
} finally { loadingSetting.value = false }
|
||||
}
|
||||
|
||||
async function saveSetting() {
|
||||
await updateSetting(setting)
|
||||
ElMessage.success('设置已保存')
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
const res = await getLicenseTemplates()
|
||||
templates.value = res.data || []
|
||||
}
|
||||
|
||||
function openTemplate() {
|
||||
Object.assign(tmplForm, { id: null, name: '', duration: 365, max_activations: 1, description: '' })
|
||||
tmplVisible.value = true
|
||||
}
|
||||
|
||||
function editTemplate(row) {
|
||||
Object.assign(tmplForm, row)
|
||||
tmplVisible.value = true
|
||||
}
|
||||
|
||||
async function saveTemplate() {
|
||||
await saveLicenseTemplate(tmplForm)
|
||||
tmplVisible.value = false
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
async function removeTemplate(row) {
|
||||
await ElMessageBox.confirm(`确定删除模板「${row.name}」?`, '提示', { type: 'warning' })
|
||||
await deleteLicenseTemplate(row.id)
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSetting()
|
||||
loadTemplates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5181,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:9001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
target: 'esnext',
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user