Initial commit

This commit is contained in:
2026-01-04 23:00:21 +08:00
commit d3178871eb
124 changed files with 19300 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
VITE_API_BASE=
VITE_API_PORT=

View 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".

View 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>

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

1732
license-system-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,8 @@
<template>
<div class="app-root">
<router-view />
</div>
</template>
<script setup>
</script>

View 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}`);

View 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 });

View 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;

View 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>

View 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>

View 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>

View 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>

View 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');

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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;

View 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
};

View 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;
}
}

View 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 };

View File

@@ -0,0 +1,4 @@
export function createIdempotencyKey() {
const rand = Math.random().toString(16).slice(2);
return `${Date.now()}-${rand}`;
}

View 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
}
});