Initial commit
This commit is contained in:
2
license-system-frontend/.env.example
Normal file
2
license-system-frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE=
|
||||
VITE_API_PORT=
|
||||
42
license-system-frontend/README.md
Normal file
42
license-system-frontend/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# License System Frontend
|
||||
|
||||
Admin + Agent web UI built with Vue 3, Vite, Element Plus.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1) Copy env file:
|
||||
|
||||
```
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2) Update API base URL or port in `.env`:
|
||||
|
||||
```
|
||||
VITE_API_BASE=http://localhost:39256
|
||||
# or
|
||||
VITE_API_PORT=39256
|
||||
```
|
||||
|
||||
If both are empty, the frontend uses the current origin.
|
||||
|
||||
3) Install deps and run:
|
||||
|
||||
```
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Build:
|
||||
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Admin and agent share the same login page (role selector).
|
||||
- Super admin is required for Agents and Settings pages.
|
||||
- Backend must allow CORS for the frontend origin.
|
||||
- Admin project permissions control project-level buttons (cards/projects).
|
||||
- Editing agent allowed projects requires enabling "Override Allowed Projects".
|
||||
12
license-system-frontend/index.html
Normal file
12
license-system-frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>License System</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
license-system-frontend/jsconfig.json
Normal file
8
license-system-frontend/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
1732
license-system-frontend/package-lock.json
generated
Normal file
1732
license-system-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
license-system-frontend/package.json
Normal file
23
license-system-frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "license-system-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.8",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.5.0",
|
||||
"element-plus": "^2.6.3",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"vite": "^5.2.8"
|
||||
}
|
||||
}
|
||||
8
license-system-frontend/src/App.vue
Normal file
8
license-system-frontend/src/App.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div class="app-root">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
79
license-system-frontend/src/api/admin.js
Normal file
79
license-system-frontend/src/api/admin.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import api from './http';
|
||||
|
||||
export const adminLogin = (payload) => api.post('/api/admin/login', payload);
|
||||
export const adminLogout = () => api.post('/api/admin/logout');
|
||||
export const adminProfile = () => api.get('/api/admin/profile');
|
||||
export const adminUpdateProfile = (payload) => api.put('/api/admin/profile', payload);
|
||||
export const adminChangePassword = (payload) => api.post('/api/admin/change-password', payload);
|
||||
|
||||
export const fetchProjects = (params) => api.get('/api/admin/projects', { params });
|
||||
export const createProject = (payload) => api.post('/api/admin/projects', payload);
|
||||
export const updateProject = (id, payload) => api.put(`/api/admin/projects/${id}`, payload);
|
||||
export const deleteProject = (id) => api.delete(`/api/admin/projects/${id}`);
|
||||
export const getProject = (id) => api.get(`/api/admin/projects/${id}`);
|
||||
export const getProjectStats = (id) => api.get(`/api/admin/projects/${id}/stats`);
|
||||
export const getProjectDocs = (id) => api.get(`/api/admin/projects/${id}/docs`);
|
||||
export const updateProjectDocs = (id, payload) => api.put(`/api/admin/projects/${id}/docs`, payload);
|
||||
|
||||
export const getProjectPricing = (id) => api.get(`/api/admin/projects/${id}/pricing`);
|
||||
export const createProjectPricing = (id, payload) => api.post(`/api/admin/projects/${id}/pricing`, payload);
|
||||
export const updateProjectPricing = (id, priceId, payload) => api.put(`/api/admin/projects/${id}/pricing/${priceId}`, payload);
|
||||
export const deleteProjectPricing = (id, priceId) => api.delete(`/api/admin/projects/${id}/pricing/${priceId}`);
|
||||
|
||||
export const getProjectVersions = (id) => api.get(`/api/admin/projects/${id}/versions`);
|
||||
export const uploadProjectVersion = (id, formData) => api.post(`/api/admin/projects/${id}/versions`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
export const updateProjectVersion = (id, versionId, payload) => api.put(`/api/admin/projects/${id}/versions/${versionId}`, payload);
|
||||
export const deleteProjectVersion = (id, versionId) => api.delete(`/api/admin/projects/${id}/versions/${versionId}`);
|
||||
|
||||
export const generateCards = (payload, idempotencyKey) => api.post('/api/admin/cards/generate', payload, {
|
||||
headers: idempotencyKey ? { 'X-Idempotency-Key': idempotencyKey } : {}
|
||||
});
|
||||
export const listCards = (params) => api.get('/api/admin/cards', { params });
|
||||
export const getCard = (id) => api.get(`/api/admin/cards/${id}`);
|
||||
export const getCardLogs = (id) => api.get(`/api/admin/cards/${id}/logs`);
|
||||
export const updateCard = (id, payload) => api.put(`/api/admin/cards/${id}`, payload);
|
||||
export const banCard = (id, payload) => api.post(`/api/admin/cards/${id}/ban`, payload);
|
||||
export const unbanCard = (id) => api.post(`/api/admin/cards/${id}/unban`);
|
||||
export const extendCard = (id, payload) => api.post(`/api/admin/cards/${id}/extend`, payload);
|
||||
export const resetCardDevice = (id) => api.post(`/api/admin/cards/${id}/reset-device`);
|
||||
export const deleteCard = (id) => api.delete(`/api/admin/cards/${id}`);
|
||||
export const banCardsBatch = (payload) => api.post('/api/admin/cards/ban-batch', payload);
|
||||
export const unbanCardsBatch = (payload) => api.post('/api/admin/cards/unban-batch', payload);
|
||||
export const deleteCardsBatch = (payload) => api.delete('/api/admin/cards/batch', { data: payload });
|
||||
export const exportCards = (params) => api.get('/api/admin/cards/export', { params, responseType: 'blob' });
|
||||
export const importCards = (formData) => api.post('/api/admin/cards/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
|
||||
export const listAgents = (params) => api.get('/api/admin/agents', { params });
|
||||
export const createAgent = (payload) => api.post('/api/admin/agents', payload);
|
||||
export const getAgent = (id) => api.get(`/api/admin/agents/${id}`);
|
||||
export const updateAgent = (id, payload) => api.put(`/api/admin/agents/${id}`, payload);
|
||||
export const enableAgent = (id) => api.post(`/api/admin/agents/${id}/enable`);
|
||||
export const disableAgent = (id) => api.post(`/api/admin/agents/${id}/disable`);
|
||||
export const deleteAgent = (id) => api.delete(`/api/admin/agents/${id}`);
|
||||
export const rechargeAgent = (id, payload) => api.post(`/api/admin/agents/${id}/recharge`, payload);
|
||||
export const deductAgent = (id, payload) => api.post(`/api/admin/agents/${id}/deduct`, payload);
|
||||
export const agentTransactions = (id) => api.get(`/api/admin/agents/${id}/transactions`);
|
||||
|
||||
export const listDevices = (params) => api.get('/api/admin/devices', { params });
|
||||
export const kickDevice = (id) => api.post(`/api/admin/devices/${id}/kick`);
|
||||
export const unbindDevice = (id) => api.delete(`/api/admin/devices/${id}`);
|
||||
|
||||
export const listLogs = (params) => api.get('/api/admin/logs', { params });
|
||||
export const getLog = (id) => api.get(`/api/admin/logs/${id}`);
|
||||
|
||||
export const statsDashboard = () => api.get('/api/admin/stats/dashboard');
|
||||
export const statsProjects = () => api.get('/api/admin/stats/projects');
|
||||
export const statsAgents = () => api.get('/api/admin/stats/agents');
|
||||
export const statsLogs = (params) => api.get('/api/admin/stats/logs', { params });
|
||||
export const statsExport = (params) => api.get('/api/admin/stats/export', { params, responseType: 'blob' });
|
||||
|
||||
export const listSettings = () => api.get('/api/admin/settings');
|
||||
export const updateSettings = (payload) => api.put('/api/admin/settings', payload);
|
||||
export const listAdmins = () => api.get('/api/admin/admins');
|
||||
export const createAdmin = (payload) => api.post('/api/admin/admins', payload);
|
||||
export const updateAdmin = (id, payload) => api.put(`/api/admin/admins/${id}`, payload);
|
||||
export const deleteAdmin = (id) => api.delete(`/api/admin/admins/${id}`);
|
||||
13
license-system-frontend/src/api/agent.js
Normal file
13
license-system-frontend/src/api/agent.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import api from './http';
|
||||
|
||||
export const agentLogin = (payload) => api.post('/api/agent/login', payload);
|
||||
export const agentLogout = () => api.post('/api/agent/logout');
|
||||
export const agentProfile = () => api.get('/api/agent/profile');
|
||||
export const agentUpdateProfile = (payload) => api.put('/api/agent/profile', payload);
|
||||
export const agentChangePassword = (payload) => api.post('/api/agent/change-password', payload);
|
||||
export const agentTransactions = () => api.get('/api/agent/transactions');
|
||||
|
||||
export const agentGenerateCards = (payload, idempotencyKey) => api.post('/api/agent/cards/generate', payload, {
|
||||
headers: idempotencyKey ? { 'X-Idempotency-Key': idempotencyKey } : {}
|
||||
});
|
||||
export const agentCards = (params) => api.get('/api/agent/cards', { params });
|
||||
40
license-system-frontend/src/api/http.js
Normal file
40
license-system-frontend/src/api/http.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import axios from 'axios';
|
||||
import { session, clearSession } from '@/store/session';
|
||||
import { resolveApiBase } from '@/utils/apiBase';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: resolveApiBase(),
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
if (session.token) {
|
||||
config.headers.Authorization = `Bearer ${session.token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
if (response.config.responseType === 'blob') {
|
||||
return response;
|
||||
}
|
||||
const payload = response.data;
|
||||
if (payload && typeof payload.code === 'number') {
|
||||
if (payload.code !== 200) {
|
||||
return Promise.reject(payload);
|
||||
}
|
||||
return payload.data;
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
(error) => {
|
||||
const status = error?.response?.status;
|
||||
if (status === 401) {
|
||||
clearSession();
|
||||
}
|
||||
return Promise.reject(error?.response?.data || error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
77
license-system-frontend/src/components/BarChart.vue
Normal file
77
license-system-frontend/src/components/BarChart.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div ref="chartRef" class="chart"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const props = defineProps({
|
||||
labels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
values: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '#ff6b35'
|
||||
}
|
||||
});
|
||||
|
||||
const chartRef = ref(null);
|
||||
let chart = null;
|
||||
|
||||
const render = () => {
|
||||
if (!chart) return;
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 32, right: 20, top: 20, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.labels,
|
||||
axisLabel: { rotate: 30 }
|
||||
},
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: props.values,
|
||||
itemStyle: { color: props.color, borderRadius: [8, 8, 0, 0] }
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
chart?.resize();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
chart = echarts.init(chartRef.value);
|
||||
render();
|
||||
window.addEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
chart?.dispose();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.labels, props.values],
|
||||
() => {
|
||||
render();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
}
|
||||
</style>
|
||||
65
license-system-frontend/src/components/TrendChart.vue
Normal file
65
license-system-frontend/src/components/TrendChart.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div ref="chartRef" class="chart"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const props = defineProps({
|
||||
trend: {
|
||||
type: Object,
|
||||
default: () => ({ dates: [], activeUsers: [], newUsers: [], revenue: [] })
|
||||
}
|
||||
});
|
||||
|
||||
const chartRef = ref(null);
|
||||
let chart = null;
|
||||
|
||||
const render = () => {
|
||||
if (!chart) return;
|
||||
const { dates = [], activeUsers = [], newUsers = [], revenue = [] } = props.trend || {};
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { top: 0 },
|
||||
grid: { left: 30, right: 20, top: 40, bottom: 30 },
|
||||
xAxis: { type: 'category', data: dates },
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: '\u6d3b\u8dc3\u7528\u6237', type: 'line', smooth: true, data: activeUsers },
|
||||
{ name: '\u65b0\u589e\u7528\u6237', type: 'line', smooth: true, data: newUsers },
|
||||
{ name: '\u6536\u5165', type: 'line', smooth: true, data: revenue }
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
chart?.resize();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
chart = echarts.init(chartRef.value);
|
||||
render();
|
||||
window.addEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
chart?.dispose();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.trend,
|
||||
() => {
|
||||
render();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
}
|
||||
</style>
|
||||
266
license-system-frontend/src/layouts/AdminLayout.vue
Normal file
266
license-system-frontend/src/layouts/AdminLayout.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="layout-shell">
|
||||
<el-container class="layout-container">
|
||||
<el-aside class="sidebar" width="240px">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">LS</div>
|
||||
<div>
|
||||
<div class="brand-title">{{ '\u6388\u6743\u7cfb\u7edf' }}</div>
|
||||
<div class="brand-sub">{{ '\u7ba1\u7406\u63a7\u5236\u53f0' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-menu :default-active="activeMenu" class="menu" router>
|
||||
<el-menu-item index="/admin/dashboard">{{ '\u4eea\u8868\u76d8' }}</el-menu-item>
|
||||
<el-menu-item index="/admin/projects">{{ '\u9879\u76ee' }}</el-menu-item>
|
||||
<el-menu-item index="/admin/cards">{{ '\u5361\u5bc6' }}</el-menu-item>
|
||||
<el-menu-item index="/admin/devices">{{ '\u8bbe\u5907' }}</el-menu-item>
|
||||
<el-menu-item index="/admin/logs">{{ '\u65e5\u5fd7' }}</el-menu-item>
|
||||
<el-menu-item index="/admin/stats">{{ '\u7edf\u8ba1' }}</el-menu-item>
|
||||
<el-menu-item index="/admin/api-docs">{{ '\u63a5\u53e3\u63a5\u5165\u6587\u6863' }}</el-menu-item>
|
||||
<el-menu-item v-if="superAdmin" index="/admin/agents">{{ '\u4ee3\u7406\u5546' }}</el-menu-item>
|
||||
<el-menu-item v-if="superAdmin" index="/admin/settings">{{ '\u8bbe\u7f6e' }}</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<el-button class="mobile-toggle" text @click="drawer = true">
|
||||
<span class="toggle-icon"></span>
|
||||
</el-button>
|
||||
<div class="header-title">
|
||||
<div class="section-title">{{ '\u7ba1\u7406\u5de5\u4f5c\u53f0' }}</div>
|
||||
<div class="header-meta">
|
||||
<span class="tag">{{ roleLabel }}</span>
|
||||
<span v-if="projectHint" class="tag">{{ projectHint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="user-block">
|
||||
<div class="user-name">{{ userName }}</div>
|
||||
<div class="user-role">{{ roleLabel }}</div>
|
||||
</div>
|
||||
<el-button type="primary" plain @click="handleLogout">{{ '\u9000\u51fa\u767b\u5f55' }}</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main class="page-shell">
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view />
|
||||
</transition>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
|
||||
<el-drawer v-model="drawer" direction="ltr" size="240px" class="mobile-drawer">
|
||||
<template #header>
|
||||
<div class="drawer-title">{{ '\u83dc\u5355' }}</div>
|
||||
</template>
|
||||
<el-menu :default-active="activeMenu" router @select="drawer = false">
|
||||
<el-menu-item index="/admin/dashboard">{{ '\u4eea\u8868\u76d8' }}</el-menu-item>
|
||||
<el-menu-item index="/admin/projects">{{ '\u9879\u76ee' }}</el-menu-item>
|
||||
<el-menu-item index="/admin/cards">{{ '\u5361\u5bc6' }}</el-menu-item>
|
||||
<el-menu-item index="/admin/devices">{{ '\u8bbe\u5907' }}</el-menu-item>
|
||||
<el-menu-item index="/admin/logs">{{ '\u65e5\u5fd7' }}</el-menu-item>
|
||||
<el-menu-item index="/admin/stats">{{ '\u7edf\u8ba1' }}</el-menu-item>
|
||||
<el-menu-item index="/admin/api-docs">{{ '\u63a5\u53e3\u63a5\u5165\u6587\u6863' }}</el-menu-item>
|
||||
<el-menu-item v-if="superAdmin" index="/admin/agents">{{ '\u4ee3\u7406\u5546' }}</el-menu-item>
|
||||
<el-menu-item v-if="superAdmin" index="/admin/settings">{{ '\u8bbe\u7f6e' }}</el-menu-item>
|
||||
</el-menu>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { adminLogout } from '@/api/admin';
|
||||
import { session, clearSession, isSuperAdmin } from '@/store/session';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const drawer = ref(false);
|
||||
|
||||
const activeMenu = computed(() => route.path);
|
||||
const superAdmin = computed(() => isSuperAdmin());
|
||||
const userName = computed(() => session.user?.username || '\u7ba1\u7406\u5458');
|
||||
const roleLabel = computed(() =>
|
||||
session.role === 'super_admin' ? '\u8d85\u7ea7\u7ba1\u7406\u5458' : '\u7ba1\u7406\u5458'
|
||||
);
|
||||
const projectHint = computed(() => {
|
||||
if (superAdmin.value) return '\u5168\u90e8\u9879\u76ee';
|
||||
if ((session.permissions || []).includes('*')) return '\u5168\u90e8\u9879\u76ee';
|
||||
if ((session.permissions || []).length === 0) return '\u65e0\u9879\u76ee\u6743\u9650';
|
||||
return `${session.permissions.length}\u4e2a\u9879\u76ee`;
|
||||
});
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await adminLogout();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
clearSession();
|
||||
router.push('/login');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-shell {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.layout-container {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
color: #fff;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.12);
|
||||
padding: 18px 12px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px 20px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(140deg, #ff6b35, #0ea5a4);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.menu {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.menu :deep(.el-menu-item) {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
border-radius: 10px;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
.menu :deep(.el-menu-item.is-active) {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 28px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-bottom: 1px solid var(--line);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-block {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 12px;
|
||||
color: var(--ink-500);
|
||||
}
|
||||
|
||||
.mobile-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: var(--ink-900);
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toggle-icon::before,
|
||||
.toggle-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: var(--ink-900);
|
||||
}
|
||||
|
||||
.toggle-icon::before {
|
||||
top: -6px;
|
||||
}
|
||||
|
||||
.toggle-icon::after {
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
.mobile-drawer :deep(.el-drawer__body) {
|
||||
padding: 0 12px 16px;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
236
license-system-frontend/src/layouts/AgentLayout.vue
Normal file
236
license-system-frontend/src/layouts/AgentLayout.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div class="layout-shell">
|
||||
<el-container class="layout-container">
|
||||
<el-aside class="sidebar" width="220px">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">AG</div>
|
||||
<div>
|
||||
<div class="brand-title">{{ '\u4ee3\u7406\u5546\u540e\u53f0' }}</div>
|
||||
<div class="brand-sub">{{ '\u9500\u552e\u63a7\u5236\u53f0' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-menu :default-active="activeMenu" class="menu" router>
|
||||
<el-menu-item index="/agent/cards">{{ '\u5361\u5bc6' }}</el-menu-item>
|
||||
<el-menu-item index="/agent/transactions">{{ '\u989d\u5ea6\u6d41\u6c34' }}</el-menu-item>
|
||||
<el-menu-item index="/agent/profile">{{ '\u8d44\u6599' }}</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<el-button class="mobile-toggle" text @click="drawer = true">
|
||||
<span class="toggle-icon"></span>
|
||||
</el-button>
|
||||
<div class="header-title">
|
||||
<div class="section-title">{{ '\u4ee3\u7406\u5de5\u4f5c\u53f0' }}</div>
|
||||
<div class="header-meta">
|
||||
<span class="tag">{{ '\u4f59\u989d' }} {{ balanceLabel }}</span>
|
||||
<span class="tag">{{ '\u6298\u6263' }} {{ discountLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="user-block">
|
||||
<div class="user-name">{{ agentName }}</div>
|
||||
<div class="user-role">{{ '\u4ee3\u7406\u5546' }}</div>
|
||||
</div>
|
||||
<el-button type="primary" plain @click="handleLogout">{{ '\u9000\u51fa\u767b\u5f55' }}</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main class="page-shell">
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view />
|
||||
</transition>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
|
||||
<el-drawer v-model="drawer" direction="ltr" size="220px" class="mobile-drawer">
|
||||
<template #header>
|
||||
<div class="drawer-title">{{ '\u83dc\u5355' }}</div>
|
||||
</template>
|
||||
<el-menu :default-active="activeMenu" router @select="drawer = false">
|
||||
<el-menu-item index="/agent/cards">{{ '\u5361\u5bc6' }}</el-menu-item>
|
||||
<el-menu-item index="/agent/transactions">{{ '\u989d\u5ea6\u6d41\u6c34' }}</el-menu-item>
|
||||
<el-menu-item index="/agent/profile">{{ '\u8d44\u6599' }}</el-menu-item>
|
||||
</el-menu>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { agentLogout } from '@/api/agent';
|
||||
import { session, clearSession } from '@/store/session';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const drawer = ref(false);
|
||||
|
||||
const activeMenu = computed(() => route.path);
|
||||
const agentName = computed(() => session.agent?.companyName || session.agent?.agentCode || '\u4ee3\u7406\u5546');
|
||||
const balanceLabel = computed(() => `${(session.agent?.balance ?? 0).toFixed(2)}`);
|
||||
const discountLabel = computed(() => `${(session.agent?.discount ?? 100).toFixed(0)}%`);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await agentLogout();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
clearSession();
|
||||
router.push('/login');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-shell {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.layout-container {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
color: #fff;
|
||||
padding: 18px 12px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px 20px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(140deg, #0ea5a4, #ff6b35);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.menu {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.menu :deep(.el-menu-item) {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
border-radius: 10px;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
.menu :deep(.el-menu-item.is-active) {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 28px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-bottom: 1px solid var(--line);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-block {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 12px;
|
||||
color: var(--ink-500);
|
||||
}
|
||||
|
||||
.mobile-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: var(--ink-900);
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toggle-icon::before,
|
||||
.toggle-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: var(--ink-900);
|
||||
}
|
||||
|
||||
.toggle-icon::before {
|
||||
top: -6px;
|
||||
}
|
||||
|
||||
.toggle-icon::after {
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
12
license-system-frontend/src/main.js
Normal file
12
license-system-frontend/src/main.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue';
|
||||
import ElementPlus from 'element-plus';
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn';
|
||||
import 'element-plus/dist/index.css';
|
||||
import '@/styles/index.css';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
app.use(ElementPlus, { locale: zhCn });
|
||||
app.mount('#app');
|
||||
157
license-system-frontend/src/pages/LoginView.vue
Normal file
157
license-system-frontend/src/pages/LoginView.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card glass-card">
|
||||
<div class="login-hero">
|
||||
<div class="hero-mark">LS</div>
|
||||
<div>
|
||||
<h1 class="section-title">{{ '\u6388\u6743\u7cfb\u7edf' }}</h1>
|
||||
<p class="hero-sub">{{ '\u7ba1\u7406\u5458\u4e0e\u4ee3\u7406\u5b89\u5168\u767b\u5f55' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form :model="form" class="login-form" @submit.prevent>
|
||||
<el-segmented v-model="loginType" :options="loginOptions" class="segment" />
|
||||
|
||||
<el-form-item :label="'\u8d26\u53f7'" v-if="loginType === 'admin'">
|
||||
<el-input v-model="form.username" :placeholder="'\u8bf7\u8f93\u5165\u7ba1\u7406\u5458\u8d26\u53f7'" autocomplete="username" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="'\u4ee3\u7406\u7f16\u7801'" v-else>
|
||||
<el-input v-model="form.agentCode" :placeholder="'\u8bf7\u8f93\u5165\u4ee3\u7406\u7f16\u7801'" autocomplete="username" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="'\u5bc6\u7801'">
|
||||
<el-input v-model="form.password" type="password" show-password autocomplete="current-password" />
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" size="large" class="submit" :loading="loading" @click="handleLogin">
|
||||
{{ '\u767b\u5f55' }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<div class="login-foot">
|
||||
<span>{{ '\u9700\u8981\u5e2e\u52a9\uff1f' }}</span>
|
||||
<a :href="helpUrl" target="_blank" rel="noreferrer">{{ '\u8054\u7cfb\u652f\u6301' }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { adminLogin } from '@/api/admin';
|
||||
import { agentLogin } from '@/api/agent';
|
||||
import { setAdminSession, setAgentSession } from '@/store/session';
|
||||
|
||||
const router = useRouter();
|
||||
const loginType = ref('admin');
|
||||
const loginOptions = [
|
||||
{ label: '\u7ba1\u7406\u5458', value: 'admin' },
|
||||
{ label: '\u4ee3\u7406\u5546', value: 'agent' }
|
||||
];
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
agentCode: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const helpUrl = 'https://example.com/support';
|
||||
|
||||
const handleLogin = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
if (loginType.value === 'admin') {
|
||||
const data = await adminLogin({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
captcha: ''
|
||||
});
|
||||
setAdminSession(data);
|
||||
router.push('/admin/dashboard');
|
||||
} else {
|
||||
const data = await agentLogin({
|
||||
agentCode: form.agentCode,
|
||||
password: form.password
|
||||
});
|
||||
setAgentSession(data);
|
||||
router.push('/agent/cards');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err?.message || err?.data?.message || '\u767b\u5f55\u5931\u8d25';
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(460px, 92vw);
|
||||
padding: 28px;
|
||||
border-radius: 24px;
|
||||
animation: fadeUp 0.6s ease;
|
||||
}
|
||||
|
||||
.login-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hero-mark {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(140deg, var(--accent-500), var(--teal-500));
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
margin: 4px 0 0;
|
||||
color: var(--ink-500);
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.segment {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-foot {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: var(--ink-500);
|
||||
}
|
||||
|
||||
.login-foot a {
|
||||
color: var(--teal-700);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
35
license-system-frontend/src/pages/NotFoundView.vue
Normal file
35
license-system-frontend/src/pages/NotFoundView.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="glass-card panel notfound">
|
||||
<h2 class="section-title">{{ '\u9875\u9762\u4e0d\u5b58\u5728' }}</h2>
|
||||
<p>{{ '\u8bbf\u95ee\u8def\u5f84\u4e0d\u5b58\u5728' }}</p>
|
||||
<el-button type="primary" @click="goHome">{{ '\u8fd4\u56de\u4e3b\u9875' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
import { session } from '@/store/session';
|
||||
|
||||
const router = useRouter();
|
||||
const goHome = () => {
|
||||
if (session.type === 'agent') {
|
||||
router.push('/agent/cards');
|
||||
} else if (session.type === 'admin') {
|
||||
router.push('/admin/dashboard');
|
||||
} else {
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notfound {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
371
license-system-frontend/src/pages/admin/AgentsView.vue
Normal file
371
license-system-frontend/src/pages/admin/AgentsView.vue
Normal file
@@ -0,0 +1,371 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 class="section-title">{{ '\u4ee3\u7406\u5546' }}</h2>
|
||||
<div class="tag">{{ '\u4ee3\u7406\u8d26\u53f7\u7ba1\u7406' }}</div>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<el-button type="primary" @click="openCreate">{{ '\u65b0\u5efa\u4ee3\u7406' }}</el-button>
|
||||
<el-button @click="loadAgents">{{ '\u5237\u65b0' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card panel">
|
||||
<el-table :data="agents" v-loading="loading" @row-click="openDetail">
|
||||
<el-table-column prop="agentCode" :label="'\u4ee3\u7406\u7f16\u7801'" width="160" />
|
||||
<el-table-column prop="companyName" :label="'\u516c\u53f8'" min-width="200" />
|
||||
<el-table-column prop="contactPerson" :label="'\u8054\u7cfb\u4eba'" width="160" />
|
||||
<el-table-column prop="balance" :label="'\u4f59\u989d'" width="120" />
|
||||
<el-table-column prop="discount" :label="'\u6298\u6263'" width="120" />
|
||||
<el-table-column prop="status" :label="'\u72b6\u6001'" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 'active' ? 'success' : 'info'">{{ statusLabel(scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="'\u64cd\u4f5c'" width="220">
|
||||
<template #default="scope">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" @click.stop="editAgent(scope.row)">{{ '\u7f16\u8f91' }}</el-button>
|
||||
<el-button size="small" type="warning" @click.stop="toggleStatus(scope.row)">
|
||||
{{ scope.row.status === 'active' ? '\u7981\u7528' : '\u542f\u7528' }}
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" @click.stop="removeAgent(scope.row)">{{ '\u5220\u9664' }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table-footer">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.pageSize"
|
||||
:current-page="pagination.page"
|
||||
@current-change="handlePage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="formVisible" :title="'\u4ee3\u7406\u5546'" size="420px">
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item :label="'\u4ee3\u7406\u7f16\u7801'">
|
||||
<el-input v-model="form.agentCode" :disabled="!!form.id" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u5bc6\u7801'" v-if="!form.id">
|
||||
<el-input v-model="form.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u516c\u53f8\u540d\u79f0'">
|
||||
<el-input v-model="form.companyName" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u8054\u7cfb\u4eba'">
|
||||
<el-input v-model="form.contactPerson" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u8054\u7cfb\u7535\u8bdd'">
|
||||
<el-input v-model="form.contactPhone" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u8054\u7cfb\u90ae\u7bb1'">
|
||||
<el-input v-model="form.contactEmail" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u521d\u59cb\u4f59\u989d'" v-if="!form.id">
|
||||
<el-input-number v-model="form.initialBalance" :min="0" :step="10" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u6298\u6263'">
|
||||
<el-input-number v-model="form.discount" :min="0" :max="100" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u6388\u4fe1\u989d\u5ea6'">
|
||||
<el-input-number v-model="form.creditLimit" :min="0" :step="10" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u53ef\u7528\u9879\u76ee'">
|
||||
<el-checkbox v-if="form.id" v-model="overrideProjects">{{ '\u81ea\u5b9a\u4e49\u9879\u76ee' }}</el-checkbox>
|
||||
<el-select v-model="form.allowedProjects" multiple filterable :disabled="form.id && !overrideProjects">
|
||||
<el-option v-for="project in projects" :key="project.projectId" :label="project.name" :value="project.projectId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="formVisible = false">{{ '\u53d6\u6d88' }}</el-button>
|
||||
<el-button type="primary" @click="saveAgent">{{ '\u4fdd\u5b58' }}</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="detailVisible" :title="'\u4ee3\u7406\u8be6\u60c5'" size="760px">
|
||||
<div v-if="selected" class="detail-head">
|
||||
<div>
|
||||
<h3 class="section-title">{{ selected.agent.agentCode }}</h3>
|
||||
<div class="tag">{{ selected.agent.companyName }}</div>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<el-button size="small" @click="editAgent(selected.agent)">{{ '\u7f16\u8f91' }}</el-button>
|
||||
<el-button size="small" type="primary" @click="openBalance('recharge')">{{ '\u5145\u503c' }}</el-button>
|
||||
<el-button size="small" type="warning" @click="openBalance('deduct')">{{ '\u6263\u51cf' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selected" class="stat-grid" style="margin-bottom: 16px;">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ '\u603b\u5361\u5bc6' }}</div>
|
||||
<div class="stat-value">{{ selected.stats?.totalCards ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ '\u5df2\u6fc0\u6d3b' }}</div>
|
||||
<div class="stat-value">{{ selected.stats?.activeCards ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ '\u603b\u6536\u5165' }}</div>
|
||||
<div class="stat-value">{{ selected.stats?.totalRevenue ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="selected?.transactions || []" height="300">
|
||||
<el-table-column prop="type" :label="'\u7c7b\u578b'" width="140">
|
||||
<template #default="scope">
|
||||
{{ transactionTypeLabel(scope.row.type) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="amount" :label="'\u91d1\u989d'" width="140" />
|
||||
<el-table-column prop="balanceAfter" :label="'\u4f59\u989d'" width="140" />
|
||||
<el-table-column prop="remark" :label="'\u5907\u6ce8'" min-width="220" />
|
||||
<el-table-column prop="createdAt" :label="'\u65f6\u95f4'" width="180" />
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="balanceVisible" :title="'\u4f59\u989d\u8c03\u6574'" width="420px">
|
||||
<el-form :model="balanceForm" label-position="top">
|
||||
<el-form-item :label="'\u91d1\u989d'">
|
||||
<el-input-number v-model="balanceForm.amount" :min="1" :step="10" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u5907\u6ce8'">
|
||||
<el-input v-model="balanceForm.remark" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="balanceVisible = false">{{ '\u53d6\u6d88' }}</el-button>
|
||||
<el-button type="primary" @click="submitBalance">{{ '\u63d0\u4ea4' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
listAgents,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
enableAgent,
|
||||
disableAgent,
|
||||
getAgent,
|
||||
rechargeAgent,
|
||||
deductAgent
|
||||
} from '@/api/admin';
|
||||
import { fetchProjects } from '@/api/admin';
|
||||
|
||||
const agents = ref([]);
|
||||
const loading = ref(false);
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
const projects = ref([]);
|
||||
|
||||
const formVisible = ref(false);
|
||||
const form = reactive({
|
||||
id: null,
|
||||
agentCode: '',
|
||||
password: '',
|
||||
companyName: '',
|
||||
contactPerson: '',
|
||||
contactPhone: '',
|
||||
contactEmail: '',
|
||||
initialBalance: 0,
|
||||
discount: 80,
|
||||
creditLimit: 0,
|
||||
allowedProjects: []
|
||||
});
|
||||
const overrideProjects = ref(false);
|
||||
|
||||
const detailVisible = ref(false);
|
||||
const selected = ref(null);
|
||||
|
||||
const balanceVisible = ref(false);
|
||||
const balanceForm = reactive({ type: 'recharge', amount: 100, remark: '' });
|
||||
|
||||
const statusLabel = (value) => {
|
||||
if (value === 'active') return '\u542f\u7528';
|
||||
if (value === 'disabled') return '\u7981\u7528';
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
const transactionTypeLabel = (value) => {
|
||||
if (value === 'recharge') return '\u5145\u503c';
|
||||
if (value === 'consume') return '\u6263\u8d39';
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
const loadAgents = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await listAgents({ page: pagination.page, pageSize: pagination.pageSize });
|
||||
agents.value = res.items || [];
|
||||
pagination.total = res.pagination?.total || 0;
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u52a0\u8f7d\u4ee3\u7406\u5546\u5931\u8d25');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePage = (page) => {
|
||||
pagination.page = page;
|
||||
loadAgents();
|
||||
};
|
||||
|
||||
const loadProjects = async () => {
|
||||
const res = await fetchProjects({ page: 1, pageSize: 100 });
|
||||
projects.value = res.items || [];
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
Object.assign(form, {
|
||||
id: null,
|
||||
agentCode: '',
|
||||
password: '',
|
||||
companyName: '',
|
||||
contactPerson: '',
|
||||
contactPhone: '',
|
||||
contactEmail: '',
|
||||
initialBalance: 0,
|
||||
discount: 80,
|
||||
creditLimit: 0,
|
||||
allowedProjects: []
|
||||
});
|
||||
overrideProjects.value = true;
|
||||
formVisible.value = true;
|
||||
};
|
||||
|
||||
const editAgent = (row) => {
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
agentCode: row.agentCode,
|
||||
companyName: row.companyName,
|
||||
contactPerson: row.contactPerson,
|
||||
contactPhone: row.contactPhone,
|
||||
contactEmail: row.contactEmail,
|
||||
discount: row.discount,
|
||||
creditLimit: row.creditLimit,
|
||||
allowedProjects: row.allowedProjects || []
|
||||
});
|
||||
overrideProjects.value = false;
|
||||
formVisible.value = true;
|
||||
};
|
||||
|
||||
const saveAgent = async () => {
|
||||
try {
|
||||
if (form.id) {
|
||||
const payload = {
|
||||
companyName: form.companyName,
|
||||
contactPerson: form.contactPerson,
|
||||
contactPhone: form.contactPhone,
|
||||
contactEmail: form.contactEmail,
|
||||
discount: form.discount,
|
||||
creditLimit: form.creditLimit,
|
||||
status: form.status
|
||||
};
|
||||
if (overrideProjects.value) {
|
||||
payload.allowedProjects = form.allowedProjects;
|
||||
}
|
||||
await updateAgent(form.id, payload);
|
||||
} else {
|
||||
await createAgent(form);
|
||||
}
|
||||
ElMessage.success('\u4fdd\u5b58\u6210\u529f');
|
||||
formVisible.value = false;
|
||||
loadAgents();
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u4fdd\u5b58\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStatus = async (row) => {
|
||||
try {
|
||||
if (row.status === 'active') {
|
||||
await disableAgent(row.id);
|
||||
} else {
|
||||
await enableAgent(row.id);
|
||||
}
|
||||
loadAgents();
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u64cd\u4f5c\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const removeAgent = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('\u786e\u5b9a\u5220\u9664\u4ee3\u7406\u5546\uff1f', '\u786e\u8ba4', { type: 'warning' });
|
||||
await deleteAgent(row.id);
|
||||
ElMessage.success('\u5df2\u5220\u9664');
|
||||
loadAgents();
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') {
|
||||
ElMessage.error(err?.message || '\u5220\u9664\u5931\u8d25');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (row) => {
|
||||
try {
|
||||
selected.value = await getAgent(row.id);
|
||||
detailVisible.value = true;
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u52a0\u8f7d\u8be6\u60c5\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const openBalance = (type) => {
|
||||
balanceForm.type = type;
|
||||
balanceForm.amount = 100;
|
||||
balanceForm.remark = '';
|
||||
balanceVisible.value = true;
|
||||
};
|
||||
|
||||
const submitBalance = async () => {
|
||||
try {
|
||||
if (balanceForm.type === 'recharge') {
|
||||
await rechargeAgent(selected.value.agent.id, {
|
||||
amount: balanceForm.amount,
|
||||
remark: balanceForm.remark
|
||||
});
|
||||
} else {
|
||||
await deductAgent(selected.value.agent.id, {
|
||||
amount: balanceForm.amount,
|
||||
remark: balanceForm.remark
|
||||
});
|
||||
}
|
||||
balanceVisible.value = false;
|
||||
selected.value = await getAgent(selected.value.agent.id);
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u64cd\u4f5c\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProjects();
|
||||
await loadAgents();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
315
license-system-frontend/src/pages/admin/ApiDocsView.vue
Normal file
315
license-system-frontend/src/pages/admin/ApiDocsView.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 class="section-title">{{ '\u63a5\u53e3\u63a5\u5165\u6587\u6863' }}</h2>
|
||||
<div class="tag">{{ '\u5ba2\u6237\u7aef\u4e0e\u6388\u6743\u670d\u52a1\u5bf9\u63a5' }}</div>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<el-button type="primary" @click="openSwagger">{{ '\u6253\u5f00\u63a5\u53e3\u6587\u6863' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card panel doc-card">
|
||||
<section class="doc-section">
|
||||
<h3 class="section-title">{{ '\u57fa\u7840\u4fe1\u606f' }}</h3>
|
||||
<div class="doc-grid">
|
||||
<div>
|
||||
<div class="label">{{ '\u63a5\u53e3\u5730\u5740' }}</div>
|
||||
<div class="value mono">{{ apiBase }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">{{ '\u5bf9\u63a5\u7801\u683c\u5f0f' }}</div>
|
||||
<div class="value mono">LSC1.base64(projectId|ProjectKey)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">{{ '\u7b7e\u540d\u7b97\u6cd5' }}</div>
|
||||
<div class="value">HMAC-SHA256</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">{{ '\u65f6\u95f4\u7a97\u53e3' }}</div>
|
||||
<div class="value">{{ '\u00b1\u0033\u0030\u0030\u79d2' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">{{ '\u63a5\u53e3\u6587\u6863' }}</div>
|
||||
<div class="value mono">{{ swaggerUrl }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="doc-section">
|
||||
<h3 class="section-title">{{ '\u7b7e\u540d\u89c4\u5219' }}</h3>
|
||||
<div class="doc-block">
|
||||
<div class="label">{{ '\u7b7e\u540d\u5185\u5bb9' }}</div>
|
||||
<div class="value mono">projectId|deviceId|timestamp</div>
|
||||
</div>
|
||||
<div class="doc-block">
|
||||
<div class="label">{{ '\u7b7e\u540d\u5bc6\u94a5' }}</div>
|
||||
<div class="value mono">{{ '\u5bf9\u63a5\u7801\u4e2d\u7684 ProjectKey \u6216 ProjectSecret' }}</div>
|
||||
</div>
|
||||
<div class="doc-block">
|
||||
<div class="label">{{ '\u8ba1\u7b97\u65b9\u5f0f' }}</div>
|
||||
<div class="value mono">hex(hmac_sha256(secret, payload)).lower()</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="doc-section">
|
||||
<h3 class="section-title">{{ '\u5bf9\u63a5\u6d41\u7a0b' }}</h3>
|
||||
<ol class="flow-list">
|
||||
<li>{{ '\u7b2c\u4e00\u6b21\u542f\u52a8\u5ba2\u6237\u7aef\uff0c\u8c03\u7528\u5361\u5bc6\u9a8c\u8bc1\u63a5\u53e3' }}</li>
|
||||
<li>{{ '\u9a8c\u8bc1\u901a\u8fc7\u540e\u83b7\u53d6\u8bbf\u95ee\u4ee4\u724c\uff08accessToken\uff09' }}</li>
|
||||
<li>{{ '\u5ba2\u6237\u7aef\u6309\u5fc3\u8df3\u95f4\u9694\u4e0a\u62a5\u5fc3\u8df3' }}</li>
|
||||
<li>{{ '\u68c0\u6d4b\u66f4\u65b0\u4f7f\u7528\u7248\u672c\u68c0\u67e5\u63a5\u53e3' }}</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="doc-section">
|
||||
<h3 class="section-title">{{ '\u6838\u5fc3\u63a5\u53e3' }}</h3>
|
||||
<div v-for="item in endpoints" :key="item.path" class="endpoint">
|
||||
<div class="endpoint-head">
|
||||
<el-tag size="small" :type="methodTag(item.method)">{{ item.method }}</el-tag>
|
||||
<span class="path mono">{{ item.path }}</span>
|
||||
<span class="endpoint-name">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="endpoint-body">
|
||||
<div class="label">{{ '\u8bf7\u6c42\u5b57\u6bb5' }}</div>
|
||||
<ul class="field-list">
|
||||
<li v-for="field in item.fields" :key="field">{{ field }}</li>
|
||||
</ul>
|
||||
<div v-if="item.note" class="hint">{{ item.note }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="doc-section">
|
||||
<h3 class="section-title">{{ '\u793a\u4f8b\u4ee3\u7801' }}</h3>
|
||||
<pre class="code-block"><code class="language-python">{{ pythonExample }}</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="doc-section">
|
||||
<h3 class="section-title">{{ '\u5e38\u89c1\u9519\u8bef\u7801' }}</h3>
|
||||
<div class="error-grid">
|
||||
<div v-for="item in errorCodes" :key="item.code" class="error-item">
|
||||
<div class="code mono">{{ item.code }}</div>
|
||||
<div class="desc">{{ item.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { resolveApiBase } from '@/utils/apiBase';
|
||||
|
||||
const apiBase = computed(() => resolveApiBase());
|
||||
|
||||
const swaggerUrl = computed(() => `${apiBase.value}/swagger`);
|
||||
|
||||
const endpoints = [
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
name: '\u5361\u5bc6\u9a8c\u8bc1',
|
||||
fields: [
|
||||
'projectId - \u9879\u76ee\u7f16\u53f7',
|
||||
'keyCode - \u5361\u5bc6',
|
||||
'deviceId - \u8bbe\u5907\u7f16\u53f7',
|
||||
'clientVersion - \u5ba2\u6237\u7aef\u7248\u672c',
|
||||
'timestamp - \u5f53\u524d\u65f6\u95f4\u6233',
|
||||
'signature - \u7b7e\u540d'
|
||||
],
|
||||
note: '\u6210\u529f\u8fd4\u56de\u8bbf\u95ee\u4ee4\u724c\uff08accessToken\uff09\u4e0e\u5fc3\u8df3\u95f4\u9694'
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/auth/heartbeat',
|
||||
name: '\u5fc3\u8df3\u9a8c\u8bc1',
|
||||
fields: [
|
||||
'accessToken - \u8bbf\u95ee\u4ee4\u724c',
|
||||
'deviceId - \u8bbe\u5907\u7f16\u53f7',
|
||||
'timestamp - \u5f53\u524d\u65f6\u95f4\u6233',
|
||||
'signature - \u7b7e\u540d'
|
||||
],
|
||||
note: '\u5fc3\u8df3\u8fd4\u56de\u5269\u4f59\u5929\u6570\u4e0e\u670d\u52a1\u5668\u65f6\u95f4'
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/software/check-update',
|
||||
name: '\u7248\u672c\u68c0\u67e5',
|
||||
fields: [
|
||||
'projectId - \u9879\u76ee\u7f16\u53f7',
|
||||
'currentVersion - \u5f53\u524d\u7248\u672c',
|
||||
'platform - \u5e73\u53f0\u6807\u8bc6'
|
||||
],
|
||||
note: '\u8fd4\u56de\u6700\u65b0\u7248\u672c\u4e0e\u66f4\u65b0\u5730\u5740'
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/software/download',
|
||||
name: '\u8f6f\u4ef6\u4e0b\u8f7d',
|
||||
fields: [
|
||||
'version - \u8981\u4e0b\u8f7d\u7684\u7248\u672c',
|
||||
'token - \u8bbf\u95ee\u4ee4\u724c'
|
||||
],
|
||||
note: '\u6210\u529f\u540e\u8fd4\u56de\u4e8c\u8fdb\u5236\u6587\u4ef6\u6d41'
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/config/public',
|
||||
name: '\u516c\u5f00\u914d\u7f6e',
|
||||
fields: [],
|
||||
note: '\u8fd4\u56de\u5ba2\u6237\u7aef\u516c\u5f00\u914d\u7f6e'
|
||||
}
|
||||
];
|
||||
|
||||
const errorCodes = [
|
||||
{ code: '1001', desc: '\u5361\u5bc6\u65e0\u6548' },
|
||||
{ code: '1002', desc: '\u5361\u5bc6\u8fc7\u671f' },
|
||||
{ code: '1003', desc: '\u5361\u5bc6\u5df2\u5c01\u7981' },
|
||||
{ code: '1005', desc: '\u8bbe\u5907\u6570\u9650\u5236' },
|
||||
{ code: '1007', desc: '\u7b7e\u540d\u65e0\u6548' },
|
||||
{ code: '1008', desc: '\u65f6\u95f4\u6233\u8d85\u65f6' },
|
||||
{ code: '1011', desc: '\u9879\u76ee\u5df2\u7981\u7528' }
|
||||
];
|
||||
|
||||
const pythonExample = computed(() => {
|
||||
const base = apiBase.value;
|
||||
return `import time, hmac, hashlib, requests\n\nbase = "${base}"\nproject_id = "PROJ_xxxx"\nsecret = "ProjectKey"\nkey_code = "XXXX-XXXX-XXXX-XXXX"\ndevice_id = "PC-001"\n\nstamp = int(time.time())\npayload = f"{project_id}|{device_id}|{stamp}"\nsignature = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()\n\nresp = requests.post(f"{base}/api/auth/verify", json={\n "projectId": project_id,\n "keyCode": key_code,\n "deviceId": device_id,\n "clientVersion": "1.0.0",\n "timestamp": stamp,\n "signature": signature\n})\nprint(resp.json())\n`;
|
||||
});
|
||||
|
||||
const methodTag = (method) => {
|
||||
if (method === 'POST') return 'success';
|
||||
if (method === 'GET') return 'info';
|
||||
return 'primary';
|
||||
};
|
||||
|
||||
const openSwagger = () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.open(swaggerUrl.value, '_blank');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doc-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.doc-section {
|
||||
padding: 4px 0 12px;
|
||||
border-bottom: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.doc-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.doc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.doc-block {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.endpoint {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(15, 23, 42, 0.02);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.endpoint-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.endpoint-name {
|
||||
color: var(--ink-600);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.endpoint-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-list {
|
||||
padding-left: 18px;
|
||||
color: var(--ink-700);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--ink-500);
|
||||
}
|
||||
|
||||
.flow-list {
|
||||
padding-left: 18px;
|
||||
color: var(--ink-700);
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: #0b1020;
|
||||
color: #e2e8f0;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.error-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.error-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
.code {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
color: var(--ink-600);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SFMono-Regular', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--ink-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 600;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.endpoint-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
600
license-system-frontend/src/pages/admin/CardsView.vue
Normal file
600
license-system-frontend/src/pages/admin/CardsView.vue
Normal file
@@ -0,0 +1,600 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 class="section-title">{{ '\u5361\u5bc6' }}</h2>
|
||||
<div class="tag">{{ '\u5361\u5bc6\u751f\u6210\u4e0e\u7ba1\u7406' }}</div>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<el-button type="primary" :disabled="!canManageAny" @click="openGenerate">{{ '\u751f\u6210' }}</el-button>
|
||||
<el-button :disabled="!canManageAny" @click="openImport">{{ '\u5bfc\u5165' }}</el-button>
|
||||
<el-button @click="loadCards">{{ '\u5237\u65b0' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card panel">
|
||||
<div class="toolbar" style="margin-bottom: 12px;">
|
||||
<el-select v-model="filters.projectId" :placeholder="'\u9879\u76ee'" clearable style="min-width: 160px">
|
||||
<el-option v-for="project in projects" :key="project.projectId" :label="project.name" :value="project.projectId" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.status" :placeholder="'\u72b6\u6001'" clearable style="min-width: 140px">
|
||||
<el-option :label="'\u672a\u4f7f\u7528'" value="unused" />
|
||||
<el-option :label="'\u5df2\u6fc0\u6d3b'" value="active" />
|
||||
<el-option :label="'\u5df2\u8fc7\u671f'" value="expired" />
|
||||
<el-option :label="'\u5df2\u5c01\u7981'" value="banned" />
|
||||
</el-select>
|
||||
<el-select v-model="exportFormat" style="min-width: 160px">
|
||||
<el-option :label="'\u8868\u683c\uff08.xlsx\uff09'" value="excel" />
|
||||
<el-option :label="'\u9017\u53f7\u5206\u9694\uff08.csv\uff09'" value="csv" />
|
||||
<el-option :label="'\u6587\u672c\uff08.txt\uff09'" value="txt" />
|
||||
</el-select>
|
||||
<el-button @click="loadCards">{{ '\u7b5b\u9009' }}</el-button>
|
||||
<el-button plain :disabled="!canExport" @click="exportData">{{ '\u5bfc\u51fa' }}</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedIds.length" class="batch-bar">
|
||||
<span class="tag">{{ '\u5df2\u9009' }} {{ selectedIds.length }}</span>
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="danger" @click="batchDelete">{{ '\u6279\u91cf\u5220\u9664' }}</el-button>
|
||||
<el-button size="small" @click="batchUnban">{{ '\u6279\u91cf\u89e3\u5c01' }}</el-button>
|
||||
<el-button size="small" type="warning" @click="batchBan">{{ '\u6279\u91cf\u5c01\u7981' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="cards" v-loading="loading" @row-click="openDetail" @selection-change="handleSelection">
|
||||
<el-table-column type="selection" width="48" />
|
||||
<el-table-column prop="projectId" :label="'\u9879\u76ee'" width="160" />
|
||||
<el-table-column prop="keyCode" :label="'\u5361\u5bc6'" min-width="200" />
|
||||
<el-table-column prop="cardType" :label="'\u7c7b\u578b'" width="120">
|
||||
<template #default="scope">
|
||||
{{ cardTypeLabel(scope.row.cardType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="'\u72b6\u6001'" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusTag(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expireTime" :label="'\u8fc7\u671f\u65f6\u95f4'" min-width="160">
|
||||
<template #default="scope">
|
||||
{{ formatTime(scope.row.expireTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="note" :label="'\u5907\u6ce8'" min-width="160" />
|
||||
<el-table-column :label="'\u64cd\u4f5c'" width="220">
|
||||
<template #default="scope">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" :disabled="!canAccessRow(scope.row)" @click.stop="openDetail(scope.row)">{{ '\u8be6\u60c5' }}</el-button>
|
||||
<el-button size="small" type="warning" :disabled="!canAccessRow(scope.row)" @click.stop="resetDevice(scope.row)">{{ '\u91cd\u7f6e\u8bbe\u5907' }}</el-button>
|
||||
<el-button size="small" type="danger" :disabled="!canAccessRow(scope.row)" @click.stop="removeCard(scope.row)">{{ '\u5220\u9664' }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table-footer">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.pageSize"
|
||||
:current-page="pagination.page"
|
||||
@current-change="handlePage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="generateVisible" :title="'\u751f\u6210\u5361\u5bc6'" size="420px">
|
||||
<el-form :model="generateForm" label-position="top">
|
||||
<el-form-item :label="'\u9879\u76ee'">
|
||||
<el-select v-model="generateForm.projectId" :placeholder="'\u8bf7\u9009\u62e9\u9879\u76ee'">
|
||||
<el-option v-for="project in projects" :key="project.projectId" :label="project.name" :value="project.projectId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u5361\u5bc6\u7c7b\u578b'">
|
||||
<el-select v-model="generateForm.cardType">
|
||||
<el-option :label="'\u6d4b\u8bd5\u5361(\u4ec5\u767b\u5f55\u4e00\u6b21)'" value="test" />
|
||||
<el-option :label="'\u5929\u5361'" value="day" />
|
||||
<el-option :label="'\u5468\u5361'" value="week" />
|
||||
<el-option :label="'\u6708\u5361'" value="month" />
|
||||
<el-option :label="'\u6c38\u4e45'" value="lifetime" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u6709\u6548\u671f'">
|
||||
<div class="form-static">{{ durationLabel(generateForm.cardType) }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u6570\u91cf'">
|
||||
<el-input-number v-model="generateForm.quantity" :min="1" :max="10000" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u5907\u6ce8'">
|
||||
<el-input v-model="generateForm.note" type="textarea" rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="generateVisible = false">{{ '\u53d6\u6d88' }}</el-button>
|
||||
<el-button type="primary" :disabled="!canManageAny" @click="generateCardsAction">{{ '\u751f\u6210' }}</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="importVisible" :title="'\u5bfc\u5165\u5361\u5bc6'" size="420px">
|
||||
<el-form :model="importForm" label-position="top">
|
||||
<el-form-item :label="'\u9879\u76ee'">
|
||||
<el-select v-model="importForm.projectId" :placeholder="'\u8bf7\u9009\u62e9\u9879\u76ee'">
|
||||
<el-option v-for="project in projects" :key="project.projectId" :label="project.name" :value="project.projectId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u6587\u4ef6'">
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:on-change="handleImportFile"
|
||||
:file-list="importFileList"
|
||||
accept=".csv,.xlsx,.txt"
|
||||
>
|
||||
<el-button size="small">{{ '\u9009\u62e9\u6587\u4ef6' }}</el-button>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="importVisible = false">{{ '\u53d6\u6d88' }}</el-button>
|
||||
<el-button type="primary" :disabled="!canManageAny" @click="importCardsAction">{{ '\u5bfc\u5165' }}</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="detailVisible" size="760px" :title="'\u5361\u5bc6\u8be6\u60c5'">
|
||||
<div v-if="selected" class="detail-head">
|
||||
<div>
|
||||
<h3 class="section-title">{{ selected.keyCode }}</h3>
|
||||
<div class="tag">{{ selected.projectId }}</div>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="danger" :disabled="!canManageSelected" @click="banCardAction(selected)">{{ '\u5c01\u7981' }}</el-button>
|
||||
<el-button size="small" :disabled="!canManageSelected" @click="unbanCardAction(selected)">{{ '\u89e3\u5c01' }}</el-button>
|
||||
<el-button size="small" type="warning" :disabled="!canManageSelected" @click="extendCardAction(selected)">{{ '\u5ef6\u957f' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid" v-if="selected">
|
||||
<div>
|
||||
<div class="label">{{ '\u72b6\u6001' }}</div>
|
||||
<div class="value">{{ statusLabel(selected.status) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">{{ '\u8fc7\u671f\u65f6\u95f4' }}</div>
|
||||
<div class="value">{{ formatTime(selected.expireTime) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">{{ '\u6fc0\u6d3b\u65f6\u95f4' }}</div>
|
||||
<div class="value">{{ formatTime(selected.activateTime) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">{{ '\u673a\u5668\u7801' }}</div>
|
||||
<div class="value">{{ selected.machineCode || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="detailTab">
|
||||
<el-tab-pane :label="'\u8bbe\u5907'" name="devices">
|
||||
<el-table :data="selected.devices || []" height="260">
|
||||
<el-table-column prop="deviceId" :label="'\u8bbe\u5907\u7f16\u53f7'" min-width="200" />
|
||||
<el-table-column prop="ipAddress" :label="'\u0049\u0050\u5730\u5740'" width="140" />
|
||||
<el-table-column prop="lastHeartbeat" :label="'\u6700\u540e\u5fc3\u8df3'" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatTime(scope.row.lastHeartbeat) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isActive" :label="'\u5728\u7ebf'" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.isActive ? 'success' : 'info'">
|
||||
{{ scope.row.isActive ? '\u5728\u7ebf' : '\u79bb\u7ebf' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="'\u65e5\u5fd7'" name="logs">
|
||||
<el-table :data="cardLogs" height="260">
|
||||
<el-table-column prop="action" :label="'\u64cd\u4f5c'" width="140" />
|
||||
<el-table-column prop="operatorType" :label="'\u64cd\u4f5c\u4eba'" width="140" />
|
||||
<el-table-column prop="details" :label="'\u8be6\u60c5'" min-width="220" />
|
||||
<el-table-column prop="createdAt" :label="'\u65f6\u95f4'" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatTime(scope.row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { createIdempotencyKey } from '@/utils/idempotency';
|
||||
import {
|
||||
fetchProjects,
|
||||
listCards,
|
||||
generateCards,
|
||||
getCard,
|
||||
getCardLogs,
|
||||
banCard,
|
||||
unbanCard,
|
||||
extendCard,
|
||||
resetCardDevice,
|
||||
deleteCard,
|
||||
exportCards,
|
||||
importCards,
|
||||
banCardsBatch,
|
||||
unbanCardsBatch,
|
||||
deleteCardsBatch
|
||||
} from '@/api/admin';
|
||||
import { session, hasProjectAccess, isSuperAdmin } from '@/store/session';
|
||||
|
||||
const projects = ref([]);
|
||||
const cards = ref([]);
|
||||
const loading = ref(false);
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
const filters = reactive({ projectId: '', status: '' });
|
||||
|
||||
const generateVisible = ref(false);
|
||||
const durationMap = {
|
||||
test: 1,
|
||||
day: 1,
|
||||
week: 7,
|
||||
month: 30,
|
||||
lifetime: 0
|
||||
};
|
||||
|
||||
const durationLabelMap = {
|
||||
test: '\u4ec5\u767b\u5f55\u4e00\u6b21',
|
||||
day: '1\u5929',
|
||||
week: '7\u5929',
|
||||
month: '30\u5929',
|
||||
lifetime: '\u6c38\u4e45'
|
||||
};
|
||||
|
||||
const generateForm = reactive({ projectId: '', cardType: 'month', durationDays: durationMap.month, quantity: 10, note: '' });
|
||||
|
||||
const importVisible = ref(false);
|
||||
const importForm = reactive({ projectId: '' });
|
||||
const importFileList = ref([]);
|
||||
|
||||
const detailVisible = ref(false);
|
||||
const detailTab = ref('devices');
|
||||
const selected = ref(null);
|
||||
const cardLogs = ref([]);
|
||||
const selectedRows = ref([]);
|
||||
const exportFormat = ref('excel');
|
||||
|
||||
const selectedIds = computed(() => selectedRows.value.map((row) => row.id));
|
||||
const canManageAny = computed(() => {
|
||||
if (isSuperAdmin()) return true;
|
||||
if ((session.permissions || []).includes('*')) return true;
|
||||
return (session.permissions || []).length > 0;
|
||||
});
|
||||
const canManageSelected = computed(() => {
|
||||
if (!selected.value) return false;
|
||||
return hasProjectAccess(selected.value.projectId);
|
||||
});
|
||||
const canExport = computed(() => {
|
||||
if (isSuperAdmin()) return true;
|
||||
if ((session.permissions || []).includes('*')) return true;
|
||||
return (session.permissions || []).length > 0;
|
||||
});
|
||||
|
||||
const loadProjects = async () => {
|
||||
const res = await fetchProjects({ page: 1, pageSize: 100 });
|
||||
projects.value = res.items || [];
|
||||
};
|
||||
|
||||
const loadCards = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await listCards({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
projectId: filters.projectId || undefined,
|
||||
status: filters.status || undefined
|
||||
});
|
||||
cards.value = res.items || [];
|
||||
pagination.total = res.pagination?.total || 0;
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u52a0\u8f7d\u5361\u5bc6\u5931\u8d25');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePage = (page) => {
|
||||
pagination.page = page;
|
||||
loadCards();
|
||||
};
|
||||
|
||||
const setDurationByType = (cardType) => {
|
||||
generateForm.durationDays = durationMap[cardType] ?? durationMap.month;
|
||||
};
|
||||
|
||||
const durationLabel = (cardType) => durationLabelMap[cardType] || '-';
|
||||
|
||||
const openGenerate = () => {
|
||||
Object.assign(generateForm, { projectId: '', cardType: 'month', durationDays: durationMap.month, quantity: 10, note: '' });
|
||||
generateVisible.value = true;
|
||||
};
|
||||
|
||||
const generateCardsAction = async () => {
|
||||
try {
|
||||
if (!generateForm.projectId) {
|
||||
ElMessage.warning('\u8bf7\u9009\u62e9\u9879\u76ee');
|
||||
return;
|
||||
}
|
||||
setDurationByType(generateForm.cardType);
|
||||
const key = createIdempotencyKey();
|
||||
await generateCards(generateForm, key);
|
||||
ElMessage.success('\u5df2\u751f\u6210');
|
||||
generateVisible.value = false;
|
||||
loadCards();
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u751f\u6210\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (row) => {
|
||||
try {
|
||||
if (!canAccessRow(row)) {
|
||||
ElMessage.warning('\u65e0\u6743\u9650');
|
||||
return;
|
||||
}
|
||||
selected.value = await getCard(row.id);
|
||||
detailVisible.value = true;
|
||||
detailTab.value = 'devices';
|
||||
cardLogs.value = await getCardLogs(row.id);
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u52a0\u8f7d\u5361\u5bc6\u8be6\u60c5\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const banCardAction = async (row) => {
|
||||
try {
|
||||
await banCard(row.id, { reason: 'manual' });
|
||||
ElMessage.success('\u5df2\u5c01\u7981');
|
||||
loadCards();
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u64cd\u4f5c\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const unbanCardAction = async (row) => {
|
||||
try {
|
||||
await unbanCard(row.id);
|
||||
ElMessage.success('\u5df2\u89e3\u5c01');
|
||||
loadCards();
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u64cd\u4f5c\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const extendCardAction = async (row) => {
|
||||
try {
|
||||
await extendCard(row.id, { days: 30 });
|
||||
ElMessage.success('\u5df2\u5ef6\u957f');
|
||||
loadCards();
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u64cd\u4f5c\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const resetDevice = async (row) => {
|
||||
try {
|
||||
await resetCardDevice(row.id);
|
||||
ElMessage.success('\u5df2\u91cd\u7f6e\u8bbe\u5907');
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u64cd\u4f5c\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const removeCard = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('\u5220\u9664\u5361\u5bc6\uff1f', '\u786e\u8ba4', { type: 'warning' });
|
||||
await deleteCard(row.id);
|
||||
ElMessage.success('\u5df2\u5220\u9664');
|
||||
loadCards();
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') {
|
||||
ElMessage.error(err?.message || '\u64cd\u4f5c\u5931\u8d25');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const exportData = async () => {
|
||||
try {
|
||||
const response = await exportCards({ projectId: filters.projectId || undefined, format: exportFormat.value });
|
||||
const blob = response.data;
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
const ext = exportFormat.value === 'excel' ? 'xlsx' : exportFormat.value;
|
||||
link.download = `cardkeys.${ext}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u5bfc\u51fa\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const statusTag = (status) => {
|
||||
if (status === 'active') return 'success';
|
||||
if (status === 'banned') return 'danger';
|
||||
if (status === 'expired') return 'warning';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const cardTypeLabel = (value) => {
|
||||
if (value === 'test') return '\u6d4b\u8bd5\u5361';
|
||||
if (value === 'day') return '\u5929\u5361';
|
||||
if (value === 'week') return '\u5468\u5361';
|
||||
if (value === 'month') return '\u6708\u5361';
|
||||
if (value === 'year') return '\u5e74\u5361';
|
||||
if (value === 'lifetime') return '\u6c38\u4e45';
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
watch(
|
||||
() => generateForm.cardType,
|
||||
(value) => {
|
||||
setDurationByType(value);
|
||||
}
|
||||
);
|
||||
|
||||
const statusLabel = (status) => {
|
||||
if (status === 'active') return '\u5df2\u6fc0\u6d3b';
|
||||
if (status === 'expired') return '\u5df2\u8fc7\u671f';
|
||||
if (status === 'banned') return '\u5df2\u5c01\u7981';
|
||||
if (status === 'unused') return '\u672a\u4f7f\u7528';
|
||||
return status || '-';
|
||||
};
|
||||
|
||||
const formatTime = (value) => {
|
||||
if (!value) return '-';
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm');
|
||||
};
|
||||
|
||||
const canAccessRow = (row) => {
|
||||
return hasProjectAccess(row.projectId);
|
||||
};
|
||||
|
||||
const handleSelection = (rows) => {
|
||||
selectedRows.value = rows.filter((row) => canAccessRow(row));
|
||||
};
|
||||
|
||||
const batchBan = async () => {
|
||||
if (!selectedIds.value.length) return;
|
||||
try {
|
||||
const result = await ElMessageBox.prompt('\u539f\u56e0', '\u6279\u91cf\u5c01\u7981', {
|
||||
confirmButtonText: '\u5c01\u7981',
|
||||
cancelButtonText: '\u53d6\u6d88'
|
||||
});
|
||||
await banCardsBatch({ ids: selectedIds.value, reason: result.value || 'batch' });
|
||||
ElMessage.success('\u5df2\u5c01\u7981');
|
||||
loadCards();
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') {
|
||||
ElMessage.error(err?.message || '\u64cd\u4f5c\u5931\u8d25');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const batchUnban = async () => {
|
||||
if (!selectedIds.value.length) return;
|
||||
try {
|
||||
await unbanCardsBatch({ ids: selectedIds.value });
|
||||
ElMessage.success('\u5df2\u89e3\u5c01');
|
||||
loadCards();
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u64cd\u4f5c\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const batchDelete = async () => {
|
||||
if (!selectedIds.value.length) return;
|
||||
try {
|
||||
await ElMessageBox.confirm('\u5220\u9664\u5df2\u9009\u5361\u5bc6\uff1f', '\u786e\u8ba4', { type: 'warning' });
|
||||
await deleteCardsBatch({ ids: selectedIds.value });
|
||||
ElMessage.success('\u5df2\u5220\u9664');
|
||||
loadCards();
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') {
|
||||
ElMessage.error(err?.message || '\u64cd\u4f5c\u5931\u8d25');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openImport = () => {
|
||||
importForm.projectId = '';
|
||||
importFileList.value = [];
|
||||
importVisible.value = true;
|
||||
};
|
||||
|
||||
const handleImportFile = (file, files) => {
|
||||
importFileList.value = files.slice(-1);
|
||||
};
|
||||
|
||||
const importCardsAction = async () => {
|
||||
try {
|
||||
if (!importForm.projectId) {
|
||||
ElMessage.warning('\u8bf7\u9009\u62e9\u9879\u76ee');
|
||||
return;
|
||||
}
|
||||
if (!importFileList.value.length) {
|
||||
ElMessage.warning('\u8bf7\u9009\u62e9\u6587\u4ef6');
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('projectId', importForm.projectId);
|
||||
formData.append('file', importFileList.value[0].raw);
|
||||
const result = await importCards(formData);
|
||||
const success = result?.success ?? 0;
|
||||
const failed = result?.failed ?? 0;
|
||||
ElMessage.success(`\u5bfc\u5165\u6210\u529f ${success}\uff0c\u5931\u8d25 ${failed}`);
|
||||
importVisible.value = false;
|
||||
loadCards();
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u5bfc\u5165\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProjects();
|
||||
await loadCards();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--ink-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.form-static {
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 23, 42, 0.04);
|
||||
color: var(--ink-700);
|
||||
}
|
||||
</style>
|
||||
94
license-system-frontend/src/pages/admin/DashboardView.vue
Normal file
94
license-system-frontend/src/pages/admin/DashboardView.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 class="section-title">{{ '\u4eea\u8868\u76d8' }}</h2>
|
||||
<div class="tag">{{ '\u5b9e\u65f6\u6982\u89c8' }}</div>
|
||||
</div>
|
||||
<el-button type="primary" plain @click="loadData">{{ '\u5237\u65b0' }}</el-button>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card" v-for="item in overviewCards" :key="item.label">
|
||||
<div class="stat-label">{{ item.label }}</div>
|
||||
<div class="stat-value">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-two">
|
||||
<div class="glass-card panel">
|
||||
<div class="chart-header">
|
||||
<h3 class="section-title">{{ '\u6d3b\u8dc3\u8d8b\u52bf' }}</h3>
|
||||
<span class="tag">{{ '\u6700\u8fd130\u5929' }}</span>
|
||||
</div>
|
||||
<TrendChart :trend="trend" />
|
||||
</div>
|
||||
<div class="glass-card panel">
|
||||
<div class="chart-header">
|
||||
<h3 class="section-title">{{ '\u9879\u76ee\u5206\u5e03' }}</h3>
|
||||
<span class="tag">{{ '\u6d3b\u8dc3\u5361\u5bc6' }}</span>
|
||||
</div>
|
||||
<el-table :data="projectDistribution" height="320">
|
||||
<el-table-column prop="project" :label="'\u9879\u76ee'" min-width="120" />
|
||||
<el-table-column prop="count" :label="'\u5361\u5bc6'" width="120" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import TrendChart from '@/components/TrendChart.vue';
|
||||
import { statsDashboard } from '@/api/admin';
|
||||
|
||||
const data = ref({ overview: {}, trend: {}, projectDistribution: [] });
|
||||
|
||||
const overviewCards = computed(() => {
|
||||
const overview = data.value.overview || {};
|
||||
return [
|
||||
{ label: '\u9879\u76ee\u603b\u6570', value: overview.totalProjects ?? 0 },
|
||||
{ label: '\u5361\u5bc6\u603b\u6570', value: overview.totalCards ?? 0 },
|
||||
{ label: '\u6d3b\u8dc3\u5361\u5bc6', value: overview.activeCards ?? 0 },
|
||||
{ label: '\u5728\u7ebf\u8bbe\u5907', value: overview.activeDevices ?? 0 },
|
||||
{ label: '\u4eca\u65e5\u6536\u5165', value: overview.todayRevenue ?? 0 },
|
||||
{ label: '\u672c\u6708\u6536\u5165', value: overview.monthRevenue ?? 0 }
|
||||
];
|
||||
});
|
||||
|
||||
const trend = computed(() => data.value.trend || {});
|
||||
const projectDistribution = computed(() => data.value.projectDistribution || []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
data.value = await statsDashboard();
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u52a0\u8f7d\u4eea\u8868\u76d8\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadData);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid-two {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.grid-two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
116
license-system-frontend/src/pages/admin/DevicesView.vue
Normal file
116
license-system-frontend/src/pages/admin/DevicesView.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 class="section-title">{{ '\u8bbe\u5907' }}</h2>
|
||||
<div class="tag">{{ '\u8bbe\u5907\u5728\u7ebf\u76d1\u63a7' }}</div>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<el-button @click="loadDevices">{{ '\u5237\u65b0' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card panel">
|
||||
<div class="toolbar" style="margin-bottom: 12px;">
|
||||
<el-select v-model="filters.projectId" :placeholder="'\u9879\u76ee'" clearable style="min-width: 160px">
|
||||
<el-option v-for="project in projects" :key="project.projectId" :label="project.name" :value="project.projectId" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.isActive" :placeholder="'\u72b6\u6001'" clearable style="min-width: 140px">
|
||||
<el-option :label="'\u5728\u7ebf'" :value="true" />
|
||||
<el-option :label="'\u79bb\u7ebf'" :value="false" />
|
||||
</el-select>
|
||||
<el-button @click="loadDevices">{{ '\u7b5b\u9009' }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="devices" v-loading="loading">
|
||||
<el-table-column prop="deviceId" :label="'\u8bbe\u5907\u7f16\u53f7'" min-width="200" />
|
||||
<el-table-column prop="ipAddress" :label="'\u0049\u0050\u5730\u5740'" width="140" />
|
||||
<el-table-column prop="lastHeartbeat" :label="'\u6700\u540e\u5fc3\u8df3'" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatTime(scope.row.lastHeartbeat) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isActive" :label="'\u72b6\u6001'" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.isActive ? 'success' : 'info'">
|
||||
{{ scope.row.isActive ? '\u5728\u7ebf' : '\u79bb\u7ebf' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="'\u64cd\u4f5c'" width="200">
|
||||
<template #default="scope">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="warning" @click="kick(scope.row)">{{ '\u5f3a\u5236\u4e0b\u7ebf' }}</el-button>
|
||||
<el-button size="small" type="danger" @click="unbind(scope.row)">{{ '\u89e3\u7ed1' }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchProjects, listDevices, kickDevice, unbindDevice } from '@/api/admin';
|
||||
|
||||
const projects = ref([]);
|
||||
const devices = ref([]);
|
||||
const loading = ref(false);
|
||||
const filters = reactive({ projectId: '', isActive: undefined });
|
||||
|
||||
const loadProjects = async () => {
|
||||
const res = await fetchProjects({ page: 1, pageSize: 100 });
|
||||
projects.value = res.items || [];
|
||||
};
|
||||
|
||||
const loadDevices = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await listDevices({
|
||||
projectId: filters.projectId || undefined,
|
||||
isActive: filters.isActive
|
||||
});
|
||||
devices.value = res || [];
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u52a0\u8f7d\u8bbe\u5907\u5931\u8d25');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const kick = async (row) => {
|
||||
try {
|
||||
await kickDevice(row.id);
|
||||
ElMessage.success('\u5df2\u4e0b\u7ebf');
|
||||
loadDevices();
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u64cd\u4f5c\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const unbind = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('\u89e3\u7ed1\u8bbe\u5907\uff1f', '\u786e\u8ba4', { type: 'warning' });
|
||||
await unbindDevice(row.id);
|
||||
ElMessage.success('\u5df2\u89e3\u7ed1');
|
||||
loadDevices();
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') {
|
||||
ElMessage.error(err?.message || '\u64cd\u4f5c\u5931\u8d25');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (value) => {
|
||||
if (!value) return '-';
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm');
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProjects();
|
||||
await loadDevices();
|
||||
});
|
||||
</script>
|
||||
92
license-system-frontend/src/pages/admin/LogsView.vue
Normal file
92
license-system-frontend/src/pages/admin/LogsView.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 class="section-title">{{ '\u65e5\u5fd7' }}</h2>
|
||||
<div class="tag">{{ '\u8bbf\u95ee\u5ba1\u8ba1' }}</div>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<el-button @click="loadLogs">{{ '\u5237\u65b0' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card panel">
|
||||
<div class="toolbar" style="margin-bottom: 12px;">
|
||||
<el-input v-model="filters.action" :placeholder="'\u64cd\u4f5c'" style="max-width: 200px" />
|
||||
<el-button @click="loadLogs">{{ '\u7b5b\u9009' }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="logs" v-loading="loading">
|
||||
<el-table-column prop="action" :label="'\u64cd\u4f5c'" width="160" />
|
||||
<el-table-column prop="projectId" :label="'\u9879\u76ee'" width="160" />
|
||||
<el-table-column prop="deviceId" :label="'\u8bbe\u5907'" min-width="180" />
|
||||
<el-table-column prop="ipAddress" :label="'\u0049\u0050\u5730\u5740'" width="140" />
|
||||
<el-table-column prop="createdAt" :label="'\u65f6\u95f4'" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatTime(scope.row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table-footer">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.pageSize"
|
||||
:current-page="pagination.page"
|
||||
@current-change="handlePage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { listLogs } from '@/api/admin';
|
||||
|
||||
const logs = ref([]);
|
||||
const loading = ref(false);
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
const filters = reactive({ action: '' });
|
||||
|
||||
const loadLogs = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await listLogs({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
action: filters.action || undefined
|
||||
});
|
||||
logs.value = res.items || [];
|
||||
pagination.total = res.pagination?.total || 0;
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u52a0\u8f7d\u65e5\u5fd7\u5931\u8d25');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePage = (page) => {
|
||||
pagination.page = page;
|
||||
loadLogs();
|
||||
};
|
||||
|
||||
const formatTime = (value) => {
|
||||
if (!value) return '-';
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm');
|
||||
};
|
||||
|
||||
onMounted(loadLogs);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
671
license-system-frontend/src/pages/admin/ProjectsView.vue
Normal file
671
license-system-frontend/src/pages/admin/ProjectsView.vue
Normal file
@@ -0,0 +1,671 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 class="section-title">{{ '\u9879\u76ee' }}</h2>
|
||||
<div class="tag">{{ '\u9879\u76ee\u4e0e\u7248\u672c\u7ba1\u7406' }}</div>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<el-button type="primary" @click="openCreate">{{ '\u65b0\u5efa\u9879\u76ee' }}</el-button>
|
||||
<el-button @click="loadProjects">{{ '\u5237\u65b0' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card panel">
|
||||
<el-table :data="projects" v-loading="loading" @row-click="openDetail">
|
||||
<el-table-column prop="projectId" :label="'\u9879\u76ee\u7f16\u53f7'" width="160" />
|
||||
<el-table-column prop="name" :label="'\u540d\u79f0'" min-width="160" />
|
||||
<el-table-column prop="description" :label="'\u63cf\u8ff0'" min-width="220" />
|
||||
<el-table-column prop="maxDevices" :label="'\u6700\u5927\u8bbe\u5907\u6570'" width="120" />
|
||||
<el-table-column prop="autoUpdate" :label="'\u81ea\u52a8\u66f4\u65b0'" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.autoUpdate ? 'success' : 'info'">
|
||||
{{ scope.row.autoUpdate ? '\u542f\u7528' : '\u7981\u7528' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isEnabled" :label="'\u72b6\u6001'" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.isEnabled ? 'success' : 'danger'">
|
||||
{{ scope.row.isEnabled ? '\u542f\u7528' : '\u7981\u7528' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="'\u64cd\u4f5c'" width="200">
|
||||
<template #default="scope">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" :disabled="!canManage(scope.row)" @click.stop="editProject(scope.row)">{{ '\u7f16\u8f91' }}</el-button>
|
||||
<el-button size="small" type="danger" :disabled="!canManage(scope.row)" @click.stop="removeProject(scope.row)">{{ '\u7981\u7528' }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table-footer">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.pageSize"
|
||||
:current-page="pagination.page"
|
||||
@current-change="handlePage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="formVisible" :title="'\u9879\u76ee'" size="420px">
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item :label="'\u540d\u79f0'">
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u63cf\u8ff0'">
|
||||
<el-input v-model="form.description" type="textarea" rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u6700\u5927\u8bbe\u5907\u6570'">
|
||||
<el-input-number v-model="form.maxDevices" :min="1" :max="50" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u81ea\u52a8\u66f4\u65b0'">
|
||||
<el-switch v-model="form.autoUpdate" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u56fe\u6807\u5730\u5740'">
|
||||
<el-input v-model="form.iconUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u542f\u7528'" v-if="form.id">
|
||||
<el-switch v-model="form.isEnabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="formVisible = false">{{ '\u53d6\u6d88' }}</el-button>
|
||||
<el-button type="primary" @click="saveProject">{{ '\u4fdd\u5b58' }}</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="detailVisible" size="760px" :title="'\u9879\u76ee\u8be6\u60c5'">
|
||||
<div v-if="selected" class="detail-head">
|
||||
<div>
|
||||
<h3 class="section-title">{{ selected.name }}</h3>
|
||||
<div class="tag">{{ selected.projectId }}</div>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<el-button size="small" :disabled="!canManageSelected" @click="editProject(selected)">{{ '\u7f16\u8f91' }}</el-button>
|
||||
<el-button size="small" type="danger" :disabled="!canManageSelected" @click="removeProject(selected)">{{ '\u7981\u7528' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="detailTab" @tab-change="handleTabChange">
|
||||
<el-tab-pane :label="'\u4fe1\u606f'" name="info">
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<div class="label">{{ '\u5bf9\u63a5\u7801' }}</div>
|
||||
<div class="value mono value-inline">
|
||||
<span>{{ integrationCode }}</span>
|
||||
<el-button size="small" @click="copyIntegration">{{ '\u590d\u5236' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">{{ '\u9879\u76ee\u5bc6\u94a5' }}</div>
|
||||
<div class="value">{{ selected.projectKey }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">{{ '\u6700\u5927\u8bbe\u5907\u6570' }}</div>
|
||||
<div class="value">{{ selected.maxDevices }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">{{ '\u81ea\u52a8\u66f4\u65b0' }}</div>
|
||||
<div class="value">{{ selected.autoUpdate ? '\u542f\u7528' : '\u7981\u7528' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">{{ '\u72b6\u6001' }}</div>
|
||||
<div class="value">{{ selected.isEnabled ? '\u542f\u7528' : '\u7981\u7528' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="'\u4ef7\u683c'" name="pricing">
|
||||
<div class="table-actions" style="margin-bottom: 12px;">
|
||||
<el-button type="primary" size="small" :disabled="!canManageSelected" @click="openPricingDialog()">{{ '\u65b0\u589e\u4ef7\u683c' }}</el-button>
|
||||
</div>
|
||||
<el-table :data="pricing">
|
||||
<el-table-column prop="cardType" :label="'\u5361\u5bc6\u7c7b\u578b'" width="140">
|
||||
<template #default="scope">
|
||||
{{ cardTypeLabel(scope.row.cardType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="durationDays" :label="'\u5929\u6570'" width="120" />
|
||||
<el-table-column prop="originalPrice" :label="'\u4ef7\u683c'" width="140" />
|
||||
<el-table-column prop="isEnabled" :label="'\u542f\u7528'" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.isEnabled ? 'success' : 'info'">
|
||||
{{ scope.row.isEnabled ? '\u662f' : '\u5426' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="'\u64cd\u4f5c'" width="180">
|
||||
<template #default="scope">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" :disabled="!canManageSelected" @click="openPricingDialog(scope.row)">{{ '\u7f16\u8f91' }}</el-button>
|
||||
<el-button size="small" type="danger" :disabled="!canManageSelected" @click="deletePricing(scope.row)">{{ '\u5220\u9664' }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="'\u7248\u672c'" name="versions">
|
||||
<div class="version-panel">
|
||||
<el-form :model="versionForm" label-position="top" class="version-form">
|
||||
<el-form-item :label="'\u7248\u672c\u53f7'">
|
||||
<el-input v-model="versionForm.version" placeholder="1.0.0" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u66f4\u65b0\u8bf4\u660e'">
|
||||
<el-input v-model="versionForm.changelog" type="textarea" rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u6807\u8bb0'">
|
||||
<el-checkbox v-model="versionForm.isForceUpdate">{{ '\u5f3a\u5236\u66f4\u65b0' }}</el-checkbox>
|
||||
<el-checkbox v-model="versionForm.isStable">{{ '\u7a33\u5b9a' }}</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u6587\u4ef6'">
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:on-change="handleFile"
|
||||
:file-list="fileList"
|
||||
accept="*/*"
|
||||
>
|
||||
<el-button size="small">{{ '\u9009\u62e9\u6587\u4ef6' }}</el-button>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-button type="primary" :disabled="!canManageSelected" @click="uploadVersion">{{ '\u4e0a\u4f20' }}</el-button>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="versions" style="margin-top: 16px;">
|
||||
<el-table-column prop="version" :label="'\u7248\u672c\u53f7'" width="120" />
|
||||
<el-table-column prop="fileSize" :label="'\u5927\u5c0f'" width="120" />
|
||||
<el-table-column prop="isStable" :label="'\u7a33\u5b9a'" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.isStable ? 'success' : 'info'">
|
||||
{{ scope.row.isStable ? '\u662f' : '\u5426' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isForceUpdate" :label="'\u5f3a\u5236\u66f4\u65b0'" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.isForceUpdate ? 'danger' : 'info'">
|
||||
{{ scope.row.isForceUpdate ? '\u662f' : '\u5426' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="publishedAt" :label="'\u53d1\u5e03\u65f6\u95f4'" min-width="160">
|
||||
<template #default="scope">
|
||||
{{ formatTime(scope.row.publishedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="'\u64cd\u4f5c'" width="160">
|
||||
<template #default="scope">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" :disabled="!canManageSelected" @click="openVersionEdit(scope.row)">{{ '\u7f16\u8f91' }}</el-button>
|
||||
<el-button size="small" type="danger" :disabled="!canManageSelected" @click="deleteVersion(scope.row)">{{ '\u5220\u9664' }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="'\u6587\u6863'" name="docs">
|
||||
<el-input v-model="docs" type="textarea" rows="12" :placeholder="'\u9879\u76ee\u6587\u6863'" />
|
||||
<div class="table-actions" style="margin-top: 12px;">
|
||||
<el-button type="primary" :disabled="!canManageSelected" @click="saveDocs">{{ '\u4fdd\u5b58\u6587\u6863' }}</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="'\u7edf\u8ba1'" name="stats">
|
||||
<el-table :data="stats" height="320">
|
||||
<el-table-column prop="date" :label="'\u65e5\u671f'" width="140" />
|
||||
<el-table-column prop="activeUsers" :label="'\u6d3b\u8dc3'" width="120" />
|
||||
<el-table-column prop="newUsers" :label="'\u65b0\u589e'" width="120" />
|
||||
<el-table-column prop="totalDownloads" :label="'\u4e0b\u8f7d'" width="140" />
|
||||
<el-table-column prop="revenue" :label="'\u6536\u5165'" width="140" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="pricingVisible" :title="'\u4ef7\u683c'" width="420px">
|
||||
<el-form :model="pricingForm" label-position="top">
|
||||
<el-form-item :label="'\u5361\u5bc6\u7c7b\u578b'">
|
||||
<el-select v-model="pricingForm.cardType" :placeholder="'\u7c7b\u578b'">
|
||||
<el-option :label="'\u6d4b\u8bd5\u5361(\u4ec5\u767b\u5f55\u4e00\u6b21)'" value="test" />
|
||||
<el-option :label="'\u5929\u5361'" value="day" />
|
||||
<el-option :label="'\u5468\u5361'" value="week" />
|
||||
<el-option :label="'\u6708\u5361'" value="month" />
|
||||
<el-option :label="'\u5e74\u5361'" value="year" />
|
||||
<el-option :label="'\u6c38\u4e45'" value="lifetime" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u6709\u6548\u5929\u6570'">
|
||||
<el-input-number v-model="pricingForm.durationDays" :min="pricingDurationMin" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u4ef7\u683c'">
|
||||
<el-input-number v-model="pricingForm.originalPrice" :min="0" :step="0.1" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u542f\u7528'">
|
||||
<el-switch v-model="pricingForm.isEnabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="pricingVisible = false">{{ '\u53d6\u6d88' }}</el-button>
|
||||
<el-button type="primary" @click="savePricing">{{ '\u4fdd\u5b58' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="versionVisible" :title="'\u7f16\u8f91\u7248\u672c'" width="420px">
|
||||
<el-form :model="versionEdit" label-position="top">
|
||||
<el-form-item :label="'\u66f4\u65b0\u8bf4\u660e'">
|
||||
<el-input v-model="versionEdit.changelog" type="textarea" rows="4" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="versionEdit.isForceUpdate">{{ '\u5f3a\u5236\u66f4\u65b0' }}</el-checkbox>
|
||||
<el-checkbox v-model="versionEdit.isStable">{{ '\u7a33\u5b9a' }}</el-checkbox>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="versionVisible = false">{{ '\u53d6\u6d88' }}</el-button>
|
||||
<el-button type="primary" @click="saveVersion">{{ '\u4fdd\u5b58' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchProjects,
|
||||
createProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
getProject,
|
||||
getProjectPricing,
|
||||
createProjectPricing,
|
||||
updateProjectPricing,
|
||||
deleteProjectPricing,
|
||||
getProjectVersions,
|
||||
uploadProjectVersion,
|
||||
updateProjectVersion,
|
||||
deleteProjectVersion,
|
||||
getProjectDocs,
|
||||
updateProjectDocs,
|
||||
getProjectStats
|
||||
} from '@/api/admin';
|
||||
import { hasProjectAccess } from '@/store/session';
|
||||
|
||||
const projects = ref([]);
|
||||
const loading = ref(false);
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
|
||||
const formVisible = ref(false);
|
||||
const form = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
maxDevices: 1,
|
||||
autoUpdate: true,
|
||||
iconUrl: '',
|
||||
isEnabled: true
|
||||
});
|
||||
|
||||
const detailVisible = ref(false);
|
||||
const detailTab = ref('info');
|
||||
const selected = ref(null);
|
||||
const pricing = ref([]);
|
||||
const versions = ref([]);
|
||||
const docs = ref('');
|
||||
const stats = ref([]);
|
||||
|
||||
const pricingVisible = ref(false);
|
||||
const pricingForm = reactive({ id: null, cardType: 'month', durationDays: 30, originalPrice: 0, isEnabled: true });
|
||||
const pricingDurationMap = {
|
||||
test: 1,
|
||||
day: 1,
|
||||
week: 7,
|
||||
month: 30,
|
||||
year: 365,
|
||||
lifetime: 0
|
||||
};
|
||||
const pricingDurationMin = computed(() => (pricingForm.cardType === 'lifetime' ? 0 : 1));
|
||||
|
||||
const versionForm = reactive({ version: '', changelog: '', isForceUpdate: false, isStable: true });
|
||||
const fileList = ref([]);
|
||||
|
||||
const versionVisible = ref(false);
|
||||
const versionEdit = reactive({ id: null, changelog: '', isForceUpdate: false, isStable: true });
|
||||
|
||||
const canManage = (row) => {
|
||||
return hasProjectAccess(row.projectId);
|
||||
};
|
||||
|
||||
const canManageSelected = computed(() => {
|
||||
if (!selected.value) return false;
|
||||
return hasProjectAccess(selected.value.projectId);
|
||||
});
|
||||
|
||||
const encodeBase64 = (value) => {
|
||||
if (typeof window !== 'undefined' && typeof window.btoa === 'function') {
|
||||
return window.btoa(value);
|
||||
}
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return Buffer.from(value, 'utf-8').toString('base64');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const integrationCode = computed(() => {
|
||||
if (!selected.value) return '';
|
||||
const raw = `${selected.value.projectId}|${selected.value.projectKey}`;
|
||||
const encoded = encodeBase64(raw);
|
||||
return encoded ? `LSC1.${encoded}` : '';
|
||||
});
|
||||
|
||||
const copyIntegration = async () => {
|
||||
if (!integrationCode.value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(integrationCode.value);
|
||||
ElMessage.success('\u5df2\u590d\u5236');
|
||||
} catch (err) {
|
||||
ElMessage.error('\u590d\u5236\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const cardTypeLabel = (value) => {
|
||||
if (value === 'test') return '\u6d4b\u8bd5\u5361';
|
||||
if (value === 'day') return '\u5929\u5361';
|
||||
if (value === 'week') return '\u5468\u5361';
|
||||
if (value === 'month') return '\u6708\u5361';
|
||||
if (value === 'year') return '\u5e74\u5361';
|
||||
if (value === 'lifetime') return '\u6c38\u4e45';
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
const loadProjects = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetchProjects({ page: pagination.page, pageSize: pagination.pageSize });
|
||||
projects.value = res.items || [];
|
||||
pagination.total = res.pagination?.total || 0;
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u52a0\u8f7d\u9879\u76ee\u5931\u8d25');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => pricingForm.cardType,
|
||||
(value) => {
|
||||
if (pricingForm.id) return;
|
||||
const mapped = pricingDurationMap[value];
|
||||
if (mapped !== undefined) pricingForm.durationDays = mapped;
|
||||
}
|
||||
);
|
||||
|
||||
const handlePage = (page) => {
|
||||
pagination.page = page;
|
||||
loadProjects();
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
Object.assign(form, { id: null, name: '', description: '', maxDevices: 1, autoUpdate: true, iconUrl: '', isEnabled: true });
|
||||
formVisible.value = true;
|
||||
};
|
||||
|
||||
const editProject = (row) => {
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
maxDevices: row.maxDevices,
|
||||
autoUpdate: row.autoUpdate,
|
||||
iconUrl: row.iconUrl,
|
||||
isEnabled: row.isEnabled
|
||||
});
|
||||
formVisible.value = true;
|
||||
};
|
||||
|
||||
const saveProject = async () => {
|
||||
try {
|
||||
if (form.id) {
|
||||
await updateProject(form.id, form);
|
||||
ElMessage.success('\u5df2\u66f4\u65b0');
|
||||
} else {
|
||||
const created = await createProject(form);
|
||||
ElMessage.success('\u5df2\u521b\u5efa');
|
||||
await loadProjects();
|
||||
if (created?.projectKey) {
|
||||
ElMessage.info('\u8bf7\u59a5\u5584\u4fdd\u5b58\u5bf9\u63a5\u7801');
|
||||
}
|
||||
}
|
||||
formVisible.value = false;
|
||||
loadProjects();
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u4fdd\u5b58\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const removeProject = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`\u7981\u7528\u9879\u76ee ${row.name}\uff1f`, '\u786e\u8ba4', { type: 'warning' });
|
||||
await deleteProject(row.id);
|
||||
ElMessage.success('\u7981\u7528\u6210\u529f');
|
||||
loadProjects();
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') {
|
||||
ElMessage.error(err?.message || '\u64cd\u4f5c\u5931\u8d25');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (row) => {
|
||||
try {
|
||||
if (!canManage(row)) {
|
||||
ElMessage.warning('\u65e0\u6743\u9650');
|
||||
return;
|
||||
}
|
||||
selected.value = await getProject(row.id);
|
||||
detailVisible.value = true;
|
||||
detailTab.value = 'info';
|
||||
await loadDetailData();
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u52a0\u8f7d\u9879\u76ee\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const loadDetailData = async () => {
|
||||
if (!selected.value) return;
|
||||
const id = selected.value.id;
|
||||
try {
|
||||
const [pricingRes, versionsRes, docsRes, statsRes] = await Promise.all([
|
||||
getProjectPricing(id),
|
||||
getProjectVersions(id),
|
||||
getProjectDocs(id),
|
||||
getProjectStats(id)
|
||||
]);
|
||||
pricing.value = pricingRes || [];
|
||||
versions.value = versionsRes || [];
|
||||
docs.value = docsRes?.content || '';
|
||||
stats.value = (statsRes || []).map((row) => ({
|
||||
...row,
|
||||
date: row.date || row.Date || row.date
|
||||
}));
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u52a0\u8f7d\u8be6\u60c5\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = async (tab) => {
|
||||
if (!selected.value) return;
|
||||
try {
|
||||
if (tab === 'pricing') {
|
||||
pricing.value = await getProjectPricing(selected.value.id);
|
||||
} else if (tab === 'versions') {
|
||||
versions.value = await getProjectVersions(selected.value.id);
|
||||
} else if (tab === 'docs') {
|
||||
const res = await getProjectDocs(selected.value.id);
|
||||
docs.value = res?.content || '';
|
||||
} else if (tab === 'stats') {
|
||||
stats.value = await getProjectStats(selected.value.id);
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u52a0\u8f7d\u6570\u636e\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const openPricingDialog = (row) => {
|
||||
if (row) {
|
||||
Object.assign(pricingForm, row);
|
||||
} else {
|
||||
Object.assign(pricingForm, { id: null, cardType: 'month', durationDays: 30, originalPrice: 0, isEnabled: true });
|
||||
}
|
||||
pricingVisible.value = true;
|
||||
};
|
||||
|
||||
const savePricing = async () => {
|
||||
try {
|
||||
if (pricingForm.id) {
|
||||
await updateProjectPricing(selected.value.id, pricingForm.id, pricingForm);
|
||||
} else {
|
||||
await createProjectPricing(selected.value.id, pricingForm);
|
||||
}
|
||||
pricingVisible.value = false;
|
||||
pricing.value = await getProjectPricing(selected.value.id);
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u4fdd\u5b58\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const deletePricing = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('\u5220\u9664\u4ef7\u683c\uff1f', '\u786e\u8ba4', { type: 'warning' });
|
||||
await deleteProjectPricing(selected.value.id, row.id);
|
||||
pricing.value = await getProjectPricing(selected.value.id);
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') {
|
||||
ElMessage.error(err?.message || '\u5220\u9664\u5931\u8d25');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFile = (file, files) => {
|
||||
fileList.value = files.slice(-1);
|
||||
};
|
||||
|
||||
const uploadVersion = async () => {
|
||||
try {
|
||||
if (!versionForm.version || fileList.value.length === 0) {
|
||||
ElMessage.warning('\u8bf7\u8f93\u5165\u7248\u672c\u5e76\u9009\u62e9\u6587\u4ef6');
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('version', versionForm.version);
|
||||
formData.append('file', fileList.value[0].raw);
|
||||
formData.append('changelog', versionForm.changelog || '');
|
||||
formData.append('isForceUpdate', String(versionForm.isForceUpdate));
|
||||
formData.append('isStable', String(versionForm.isStable));
|
||||
await uploadProjectVersion(selected.value.id, formData);
|
||||
ElMessage.success('\u5df2\u4e0a\u4f20');
|
||||
versions.value = await getProjectVersions(selected.value.id);
|
||||
Object.assign(versionForm, { version: '', changelog: '', isForceUpdate: false, isStable: true });
|
||||
fileList.value = [];
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u4e0a\u4f20\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const openVersionEdit = (row) => {
|
||||
Object.assign(versionEdit, {
|
||||
id: row.id,
|
||||
changelog: row.changelog,
|
||||
isForceUpdate: row.isForceUpdate,
|
||||
isStable: row.isStable
|
||||
});
|
||||
versionVisible.value = true;
|
||||
};
|
||||
|
||||
const saveVersion = async () => {
|
||||
try {
|
||||
await updateProjectVersion(selected.value.id, versionEdit.id, versionEdit);
|
||||
versionVisible.value = false;
|
||||
versions.value = await getProjectVersions(selected.value.id);
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u4fdd\u5b58\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteVersion = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('\u5220\u9664\u7248\u672c\uff1f', '\u786e\u8ba4', { type: 'warning' });
|
||||
await deleteProjectVersion(selected.value.id, row.id);
|
||||
versions.value = await getProjectVersions(selected.value.id);
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') {
|
||||
ElMessage.error(err?.message || '\u5220\u9664\u5931\u8d25');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const saveDocs = async () => {
|
||||
try {
|
||||
await updateProjectDocs(selected.value.id, { content: docs.value });
|
||||
ElMessage.success('\u5df2\u4fdd\u5b58');
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u4fdd\u5b58\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (value) => {
|
||||
if (!value) return '-';
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm');
|
||||
};
|
||||
|
||||
onMounted(loadProjects);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--ink-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.value-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.version-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
337
license-system-frontend/src/pages/admin/SettingsView.vue
Normal file
337
license-system-frontend/src/pages/admin/SettingsView.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 class="section-title">{{ '\u8bbe\u7f6e' }}</h2>
|
||||
<div class="tag">{{ '\u7cfb\u7edf\u7ef4\u62a4' }}</div>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<el-button type="primary" @click="saveConfigs">{{ '\u4fdd\u5b58\u4fee\u6539' }}</el-button>
|
||||
<el-button @click="loadConfigs">{{ '\u5237\u65b0' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-two">
|
||||
<div class="glass-card panel">
|
||||
<div class="chart-header">
|
||||
<h3 class="section-title">{{ '\u914d\u7f6e\u9879' }}</h3>
|
||||
</div>
|
||||
<el-table :data="configs" v-loading="loading" height="520">
|
||||
<el-table-column prop="category" :label="'\u5206\u7c7b'" width="120">
|
||||
<template #default="scope">
|
||||
{{ categoryLabel(scope.row.category) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="'\u540d\u79f0'" min-width="220">
|
||||
<template #default="scope">
|
||||
{{ configLabel(scope.row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="'\u503c'" min-width="220">
|
||||
<template #default="scope">
|
||||
<el-switch v-if="scope.row.valueType === 'bool'" v-model="scope.row.configValue" active-value="true" inactive-value="false" />
|
||||
<el-input-number v-else-if="scope.row.valueType === 'number'" v-model="scope.row.configValue" />
|
||||
<el-select v-else-if="selectOptions(scope.row)" v-model="scope.row.configValue" filterable style="width: 100%">
|
||||
<el-option v-for="item in selectOptions(scope.row)" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-input v-else v-model="scope.row.configValue" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="glass-card panel">
|
||||
<div class="chart-header">
|
||||
<h3 class="section-title">{{ '\u7ba1\u7406\u5458' }}</h3>
|
||||
<el-button type="primary" size="small" @click="openAdminDialog()">{{ '\u65b0\u589e\u7ba1\u7406\u5458' }}</el-button>
|
||||
</div>
|
||||
<el-table :data="admins" height="520">
|
||||
<el-table-column prop="username" :label="'\u7528\u6237\u540d'" width="160" />
|
||||
<el-table-column prop="role" :label="'\u89d2\u8272'" width="120">
|
||||
<template #default="scope">
|
||||
{{ roleLabel(scope.row.role) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="'\u72b6\u6001'" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 'active' ? 'success' : 'info'">
|
||||
{{ statusLabel(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="'\u64cd\u4f5c'" width="200">
|
||||
<template #default="scope">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" @click="openAdminDialog(scope.row)">{{ '\u7f16\u8f91' }}</el-button>
|
||||
<el-button size="small" type="danger" @click="removeAdmin(scope.row)">{{ '\u5220\u9664' }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="adminVisible" :title="'\u7ba1\u7406\u5458'" width="420px">
|
||||
<el-form :model="adminForm" label-position="top">
|
||||
<el-form-item :label="'\u7528\u6237\u540d'">
|
||||
<el-input v-model="adminForm.username" :disabled="!!adminForm.id" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u5bc6\u7801'" v-if="!adminForm.id">
|
||||
<el-input v-model="adminForm.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u90ae\u7bb1'">
|
||||
<el-input v-model="adminForm.email" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u89d2\u8272'">
|
||||
<el-select v-model="adminForm.role">
|
||||
<el-option :label="'\u7ba1\u7406\u5458'" value="admin" />
|
||||
<el-option :label="'\u8d85\u7ea7\u7ba1\u7406\u5458'" value="super_admin" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u6743\u9650'">
|
||||
<el-input v-model="adminForm.permissions" :placeholder="'\u8f93\u5165\u9879\u76ee\u7f16\u53f7\u6216 *'" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u72b6\u6001'" v-if="adminForm.id">
|
||||
<el-select v-model="adminForm.status">
|
||||
<el-option :label="'\u542f\u7528'" value="active" />
|
||||
<el-option :label="'\u7981\u7528'" value="disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="adminVisible = false">{{ '\u53d6\u6d88' }}</el-button>
|
||||
<el-button type="primary" @click="saveAdmin">{{ '\u4fdd\u5b58' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { listSettings, updateSettings, listAdmins, createAdmin, updateAdmin, deleteAdmin } from '@/api/admin';
|
||||
|
||||
const configs = ref([]);
|
||||
const admins = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const categoryLabels = {
|
||||
auth: '\u8ba4\u8bc1',
|
||||
client: '\u5ba2\u6237\u7aef',
|
||||
feature: '\u529f\u80fd',
|
||||
heartbeat: '\u5fc3\u8df3',
|
||||
ratelimit: '\u9650\u6d41',
|
||||
risk: '\u98ce\u63a7',
|
||||
system: '\u7cfb\u7edf',
|
||||
trial: '\u8bd5\u7528'
|
||||
};
|
||||
|
||||
const categoryLabel = (value) => categoryLabels[value] || value || '-';
|
||||
|
||||
const configLabelMap = {
|
||||
'auth.need_activate': '\u9700\u6fc0\u6d3b',
|
||||
'auth.allow_multi_device': '\u591a\u8bbe\u5907',
|
||||
'auth.expire_type': '\u5230\u671f\u65b9\u5f0f',
|
||||
'auth.max_devices': '\u8bbe\u5907\u6570',
|
||||
'client.show_balance': '\u663e\u793a\u4f59\u989d',
|
||||
'client.help_url': '\u5e2e\u52a9\u94fe\u63a5',
|
||||
'client.contact_url': '\u8054\u7cfb\u94fe\u63a5',
|
||||
'client.notice_content': '\u516c\u544a\u5185\u5bb9',
|
||||
'client.notice_title': '\u516c\u544a\u6807\u9898',
|
||||
'feature.trial_mode': '\u8bd5\u7528',
|
||||
'feature.card_renewal': '\u7eed\u8d39',
|
||||
'feature.agent_system': '\u4ee3\u7406',
|
||||
'feature.device_bind': '\u7ed1\u5b9a\u8bbe\u5907',
|
||||
'feature.heartbeat': '\u5fc3\u8df3',
|
||||
'feature.auto_update': '\u81ea\u52a8\u66f4\u65b0',
|
||||
'feature.force_update': '\u5f3a\u5236\u66f4\u65b0',
|
||||
'heartbeat.enabled': '\u5fc3\u8df3\u5f00\u5173',
|
||||
'heartbeat.interval': '\u5fc3\u8df3\u95f4\u9694',
|
||||
'heartbeat.timeout': '\u5fc3\u8df3\u8d85\u65f6',
|
||||
'heartbeat.offline_action': '\u6389\u7ebf\u52a8\u4f5c',
|
||||
'ratelimit.ip_per_minute': '\u0049\u0050\u9650\u6d41',
|
||||
'ratelimit.enabled': '\u9650\u6d41\u5f00\u5173',
|
||||
'ratelimit.device_per_minute': '\u8bbe\u5907\u9650\u6d41',
|
||||
'ratelimit.block_duration': '\u5c01\u7981\u5206\u949f',
|
||||
'risk.proxy_prefixes': '\u4ee3\u7406\u524d\u7f00',
|
||||
'risk.auto_ban': '\u81ea\u52a8\u5c01\u7981',
|
||||
'risk.check_device_change': '\u8bbe\u5907\u53d8\u66f4',
|
||||
'risk.check_location': '\u5730\u70b9\u53d8\u66f4',
|
||||
'risk.enabled': '\u98ce\u63a7\u5f00\u5173',
|
||||
'system.log.retention_days': '\u65e5\u5fd7\u5929\u6570',
|
||||
'system.site_name': '\u7ad9\u70b9\u540d',
|
||||
'system.logo_url': '\u7ad9\u70b9\u6807\u8bc6',
|
||||
'system.enable_register': '\u5f00\u653e\u6ce8\u518c',
|
||||
'trial.days': '\u8bd5\u7528\u5929\u6570'
|
||||
};
|
||||
|
||||
const configLabel = (row) => configLabelMap[row?.configKey] || row?.displayName || row?.configKey || '-';
|
||||
|
||||
const selectOptionsMap = {
|
||||
'auth.expire_type': [
|
||||
{ value: 'activate', label: '\u6fc0\u6d3b\u540e\u5f00\u59cb\u8ba1\u65f6' },
|
||||
{ value: 'fix', label: '\u751f\u6210\u540e\u5f00\u59cb\u8ba1\u65f6' }
|
||||
],
|
||||
'heartbeat.offline_action': [
|
||||
{ value: 'exit', label: '\u5f3a\u5236\u9000\u51fa' },
|
||||
{ value: 'warning', label: '\u4ec5\u8b66\u544a' },
|
||||
{ value: 'none', label: '\u4e0d\u5904\u7406' }
|
||||
]
|
||||
};
|
||||
|
||||
const selectOptions = (row) => {
|
||||
const options = selectOptionsMap[row?.configKey];
|
||||
if (!options) return null;
|
||||
const value = row?.configValue == null ? '' : String(row.configValue);
|
||||
if (!value) return options;
|
||||
if (options.some((item) => item.value === value)) return options;
|
||||
return [...options, { value, label: `\u81ea\u5b9a\u4e49(${value})` }];
|
||||
};
|
||||
|
||||
const roleLabel = (value) => {
|
||||
if (value === 'super_admin') return '\u8d85\u7ea7\u7ba1\u7406\u5458';
|
||||
if (value === 'admin') return '\u7ba1\u7406\u5458';
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
const statusLabel = (value) => {
|
||||
if (value === 'active') return '\u542f\u7528';
|
||||
if (value === 'disabled') return '\u7981\u7528';
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
const adminVisible = ref(false);
|
||||
const adminForm = reactive({
|
||||
id: null,
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
role: 'admin',
|
||||
permissions: '',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
const loadConfigs = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await listSettings();
|
||||
configs.value = (res || []).map((item) => {
|
||||
if (item.valueType === 'number') {
|
||||
return { ...item, configValue: Number(item.configValue) };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u52a0\u8f7d\u914d\u7f6e\u5931\u8d25');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfigs = async () => {
|
||||
try {
|
||||
const payload = configs.value.map((item) => ({
|
||||
...item,
|
||||
configValue: item.configValue === null || item.configValue === undefined ? '' : String(item.configValue)
|
||||
}));
|
||||
await updateSettings(payload);
|
||||
ElMessage.success('\u4fdd\u5b58\u6210\u529f');
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u4fdd\u5b58\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const loadAdmins = async () => {
|
||||
try {
|
||||
admins.value = await listAdmins();
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u52a0\u8f7d\u7ba1\u7406\u5458\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const openAdminDialog = (row) => {
|
||||
if (row) {
|
||||
Object.assign(adminForm, {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
password: '',
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
permissions: Array.isArray(row.permissions) ? JSON.stringify(row.permissions) : row.permissions,
|
||||
status: row.status
|
||||
});
|
||||
} else {
|
||||
Object.assign(adminForm, {
|
||||
id: null,
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
role: 'admin',
|
||||
permissions: '',
|
||||
status: 'active'
|
||||
});
|
||||
}
|
||||
adminVisible.value = true;
|
||||
};
|
||||
|
||||
const saveAdmin = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
username: adminForm.username,
|
||||
password: adminForm.password,
|
||||
email: adminForm.email,
|
||||
role: adminForm.role,
|
||||
permissions: adminForm.permissions,
|
||||
status: adminForm.status
|
||||
};
|
||||
|
||||
if (adminForm.id) {
|
||||
await updateAdmin(adminForm.id, payload);
|
||||
} else {
|
||||
await createAdmin(payload);
|
||||
}
|
||||
adminVisible.value = false;
|
||||
loadAdmins();
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u4fdd\u5b58\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const removeAdmin = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('\u786e\u5b9a\u5220\u9664\u7ba1\u7406\u5458\uff1f', '\u786e\u8ba4', { type: 'warning' });
|
||||
await deleteAdmin(row.id);
|
||||
loadAdmins();
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') {
|
||||
ElMessage.error(err?.message || '\u5220\u9664\u5931\u8d25');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadConfigs();
|
||||
await loadAdmins();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid-two {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.grid-two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
160
license-system-frontend/src/pages/admin/StatsView.vue
Normal file
160
license-system-frontend/src/pages/admin/StatsView.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 class="section-title">{{ '\u7edf\u8ba1' }}</h2>
|
||||
<div class="tag">{{ '\u7edf\u8ba1\u5206\u6790' }}</div>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<el-select v-model="logDays" style="min-width: 140px">
|
||||
<el-option :label="'\u6700\u8fd17\u5929'" :value="7" />
|
||||
<el-option :label="'\u6700\u8fd130\u5929'" :value="30" />
|
||||
<el-option :label="'\u6700\u8fd190\u5929'" :value="90" />
|
||||
</el-select>
|
||||
<el-select v-model="exportDays" style="min-width: 140px">
|
||||
<el-option :label="'\u5bfc\u51fa7\u5929'" :value="7" />
|
||||
<el-option :label="'\u5bfc\u51fa30\u5929'" :value="30" />
|
||||
<el-option :label="'\u5bfc\u51fa90\u5929'" :value="90" />
|
||||
</el-select>
|
||||
<el-button @click="loadStats">{{ '\u5237\u65b0' }}</el-button>
|
||||
<el-button plain @click="exportStats">{{ '\u5bfc\u51fa\u62a5\u8868' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card panel">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane :label="'\u9879\u76ee'" name="projects">
|
||||
<div class="chart-panel">
|
||||
<div class="chart-header">
|
||||
<h3 class="section-title">{{ '\u9879\u76ee\u8868\u73b0' }}</h3>
|
||||
<el-select v-model="projectMetric" style="min-width: 160px">
|
||||
<el-option :label="'\u6d3b\u8dc3\u5361\u5bc6'" value="activeCards" />
|
||||
<el-option :label="'\u5361\u5bc6\u603b\u6570'" value="totalCards" />
|
||||
<el-option :label="'\u6536\u5165'" value="revenue" />
|
||||
</el-select>
|
||||
</div>
|
||||
<BarChart :labels="projectLabels" :values="projectValues" color="#0ea5a4" />
|
||||
</div>
|
||||
<el-table :data="projectStats" v-loading="loading" style="margin-top: 12px;">
|
||||
<el-table-column prop="projectId" :label="'\u9879\u76ee\u7f16\u53f7'" width="160" />
|
||||
<el-table-column prop="projectName" :label="'\u540d\u79f0'" min-width="160" />
|
||||
<el-table-column prop="totalCards" :label="'\u5361\u5bc6\u603b\u6570'" width="140" />
|
||||
<el-table-column prop="activeCards" :label="'\u6d3b\u8dc3\u5361\u5bc6'" width="120" />
|
||||
<el-table-column prop="activeDevices" :label="'\u5728\u7ebf\u8bbe\u5907'" width="120" />
|
||||
<el-table-column prop="revenue" :label="'\u6536\u5165'" width="140" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="'\u65e5\u5fd7'" name="logs">
|
||||
<div class="chart-panel">
|
||||
<div class="chart-header">
|
||||
<h3 class="section-title">{{ '\u65e5\u5fd7\u9891\u6b21' }}</h3>
|
||||
<span class="tag">{{ '\u6700\u8fd1' }} {{ logDays }} {{ '\u5929' }}</span>
|
||||
</div>
|
||||
<BarChart :labels="logLabels" :values="logValues" color="#ff6b35" />
|
||||
</div>
|
||||
<el-table :data="logStats" v-loading="loading" style="margin-top: 12px;">
|
||||
<el-table-column prop="action" :label="'\u64cd\u4f5c'" width="200" />
|
||||
<el-table-column prop="count" :label="'\u6b21\u6570'" width="140" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="'\u4ee3\u7406\u5546'" name="agents" v-if="superAdmin">
|
||||
<div class="chart-panel">
|
||||
<div class="chart-header">
|
||||
<h3 class="section-title">{{ '\u4ee3\u7406\u6536\u5165' }}</h3>
|
||||
</div>
|
||||
<BarChart :labels="agentLabels" :values="agentValues" color="#6366f1" />
|
||||
</div>
|
||||
<el-table :data="agentStats" v-loading="loading" style="margin-top: 12px;">
|
||||
<el-table-column prop="agentCode" :label="'\u4ee3\u7406\u7f16\u7801'" width="160" />
|
||||
<el-table-column prop="companyName" :label="'\u516c\u53f8'" min-width="200" />
|
||||
<el-table-column prop="totalCards" :label="'\u603b\u5361\u5bc6'" width="120" />
|
||||
<el-table-column prop="activeCards" :label="'\u6d3b\u8dc3'" width="120" />
|
||||
<el-table-column prop="totalRevenue" :label="'\u6536\u5165'" width="140" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { statsProjects, statsLogs, statsAgents, statsExport } from '@/api/admin';
|
||||
import { isSuperAdmin } from '@/store/session';
|
||||
import BarChart from '@/components/BarChart.vue';
|
||||
|
||||
const activeTab = ref('projects');
|
||||
const loading = ref(false);
|
||||
const projectStats = ref([]);
|
||||
const logStats = ref([]);
|
||||
const agentStats = ref([]);
|
||||
const superAdmin = isSuperAdmin();
|
||||
const logDays = ref(7);
|
||||
const exportDays = ref(30);
|
||||
const projectMetric = ref('activeCards');
|
||||
|
||||
const projectLabels = computed(() => projectStats.value.map((item) => item.projectName || item.projectId));
|
||||
const projectValues = computed(() => {
|
||||
return projectStats.value.map((item) => item[projectMetric.value] ?? 0);
|
||||
});
|
||||
const logLabels = computed(() => logStats.value.map((item) => item.action));
|
||||
const logValues = computed(() => logStats.value.map((item) => item.count));
|
||||
const agentLabels = computed(() => agentStats.value.map((item) => item.agentCode));
|
||||
const agentValues = computed(() => agentStats.value.map((item) => item.totalRevenue));
|
||||
|
||||
const loadStats = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [projects, logs] = await Promise.all([
|
||||
statsProjects(),
|
||||
statsLogs({ days: logDays.value })
|
||||
]);
|
||||
projectStats.value = projects || [];
|
||||
logStats.value = logs || [];
|
||||
if (superAdmin) {
|
||||
agentStats.value = await statsAgents();
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u52a0\u8f7d\u7edf\u8ba1\u5931\u8d25');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const exportStats = async () => {
|
||||
try {
|
||||
const response = await statsExport({ days: exportDays.value });
|
||||
const blob = response.data;
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'stats.csv';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u5bfc\u51fa\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadStats);
|
||||
|
||||
watch(logDays, () => {
|
||||
loadStats();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-panel {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
224
license-system-frontend/src/pages/agent/AgentCardsView.vue
Normal file
224
license-system-frontend/src/pages/agent/AgentCardsView.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 class="section-title">{{ '\u4ee3\u7406\u5361\u5bc6' }}</h2>
|
||||
<div class="tag">{{ '\u751f\u6210\u5361\u5bc6\u5e76\u67e5\u770b\u9500\u552e' }}</div>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<el-button type="primary" @click="openGenerate">{{ '\u751f\u6210' }}</el-button>
|
||||
<el-button @click="loadCards">{{ '\u5237\u65b0' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card panel">
|
||||
<div class="toolbar" style="margin-bottom: 12px;">
|
||||
<el-select v-model="filters.projectId" :placeholder="'\u9879\u76ee'" clearable style="min-width: 160px">
|
||||
<el-option v-for="project in allowedProjects" :key="project.projectId" :label="project.projectName" :value="project.projectId" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.status" :placeholder="'\u72b6\u6001'" clearable style="min-width: 140px">
|
||||
<el-option :label="'\u672a\u4f7f\u7528'" value="unused" />
|
||||
<el-option :label="'\u5df2\u6fc0\u6d3b'" value="active" />
|
||||
<el-option :label="'\u5df2\u8fc7\u671f'" value="expired" />
|
||||
</el-select>
|
||||
<el-button @click="loadCards">{{ '\u7b5b\u9009' }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="cards" v-loading="loading">
|
||||
<el-table-column prop="keyCode" :label="'\u5361\u5bc6'" min-width="200" />
|
||||
<el-table-column prop="cardType" :label="'\u7c7b\u578b'" width="120">
|
||||
<template #default="scope">
|
||||
{{ cardTypeLabel(scope.row.cardType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="'\u72b6\u6001'" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusTag(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expireTime" :label="'\u5230\u671f'" min-width="160">
|
||||
<template #default="scope">
|
||||
{{ formatTime(scope.row.expireTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="note" :label="'\u5907\u6ce8'" min-width="160" />
|
||||
</el-table>
|
||||
|
||||
<div class="table-footer">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.pageSize"
|
||||
:current-page="pagination.page"
|
||||
@current-change="handlePage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="generateVisible" :title="'\u751f\u6210\u5361\u5bc6'" size="420px">
|
||||
<el-form :model="generateForm" label-position="top">
|
||||
<el-form-item :label="'\u9879\u76ee'">
|
||||
<el-select v-model="generateForm.projectId" :placeholder="'\u9009\u62e9\u9879\u76ee'">
|
||||
<el-option v-for="project in allowedProjects" :key="project.projectId" :label="project.projectName" :value="project.projectId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u5361\u5bc6\u7c7b\u578b'">
|
||||
<el-select v-model="generateForm.cardType">
|
||||
<el-option :label="'\u6d4b\u8bd5\u5361(\u4ec5\u767b\u5f55\u4e00\u6b21)'" value="test" />
|
||||
<el-option :label="'\u5929\u5361'" value="day" />
|
||||
<el-option :label="'\u5468\u5361'" value="week" />
|
||||
<el-option :label="'\u6708\u5361'" value="month" />
|
||||
<el-option :label="'\u6c38\u4e45'" value="lifetime" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u6709\u6548\u671f'">
|
||||
<div class="form-static">{{ durationLabel(generateForm.cardType) }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u6570\u91cf'">
|
||||
<el-input-number v-model="generateForm.quantity" :min="1" :max="10000" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u5907\u6ce8'">
|
||||
<el-input v-model="generateForm.note" type="textarea" rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="generateVisible = false">{{ '\u53d6\u6d88' }}</el-button>
|
||||
<el-button type="primary" @click="generate">{{ '\u751f\u6210' }}</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { createIdempotencyKey } from '@/utils/idempotency';
|
||||
import { agentGenerateCards, agentCards } from '@/api/agent';
|
||||
import { session } from '@/store/session';
|
||||
|
||||
const allowedProjects = computed(() => session.allowedProjects || []);
|
||||
const cards = ref([]);
|
||||
const loading = ref(false);
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
const filters = reactive({ projectId: '', status: '' });
|
||||
|
||||
const generateVisible = ref(false);
|
||||
const durationMap = {
|
||||
test: 1,
|
||||
day: 1,
|
||||
week: 7,
|
||||
month: 30,
|
||||
lifetime: 0
|
||||
};
|
||||
|
||||
const durationLabelMap = {
|
||||
test: '\u4ec5\u767b\u5f55\u4e00\u6b21',
|
||||
day: '1\u5929',
|
||||
week: '7\u5929',
|
||||
month: '30\u5929',
|
||||
lifetime: '\u6c38\u4e45'
|
||||
};
|
||||
|
||||
const generateForm = reactive({ projectId: '', cardType: 'month', durationDays: durationMap.month, quantity: 10, note: '' });
|
||||
|
||||
const statusLabel = (status) => {
|
||||
if (status === 'active') return '\u5df2\u6fc0\u6d3b';
|
||||
if (status === 'expired') return '\u5df2\u8fc7\u671f';
|
||||
if (status === 'unused') return '\u672a\u4f7f\u7528';
|
||||
return status || '-';
|
||||
};
|
||||
|
||||
const cardTypeLabel = (value) => {
|
||||
if (value === 'test') return '\u6d4b\u8bd5\u5361';
|
||||
if (value === 'day') return '\u5929\u5361';
|
||||
if (value === 'week') return '\u5468\u5361';
|
||||
if (value === 'month') return '\u6708\u5361';
|
||||
if (value === 'year') return '\u5e74\u5361';
|
||||
if (value === 'lifetime') return '\u6c38\u4e45';
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
const setDurationByType = (cardType) => {
|
||||
generateForm.durationDays = durationMap[cardType] ?? durationMap.month;
|
||||
};
|
||||
|
||||
const durationLabel = (cardType) => durationLabelMap[cardType] || '-';
|
||||
|
||||
const loadCards = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await agentCards({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
projectId: filters.projectId || undefined,
|
||||
status: filters.status || undefined
|
||||
});
|
||||
cards.value = res.items || [];
|
||||
pagination.total = res.pagination?.total || 0;
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u52a0\u8f7d\u5361\u5bc6\u5931\u8d25');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePage = (page) => {
|
||||
pagination.page = page;
|
||||
loadCards();
|
||||
};
|
||||
|
||||
const openGenerate = () => {
|
||||
Object.assign(generateForm, { projectId: '', cardType: 'month', durationDays: durationMap.month, quantity: 10, note: '' });
|
||||
generateVisible.value = true;
|
||||
};
|
||||
|
||||
const generate = async () => {
|
||||
try {
|
||||
const key = createIdempotencyKey();
|
||||
setDurationByType(generateForm.cardType);
|
||||
await agentGenerateCards(generateForm, key);
|
||||
ElMessage.success('\u751f\u6210\u6210\u529f');
|
||||
generateVisible.value = false;
|
||||
loadCards();
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u751f\u6210\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const statusTag = (status) => {
|
||||
if (status === 'active') return 'success';
|
||||
if (status === 'expired') return 'warning';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const formatTime = (value) => {
|
||||
if (!value) return '-';
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm');
|
||||
};
|
||||
|
||||
watch(
|
||||
() => generateForm.cardType,
|
||||
(value) => {
|
||||
setDurationByType(value);
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(loadCards);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.form-static {
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 23, 42, 0.04);
|
||||
color: var(--ink-700);
|
||||
}
|
||||
</style>
|
||||
109
license-system-frontend/src/pages/agent/AgentProfileView.vue
Normal file
109
license-system-frontend/src/pages/agent/AgentProfileView.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 class="section-title">{{ '\u8d44\u6599' }}</h2>
|
||||
<div class="tag">{{ '\u4ee3\u7406\u8d44\u6599\u7ba1\u7406' }}</div>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<el-button @click="loadProfile">{{ '\u5237\u65b0' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-two">
|
||||
<div class="glass-card panel">
|
||||
<h3 class="section-title">{{ '\u516c\u53f8\u4fe1\u606f' }}</h3>
|
||||
<el-form :model="profile" label-position="top" class="profile-form">
|
||||
<el-form-item :label="'\u516c\u53f8\u540d\u79f0'">
|
||||
<el-input v-model="profile.companyName" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u8054\u7cfb\u4eba'">
|
||||
<el-input v-model="profile.contactPerson" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u8054\u7cfb\u7535\u8bdd'">
|
||||
<el-input v-model="profile.contactPhone" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u8054\u7cfb\u90ae\u7bb1'">
|
||||
<el-input v-model="profile.contactEmail" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" @click="saveProfile">{{ '\u4fdd\u5b58' }}</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="glass-card panel">
|
||||
<h3 class="section-title">{{ '\u4fee\u6539\u5bc6\u7801' }}</h3>
|
||||
<el-form :model="password" label-position="top" class="profile-form">
|
||||
<el-form-item :label="'\u65e7\u5bc6\u7801'">
|
||||
<el-input v-model="password.oldPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item :label="'\u65b0\u5bc6\u7801'">
|
||||
<el-input v-model="password.newPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-button type="primary" plain @click="changePassword">{{ '\u66f4\u65b0\u5bc6\u7801' }}</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { agentProfile, agentUpdateProfile, agentChangePassword } from '@/api/agent';
|
||||
|
||||
const profile = reactive({
|
||||
companyName: '',
|
||||
contactPerson: '',
|
||||
contactPhone: '',
|
||||
contactEmail: ''
|
||||
});
|
||||
|
||||
const password = reactive({
|
||||
oldPassword: '',
|
||||
newPassword: ''
|
||||
});
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const res = await agentProfile();
|
||||
Object.assign(profile, res);
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u52a0\u8f7d\u8d44\u6599\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const saveProfile = async () => {
|
||||
try {
|
||||
await agentUpdateProfile(profile);
|
||||
ElMessage.success('\u4fdd\u5b58\u6210\u529f');
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u4fdd\u5b58\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async () => {
|
||||
try {
|
||||
await agentChangePassword(password);
|
||||
password.oldPassword = '';
|
||||
password.newPassword = '';
|
||||
ElMessage.success('\u5bc6\u7801\u5df2\u66f4\u65b0');
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u66f4\u65b0\u5931\u8d25');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadProfile);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid-two {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 class="section-title">{{ '\u989d\u5ea6\u6d41\u6c34' }}</h2>
|
||||
<div class="tag">{{ '\u4f59\u989d\u53d8\u52a8\u8bb0\u5f55' }}</div>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<el-button @click="loadTransactions">{{ '\u5237\u65b0' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card panel">
|
||||
<el-table :data="transactions" v-loading="loading">
|
||||
<el-table-column prop="type" :label="'\u7c7b\u578b'" width="140">
|
||||
<template #default="scope">
|
||||
{{ typeLabel(scope.row.type) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="amount" :label="'\u91d1\u989d'" width="140" />
|
||||
<el-table-column prop="balanceAfter" :label="'\u4f59\u989d'" width="140" />
|
||||
<el-table-column prop="remark" :label="'\u5907\u6ce8'" min-width="240" />
|
||||
<el-table-column prop="createdAt" :label="'\u65f6\u95f4'" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatTime(scope.row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { agentTransactions } from '@/api/agent';
|
||||
|
||||
const transactions = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const typeLabel = (value) => {
|
||||
if (value === 'recharge') return '\u5145\u503c';
|
||||
if (value === 'consume') return '\u6263\u8d39';
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
const loadTransactions = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
transactions.value = await agentTransactions();
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '\u52a0\u8f7d\u6d41\u6c34\u5931\u8d25');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (value) => {
|
||||
if (!value) return '-';
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm');
|
||||
};
|
||||
|
||||
onMounted(loadTransactions);
|
||||
</script>
|
||||
75
license-system-frontend/src/router/index.js
Normal file
75
license-system-frontend/src/router/index.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { session, isLoggedIn, isSuperAdmin } from '@/store/session';
|
||||
|
||||
import LoginView from '@/pages/LoginView.vue';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import AgentLayout from '@/layouts/AgentLayout.vue';
|
||||
import DashboardView from '@/pages/admin/DashboardView.vue';
|
||||
import ProjectsView from '@/pages/admin/ProjectsView.vue';
|
||||
import CardsView from '@/pages/admin/CardsView.vue';
|
||||
import DevicesView from '@/pages/admin/DevicesView.vue';
|
||||
import LogsView from '@/pages/admin/LogsView.vue';
|
||||
import StatsView from '@/pages/admin/StatsView.vue';
|
||||
import AgentsView from '@/pages/admin/AgentsView.vue';
|
||||
import SettingsView from '@/pages/admin/SettingsView.vue';
|
||||
import ApiDocsView from '@/pages/admin/ApiDocsView.vue';
|
||||
import AgentCardsView from '@/pages/agent/AgentCardsView.vue';
|
||||
import AgentTransactionsView from '@/pages/agent/AgentTransactionsView.vue';
|
||||
import AgentProfileView from '@/pages/agent/AgentProfileView.vue';
|
||||
import NotFoundView from '@/pages/NotFoundView.vue';
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
|
||||
{
|
||||
path: '/admin',
|
||||
component: AdminLayout,
|
||||
children: [
|
||||
{ path: 'dashboard', name: 'admin-dashboard', component: DashboardView, meta: { type: 'admin' } },
|
||||
{ path: 'projects', name: 'admin-projects', component: ProjectsView, meta: { type: 'admin' } },
|
||||
{ path: 'cards', name: 'admin-cards', component: CardsView, meta: { type: 'admin' } },
|
||||
{ path: 'devices', name: 'admin-devices', component: DevicesView, meta: { type: 'admin' } },
|
||||
{ path: 'logs', name: 'admin-logs', component: LogsView, meta: { type: 'admin' } },
|
||||
{ path: 'stats', name: 'admin-stats', component: StatsView, meta: { type: 'admin' } },
|
||||
{ path: 'api-docs', name: 'admin-api-docs', component: ApiDocsView, meta: { type: 'admin' } },
|
||||
{ path: 'agents', name: 'admin-agents', component: AgentsView, meta: { type: 'admin', super: true } },
|
||||
{ path: 'settings', name: 'admin-settings', component: SettingsView, meta: { type: 'admin', super: true } }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/agent',
|
||||
component: AgentLayout,
|
||||
children: [
|
||||
{ path: 'cards', name: 'agent-cards', component: AgentCardsView, meta: { type: 'agent' } },
|
||||
{ path: 'transactions', name: 'agent-transactions', component: AgentTransactionsView, meta: { type: 'agent' } },
|
||||
{ path: 'profile', name: 'agent-profile', component: AgentProfileView, meta: { type: 'agent' } }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
redirect: () => {
|
||||
if (!isLoggedIn()) return '/login';
|
||||
if (session.type === 'agent') return '/agent/cards';
|
||||
return '/admin/dashboard';
|
||||
}
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { public: true } }
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
});
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.meta.public) return next();
|
||||
if (!isLoggedIn()) return next('/login');
|
||||
if (to.meta.type && session.type !== to.meta.type) {
|
||||
return next(session.type === 'agent' ? '/agent/cards' : '/admin/dashboard');
|
||||
}
|
||||
if (to.meta.super && !isSuperAdmin()) {
|
||||
return next('/admin/dashboard');
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
export default router;
|
||||
79
license-system-frontend/src/store/session.js
Normal file
79
license-system-frontend/src/store/session.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { reactive } from 'vue';
|
||||
|
||||
const state = reactive({
|
||||
token: localStorage.getItem('token') || '',
|
||||
type: localStorage.getItem('type') || '',
|
||||
role: localStorage.getItem('role') || '',
|
||||
permissions: JSON.parse(localStorage.getItem('permissions') || '[]'),
|
||||
user: JSON.parse(localStorage.getItem('user') || 'null'),
|
||||
agent: JSON.parse(localStorage.getItem('agent') || 'null'),
|
||||
allowedProjects: JSON.parse(localStorage.getItem('allowedProjects') || '[]')
|
||||
});
|
||||
|
||||
function persist() {
|
||||
localStorage.setItem('token', state.token || '');
|
||||
localStorage.setItem('type', state.type || '');
|
||||
localStorage.setItem('role', state.role || '');
|
||||
localStorage.setItem('permissions', JSON.stringify(state.permissions || []));
|
||||
localStorage.setItem('user', JSON.stringify(state.user || null));
|
||||
localStorage.setItem('agent', JSON.stringify(state.agent || null));
|
||||
localStorage.setItem('allowedProjects', JSON.stringify(state.allowedProjects || []));
|
||||
}
|
||||
|
||||
function setAdminSession(payload) {
|
||||
state.token = payload.token;
|
||||
state.type = 'admin';
|
||||
state.role = payload.user?.role || 'admin';
|
||||
state.permissions = payload.user?.permissions || [];
|
||||
state.user = payload.user || null;
|
||||
state.agent = null;
|
||||
state.allowedProjects = [];
|
||||
persist();
|
||||
}
|
||||
|
||||
function setAgentSession(payload) {
|
||||
state.token = payload.token;
|
||||
state.type = 'agent';
|
||||
state.role = 'agent';
|
||||
state.permissions = [];
|
||||
state.user = null;
|
||||
state.agent = payload.agent || null;
|
||||
state.allowedProjects = payload.allowedProjects || [];
|
||||
persist();
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
state.token = '';
|
||||
state.type = '';
|
||||
state.role = '';
|
||||
state.permissions = [];
|
||||
state.user = null;
|
||||
state.agent = null;
|
||||
state.allowedProjects = [];
|
||||
persist();
|
||||
}
|
||||
|
||||
function isLoggedIn() {
|
||||
return Boolean(state.token);
|
||||
}
|
||||
|
||||
function isSuperAdmin() {
|
||||
return state.type === 'admin' && state.role === 'super_admin';
|
||||
}
|
||||
|
||||
function hasProjectAccess(projectId) {
|
||||
if (!projectId) return false;
|
||||
if (isSuperAdmin()) return true;
|
||||
if ((state.permissions || []).includes('*')) return true;
|
||||
return (state.permissions || []).includes(projectId);
|
||||
}
|
||||
|
||||
export {
|
||||
state as session,
|
||||
setAdminSession,
|
||||
setAgentSession,
|
||||
clearSession,
|
||||
isLoggedIn,
|
||||
isSuperAdmin,
|
||||
hasProjectAccess
|
||||
};
|
||||
175
license-system-frontend/src/styles/index.css
Normal file
175
license-system-frontend/src/styles/index.css
Normal file
@@ -0,0 +1,175 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Space+Grotesk:wght@500;600&display=swap');
|
||||
|
||||
:root {
|
||||
--ink-950: #0b1220;
|
||||
--ink-900: #0f172a;
|
||||
--ink-700: #334155;
|
||||
--ink-500: #64748b;
|
||||
--sand-50: #f7f2e9;
|
||||
--sand-100: #efe7d9;
|
||||
--accent-500: #ff6b35;
|
||||
--accent-600: #e95b2b;
|
||||
--teal-500: #0ea5a4;
|
||||
--teal-700: #0f766e;
|
||||
--line: rgba(15, 23, 42, 0.08);
|
||||
--shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
|
||||
--radius-lg: 18px;
|
||||
--radius-md: 12px;
|
||||
--radius-sm: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Manrope', 'Segoe UI', Tahoma, sans-serif;
|
||||
color: var(--ink-900);
|
||||
background: radial-gradient(circle at 10% 10%, rgba(255, 107, 53, 0.2), transparent 35%),
|
||||
radial-gradient(circle at 90% 20%, rgba(14, 165, 164, 0.18), transparent 40%),
|
||||
linear-gradient(160deg, #fbf7f0, #f2efe7 40%, #f7f4ed 80%);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
padding: 24px 32px 40px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: 'Space Grotesk', 'Manrope', sans-serif;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 18px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(140deg, rgba(255, 255, 255, 0.9), rgba(247, 242, 233, 0.85));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
animation: fadeUp 0.5s ease both;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--ink-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-900);
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
color: var(--ink-700);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.badge-dot.active {
|
||||
background: var(--teal-500);
|
||||
}
|
||||
|
||||
.badge-dot.inactive {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.page-shell {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
35
license-system-frontend/src/utils/apiBase.js
Normal file
35
license-system-frontend/src/utils/apiBase.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const normalizePort = (value) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const DEFAULT_API_PORT = normalizePort(import.meta.env.VITE_API_PORT);
|
||||
|
||||
const normalizeUrl = (value) => (value ? value.replace(/\/$/, '') : '');
|
||||
|
||||
export const resolveApiBase = ({
|
||||
fallbackPort = DEFAULT_API_PORT,
|
||||
fallbackHost = '127.0.0.1',
|
||||
preferSameOrigin = true
|
||||
} = {}) => {
|
||||
const envBase = import.meta.env.VITE_API_BASE;
|
||||
if (envBase) return normalizeUrl(envBase);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return fallbackPort ? `http://${fallbackHost}:${fallbackPort}` : '';
|
||||
}
|
||||
|
||||
const { protocol, hostname, origin } = window.location;
|
||||
const originBase = normalizeUrl(origin);
|
||||
|
||||
if (preferSameOrigin) {
|
||||
return originBase;
|
||||
}
|
||||
|
||||
if (!fallbackPort) return originBase;
|
||||
|
||||
const safeHost = hostname.includes(':') ? `[${hostname}]` : hostname;
|
||||
return `${protocol}//${safeHost}:${fallbackPort}`;
|
||||
};
|
||||
|
||||
export { DEFAULT_API_PORT };
|
||||
4
license-system-frontend/src/utils/idempotency.js
Normal file
4
license-system-frontend/src/utils/idempotency.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export function createIdempotencyKey() {
|
||||
const rand = Math.random().toString(16).slice(2);
|
||||
return `${Date.now()}-${rand}`;
|
||||
}
|
||||
15
license-system-frontend/vite.config.js
Normal file
15
license-system-frontend/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(process.cwd(), './src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user