权限管理、apk包下载
Some checks are pending
CI / Test (ubuntu-latest) (push) Waiting to run
CI / Test (windows-latest) (push) Waiting to run
CI / Lint (ubuntu-latest) (push) Waiting to run
CI / Lint (windows-latest) (push) Waiting to run
CI / Check (ubuntu-latest) (push) Waiting to run
CI / Check (windows-latest) (push) Waiting to run
CI / CI OK (push) Blocked by required conditions
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
Deploy Website on push / Deploy Push Playground Ftp (push) Waiting to run
Deploy Website on push / Deploy Push Docs Ftp (push) Waiting to run
Deploy Website on push / Deploy Push Antd Ftp (push) Waiting to run
Deploy Website on push / Deploy Push Element Ftp (push) Waiting to run
Deploy Website on push / Deploy Push Naive Ftp (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run

This commit is contained in:
hahwu 2026-05-07 14:39:06 +08:00
parent 39195fb2e6
commit 5cc74ac42c
30 changed files with 709 additions and 185 deletions

View File

@ -40,6 +40,14 @@ export async function addAdminApi(param: UserInfo) {
return requestClient.post<UserInfo>('/admin/add', param);
}
export async function editAdminApi(param: UserInfo) {
return requestClient.post('/admin/edit', param);
}
export async function deleteAdminApi(id: number) {
return requestClient.post('/admin/delete', { id });
}
export async function getAdminConfigList(param:AdminConfigListParam) {
return requestClient.post<AdminConfig>('/admin/config/list', param);
}

View File

@ -0,0 +1,19 @@
import { requestClient } from '#/api/request';
export namespace ApkApi {
export type Environment = 'dev' | 'prod' | 'stable';
export interface PackageItem {
downloadPath: string;
env: Environment;
exists: boolean;
fileName: string;
size: number;
uploadedAt: string;
version: string;
}
}
export async function getApkPackagesApi() {
return requestClient.get<ApkApi.PackageItem[]>('/apk/packages');
}

View File

@ -209,3 +209,7 @@ export async function setUserPermissionsDirectApi(params: PermissionApi.UserPerm
export async function getUserRolesApi(admin_id: number) {
return requestClient.post<PermissionApi.Role[]>('/admin/user/role/list', { admin_id });
}
export async function getUserRolesBatchApi(admin_ids: number[]) {
return requestClient.post<Record<string, PermissionApi.Role[]>>('/admin/user/role/batch-list', { admin_ids });
}

View File

@ -41,7 +41,10 @@
},
"dashboard": {
"title": "Dashboard",
"analytics": "Analytics",
"analytics": "Analytics"
},
"devops": {
"title": "DevOps",
"server-list": "Server List",
"node-list": "Node List",
"mysql-list": "MySQL List",
@ -65,6 +68,7 @@
},
"operation": {
"title": "Operation",
"apk": "Client APK",
"level": "Level",
"mail": "Mail",
"order": "Order",

View File

@ -40,8 +40,11 @@
}
},
"dashboard": {
"title": "工作台",
"analytics": "分析台"
},
"devops": {
"title": "运维管理",
"analytics": "分析台",
"server-list": "区服列表",
"node-list": "节点列表",
"mysql-list": "MySQL列表",
@ -65,6 +68,7 @@
},
"operation": {
"title": "运营管理",
"apk": "客户端 APK 下载",
"scripts": "自动化脚本",
"mail": "邮件管理",
"order": "订单管理",

View File

@ -2,12 +2,18 @@ export interface UserInfo {
id?: number;
username: string;
password?: string;
phone: string;
real_name?: string;
nickname?: string;
phone?: string;
email?: string;
avatar?: string;
uid?: string;
group: string;
group?: string;
role: number;
status?: number;
lastLoginTime?: number;
lastLoginIp?: string;
createTime?: number;
updateTime?: number;
remark?: string;
}

View File

@ -50,13 +50,24 @@ function setupAccessGuard(router: Router) {
const userStore = useUserStore();
const authStore = useAuthStore();
const getResolvedHomePath = async () => {
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
return userInfo.homePath || DEFAULT_HOME_PATH;
};
// 基本路由,这些路由不需要进入权限拦截
if (coreRouteNames.includes(to.name as string)) {
if (to.path === LOGIN_PATH && accessStore.accessToken) {
const homePath = await getResolvedHomePath();
return decodeURIComponent(
(to.query?.redirect as string) || DEFAULT_HOME_PATH,
(to.query?.redirect as string) || homePath,
);
}
if (to.path === '/' && accessStore.accessToken) {
return await getResolvedHomePath();
}
return true;
}
@ -89,10 +100,13 @@ function setupAccessGuard(router: Router) {
// 当前登录用户拥有的角色标识列表
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
const userRoles = userInfo.roles ?? [];
// 将单点权限码合并进 roles供路由 authority 字段做精细匹配
const userPermissions: string[] = (userInfo as any).permissions ?? [];
const accessIdentifiers = [...new Set([...userRoles, ...userPermissions])];
// 生成菜单和路由
const { accessibleMenus, accessibleRoutes } = await generateAccess({
roles: userRoles,
roles: accessIdentifiers,
router,
// 则会在菜单中显示但是访问会被重定向到403
routes: accessRoutes,

View File

@ -10,7 +10,7 @@ const routes: RouteRecordRaw[] = [
icon: 'material-symbols:brightness-empty-outline',
order: -1,
title: $t('page.admin.title'),
authority:['super'],
authority: ['super', 'AC1001', 'AC1101', 'AC1107', 'AC1113', 'AC1117'],
},
name: 'Admin',
path: '/admin',
@ -20,7 +20,7 @@ const routes: RouteRecordRaw[] = [
path: '/user-management',
component: () => import('#/views/admin/user/index.vue'),
meta: {
authority: ['super', 'admin'],
authority: ['super', 'AC1001', 'AC1002'],
affixTab: false,
icon: 'majesticons:user-box-line',
title: $t('page.admin.user'),
@ -31,7 +31,7 @@ const routes: RouteRecordRaw[] = [
path: '/user-management-log',
component: () => import('#/views/admin/log/index.vue'),
meta: {
authority: ['super', 'admin'],
authority: ['super', 'AC1003'],
affixTab: false,
icon: 'material-symbols:assignment-rounded',
title: $t('page.admin.log'),
@ -42,7 +42,7 @@ const routes: RouteRecordRaw[] = [
path: '/user-management-config',
component: () => import('#/views/admin/config/index.vue'),
meta: {
authority: ['super', 'admin'],
authority: ['super', 'AC1004', 'AC1005', 'AC1006'],
affixTab: false,
icon: 'material-symbols:assignment-rounded',
title: $t('page.admin.config'),
@ -53,7 +53,7 @@ const routes: RouteRecordRaw[] = [
path: '/permission/user-group',
component: () => import('#/views/admin/permission/user-group.vue'),
meta: {
authority: ['super'],
authority: ['super', 'AC1101', 'AC1102', 'AC1103', 'AC1104', 'AC1105', 'AC1106'],
affixTab: false,
icon: 'material-symbols:group',
title: $t('page.admin.permission.userGroup'),
@ -64,7 +64,7 @@ const routes: RouteRecordRaw[] = [
path: '/permission/role',
component: () => import('#/views/admin/permission/role.vue'),
meta: {
authority: ['super'],
authority: ['super', 'AC1107', 'AC1108', 'AC1109', 'AC1110', 'AC1111', 'AC1112'],
affixTab: false,
icon: 'material-symbols:shield-person',
title: $t('page.admin.permission.role'),
@ -75,7 +75,7 @@ const routes: RouteRecordRaw[] = [
path: '/permission/permission',
component: () => import('#/views/admin/permission/permission.vue'),
meta: {
authority: ['super'],
authority: ['super', 'AC1113', 'AC1114', 'AC1115', 'AC1116'],
affixTab: false,
icon: 'material-symbols:key',
title: $t('page.admin.permission.permission'),
@ -86,7 +86,7 @@ const routes: RouteRecordRaw[] = [
path: '/permission/user-assign',
component: () => import('#/views/admin/permission/user-assign.vue'),
meta: {
authority: ['super'],
authority: ['super', 'AC1117', 'AC1118', 'AC1119', 'AC1120', 'AC1121'],
affixTab: false,
icon: 'material-symbols:manage-accounts',
title: $t('page.admin.permission.userAssign'),

View File

@ -7,6 +7,7 @@ const routes: RouteRecordRaw[] = [
{
component: BasicLayout,
meta: {
authority: ['super', 'AC5003', 'AC5004'],
icon: 'lucide:layout-dashboard',
order: -1,
title: $t('page.dashboard.title'),
@ -19,56 +20,14 @@ const routes: RouteRecordRaw[] = [
path: '/analytics',
component: () => import('#/views/dashboard/analytics/index.vue'),
meta: {
affixTab: false,
affixTab: true,
authority: ['super', 'AC5003', 'AC5004'],
icon: 'lucide:area-chart',
title: $t('page.dashboard.analytics'),
},
},
{
name: 'ServerList',
path: '/server-list',
component: () => import('#/views/dashboard/serverList/index.vue'),
meta: {
affixTab: false,
icon: 'lucide:gamepad',
title: $t('page.dashboard.server-list'),
authority: ['super', 'admin'],
},
},
{
name: 'AppList',
path: '/app-list',
component: () => import('#/views/dashboard/appList/index.vue'),
meta: {
affixTab: false,
icon: 'lucide:app-window',
title: $t('page.dashboard.app-list'),
authority: ['super'],
},
},
{
name: 'NodeList',
path: '/node-list',
component: () => import('#/views/dashboard/nodeList/index.vue'),
meta: {
affixTab: false,
icon: 'lucide:server',
title: $t('page.dashboard.node-list'),
authority: ['super'],
},
},
{
name: 'MysqlList',
path: '/mysql-list',
component: () => import('#/views/dashboard/mysqlList/index.vue'),
meta: {
affixTab: false,
icon: 'lucide:database',
title: $t('page.dashboard.mysql-list'),
authority: ['super'],
},
},
],
redirect: '/analytics',
},
];

View File

@ -0,0 +1,66 @@
import type { RouteRecordRaw } from 'vue-router';
import { BasicLayout } from '#/layouts';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
component: BasicLayout,
meta: {
icon: 'lucide:monitor-cog',
order: 0,
title: $t('page.devops.title'),
authority: ['super', 'AC4004', 'AC4006', 'AC4007', 'AC4008', 'AC4009', 'AC4010', 'AC4011'],
},
name: 'Devops',
path: '/devops',
children: [
{
name: 'ServerList',
path: '/server-list',
component: () => import('#/views/dashboard/serverList/index.vue'),
meta: {
affixTab: false,
icon: 'lucide:gamepad',
title: $t('page.devops.server-list'),
authority: ['super', 'AC4006', 'AC4010', 'AC4011'],
},
},
{
name: 'AppList',
path: '/app-list',
component: () => import('#/views/dashboard/appList/index.vue'),
meta: {
affixTab: false,
icon: 'lucide:app-window',
title: $t('page.devops.app-list'),
authority: ['super', 'AC4007', 'AC4008', 'AC4009'],
},
},
{
name: 'NodeList',
path: '/node-list',
component: () => import('#/views/dashboard/nodeList/index.vue'),
meta: {
affixTab: false,
icon: 'lucide:server',
title: $t('page.devops.node-list'),
authority: ['super', 'AC4004', 'AC4005'],
},
},
{
name: 'MysqlList',
path: '/mysql-list',
component: () => import('#/views/dashboard/mysqlList/index.vue'),
meta: {
affixTab: false,
icon: 'lucide:database',
title: $t('page.devops.mysql-list'),
authority: ['super'],
},
},
],
},
];
export default routes;

View File

@ -10,7 +10,7 @@ const routes: RouteRecordRaw[] = [
icon: 'lucide:layout-dashboard',
order: -1,
title: $t('page.experiment.title'),
authority: ['super', 'admin'],
authority: ['super', 'AC9201', 'AC9202', 'AC9214'],
},
name: 'Experiment',
path: '/experiment',
@ -23,6 +23,7 @@ const routes: RouteRecordRaw[] = [
affixTab: false,
icon: 'lucide:flask-conical',
title: $t('page.experiment.abtest'),
authority: ['super', 'AC9201', 'AC9202', 'AC9203', 'AC9204', 'AC9205', 'AC9206', 'AC9207', 'AC9208', 'AC9209', 'AC9210', 'AC9211', 'AC9212', 'AC9213'],
},
},
{
@ -33,6 +34,7 @@ const routes: RouteRecordRaw[] = [
affixTab: false,
icon: 'lucide:users',
title: $t('page.experiment.groupQuery'),
authority: ['super', 'AC9214'],
},
},
],

View File

@ -10,6 +10,7 @@ const routes: RouteRecordRaw[] = [
icon: 'solar:book-2-bold',
order: 1001,
title: $t('page.language.title'),
authority: ['super', 'AC9001', 'AC9002', 'AC9003', 'AC9004', 'AC9005'],
},
name: 'Translate',
path: '/translate',
@ -22,6 +23,7 @@ const routes: RouteRecordRaw[] = [
affixTab: true,
icon: 'lets-icons:order',
title: $t('page.language.translationList'),
authority: ['super', 'AC9001', 'AC9002', 'AC9003', 'AC9004', 'AC9005'],
},
}

View File

@ -10,7 +10,7 @@ const routes: RouteRecordRaw[] = [
icon: 'lucide:bell-ring',
order: 1002,
title: $t('page.notification.title'),
authority: ['super', 'admin'],
authority: ['super', 'AC9101', 'AC9102'],
},
name: 'Notification',
path: '/notification',
@ -23,7 +23,7 @@ const routes: RouteRecordRaw[] = [
affixTab: true,
icon: 'lucide:bell-plus',
title: $t('page.notification.config'),
authority: ['super', 'admin'],
authority: ['super', 'AC9101', 'AC9102'],
},
},
],

View File

@ -10,11 +10,22 @@ const routes: RouteRecordRaw[] = [
icon: 'lucide:file-clock',
order: 1001,
title: $t('page.operation.title'),
authority: ['super', 'admin'],
authority: ['super', 'AC6001', 'AC7001', 'AC8001', 'AC9301', 'AC5002','AC9401'],
},
name: 'Operation',
path: '/operation',
children: [
{
name: 'ApkDownload',
path: '/apk-download',
component: () => import('#/views/operation/apk/index.vue'),
meta: {
affixTab: true,
icon: 'material-symbols:cloud-download',
title: $t('page.operation.apk'),
authority: ['super', 'AC9401'],
},
},
{
name: 'Scripts',
path: '/scripts',
@ -23,7 +34,7 @@ const routes: RouteRecordRaw[] = [
affixTab: true,
icon: 'lucide:chart-no-axes-column-increasing',
title: $t('page.operation.scripts'),
authority: ['super', 'admin'],
authority: ['super', 'AC9301', 'AC9302', 'AC9303'],
},
},
{
@ -34,6 +45,7 @@ const routes: RouteRecordRaw[] = [
affixTab: true,
icon: 'lucide:mail',
title: $t('page.operation.mail'),
authority: ['super', 'AC7001', 'AC7002', 'AC7003'],
},
},
{
@ -44,6 +56,7 @@ const routes: RouteRecordRaw[] = [
affixTab: true,
icon: 'lucide:mail',
title: $t('page.operation.copyUser'),
authority: ['super', 'AC8001'],
},
},
{
@ -54,6 +67,7 @@ const routes: RouteRecordRaw[] = [
affixTab: true,
icon: 'lets-icons:order',
title: $t('page.operation.order'),
authority: ['super', 'AC5002'],
},
},
{
@ -64,6 +78,7 @@ const routes: RouteRecordRaw[] = [
affixTab: true,
icon: 'lets-icons:order',
title: $t('page.operation.activity'),
authority: ['super', 'AC6001', 'AC6002', 'AC6003', 'AC6004', 'AC6005'],
},
}
],

View File

@ -10,7 +10,7 @@ const routes: RouteRecordRaw[] = [
icon: 'lucide:laugh',
order: 1000,
title: $t('page.userlog.title'),
authority: ['super', 'admin'],
authority: ['super', 'AC2001', 'AC2002', 'AC2003', 'AC2004', 'AC2005', 'AC3001', 'AC3003', 'AC3004'],
},
name: 'Userlog',
path: '/userlog',
@ -23,6 +23,7 @@ const routes: RouteRecordRaw[] = [
affixTab: true,
icon: 'lucide:list',
title: $t('page.userlog.userlist'),
authority: ['super', 'AC3001', 'AC3002', 'AC3003', 'AC3004'],
},
}
],

View File

@ -46,7 +46,10 @@ export const useAuthStore = defineStore('auth', () => {
userInfo = fetchUserInfoResult;
userStore.setUserInfo(userInfo);
accessStore.setAccessCodes(accessCodes);
const mergedAccessCodes = [
...new Set([...(accessCodes || []), ...(((userInfo as any)?.permissions || []) as string[])]),
];
accessStore.setAccessCodes(mergedAccessCodes);
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
@ -96,7 +99,10 @@ export const useAuthStore = defineStore('auth', () => {
userInfo = fetchUserInfoResult;
userStore.setUserInfo(userInfo);
accessStore.setAccessCodes(accessCodes);
const mergedAccessCodes = [
...new Set([...(accessCodes || []), ...(((userInfo as any)?.permissions || []) as string[])]),
];
accessStore.setAccessCodes(mergedAccessCodes);
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
@ -147,6 +153,13 @@ export const useAuthStore = defineStore('auth', () => {
let userInfo: null | UserInfo = null;
userInfo = await getUserInfoApi();
userStore.setUserInfo(userInfo);
const mergedAccessCodes = [
...new Set([
...(accessStore.accessCodes || []),
...(((userInfo as any)?.permissions || []) as string[]),
]),
];
accessStore.setAccessCodes(mergedAccessCodes);
return userInfo;
}

View File

@ -15,7 +15,7 @@ const gridOptions: VxeGridProps<AdminConfig> = {
{ field: 'remark', title: '备注', },
{title: '操作',width: 200,fixed: 'right',slots: {default:"operation"},},
],
height: 'auto',
minHeight: 650,
pagerConfig: {},
proxyConfig: {
response: {

View File

@ -44,7 +44,7 @@ const gridOptions: VxeGridProps<AdminLog> = {
field: 'createTime', title: '时间', formatter: ({ cellValue }) => formatUTC8Time(cellValue), slots: { header: 'time_header' }
},
],
height: 'auto',
minHeight: 650,
pagerConfig: {
pageSize: 100,
},

View File

@ -139,7 +139,8 @@ const gridOptions: VxeGridProps<PermissionApi.Permission> = {
},
{ fixed: 'right', slots: { default: 'actionSlot' }, title: '操作', width: 160 },
],
height: 'auto',
minHeight: 650,
autoResize: true,
pagerConfig: {},
proxyConfig: {
response: { result: 'items', total: 'total' },

View File

@ -120,7 +120,7 @@ const gridOptions: VxeGridProps<PermissionApi.Role> = {
{ field: 'remark', title: '备注', minWidth: 200 },
{ fixed: 'right', slots: { default: 'actionSlot' }, title: '操作', width: 160 },
],
height: 'auto',
minHeight: 650,
pagerConfig: {},
proxyConfig: {
response: { result: 'items', total: 'total' },

View File

@ -8,6 +8,7 @@ import {
getUserGroupsApi,
setUserGroupsApi,
getUserRolesApi,
getUserRolesBatchApi,
getPermissionListApi,
getUserPermissionsDirectApi,
setUserPermissionsDirectApi,
@ -18,9 +19,10 @@ import {
Button, Card, Space, Tag, Spin, message,
Modal, Tabs, TabPane, Transfer,
} from 'ant-design-vue';
import { ref, computed } from 'vue';
import { computed, onActivated, ref } from 'vue';
//
const currentUsers = ref<UserInfo[]>([]);
const userGroupsMap = ref<Map<number, PermissionApi.UserGroup[]>>(new Map());
const userRolesMap = ref<Map<number, PermissionApi.Role[]>>(new Map());
@ -28,18 +30,27 @@ async function loadRowDataForList(users: UserInfo[]) {
const ids = users.map((u) => u.id).filter((id): id is number => id != null);
const [groupResults, roleResults] = await Promise.all([
Promise.allSettled(ids.map((id) => getUserGroupsApi(id))),
Promise.allSettled(ids.map((id) => getUserRolesApi(id))),
getUserRolesBatchApi(ids),
]);
const gMap = new Map<number, PermissionApi.UserGroup[]>();
const rMap = new Map<number, PermissionApi.Role[]>();
ids.forEach((id, i) => {
if (groupResults[i]!.status === 'fulfilled') gMap.set(id, (groupResults[i] as any).value || []);
if (roleResults[i]!.status === 'fulfilled') rMap.set(id, (roleResults[i] as any).value || []);
rMap.set(id, roleResults?.[String(id)] || []);
});
userGroupsMap.value = gMap;
userRolesMap.value = rMap;
}
async function refreshUserRelationCache(adminId: number) {
const [groups, roles] = await Promise.all([
getUserGroupsApi(adminId),
getUserRolesApi(adminId),
]);
userGroupsMap.value = new Map(userGroupsMap.value).set(adminId, groups || []);
userRolesMap.value = new Map(userRolesMap.value).set(adminId, roles || []);
}
//
const modalOpen = ref(false);
const modalLoading = ref(false);
@ -82,15 +93,19 @@ async function openAssignModal(row: UserInfo) {
modalLoading.value = true;
modalOpen.value = true;
try {
const [groupsRes, permsRes, userGroups, userDirectPerms] = await Promise.all([
const [groupsRes, permsRes, userGroups, userDirectPerms, userRoles] = await Promise.all([
getUserGroupListApi({ page: 1, pageSize: 500 }),
getPermissionListApi({ page: 1, pageSize: 500 }),
getUserGroupsApi(row.id),
getUserPermissionsDirectApi(row.id),
getUserRolesApi(row.id),
]);
console.log('userGroups', userGroups);
allGroups.value = groupsRes.items || [];
allPermissions.value = permsRes.items || [];
selectedGroupIds.value = (userGroups || []).map((g) => String(g.id));
userGroupsMap.value = new Map(userGroupsMap.value).set(row.id, userGroups || []);
userRolesMap.value = new Map(userRolesMap.value).set(row.id, userRoles || []);
const directPerms = userDirectPerms || [];
selectedAllowPermIds.value = directPerms.filter((p) => p.grant_type === 1).map((p) => String(p.permission_id));
selectedDenyPermIds.value = directPerms.filter((p) => p.grant_type === 2).map((p) => String(p.permission_id));
@ -113,13 +128,7 @@ async function handleConfirm() {
]);
message.success('权限分配成功');
modalOpen.value = false;
//
const [groups, roles] = await Promise.all([
getUserGroupsApi(currentAdminId.value),
getUserRolesApi(currentAdminId.value),
]);
userGroupsMap.value = new Map(userGroupsMap.value).set(currentAdminId.value, groups || []);
userRolesMap.value = new Map(userRolesMap.value).set(currentAdminId.value, roles || []);
await refreshUserRelationCache(currentAdminId.value);
} finally {
modalLoading.value = false;
}
@ -136,7 +145,7 @@ const gridOptions: VxeGridProps<UserInfo> = {
{ field: 'userRoles', title: '权限组(角色)', minWidth: 200, slots: { default: 'userRolesSlot' } },
{ fixed: 'right', slots: { default: 'actionSlot' }, title: '操作', width: 120 },
],
height: '100%',
minHeight: 650,
pagerConfig: {},
proxyConfig: {
response: { total: 'total', result: 'data' },
@ -144,6 +153,7 @@ const gridOptions: VxeGridProps<UserInfo> = {
query: async () => {
const result = await getAdminListApi();
const list: UserInfo[] = Array.isArray(result) ? result : ((result as any)?.data ?? []);
currentUsers.value = list;
void loadRowDataForList(list);
return result;
},
@ -153,6 +163,12 @@ const gridOptions: VxeGridProps<UserInfo> = {
};
const [Grid, GridApi] = useVbenVxeGrid({ gridOptions });
onActivated(() => {
if (currentUsers.value.length > 0) {
void loadRowDataForList(currentUsers.value);
}
});
</script>
<template>

View File

@ -140,7 +140,7 @@ const gridOptions: VxeGridProps<PermissionApi.UserGroup> = {
{ field: 'remark', title: '备注', minWidth: 200 },
{ fixed: 'right', slots: { default: 'actionSlot' }, title: '操作', width: 160 },
],
height: 'auto',
minHeight: 650,
pagerConfig: {},
proxyConfig: {
response: { result: 'items', total: 'total' },

View File

@ -1,62 +1,279 @@
<script setup lang="ts">
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { Button, Card, Space } from 'ant-design-vue';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { useVbenModal, useVbenForm, Page } from '@vben/common-ui';
import { Button, Card, Space, Tag, Modal, message } from 'ant-design-vue';
import { ref } from 'vue';
import dayjs from 'dayjs';
import type { UserInfo } from '#/model/admin.user';
import { getAdminListApi } from '#/api/core/admin.user';
import { useVbenModal } from '@vben/common-ui';
import addUserModal from './addUser.vue';
import {
getAdminListApi,
addAdminApi,
editAdminApi,
deleteAdminApi,
} from '#/api/core/admin.user';
const ROLE_MAP: Record<number, { label: string; color: string }> = {
0: { label: '超级管理员', color: 'red' },
1: { label: '管理员', color: 'blue' },
2: { label: '普通用户', color: 'green' },
99: { label: '外包翻译', color: 'orange' },
};
const editingId = ref<number | undefined>(undefined);
const [Form, FormApi] = useVbenForm({
layout: 'horizontal',
wrapperClass: 'grid-cols-2',
showDefaultActions: false,
schema: [
{
component: 'Input',
fieldName: 'username',
label: '用户名',
formItemClass: 'col-span-1',
rules: 'required',
componentProps: { placeholder: '登录用户名' },
},
{
component: 'InputPassword',
fieldName: 'password',
label: '密码',
formItemClass: 'col-span-1',
componentProps: { placeholder: '留空则不修改密码' },
},
{
component: 'Input',
fieldName: 'real_name',
label: '真实姓名',
formItemClass: 'col-span-1',
componentProps: { placeholder: '真实姓名' },
},
{
component: 'Input',
fieldName: 'nickname',
label: '昵称',
formItemClass: 'col-span-1',
componentProps: { placeholder: '昵称' },
},
{
component: 'Input',
fieldName: 'phone',
label: '手机号',
formItemClass: 'col-span-1',
componentProps: { placeholder: '手机号' },
},
{
component: 'Input',
fieldName: 'email',
label: '邮箱',
formItemClass: 'col-span-1',
componentProps: { placeholder: '邮箱地址' },
},
{
component: 'Select',
fieldName: 'role',
label: '角色',
formItemClass: 'col-span-1',
defaultValue: 1,
rules: 'required',
componentProps: {
options: [
{ label: '超级管理员', value: 0 },
{ label: '管理员', value: 1 },
{ label: '普通用户', value: 2 },
{ label: '外包翻译', value: 99 },
],
},
},
{
component: 'Select',
fieldName: 'status',
label: '状态',
formItemClass: 'col-span-1',
defaultValue: 1,
rules: 'required',
componentProps: {
options: [
{ label: '正常', value: 1 },
{ label: '禁用', value: 0 },
],
},
},
{
component: 'Input',
fieldName: 'group',
label: '用户组',
formItemClass: 'col-span-2',
componentProps: { placeholder: '用户组标识(可选)' },
},
{
component: 'Textarea',
fieldName: 'remark',
label: '备注',
formItemClass: 'col-span-2',
componentProps: { rows: 3 },
},
],
});
const [FormModal, FormModalApi] = useVbenModal({
confirmText: '保存',
onConfirm: async () => {
const { valid } = await FormApi.validate();
if (!valid) return;
const values = await FormApi.getValues();
const params: UserInfo = {
id: editingId.value,
username: values.username,
password: values.password || '',
real_name: values.real_name,
nickname: values.nickname,
phone: values.phone,
email: values.email,
role: values.role,
status: values.status,
group: values.group,
remark: values.remark,
};
if (editingId.value) {
await editAdminApi(params);
message.success('更新成功');
} else {
if (!params.password) {
message.error('新增用户必须填写密码');
return;
}
await addAdminApi(params);
message.success('新增成功');
}
FormModalApi.close();
GridApi.reload();
},
});
const gridOptions: VxeGridProps<UserInfo> = {
columns: [
{ field: 'username', title: '用户名', },
// { field: 'phone', title: '' },
{ field: 'role', title: '角色' },
{ field: 'group', title: '用户组' },
{ field: 'remark', title: '备注' },
{ field: 'id', title: 'ID', width: 60 },
{ field: 'username', title: '用户名', width: 120 },
{ field: 'real_name', title: '真实姓名', width: 110 },
{ field: 'nickname', title: '昵称', width: 110 },
{ field: 'phone', title: '手机号', width: 130 },
{
field: 'role',
title: '角色',
width: 120,
slots: { default: 'roleSlot' },
},
{
field: 'status',
title: '状态',
width: 90,
slots: { default: 'statusSlot' },
},
{
field: 'lastLoginTime',
title: '最后登录',
minWidth: 160,
slots: { default: 'loginTimeSlot' },
},
{ field: 'lastLoginIp', title: '登录IP', width: 130 },
{ field: 'remark', title: '备注', minWidth: 120 },
{ fixed: 'right', slots: { default: 'actionSlot' }, title: '操作', width: 160 },
],
height: 'auto',
minHeight: 650,
autoResize: true,
pagerConfig: {},
proxyConfig: {
response: {
total: 'total',
result: 'data',
},
response: { result: 'data', total: 'total' },
ajax: {
query: async () => {
return await getAdminListApi();
},
},
},
rowConfig: {
isHover: true,
},
rowConfig: { isHover: true },
};
const [Grid, GridApi] = useVbenVxeGrid({ gridOptions });
const [addUserM, addUserApi] = useVbenModal({
connectedComponent: addUserModal,
onClosed: async () => {
addUserApi.close();
function openCreate() {
editingId.value = undefined;
FormApi.resetForm();
FormModalApi.setState({ title: '新增管理员' });
FormModalApi.open();
}
function openEdit(row: UserInfo) {
editingId.value = row.id;
FormApi.setValues({
username: row.username,
password: '',
real_name: row.real_name,
nickname: row.nickname,
phone: row.phone,
email: row.email,
role: row.role,
status: row.status,
group: row.group,
remark: row.remark,
});
FormModalApi.setState({ title: `编辑管理员 - ${row.username}` });
FormModalApi.open();
}
function handleDelete(row: UserInfo) {
Modal.confirm({
title: '删除确认',
content: `确认删除管理员「${row.username}」吗?此操作不可撤销。`,
okType: 'danger',
async onOk() {
await deleteAdminApi(row.id!);
message.success('删除成功');
GridApi.reload();
},
});
const addAdmin = () => {
addUserApi.open();
};
}
function formatTime(ts: number | undefined) {
if (!ts) return '-';
return dayjs.unix(ts).format('YYYY-MM-DD HH:mm');
}
</script>
<template>
<Page auto-content-height>
<addUserM class="w-[50%]" />
<Card class="mb-5" title="用户操作">
<Space>
<Button @click="addAdmin">新增</Button>
<Button> 删除 </Button>
</Space>
<FormModal>
<Form />
</FormModal>
<Card class="mb-4">
<template #title>管理员列表</template>
<template #extra>
<Button type="primary" @click="openCreate">新增管理员</Button>
</template>
</Card>
<Grid />
<Grid>
<template #roleSlot="{ row }">
<Tag :color="ROLE_MAP[row.role]?.color ?? 'default'">
{{ ROLE_MAP[row.role]?.label ?? `角色${row.role}` }}
</Tag>
</template>
<template #statusSlot="{ row }">
<Tag :color="row.status === 1 ? 'green' : 'red'">
{{ row.status === 1 ? '正常' : '禁用' }}
</Tag>
</template>
<template #loginTimeSlot="{ row }">
{{ formatTime(row.lastLoginTime) }}
</template>
<template #actionSlot="{ row }">
<Space>
<Button size="small" type="primary" @click="openEdit(row)">编辑</Button>
<Button danger size="small" @click="handleDelete(row)">删除</Button>
</Space>
</template>
</Grid>
</Page>
</template>

View File

@ -29,15 +29,7 @@ onMounted(async () => {
itemStyle: {
color: '#5ab1ef',
},
smooth: true,
type: 'line',
},
{
areaStyle: {},
data: data.value2,
itemStyle: {
color: '#019680',
},
name: '日活',
smooth: true,
type: 'line',
},
@ -51,14 +43,6 @@ onMounted(async () => {
},
trigger: 'axis',
},
// xAxis: {
// axisTick: {
// show: false,
// },
// boundaryGap: false,
// data: Array.from({ length: 18 }).map((_item, index) => `${index + 6}:00`),
// type: 'category',
// },
xAxis: {
axisTick: {
show: false,
@ -79,7 +63,6 @@ onMounted(async () => {
axisTick: {
show: false,
},
max: 5_000,
splitArea: {
show: true,
},

View File

@ -1,5 +1,6 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { getstatisticsHeat } from '#/api/core/statistics';
import {
EchartsUI,
@ -10,7 +11,8 @@ import {
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
onMounted(() => {
onMounted(async () => {
const data = await getstatisticsHeat({ AppId: 0 });
renderEcharts({
grid: {
bottom: 0,
@ -22,29 +24,24 @@ onMounted(() => {
series: [
{
barMaxWidth: 80,
// color: '#4f69fd',
data: [
3000, 2000, 3333, 5000, 3200, 4200, 3200, 2100, 3000, 5100, 6000,
3200, 4800,
],
data: data.value2,
name: '注册人数',
type: 'bar',
},
],
tooltip: {
axisPointer: {
lineStyle: {
// color: '#4f69fd',
width: 1,
},
},
trigger: 'axis',
},
xAxis: {
data: Array.from({ length: 12 }).map((_item, index) => `${index + 1}`),
data: data.key,
type: 'category',
},
yAxis: {
max: 8000,
splitNumber: 4,
type: 'value',
},

View File

@ -3,10 +3,6 @@ import { onMounted, ref } from 'vue';
import type { AnalysisOverviewItem } from '@vben/common-ui';
import type { TabOption } from '@vben/types';
import { getstatisticsInfo } from '#/api/core/statistics';
import { AccessControl } from '@vben/access';
import { useAccess } from '@vben/access';
const { hasAccessByRoles } = useAccess();
import {
AnalysisChartCard,
AnalysisChartsTabs,
@ -77,11 +73,11 @@ onMounted(async () => {
});
const chartTabs: TabOption[] = [
{
label: '热度趋势',
label: '日活',
value: 'trends',
},
{
label: '月访问量',
label: '注册人数',
value: 'visits',
},
];
@ -89,7 +85,6 @@ const chartTabs: TabOption[] = [
<template>
<div class="p-5">
<AccessControl :codes="['super', 'admin']" type="role">
<AnalysisOverview :items="overviewItems" />
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
@ -99,10 +94,9 @@ const chartTabs: TabOption[] = [
<template #visits>
<AnalyticsVisits />
</template>
</AnalysisChartsTabs>
<div class="mt-5 w-full md:flex">
<!-- <div class="mt-5 w-full md:flex">
<AnalysisChartCard class="mt-5 md:mr-4 md:mt-0 md:w-1/3" title="访问数量">
<AnalyticsVisitsData />
</AnalysisChartCard>
@ -112,7 +106,6 @@ const chartTabs: TabOption[] = [
<AnalysisChartCard class="mt-5 md:mt-0 md:w-1/3" title="访问来源">
<AnalyticsVisitsSales />
</AnalysisChartCard>
</div>
</AccessControl>
</div> -->
</div>
</template>

View File

@ -72,7 +72,7 @@ const gridOptions: VxeGridProps<ActivityData> = {
{ field: 'interval', title: '活动循环间隔从上次开始时间开始计算0表示不循环', },
{ field: 'tag', title: '状态', slots: { default: 'tag' } },
],
height: 'auto',
minHeight: '650px',
pagerConfig: {},
proxyConfig: {
response: {
@ -83,7 +83,6 @@ const gridOptions: VxeGridProps<ActivityData> = {
query: async ({ page }, formValues) => {
let AppId = parseNumber(formValues.AppId);
let activityType = parseNumber(formValues.activityType);
console.log('query', formValues, page);
const response = await getActivityListApi({
AppId: AppId,
ServerId: formValues.ServerId,
@ -125,6 +124,8 @@ const gridOptions: VxeGridProps<ActivityData> = {
item.tag = '生效中';
}
}
console.log(response);
return response;
},
},

View File

@ -0,0 +1,182 @@
<script setup lang="ts">
import { useAppConfig } from '@vben/hooks';
import { useAccessStore } from '@vben/stores';
import { Page } from '@vben/common-ui';
import { Button, Card, Empty, Space, Tag, TypographyParagraph, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { onMounted, ref } from 'vue';
import { getApkPackagesApi } from '#/api/core/apk';
import type { ApkApi } from '#/api/core/apk';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const accessStore = useAccessStore();
const loading = ref(false);
const downloadingEnv = ref<ApkApi.Environment | ''>('');
const packages = ref<ApkApi.PackageItem[]>([]);
const envLabelMap: Record<ApkApi.Environment, string> = {
dev: '开发环境',
stable: '稳定环境',
prod: '正式环境',
};
const envDescriptionMap: Record<ApkApi.Environment, string> = {
dev: '用于日常开发和联调验证。',
stable: '用于提测、回归和灰度验证。',
prod: '用于正式对外发布。',
};
async function loadPackages() {
loading.value = true;
try {
packages.value = await getApkPackagesApi();
} catch {
packages.value = [];
} finally {
loading.value = false;
}
}
function formatSize(size: number) {
if (!size) {
return '-';
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(2)} KB`;
}
return `${(size / 1024 / 1024).toFixed(2)} MB`;
}
function formatTime(value: string) {
if (!value) {
return '-';
}
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
}
function resolveDownloadUrl(env: ApkApi.Environment) {
return `${apiURL.replace(/\/$/, '')}/apk/download/${env}`;
}
function resolveDownloadName(item: ApkApi.PackageItem, disposition: null | string) {
const fallbackName = item.fileName || `${item.env}.apk`;
if (!disposition) {
return fallbackName;
}
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
return decodeURIComponent(utf8Match[1]);
}
const basicMatch = disposition.match(/filename="?([^";]+)"?/i);
if (basicMatch?.[1]) {
return decodeURIComponent(basicMatch[1]);
}
return fallbackName;
}
async function downloadPackage(item: ApkApi.PackageItem) {
if (!item.exists) {
return;
}
if (!accessStore.accessToken) {
message.error('登录状态已失效,请重新登录后再试。');
return;
}
downloadingEnv.value = item.env;
try {
const response = await fetch(resolveDownloadUrl(item.env), {
headers: {
Authorization: `Bearer ${accessStore.accessToken}`,
},
});
if (!response.ok) {
throw new Error('下载失败');
}
const blob = await response.blob();
const downloadName = resolveDownloadName(
item,
response.headers.get('content-disposition'),
);
const downloadUrl = window.URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = downloadUrl;
anchor.download = downloadName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.URL.revokeObjectURL(downloadUrl);
message.success(`开始下载 ${downloadName}`);
} catch (error) {
console.error(error);
message.error(`${envLabelMap[item.env]} APK 下载失败`);
} finally {
downloadingEnv.value = '';
}
}
onMounted(() => {
loadPackages();
});
</script>
<template>
<Page auto-content-height>
<div class="space-y-4 p-4">
<Card title="客户端下载说明">
<TypographyParagraph>
当前页面展示 devstableprod 三套客户端 APK
Jenkins 通过后端上传接口更新包后这里会自动展示最新版本并支持后台下载
</TypographyParagraph>
<Button :loading="loading" type="primary" @click="loadPackages">刷新列表</Button>
</Card>
<div v-if="packages.length" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card v-for="item in packages" :key="item.env" :title="envLabelMap[item.env]">
<Space wrap>
<Tag :color="item.exists ? 'green' : 'default'">
{{ item.exists ? '已上传' : '暂无包' }}
</Tag>
<Tag color="blue">{{ item.env }}</Tag>
</Space>
<TypographyParagraph class="mt-4 text-gray-500">
{{ envDescriptionMap[item.env] }}
</TypographyParagraph>
<div class="space-y-2 text-sm leading-6">
<div>文件名{{ item.fileName || '-' }}</div>
<div>版本号{{ item.version || '-' }}</div>
<div>文件大小{{ formatSize(item.size) }}</div>
<div>上传时间{{ formatTime(item.uploadedAt) }}</div>
</div>
<div class="mt-4">
<Button
block
type="primary"
:disabled="!item.exists"
:loading="downloadingEnv === item.env"
@click="downloadPackage(item)"
>
下载 APK
</Button>
</div>
</Card>
</div>
<Card v-else>
<Empty description="暂无可展示的 APK 包信息" />
</Card>
</div>
</Page>
</template>

View File

@ -16,7 +16,7 @@ import type { Order, Merge, Chess, friendRecord } from '#/model/type';
import dayjs from 'dayjs';
import { WorkbenchDetail } from '@vben/common-ui';
import UserHeader from './user-header.vue';
import { AccessControl } from '@vben/access';
import { AccessControl, useAccess } from '@vben/access';
import eventTable from './event-table.vue';
import assetTable from './asset-table.vue';
import orderTable from './order-table.vue';
@ -27,6 +27,14 @@ import orderTable from './order-table.vue';
// url: /dashboard/workspace
const projectItems: WorkbenchProjectItem[] = [];
const { hasAccessByCodes, hasAccessByRoles } = useAccess();
const canUseGm = computed(() =>
hasAccessByRoles(['super']) || hasAccessByCodes(['AC3003']),
);
const canBanUser = computed(() =>
hasAccessByRoles(['super']) || hasAccessByCodes(['AC3004']),
);
const chargeDisplay = computed(() => (Number(info.value?.Charge ?? 0)).toFixed(2));
const [BaseForm] = useVbenForm({
@ -268,6 +276,8 @@ const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (isOpen) {
data.value = modalApi.getData<Record<string, any>>();
const b = hasAccessByCodes(['AC3003']);
console.log('canUseGm', canUseGm.value, 'hasAccessByCodes AC3003', b);
try {
const r = await getUserlogInfoApi({
Id: data.value.uid,
@ -476,16 +486,18 @@ function formatActLog(type: number, content = ''): [string, string] {
<template #energy>{{ info.Energy }} </template>
<template #diamond>{{ info.Diamond }}</template>
</UserHeader>
<AccessControl :codes="['super', 'admin']" type="role">
<div class="mt-5 flex">
<AccessControl :codes="['AC3003']" type="code">
<Card class="card-box flex flex-col p-5 w-[50%]">
<BaseForm />
</Card>
</AccessControl>
<AccessControl :codes="['AC3004']" type="code">
<Card class="card-box flex flex-col p-5 w-[45%] ml-5">
<BanForm />
</Card>
</div>
</AccessControl>
</div>
<LoginHeatmap :app-id="data?.AppId" :uid="data?.uid" title="登录热力图" class="mt-5" />
<div class="mt-5 flex flex-col lg:flex-row">
<div class="mr-4 w-full lg:w-3/5">

View File

@ -15,6 +15,11 @@ interface UserInfo extends BasicUserInfo {
* accessToken
*/
token: string;
/**
* /user/info
*/
permissions?: string[];
}
export type { UserInfo };