ab test config and notification config
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
This commit is contained in:
parent
dbfb3c4bd9
commit
3c746fcea9
227
apps/web-antd/src/api/core/experiment.ts
Normal file
227
apps/web-antd/src/api/core/experiment.ts
Normal file
@ -0,0 +1,227 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace ExperimentApi {
|
||||
export interface ExperimentItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
status: number;
|
||||
start_time: null | string;
|
||||
end_time: null | string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface ListResponse {
|
||||
list: ExperimentItem[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CreateParams {
|
||||
description: string;
|
||||
end_time?: null | string;
|
||||
name: string;
|
||||
start_time?: null | string;
|
||||
status?: number;
|
||||
variants?: CreateVariantParams[];
|
||||
whitelist?: CreateExperimentWhitelistParams[];
|
||||
}
|
||||
|
||||
export interface VariantItem {
|
||||
created_at: string;
|
||||
description: string;
|
||||
experiment_id: number;
|
||||
id: number;
|
||||
name: string;
|
||||
params: null | Record<string, any>;
|
||||
updated_at: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface CreateVariantParams {
|
||||
description?: string;
|
||||
name: string;
|
||||
params?: any;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
export interface UpdateVariantParams {
|
||||
description?: string;
|
||||
name?: string;
|
||||
params?: any;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
export interface UpdateExperimentVariantParams {
|
||||
description?: string;
|
||||
id?: number;
|
||||
name: string;
|
||||
params?: any;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
export interface WhitelistItem {
|
||||
created_at: string;
|
||||
experiment_id: number;
|
||||
id: number;
|
||||
user_id: string;
|
||||
variant_id: number;
|
||||
}
|
||||
|
||||
export interface CreateWhitelistParams {
|
||||
user_id: string;
|
||||
variant_id: number;
|
||||
}
|
||||
|
||||
export interface CreateExperimentWhitelistParams {
|
||||
user_id: string;
|
||||
variant_name: string;
|
||||
}
|
||||
|
||||
export interface BatchCreateWhitelistParams {
|
||||
items: CreateWhitelistParams[];
|
||||
}
|
||||
|
||||
export interface VariantStats {
|
||||
convert_count: number;
|
||||
convert_rate: number;
|
||||
exposure_count: number;
|
||||
total_value: number;
|
||||
user_count: number;
|
||||
variant_id: number;
|
||||
variant_name: string;
|
||||
}
|
||||
|
||||
export interface ExperimentResult {
|
||||
experiment_id: number;
|
||||
experiment_name: string;
|
||||
status: number;
|
||||
variants: VariantStats[];
|
||||
}
|
||||
|
||||
export interface UserExperimentGroupItem {
|
||||
experiment_id: number;
|
||||
experiment_name: string;
|
||||
params: null | Record<string, any>;
|
||||
variant_id: number;
|
||||
variant_name: string;
|
||||
}
|
||||
|
||||
export interface UpdateParams {
|
||||
description?: string;
|
||||
end_time?: null | string;
|
||||
name?: string;
|
||||
start_time?: null | string;
|
||||
status?: number;
|
||||
variants?: UpdateExperimentVariantParams[];
|
||||
whitelist?: CreateExperimentWhitelistParams[];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExperimentListApi(params: ExperimentApi.ListParams) {
|
||||
return requestClient.get<ExperimentApi.ListResponse>('/v1/experiments', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createExperimentApi(params: ExperimentApi.CreateParams) {
|
||||
return requestClient.post<ExperimentApi.ExperimentItem>('/v1/experiments', params);
|
||||
}
|
||||
|
||||
export async function updateExperimentApi(
|
||||
id: number,
|
||||
params: ExperimentApi.UpdateParams,
|
||||
) {
|
||||
return requestClient.put<ExperimentApi.ExperimentItem>(`/v1/experiments/${id}`, params);
|
||||
}
|
||||
|
||||
export async function deleteExperimentApi(id: number) {
|
||||
return requestClient.delete(`/v1/experiments/${id}`);
|
||||
}
|
||||
|
||||
export async function getExperimentVariantsApi(experimentId: number) {
|
||||
return requestClient.get<ExperimentApi.VariantItem[]>(
|
||||
`/v1/experiments/${experimentId}/variants`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createExperimentVariantApi(
|
||||
experimentId: number,
|
||||
params: ExperimentApi.CreateVariantParams,
|
||||
) {
|
||||
return requestClient.post<ExperimentApi.VariantItem>(
|
||||
`/v1/experiments/${experimentId}/variants`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateExperimentVariantApi(
|
||||
experimentId: number,
|
||||
variantId: number,
|
||||
params: ExperimentApi.UpdateVariantParams,
|
||||
) {
|
||||
return requestClient.put<ExperimentApi.VariantItem>(
|
||||
`/v1/experiments/${experimentId}/variants/${variantId}`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteExperimentVariantApi(
|
||||
experimentId: number,
|
||||
variantId: number,
|
||||
) {
|
||||
return requestClient.delete(`/v1/experiments/${experimentId}/variants/${variantId}`);
|
||||
}
|
||||
|
||||
export async function getExperimentWhitelistApi(experimentId: number) {
|
||||
return requestClient.get<ExperimentApi.WhitelistItem[]>(
|
||||
`/v1/experiments/${experimentId}/whitelist`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createExperimentWhitelistApi(
|
||||
experimentId: number,
|
||||
params: ExperimentApi.CreateWhitelistParams,
|
||||
) {
|
||||
return requestClient.post<ExperimentApi.WhitelistItem>(
|
||||
`/v1/experiments/${experimentId}/whitelist`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
export async function batchCreateExperimentWhitelistApi(
|
||||
experimentId: number,
|
||||
params: ExperimentApi.BatchCreateWhitelistParams,
|
||||
) {
|
||||
return requestClient.post<ExperimentApi.WhitelistItem[]>(
|
||||
`/v1/experiments/${experimentId}/whitelist/batch`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeExperimentWhitelistApi(
|
||||
experimentId: number,
|
||||
userId: string,
|
||||
) {
|
||||
return requestClient.delete(`/v1/experiments/${experimentId}/whitelist/${userId}`);
|
||||
}
|
||||
|
||||
export async function getExperimentResultApi(experimentId: number) {
|
||||
return requestClient.get<ExperimentApi.ExperimentResult>(
|
||||
`/v1/experiments/${experimentId}/results`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserExperimentGroupsApi(userId: string) {
|
||||
return requestClient.get<ExperimentApi.UserExperimentGroupItem[]>(
|
||||
`/v1/users/${encodeURIComponent(userId)}/groups`,
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
export * from './auth';
|
||||
export * from './experiment';
|
||||
export * from './menu';
|
||||
export * from './notification';
|
||||
export * from './user';
|
||||
|
||||
92
apps/web-antd/src/api/core/notification.ts
Normal file
92
apps/web-antd/src/api/core/notification.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace NotificationApi {
|
||||
export type ConfigSectionKey = 'activeNotis' | 'inactiveNotis' | 'presentations' | 'schedules';
|
||||
|
||||
export interface TickValue {
|
||||
ticks: number | string;
|
||||
}
|
||||
|
||||
export interface ScheduleItem {
|
||||
cancelType: number;
|
||||
cancelValue: number;
|
||||
deltaTime: TickValue;
|
||||
fireTime: TickValue;
|
||||
id: number;
|
||||
repeatInterval: number;
|
||||
repeats: boolean;
|
||||
scheduleType: number;
|
||||
}
|
||||
|
||||
export interface LocalizedText {
|
||||
language: number;
|
||||
locText: string;
|
||||
param: number;
|
||||
}
|
||||
|
||||
export interface PresentationItem {
|
||||
id: number;
|
||||
infos: LocalizedText[];
|
||||
titles: LocalizedText[];
|
||||
}
|
||||
|
||||
export interface NoticeItem {
|
||||
conditionId: number;
|
||||
id: number;
|
||||
presentationId: number;
|
||||
scheduleId: number;
|
||||
}
|
||||
|
||||
export interface NotificationConfig {
|
||||
activeNotis: NoticeItem[];
|
||||
inactiveNotis: NoticeItem[];
|
||||
presentations: PresentationItem[];
|
||||
schedules: ScheduleItem[];
|
||||
}
|
||||
|
||||
export interface ConfigState {
|
||||
config: NotificationConfig;
|
||||
updated_at: string;
|
||||
}
|
||||
}
|
||||
|
||||
const NOTIFICATION_CONFIG_BASE_URL = '/config/notification';
|
||||
function normalizeForCSharp(value: any): any {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeForCSharp(item));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, currentValue]) => {
|
||||
if (key === 'ticks' && currentValue != null) {
|
||||
return [key, String(currentValue)];
|
||||
}
|
||||
return [key, normalizeForCSharp(currentValue)];
|
||||
}),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeConfigState(parsed: NotificationApi.ConfigState) {
|
||||
return {
|
||||
config: normalizeForCSharp(parsed.config),
|
||||
updated_at: parsed.updated_at,
|
||||
} satisfies NotificationApi.ConfigState;
|
||||
}
|
||||
|
||||
export async function getNotificationConfigApi() {
|
||||
const state = await requestClient.get<NotificationApi.ConfigState>(
|
||||
NOTIFICATION_CONFIG_BASE_URL,
|
||||
);
|
||||
return normalizeConfigState(state);
|
||||
}
|
||||
|
||||
|
||||
export async function updateNotificationConfigApi(params: NotificationApi.NotificationConfig) {
|
||||
const state = await requestClient.put<NotificationApi.ConfigState>(
|
||||
`${NOTIFICATION_CONFIG_BASE_URL}/update`,
|
||||
{ config: JSON.stringify(normalizeForCSharp(params)) },
|
||||
);
|
||||
return normalizeConfigState(state);
|
||||
}
|
||||
@ -8,4 +8,7 @@ export async function copywritingscript(params :scripts_params) {
|
||||
}
|
||||
export async function copyonlinescript(params :scripts_params) {
|
||||
return requestClient.post('/scripts/copyonline', params, {timeout: 1800000});
|
||||
}
|
||||
export async function clientImageGitPull(params :scripts_params) {
|
||||
return requestClient.post('/scripts/clientImageGitPull', params, {timeout: 1800000});
|
||||
}
|
||||
@ -13,6 +13,12 @@
|
||||
"endTime": "End Time",
|
||||
"action": "Action"
|
||||
},
|
||||
"experiment": {
|
||||
"title": "A/B Test",
|
||||
"index": "A/B Test Home",
|
||||
"abtest": "Experiment Management",
|
||||
"groupQuery": "Player Experiment Groups"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
@ -55,7 +61,12 @@
|
||||
"level": "Level",
|
||||
"mail": "Mail",
|
||||
"order": "Order",
|
||||
"language": "Language"
|
||||
"language": "Language",
|
||||
"notification": "Client Notification"
|
||||
},
|
||||
"notification": {
|
||||
"title": "Client Notification",
|
||||
"config": "Notification Config"
|
||||
},
|
||||
"log": {
|
||||
"event": {
|
||||
|
||||
@ -15,7 +15,9 @@
|
||||
},
|
||||
"experiment": {
|
||||
"title": "A/B测试",
|
||||
"index": "A/B测试首页"
|
||||
"index": "A/B测试首页",
|
||||
"abtest": "实验管理",
|
||||
"groupQuery": "玩家实验分组查询"
|
||||
},
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
@ -62,7 +64,12 @@
|
||||
"order": "订单管理",
|
||||
"language": "翻译管理",
|
||||
"copyUser": "用户数据复制",
|
||||
"activity": "活动管理"
|
||||
"activity": "活动管理",
|
||||
"notification": "客户端通知配置"
|
||||
},
|
||||
"notification": {
|
||||
"title": "客户端通知",
|
||||
"config": "通知配置"
|
||||
},
|
||||
"server":{
|
||||
"merge_pet_test":"测试服",
|
||||
|
||||
@ -10,18 +10,29 @@ const routes: RouteRecordRaw[] = [
|
||||
icon: 'lucide:layout-dashboard',
|
||||
order: -1,
|
||||
title: $t('page.experiment.title'),
|
||||
authority: ['super', 'admin'],
|
||||
},
|
||||
name: 'Experiment',
|
||||
path: '/experiment',
|
||||
children: [
|
||||
{
|
||||
name: 'Index',
|
||||
path: '/index',
|
||||
component: () => import('#/views/experiment/index/index.vue'),
|
||||
name: 'Abtest',
|
||||
path: '/abtest',
|
||||
component: () => import('#/views/experiment/abtest/index.vue'),
|
||||
meta: {
|
||||
affixTab: false,
|
||||
icon: 'lucide:area-chart',
|
||||
title: $t('page.experiment.index'),
|
||||
icon: 'lucide:flask-conical',
|
||||
title: $t('page.experiment.abtest'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ExperimentGroupQuery',
|
||||
path: '/group-query',
|
||||
component: () => import('#/views/experiment/group-query/index.vue'),
|
||||
meta: {
|
||||
affixTab: false,
|
||||
icon: 'lucide:users',
|
||||
title: $t('page.experiment.groupQuery'),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
33
apps/web-antd/src/router/routes/modules/notification.ts
Normal file
33
apps/web-antd/src/router/routes/modules/notification.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import { BasicLayout } from '#/layouts';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
component: BasicLayout,
|
||||
meta: {
|
||||
icon: 'lucide:bell-ring',
|
||||
order: 1002,
|
||||
title: $t('page.notification.title'),
|
||||
authority: ['super', 'admin'],
|
||||
},
|
||||
name: 'Notification',
|
||||
path: '/notification',
|
||||
children: [
|
||||
{
|
||||
name: 'NotificationConfig',
|
||||
path: '/index',
|
||||
component: () => import('#/views/notification/index.vue'),
|
||||
meta: {
|
||||
affixTab: true,
|
||||
icon: 'lucide:bell-plus',
|
||||
title: $t('page.notification.config'),
|
||||
authority: ['super', 'admin'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
@ -24,40 +24,7 @@ const routes: RouteRecordRaw[] = [
|
||||
icon: 'lucide:list',
|
||||
title: $t('page.userlog.userlist'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Assetlog',
|
||||
path: '/assetlog',
|
||||
component: () => import('#/views/userlog/assetlog/index.vue'),
|
||||
meta: {
|
||||
affixTab: true,
|
||||
icon: 'solar:stars-bold',
|
||||
title: $t('page.userlog.assetlog'),
|
||||
authority: ['super'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'EventLog',
|
||||
path: '/eventlog',
|
||||
component: () => import('#/views/userlog/eventlog/index.vue'),
|
||||
meta: {
|
||||
affixTab: true,
|
||||
icon: 'lucide:apple',
|
||||
title: $t('page.userlog.eventlog'),
|
||||
authority: ['super'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'OrderLog',
|
||||
path: '/orderlog',
|
||||
component: () => import('#/views/userlog/orderlog/index.vue'),
|
||||
meta: {
|
||||
affixTab: true,
|
||||
icon: 'solar:chat-round-money-bold',
|
||||
title: $t('page.userlog.orderlog'),
|
||||
authority: ['super'],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -23,8 +23,9 @@ const gridOptions: VxeGridProps<AdminConfig> = {
|
||||
result: 'data',
|
||||
},
|
||||
ajax: {
|
||||
query: async ( { page }: { page: { pageSize: number; currentPage: number } },
|
||||
formValues: Record<string, any>,) => {
|
||||
query: async (
|
||||
{ page }: { page: { pageSize: number; currentPage: number } },
|
||||
) => {
|
||||
return await getAdminConfigList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
@ -67,7 +68,7 @@ const deleteConfig = (row: AdminConfig) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height class="h-[1200px]">
|
||||
<Page auto-content-height>
|
||||
<addConfigM class="w-[50%]" />
|
||||
<editConfigM class="w-[50%]" />
|
||||
<Card class="mb-5" title="配置操作">
|
||||
|
||||
@ -49,7 +49,7 @@ const addAdmin = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height class="h-[1200px]">
|
||||
<Page auto-content-height>
|
||||
<addUserM class="w-[50%]" />
|
||||
<Card class="mb-5" title="用户操作">
|
||||
<Space>
|
||||
|
||||
@ -0,0 +1,438 @@
|
||||
<script setup lang="ts">
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
createExperimentApi,
|
||||
getExperimentVariantsApi,
|
||||
getExperimentWhitelistApi,
|
||||
updateExperimentApi,
|
||||
} from '#/api/core/experiment';
|
||||
import type { ExperimentApi } from '#/api/core/experiment';
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { Alert, Button, Card, Input, InputNumber, Select, Space, message } from 'ant-design-vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
interface ModalData {
|
||||
mode: 'create' | 'edit';
|
||||
record?: ExperimentApi.ExperimentItem;
|
||||
}
|
||||
|
||||
interface VariantDraft {
|
||||
clientKey: string;
|
||||
description: string;
|
||||
id?: number;
|
||||
name: string;
|
||||
paramsText: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
interface WhitelistDraft {
|
||||
clientKey: string;
|
||||
userId: string;
|
||||
variantClientKey: string;
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '草稿', value: 0 },
|
||||
{ label: '运行中', value: 1 },
|
||||
{ label: '已暂停', value: 2 },
|
||||
{ label: '已结束', value: 3 },
|
||||
];
|
||||
|
||||
let draftSeed = 0;
|
||||
|
||||
function createDraftKey(prefix: string) {
|
||||
draftSeed += 1;
|
||||
return `${prefix}-${draftSeed}`;
|
||||
}
|
||||
|
||||
function createVariantDraft(
|
||||
value?: Partial<ExperimentApi.VariantItem> & { clientKey?: string },
|
||||
): VariantDraft {
|
||||
return {
|
||||
clientKey: value?.clientKey || createDraftKey('variant'),
|
||||
description: value?.description || '',
|
||||
id: value?.id,
|
||||
name: value?.name || '',
|
||||
paramsText: value?.params ? JSON.stringify(value.params, null, 2) : '{}',
|
||||
weight: value?.weight || 1,
|
||||
};
|
||||
}
|
||||
|
||||
function createWhitelistDraft(value?: {
|
||||
clientKey?: string;
|
||||
userId?: string;
|
||||
variantClientKey?: string;
|
||||
}) {
|
||||
return {
|
||||
clientKey: value?.clientKey || createDraftKey('whitelist'),
|
||||
userId: value?.userId || '',
|
||||
variantClientKey: value?.variantClientKey || '',
|
||||
};
|
||||
}
|
||||
|
||||
const variants = ref<VariantDraft[]>([]);
|
||||
const whitelist = ref<WhitelistDraft[]>([]);
|
||||
|
||||
const variantOptions = computed(() =>
|
||||
variants.value.map((item, index) => ({
|
||||
label: item.name || `变体${index + 1}`,
|
||||
value: item.clientKey,
|
||||
})),
|
||||
);
|
||||
|
||||
function resetChildren() {
|
||||
variants.value = [
|
||||
createVariantDraft({ name: 'control' }),
|
||||
createVariantDraft({ name: 'treatment' }),
|
||||
];
|
||||
whitelist.value = [];
|
||||
}
|
||||
|
||||
function addVariant() {
|
||||
variants.value.push(createVariantDraft());
|
||||
}
|
||||
|
||||
function removeVariant(clientKey: string) {
|
||||
variants.value = variants.value.filter((item) => item.clientKey !== clientKey);
|
||||
whitelist.value = whitelist.value.map((item) => {
|
||||
if (item.variantClientKey === clientKey) {
|
||||
return {
|
||||
...item,
|
||||
variantClientKey: '',
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function addWhitelist() {
|
||||
whitelist.value.push(createWhitelistDraft());
|
||||
}
|
||||
|
||||
function removeWhitelist(clientKey: string) {
|
||||
whitelist.value = whitelist.value.filter((item) => item.clientKey !== clientKey);
|
||||
}
|
||||
|
||||
function buildBasePayload(values: Record<string, any>): ExperimentApi.UpdateParams {
|
||||
return {
|
||||
description: values.description || '',
|
||||
end_time: values.end_time || null,
|
||||
name: values.name,
|
||||
start_time: values.start_time || null,
|
||||
status: values.status,
|
||||
};
|
||||
}
|
||||
|
||||
function buildExperimentWhitelistPayload() {
|
||||
const variantPayloads = parseVariantPayloads();
|
||||
const variantNameByClientKey = new Map(
|
||||
variantPayloads.map((item) => [item.clientKey, item.payload.name]),
|
||||
);
|
||||
const userSet = new Set<string>();
|
||||
|
||||
return whitelist.value.map((item, index) => {
|
||||
const userId = item.userId.trim();
|
||||
if (!userId) {
|
||||
throw new Error(`第${index + 1}条白名单用户不能为空`);
|
||||
}
|
||||
if (userSet.has(userId)) {
|
||||
throw new Error(`白名单用户“${userId}”重复`);
|
||||
}
|
||||
userSet.add(userId);
|
||||
|
||||
if (!item.variantClientKey || !variantNameByClientKey.has(item.variantClientKey)) {
|
||||
throw new Error(`第${index + 1}条白名单绑定的变体无效`);
|
||||
}
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
variant_name: variantNameByClientKey.get(item.variantClientKey) as string,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildCreatePayload(values: Record<string, any>): ExperimentApi.CreateParams {
|
||||
const payload = buildBasePayload(values);
|
||||
const variantPayloads = parseVariantPayloads();
|
||||
const whitelistPayloads = buildExperimentWhitelistPayload();
|
||||
|
||||
return {
|
||||
description: String(payload.description || ''),
|
||||
end_time: payload.end_time || null,
|
||||
name: String(payload.name || ''),
|
||||
start_time: payload.start_time || null,
|
||||
status: payload.status,
|
||||
variants: variantPayloads.map((item) => item.payload),
|
||||
whitelist: whitelistPayloads,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUpdatePayload(values: Record<string, any>): ExperimentApi.UpdateParams {
|
||||
const payload = buildBasePayload(values);
|
||||
const variantPayloads = parseVariantPayloads();
|
||||
|
||||
return {
|
||||
...payload,
|
||||
variants: variantPayloads.map((item) => ({
|
||||
...item.payload,
|
||||
id: item.id,
|
||||
})),
|
||||
whitelist: buildExperimentWhitelistPayload(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseVariantPayloads() {
|
||||
if (variants.value.length === 0) {
|
||||
throw new Error('请至少配置一个变体');
|
||||
}
|
||||
|
||||
const nameSet = new Set<string>();
|
||||
|
||||
return variants.value.map((item, index) => {
|
||||
const name = item.name.trim();
|
||||
if (!name) {
|
||||
throw new Error(`第${index + 1}个变体名称不能为空`);
|
||||
}
|
||||
if (nameSet.has(name)) {
|
||||
throw new Error(`变体名称“${name}”重复`);
|
||||
}
|
||||
nameSet.add(name);
|
||||
|
||||
let params: any = null;
|
||||
if (item.paramsText.trim()) {
|
||||
try {
|
||||
params = JSON.parse(item.paramsText);
|
||||
} catch {
|
||||
throw new Error(`第${index + 1}个变体参数不是合法 JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clientKey: item.clientKey,
|
||||
id: item.id,
|
||||
payload: {
|
||||
description: item.description.trim(),
|
||||
name,
|
||||
params,
|
||||
weight: item.weight > 0 ? item.weight : 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function loadChildren(experimentId: number) {
|
||||
const [variantList, whitelistList] = await Promise.all([
|
||||
getExperimentVariantsApi(experimentId),
|
||||
getExperimentWhitelistApi(experimentId),
|
||||
]);
|
||||
|
||||
const safeVariantList = Array.isArray(variantList) ? variantList : [];
|
||||
const safeWhitelistList = Array.isArray(whitelistList) ? whitelistList : [];
|
||||
|
||||
const variantDrafts = safeVariantList.map((item) =>
|
||||
createVariantDraft({
|
||||
...item,
|
||||
clientKey: `variant-${item.id}`,
|
||||
}),
|
||||
);
|
||||
const variantClientKeyById = new Map(
|
||||
variantDrafts
|
||||
.filter((item) => item.id)
|
||||
.map((item) => [item.id as number, item.clientKey]),
|
||||
);
|
||||
|
||||
variants.value = variantDrafts.length > 0 ? variantDrafts : [createVariantDraft()];
|
||||
whitelist.value = safeWhitelistList.map((item) =>
|
||||
createWhitelistDraft({
|
||||
clientKey: `whitelist-${item.id}`,
|
||||
userId: item.user_id,
|
||||
variantClientKey: variantClientKeyById.get(item.variant_id) || '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const formOptions: VbenFormProps = {
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '实验名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
rows: 4,
|
||||
},
|
||||
fieldName: 'description',
|
||||
label: '实验描述',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: statusOptions,
|
||||
},
|
||||
defaultValue: 0,
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
},
|
||||
{
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
showTime: true,
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
},
|
||||
fieldName: 'start_time',
|
||||
label: '开始时间',
|
||||
},
|
||||
{
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
showTime: true,
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
},
|
||||
fieldName: 'end_time',
|
||||
label: '结束时间',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
};
|
||||
|
||||
const [Form, FormApi] = useVbenForm(formOptions);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
confirmText: '提交',
|
||||
onOpenChange: async (isOpen) => {
|
||||
if (!isOpen) {
|
||||
await FormApi.resetForm();
|
||||
resetChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (modalApi.getData() ?? { mode: 'create' }) as ModalData;
|
||||
|
||||
if (data.mode === 'edit' && data.record) {
|
||||
await FormApi.setValues({
|
||||
description: data.record.description,
|
||||
end_time: data.record.end_time ?? undefined,
|
||||
name: data.record.name,
|
||||
start_time: data.record.start_time ?? undefined,
|
||||
status: data.record.status,
|
||||
});
|
||||
await loadChildren(data.record.id);
|
||||
return;
|
||||
}
|
||||
|
||||
await FormApi.setValues({
|
||||
description: '',
|
||||
end_time: undefined,
|
||||
name: '',
|
||||
start_time: undefined,
|
||||
status: 0,
|
||||
});
|
||||
resetChildren();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
const data = (modalApi.getData() ?? { mode: 'create' }) as ModalData;
|
||||
const values = await FormApi.getValues();
|
||||
|
||||
if (data.mode === 'edit' && data.record) {
|
||||
await updateExperimentApi(data.record.id, buildUpdatePayload(values));
|
||||
message.success('实验更新成功');
|
||||
} else {
|
||||
await createExperimentApi(buildCreatePayload(values));
|
||||
message.success('实验创建成功');
|
||||
}
|
||||
|
||||
modalApi.close();
|
||||
},
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
name: 'ExperimentFormModal',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="modalApi.getData()?.mode === 'edit' ? '编辑实验' : '新增实验'" :width="980">
|
||||
<div class="space-y-4">
|
||||
<Form />
|
||||
|
||||
<Alert
|
||||
message="保存实验时会同步提交变体与白名单配置,白名单用户会强制命中对应变体。"
|
||||
type="info"
|
||||
/>
|
||||
|
||||
<Card title="变体配置" size="small">
|
||||
<template #extra>
|
||||
<Button type="link" @click="addVariant">新增变体</Button>
|
||||
</template>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(item, index) in variants"
|
||||
:key="item.clientKey"
|
||||
class="rounded border border-solid border-gray-200 p-3"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="font-medium">变体 {{ index + 1 }}</div>
|
||||
<Button danger type="link" @click="removeVariant(item.clientKey)">删除</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<Input v-model:value="item.name" placeholder="变体名称,如 control" />
|
||||
<InputNumber v-model:value="item.weight" :min="1" class="w-full" placeholder="权重" />
|
||||
</div>
|
||||
<Input.TextArea
|
||||
v-model:value="item.description"
|
||||
:rows="2"
|
||||
class="mt-3"
|
||||
placeholder="变体描述"
|
||||
/>
|
||||
<Input.TextArea
|
||||
v-model:value="item.paramsText"
|
||||
:rows="6"
|
||||
class="mt-3"
|
||||
placeholder='变体参数 JSON,例如 {"color":"red"}'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="白名单配置" size="small">
|
||||
<template #extra>
|
||||
<Button type="link" @click="addWhitelist">新增白名单</Button>
|
||||
</template>
|
||||
<div v-if="whitelist.length === 0" class="py-4 text-center text-gray-400">
|
||||
暂无白名单配置
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="(item, index) in whitelist"
|
||||
:key="item.clientKey"
|
||||
class="rounded border border-solid border-gray-200 p-3"
|
||||
>
|
||||
<Space class="w-full" direction="vertical" size="middle">
|
||||
<Input v-model:value="item.userId" placeholder="用户标识 user_id" />
|
||||
<Select
|
||||
v-model:value="item.variantClientKey"
|
||||
:options="variantOptions"
|
||||
placeholder="选择命中的变体"
|
||||
/>
|
||||
<div class="text-right">
|
||||
<Button danger type="link" @click="removeWhitelist(item.clientKey)">
|
||||
删除白名单 {{ index + 1 }}
|
||||
</Button>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
283
apps/web-antd/src/views/experiment/abtest/experiment-table.vue
Normal file
283
apps/web-antd/src/views/experiment/abtest/experiment-table.vue
Normal file
@ -0,0 +1,283 @@
|
||||
<script setup lang="ts">
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
deleteExperimentApi,
|
||||
getExperimentResultApi,
|
||||
getExperimentListApi,
|
||||
} from '#/api/core/experiment';
|
||||
import type { ExperimentApi } from '#/api/core/experiment';
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import dayjs from 'dayjs';
|
||||
import { Button, Card, Modal, Space, Table, Tag, message } from 'ant-design-vue';
|
||||
import { onActivated, ref } from 'vue';
|
||||
|
||||
import ExperimentFormModal from './experiment-form-modal.vue';
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: -1 },
|
||||
{ label: '草稿', value: 0 },
|
||||
{ label: '运行中', value: 1 },
|
||||
{ label: '已暂停', value: 2 },
|
||||
{ label: '已结束', value: 3 },
|
||||
];
|
||||
|
||||
function formatTime(value: null | string) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
function getStatusText(status: number) {
|
||||
return (
|
||||
statusOptions.find((item) => item.value === status)?.label ||
|
||||
`未知状态(${status})`
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusColor(status: number) {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return 'default';
|
||||
case 1:
|
||||
return 'green';
|
||||
case 2:
|
||||
return 'orange';
|
||||
case 3:
|
||||
return 'red';
|
||||
default:
|
||||
return 'blue';
|
||||
}
|
||||
}
|
||||
|
||||
const formOptions: VbenFormProps = {
|
||||
collapsed: false,
|
||||
schema: [
|
||||
{
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入实验名称',
|
||||
},
|
||||
fieldName: 'keyword',
|
||||
label: '实验名称',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: statusOptions,
|
||||
},
|
||||
defaultValue: -1,
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
},
|
||||
],
|
||||
showCollapseButton: false,
|
||||
submitButtonOptions: {
|
||||
content: '查询',
|
||||
},
|
||||
submitOnChange: false,
|
||||
submitOnEnter: false,
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-2',
|
||||
};
|
||||
|
||||
const gridOptions: VxeGridProps<ExperimentApi.ExperimentItem> = {
|
||||
columns: [
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{ field: 'name', title: '实验名称', minWidth: 180 },
|
||||
{ field: 'description', title: '实验描述', minWidth: 220 },
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
slots: { default: 'status' },
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
field: 'start_time',
|
||||
formatter: ({ cellValue }) => formatTime(cellValue),
|
||||
title: '开始时间',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
field: 'end_time',
|
||||
formatter: ({ cellValue }) => formatTime(cellValue),
|
||||
title: '结束时间',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
field: 'updated_at',
|
||||
formatter: ({ cellValue }) => formatTime(cellValue),
|
||||
title: '更新时间',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
fixed: 'right',
|
||||
slots: { default: 'operation' },
|
||||
title: '操作',
|
||||
width: 220,
|
||||
},
|
||||
],
|
||||
height: 'auto',
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
response: {
|
||||
result: 'list',
|
||||
total: 'total',
|
||||
},
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const response = await getExperimentListApi({
|
||||
page: page.currentPage,
|
||||
page_size: page.pageSize,
|
||||
status: formValues.status === -1 ? undefined : formValues.status,
|
||||
});
|
||||
|
||||
if (!formValues.keyword) {
|
||||
return {
|
||||
...response,
|
||||
};
|
||||
}
|
||||
|
||||
const keyword = String(formValues.keyword).trim().toLowerCase();
|
||||
const list = response.list.filter((item) =>
|
||||
[item.name, item.description]
|
||||
.filter(Boolean)
|
||||
.some((field) => String(field).toLowerCase().includes(keyword)),
|
||||
);
|
||||
|
||||
return {
|
||||
...response,
|
||||
list,
|
||||
total: list.length,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
isHover: true,
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, GridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions,
|
||||
});
|
||||
|
||||
onActivated(async () => {
|
||||
await GridApi.query();
|
||||
});
|
||||
|
||||
const [ExperimentModal, experimentModalApi] = useVbenModal({
|
||||
connectedComponent: ExperimentFormModal,
|
||||
onClosed: async () => {
|
||||
experimentModalApi.close();
|
||||
await GridApi.query();
|
||||
},
|
||||
});
|
||||
|
||||
const resultModalOpen = ref(false);
|
||||
const resultLoading = ref(false);
|
||||
const resultData = ref<ExperimentApi.ExperimentResult | null>(null);
|
||||
|
||||
const resultColumns = [
|
||||
{ dataIndex: 'variant_id', key: 'variant_id', title: '变体ID', width: 100 },
|
||||
{ dataIndex: 'variant_name', key: 'variant_name', title: '变体名称' },
|
||||
{ dataIndex: 'exposure_count', key: 'exposure_count', title: '曝光数' },
|
||||
{ dataIndex: 'convert_count', key: 'convert_count', title: '转化数' },
|
||||
{
|
||||
dataIndex: 'convert_rate',
|
||||
key: 'convert_rate',
|
||||
title: '转化率',
|
||||
customRender: ({ text }: { text: number }) => `${(Number(text) * 100).toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
dataIndex: 'total_value',
|
||||
key: 'total_value',
|
||||
title: '总价值',
|
||||
customRender: ({ text }: { text: number }) => Number(text).toFixed(2),
|
||||
},
|
||||
{ dataIndex: 'user_count', key: 'user_count', title: '用户数' },
|
||||
];
|
||||
|
||||
function openCreateModal() {
|
||||
experimentModalApi.setData({ mode: 'create' });
|
||||
experimentModalApi.open();
|
||||
}
|
||||
|
||||
function openEditModal(record: ExperimentApi.ExperimentItem) {
|
||||
experimentModalApi.setData({ mode: 'edit', record });
|
||||
experimentModalApi.open();
|
||||
}
|
||||
|
||||
function handleDelete(record: ExperimentApi.ExperimentItem) {
|
||||
Modal.confirm({
|
||||
content: `确认删除实验“${record.name}”吗?`,
|
||||
title: '删除确认',
|
||||
async onOk() {
|
||||
await deleteExperimentApi(record.id);
|
||||
message.success('实验删除成功');
|
||||
await GridApi.query();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function showResult(record: ExperimentApi.ExperimentItem) {
|
||||
resultLoading.value = true;
|
||||
resultModalOpen.value = true;
|
||||
try {
|
||||
resultData.value = await getExperimentResultApi(record.id);
|
||||
} finally {
|
||||
resultLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<ExperimentModal class="w-[60%]" />
|
||||
<Card class="mb-5" title="AB 实验管理">
|
||||
<Space>
|
||||
<Button type="primary" @click="openCreateModal">新增实验</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
<Grid>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="getStatusColor(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #operation="{ row }">
|
||||
<Space>
|
||||
<Button size="small" @click="showResult(row)">结果</Button>
|
||||
<Button size="small" type="primary" @click="openEditModal(row)">编辑</Button>
|
||||
<Button danger size="small" @click="handleDelete(row)">删除</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Grid>
|
||||
<Modal
|
||||
v-model:open="resultModalOpen"
|
||||
:footer="null"
|
||||
:title="resultData ? `${resultData.experiment_name} - 实验结果` : '实验结果'"
|
||||
width="960px"
|
||||
>
|
||||
<div class="mb-4" v-if="resultData">
|
||||
<Space>
|
||||
<span>实验ID:{{ resultData.experiment_id }}</span>
|
||||
<Tag :color="getStatusColor(resultData.status)">
|
||||
{{ getStatusText(resultData.status) }}
|
||||
</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
:columns="resultColumns"
|
||||
:data-source="resultData?.variants || []"
|
||||
:loading="resultLoading"
|
||||
:pagination="false"
|
||||
:scroll="{ x: 820 }"
|
||||
row-key="variant_id"
|
||||
size="small"
|
||||
/>
|
||||
</Modal>
|
||||
</Page>
|
||||
</template>
|
||||
7
apps/web-antd/src/views/experiment/abtest/index.vue
Normal file
7
apps/web-antd/src/views/experiment/abtest/index.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import AbtestTable from './experiment-table.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AbtestTable />
|
||||
</template>
|
||||
81
apps/web-antd/src/views/experiment/group-query/index.vue
Normal file
81
apps/web-antd/src/views/experiment/group-query/index.vue
Normal file
@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getUserExperimentGroupsApi } from '#/api/core/experiment';
|
||||
import type { ExperimentApi } from '#/api/core/experiment';
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { Button, Card, Input, Space, Tag, message } from 'ant-design-vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const userId = ref('');
|
||||
const userGroupLoading = ref(false);
|
||||
const userGroups = ref<ExperimentApi.UserExperimentGroupItem[]>([]);
|
||||
|
||||
const gridOptions: VxeGridProps<ExperimentApi.UserExperimentGroupItem> = {
|
||||
columns: [
|
||||
{ field: 'experiment_id', title: '实验ID', width: 100 },
|
||||
{ field: 'experiment_name', title: '实验名称', minWidth: 180 },
|
||||
{ field: 'variant_id', title: '变体ID', width: 100 },
|
||||
{ field: 'variant_name', title: '变体名称', minWidth: 180 },
|
||||
{
|
||||
field: 'params',
|
||||
formatter: ({ cellValue }) => (cellValue ? JSON.stringify(cellValue) : '-'),
|
||||
title: '变体参数',
|
||||
minWidth: 280,
|
||||
},
|
||||
],
|
||||
data: userGroups.value,
|
||||
height: 'auto',
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
isHover: true,
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, GridApi] = useVbenVxeGrid({ gridOptions });
|
||||
|
||||
async function searchUserGroups() {
|
||||
const currentUserId = userId.value.trim();
|
||||
if (!currentUserId) {
|
||||
message.warning('请输入玩家 UID');
|
||||
return;
|
||||
}
|
||||
|
||||
userGroupLoading.value = true;
|
||||
try {
|
||||
const response = await getUserExperimentGroupsApi(currentUserId);
|
||||
userGroups.value = Array.isArray(response) ? response : [];
|
||||
GridApi.setGridOptions({
|
||||
data: userGroups.value,
|
||||
});
|
||||
if (userGroups.value.length === 0) {
|
||||
message.info('该玩家暂无实验分组记录');
|
||||
}
|
||||
} finally {
|
||||
userGroupLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Card title="玩家实验分组查询">
|
||||
<Space>
|
||||
<Input v-model:value="userId" placeholder="请输入玩家 UID" style="width: 280px" />
|
||||
<Button type="primary" :loading="userGroupLoading" @click="searchUserGroups">
|
||||
查询参与实验体
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<div class="mt-4">
|
||||
<Tag color="blue">支持查询玩家当前参与的所有实验及命中的变体</Tag>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<Grid />
|
||||
</div>
|
||||
</Card>
|
||||
</Page>
|
||||
</template>
|
||||
@ -69,7 +69,7 @@ const deleteConfig = (row: AdminConfig) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height class="h-[1200px]">
|
||||
<Page auto-content-height>
|
||||
<addConfigM class="w-[50%]" />
|
||||
<editConfigM class="w-[50%]" />
|
||||
<Card class="mb-5" title="实验体操作">
|
||||
|
||||
482
apps/web-antd/src/views/notification/index.vue
Normal file
482
apps/web-antd/src/views/notification/index.vue
Normal file
@ -0,0 +1,482 @@
|
||||
<script setup lang="ts">
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
getNotificationConfigApi,
|
||||
updateNotificationConfigApi,
|
||||
} from '#/api/core/notification';
|
||||
import type { NotificationApi } from '#/api/core/notification';
|
||||
import { Page } from '@vben/common-ui';
|
||||
import dayjs from 'dayjs';
|
||||
import JsonEitorVue from 'json-editor-vue';
|
||||
import { Alert, Button, Card, Modal, Row, Col, Space, Tag, message } from 'ant-design-vue';
|
||||
import { onActivated, ref, watch } from 'vue';
|
||||
|
||||
type SectionKey = NotificationApi.ConfigSectionKey;
|
||||
|
||||
function createDefaultSectionItem(section: SectionKey) {
|
||||
if (section === 'schedules') {
|
||||
return {
|
||||
cancelType: 0,
|
||||
cancelValue: 0,
|
||||
deltaTime: { ticks: '0' },
|
||||
fireTime: { ticks: '0' },
|
||||
id: 0,
|
||||
repeatInterval: 0,
|
||||
repeats: false,
|
||||
scheduleType: 0,
|
||||
} satisfies NotificationApi.ScheduleItem;
|
||||
}
|
||||
|
||||
if (section === 'presentations') {
|
||||
return {
|
||||
id: 0,
|
||||
infos: [],
|
||||
titles: [],
|
||||
} satisfies NotificationApi.PresentationItem;
|
||||
}
|
||||
|
||||
return {
|
||||
conditionId: 0,
|
||||
id: 0,
|
||||
presentationId: 0,
|
||||
scheduleId: 0,
|
||||
} satisfies NotificationApi.NoticeItem;
|
||||
}
|
||||
|
||||
const currentConfig = ref<NotificationApi.NotificationConfig | null>(null);
|
||||
const updatedAt = ref('');
|
||||
const loading = ref(false);
|
||||
const dirty = ref(false);
|
||||
|
||||
const editorOpen = ref(false);
|
||||
const editorMode = ref<'create' | 'edit'>('create');
|
||||
const editorSection = ref<SectionKey>('schedules');
|
||||
const editorSourceIndex = ref(-1);
|
||||
const editorValue = ref<any>(null);
|
||||
|
||||
const sectionTitles: Record<SectionKey, string> = {
|
||||
schedules: 'Schedules',
|
||||
presentations: 'Presentations',
|
||||
activeNotis: 'Active Notis',
|
||||
inactiveNotis: 'Inactive Notis',
|
||||
};
|
||||
|
||||
const DOTNET_TICKS_AT_UNIX_EPOCH = 621355968000000000n;
|
||||
const TICKS_PER_MILLISECOND = 10000n;
|
||||
|
||||
function cloneValue<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-';
|
||||
}
|
||||
|
||||
function parseDotNetTicks(value?: number | string) {
|
||||
if (value === '' || value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const ticks = BigInt(String(value));
|
||||
const unixMilliseconds = Number((ticks - DOTNET_TICKS_AT_UNIX_EPOCH) / TICKS_PER_MILLISECOND);
|
||||
const date = dayjs(unixMilliseconds);
|
||||
return date.isValid() ? date : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatFireTicks(value?: number | string) {
|
||||
if (value === '' || value == null) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const date = parseDotNetTicks(value);
|
||||
if (!date) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return `${String(value)} (${date.format('YYYY-MM-DD HH:mm:ss')})`;
|
||||
}
|
||||
|
||||
function getScheduleFireTimePreview() {
|
||||
if (editorSection.value !== 'schedules') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const ticks = editorValue.value?.fireTime?.ticks;
|
||||
if (ticks === '' || ticks == null) {
|
||||
return '未配置 fireTime.ticks';
|
||||
}
|
||||
|
||||
const date = parseDotNetTicks(ticks);
|
||||
if (!date) {
|
||||
return `无法解析 fireTime.ticks:${String(ticks)}`;
|
||||
}
|
||||
|
||||
return `原始 ticks:${String(ticks)},本地时间:${date.format('YYYY-MM-DD HH:mm:ss')}`;
|
||||
}
|
||||
|
||||
function scheduleTypeText(value: number) {
|
||||
return value === 1 ? 'ExactTime' : 'Interval';
|
||||
}
|
||||
|
||||
function cancelTypeText(value: number) {
|
||||
return value === 1 ? 'OnceOnline' : 'None';
|
||||
}
|
||||
|
||||
const scheduleGridOptions: VxeGridProps<NotificationApi.ScheduleItem> = {
|
||||
columns: [
|
||||
{ field: 'id', title: 'ID', width: 90 },
|
||||
{
|
||||
field: 'scheduleType',
|
||||
formatter: ({ cellValue }) => scheduleTypeText(cellValue),
|
||||
title: '类型',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
field: 'deltaTime',
|
||||
formatter: ({ cellValue }) => cellValue?.ticks || '-',
|
||||
title: 'delta ticks',
|
||||
minWidth: 160,
|
||||
},
|
||||
{
|
||||
field: 'fireTime',
|
||||
formatter: ({ cellValue }) => formatFireTicks(cellValue?.ticks),
|
||||
title: 'fire ticks',
|
||||
minWidth: 320,
|
||||
},
|
||||
{ field: 'repeats', title: '重复', width: 90 },
|
||||
{
|
||||
field: 'cancelType',
|
||||
formatter: ({ cellValue }) => cancelTypeText(cellValue),
|
||||
title: '取消类型',
|
||||
width: 120,
|
||||
},
|
||||
{ fixed: 'right', slots: { default: 'scheduleOperation' }, title: '操作', width: 160 },
|
||||
],
|
||||
data: [],
|
||||
height: 'auto',
|
||||
minHeight: 360,
|
||||
pagerConfig: { enabled: false },
|
||||
rowConfig: { isHover: true },
|
||||
};
|
||||
|
||||
const presentationGridOptions: VxeGridProps<NotificationApi.PresentationItem> = {
|
||||
columns: [
|
||||
{ field: 'id', title: 'ID', width: 90 },
|
||||
{
|
||||
field: 'titles',
|
||||
formatter: ({ cellValue }) => (Array.isArray(cellValue) ? cellValue.length : 0),
|
||||
title: 'Title 数量',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
field: 'infos',
|
||||
formatter: ({ cellValue }) => (Array.isArray(cellValue) ? cellValue.length : 0),
|
||||
title: 'Info 数量',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
field: 'titlePreview',
|
||||
formatter: ({ row }) => row.titles?.map((item: any) => `${item.language}:${item.locText}`).join(' | ') || '-',
|
||||
title: '标题预览',
|
||||
minWidth: 280,
|
||||
},
|
||||
{ fixed: 'right', slots: { default: 'presentationOperation' }, title: '操作', width: 160 },
|
||||
],
|
||||
data: [],
|
||||
height: 'auto',
|
||||
minHeight: 360,
|
||||
pagerConfig: { enabled: false },
|
||||
rowConfig: { isHover: true },
|
||||
};
|
||||
|
||||
const activeGridOptions: VxeGridProps<NotificationApi.NoticeItem> = {
|
||||
columns: [
|
||||
{ field: 'id', title: 'ID', width: 90 },
|
||||
{ field: 'scheduleId', title: 'Schedule ID', width: 120 },
|
||||
{ field: 'conditionId', title: 'Condition ID', width: 120 },
|
||||
{ field: 'presentationId', title: 'Presentation ID', width: 140 },
|
||||
{ fixed: 'right', slots: { default: 'activeOperation' }, title: '操作', width: 160 },
|
||||
],
|
||||
data: [],
|
||||
height: 'auto',
|
||||
minHeight: 360,
|
||||
pagerConfig: { enabled: false },
|
||||
rowConfig: { isHover: true },
|
||||
};
|
||||
|
||||
const inactiveGridOptions: VxeGridProps<NotificationApi.NoticeItem> = {
|
||||
columns: [
|
||||
{ field: 'id', title: 'ID', width: 90 },
|
||||
{ field: 'scheduleId', title: 'Schedule ID', width: 120 },
|
||||
{ field: 'conditionId', title: 'Condition ID', width: 120 },
|
||||
{ field: 'presentationId', title: 'Presentation ID', width: 140 },
|
||||
{ fixed: 'right', slots: { default: 'inactiveOperation' }, title: '操作', width: 160 },
|
||||
],
|
||||
data: [],
|
||||
height: 'auto',
|
||||
minHeight: 360,
|
||||
pagerConfig: { enabled: false },
|
||||
rowConfig: { isHover: true },
|
||||
};
|
||||
|
||||
const [ScheduleGrid, ScheduleGridApi] = useVbenVxeGrid({ gridOptions: scheduleGridOptions });
|
||||
const [PresentationGrid, PresentationGridApi] = useVbenVxeGrid({ gridOptions: presentationGridOptions });
|
||||
const [ActiveGrid, ActiveGridApi] = useVbenVxeGrid({ gridOptions: activeGridOptions });
|
||||
const [InactiveGrid, InactiveGridApi] = useVbenVxeGrid({ gridOptions: inactiveGridOptions });
|
||||
|
||||
function syncGridData(config: NotificationApi.NotificationConfig | null) {
|
||||
ScheduleGridApi.setGridOptions({ data: config?.schedules || [] });
|
||||
PresentationGridApi.setGridOptions({ data: config?.presentations || [] });
|
||||
ActiveGridApi.setGridOptions({ data: config?.activeNotis || [] });
|
||||
InactiveGridApi.setGridOptions({ data: config?.inactiveNotis || [] });
|
||||
}
|
||||
|
||||
watch(
|
||||
currentConfig,
|
||||
(config) => {
|
||||
syncGridData(config);
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const state = await getNotificationConfigApi();
|
||||
currentConfig.value = cloneValue(state.config);
|
||||
updatedAt.value = state.updated_at;
|
||||
dirty.value = false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setSectionItems(section: SectionKey, items: Array<any>) {
|
||||
if (!currentConfig.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentConfig.value = {
|
||||
...currentConfig.value,
|
||||
[section]: items,
|
||||
} as NotificationApi.NotificationConfig;
|
||||
dirty.value = true;
|
||||
}
|
||||
|
||||
function getSectionItems(section: SectionKey) {
|
||||
return (currentConfig.value?.[section] as Array<any>) || [];
|
||||
}
|
||||
|
||||
function getDefaultSectionItem(section: SectionKey) {
|
||||
return cloneValue(createDefaultSectionItem(section));
|
||||
}
|
||||
|
||||
function openCreateEditor(section: SectionKey) {
|
||||
editorMode.value = 'create';
|
||||
editorSection.value = section;
|
||||
editorSourceIndex.value = -1;
|
||||
editorValue.value = getDefaultSectionItem(section);
|
||||
editorOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditEditor(section: SectionKey, row: any, index: number) {
|
||||
editorMode.value = 'edit';
|
||||
editorSection.value = section;
|
||||
editorSourceIndex.value = index;
|
||||
editorValue.value = cloneValue(row);
|
||||
editorOpen.value = true;
|
||||
}
|
||||
|
||||
async function persistConfig(action: 'create' | 'delete' | 'update', nextConfig: NotificationApi.NotificationConfig) {
|
||||
const response = await updateNotificationConfigApi(nextConfig);
|
||||
currentConfig.value = cloneValue(response.config);
|
||||
updatedAt.value = response.updated_at;
|
||||
dirty.value = false;
|
||||
}
|
||||
|
||||
async function saveEditor() {
|
||||
const parsedValue =
|
||||
typeof editorValue.value === 'string'
|
||||
? JSON.parse(editorValue.value)
|
||||
: cloneValue(editorValue.value);
|
||||
const nextConfig = cloneValue(currentConfig.value);
|
||||
const items = [...getSectionItems(editorSection.value)];
|
||||
|
||||
if (editorMode.value === 'create') {
|
||||
items.push(parsedValue);
|
||||
setSectionItems(editorSection.value, items);
|
||||
message.success(`${sectionTitles[editorSection.value]} 已加入待保存变更`);
|
||||
} else {
|
||||
if (editorSourceIndex.value < 0) {
|
||||
return;
|
||||
}
|
||||
items.splice(editorSourceIndex.value, 1, parsedValue);
|
||||
setSectionItems(editorSection.value, items);
|
||||
message.success(`${sectionTitles[editorSection.value]} 已加入待保存变更`);
|
||||
}
|
||||
|
||||
editorOpen.value = false;
|
||||
}
|
||||
|
||||
function handleDelete(section: SectionKey, index: number) {
|
||||
Modal.confirm({
|
||||
content: `确认删除 ${sectionTitles[section]} 的这条配置吗?删除后需要点击“保存配置”才会提交到服务端。`,
|
||||
title: '删除确认',
|
||||
async onOk() {
|
||||
const items = [...getSectionItems(section)];
|
||||
items.splice(index, 1);
|
||||
setSectionItems(section, items);
|
||||
message.success(`${sectionTitles[section]} 已加入待保存变更`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function saveAllConfig() {
|
||||
if (!currentConfig.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
await persistConfig('update', cloneValue(currentConfig.value));
|
||||
message.success('Notification 配置保存成功');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reloadConfig() {
|
||||
Modal.confirm({
|
||||
content: dirty.value
|
||||
? '当前有未保存修改,确认重新从服务端加载并覆盖本地草稿吗?'
|
||||
: '确认重新从服务端加载当前配置吗?',
|
||||
title: '重新加载配置',
|
||||
async onOk() {
|
||||
await loadConfig();
|
||||
message.success('已重新加载服务端配置');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
void loadConfig();
|
||||
|
||||
onActivated(async () => {
|
||||
await loadConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Card class="mb-5" title="客户端 Notification 配置">
|
||||
<Space>
|
||||
<Button :disabled="!dirty" :loading="loading" type="primary" @click="saveAllConfig">
|
||||
保存配置
|
||||
</Button>
|
||||
<Button :loading="loading" @click="reloadConfig">重新加载</Button>
|
||||
<Tag color="blue">当前配置已切换为服务端接口读写</Tag>
|
||||
<Tag :color="dirty ? 'orange' : 'green'">
|
||||
{{ dirty ? '存在未保存修改' : '已与服务端同步' }}
|
||||
</Tag>
|
||||
<Tag color="gold">最后更新时间:{{ formatTime(updatedAt) }}</Tag>
|
||||
</Space>
|
||||
<div class="mt-4">
|
||||
<Alert
|
||||
message="当前页面只提供查询与更新接口能力。新增、编辑、删除都会先写入本地草稿,点击“保存配置”后才会以完整 JSON 提交到服务端。"
|
||||
type="info"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :span="24">
|
||||
<Card class="mb-5" title="Schedules">
|
||||
<template #extra>
|
||||
<Button size="small" type="primary" @click="openCreateEditor('schedules')">新增</Button>
|
||||
</template>
|
||||
<ScheduleGrid>
|
||||
<template #scheduleOperation="{ row, rowIndex }">
|
||||
<Space>
|
||||
<Button size="small" type="primary" @click="openEditEditor('schedules', row, rowIndex)">编辑</Button>
|
||||
<Button danger size="small" @click="handleDelete('schedules', rowIndex)">删除</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</ScheduleGrid>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col :span="24">
|
||||
<Card class="mb-5" title="Presentations">
|
||||
<template #extra>
|
||||
<Button size="small" type="primary" @click="openCreateEditor('presentations')">新增</Button>
|
||||
</template>
|
||||
<PresentationGrid>
|
||||
<template #presentationOperation="{ row, rowIndex }">
|
||||
<Space>
|
||||
<Button size="small" type="primary" @click="openEditEditor('presentations', row, rowIndex)">编辑</Button>
|
||||
<Button danger size="small" @click="handleDelete('presentations', rowIndex)">删除</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</PresentationGrid>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col :span="24">
|
||||
<Card class="mb-5" title="Active Notis">
|
||||
<template #extra>
|
||||
<Button size="small" type="primary" @click="openCreateEditor('activeNotis')">新增</Button>
|
||||
</template>
|
||||
<ActiveGrid>
|
||||
<template #activeOperation="{ row, rowIndex }">
|
||||
<Space>
|
||||
<Button size="small" type="primary" @click="openEditEditor('activeNotis', row, rowIndex)">编辑</Button>
|
||||
<Button danger size="small" @click="handleDelete('activeNotis', rowIndex)">删除</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</ActiveGrid>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col :span="24">
|
||||
<Card title="Inactive Notis">
|
||||
<template #extra>
|
||||
<Button size="small" type="primary" @click="openCreateEditor('inactiveNotis')">新增</Button>
|
||||
</template>
|
||||
<InactiveGrid>
|
||||
<template #inactiveOperation="{ row, rowIndex }">
|
||||
<Space>
|
||||
<Button size="small" type="primary" @click="openEditEditor('inactiveNotis', row, rowIndex)">编辑</Button>
|
||||
<Button danger size="small" @click="handleDelete('inactiveNotis', rowIndex)">删除</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</InactiveGrid>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Modal
|
||||
v-model:open="editorOpen"
|
||||
:confirm-loading="loading"
|
||||
:title="`${editorMode === 'create' ? '新增' : '编辑'} ${sectionTitles[editorSection]}`"
|
||||
width="980px"
|
||||
@ok="saveEditor"
|
||||
>
|
||||
<Alert
|
||||
v-if="editorSection === 'schedules'"
|
||||
class="mb-4"
|
||||
message="fireTime 预览"
|
||||
type="info"
|
||||
>
|
||||
<template #description>
|
||||
{{ getScheduleFireTimePreview() }}
|
||||
</template>
|
||||
</Alert>
|
||||
|
||||
<JsonEitorVue v-model="editorValue" v-bind="{}" class="h-[600px] w-full" />
|
||||
</Modal>
|
||||
</Page>
|
||||
</template>
|
||||
@ -243,7 +243,7 @@ async function syncCfg(){
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height class="h-[800px]">
|
||||
<Page auto-content-height>
|
||||
<AddActivityM class="w-[50%]" />
|
||||
<DetailActivityM class="w-[50%]" />
|
||||
<SyncActivityM class="w-[50%]" />
|
||||
|
||||
@ -94,7 +94,7 @@ async function onSubmit(values: Record<string, any>) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height class="h-[1200px]">
|
||||
<Page auto-content-height>
|
||||
<VbenPopover class="ml-5" :content-props="{ side: 'top' }">
|
||||
<template #trigger>
|
||||
<VbenIcon icon="solar:question-circle-bold" class="ml-5" />
|
||||
|
||||
@ -212,7 +212,7 @@ function fromatItems(items: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height class="h-[800px]">
|
||||
<Page auto-content-height>
|
||||
<AddMailM class="w-[50%]" />
|
||||
<AddMailM2 class="w-[50%]" />
|
||||
<Card class="mb-5" title="邮件操作">
|
||||
|
||||
@ -147,7 +147,7 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Page auto-content-height class="h-[800px]">
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #toolbar-tools>
|
||||
<span class="mr-5 font-semibold" style="color: red;">该类订单持有数量为0的流失玩家数为该订单难度的总流失玩家数</span>
|
||||
|
||||
@ -3,10 +3,14 @@ import { Page } from '@vben/common-ui';
|
||||
import { Button, notification, Tag } from 'ant-design-vue';
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import ExtraDrawer from './drawer.vue';
|
||||
import {clientImageGitPull} from '#/api/core/scripts';
|
||||
import { ref } from 'vue';
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
connectedComponent: ExtraDrawer,
|
||||
});
|
||||
|
||||
const imageGitPullLoading = ref(false);
|
||||
|
||||
|
||||
function startScript(id: number) {
|
||||
notification.success({
|
||||
@ -38,6 +42,22 @@ function startScript2(id: number) {
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
async function scriptClientImageGitPull() {
|
||||
imageGitPullLoading.value = true;
|
||||
clientImageGitPull({ step: 1 }).then(() => {
|
||||
notification.success({
|
||||
message: '操作成功',
|
||||
description: '已成功执行客户端图片更新脚本。',
|
||||
});
|
||||
imageGitPullLoading.value = false;
|
||||
}).catch(() => {
|
||||
notification.error({
|
||||
message: '操作失败',
|
||||
description: '执行客户端图片更新脚本失败,请稍后再试。',
|
||||
});
|
||||
imageGitPullLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
<template>
|
||||
@ -105,6 +125,27 @@ function startScript2(id: number) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<div class="card-box p-4 py-6 flex justify-between h-12">
|
||||
<div class="flex flex-col justify-center md:mt-0 lg:w-1/12">
|
||||
<h1 class="text-md font-semibold md:text-ml text-center">
|
||||
<span>翻译系统图片更新</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center md:mt-0 lg:w-3/12">
|
||||
<h1 class="text-md font-semibold md:text-ml text-center">
|
||||
<Tag color="green">Language</Tag><Tag>Image</Tag><Tag>Client</Tag>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-center md:mt-0 lg:w-1/12">
|
||||
<h1 class="text-md font-semibold md:text-ml text-center">
|
||||
<Button type="primary" @click="scriptClientImageGitPull" :loading="imageGitPullLoading">Start</Button>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
|
||||
|
||||
@ -229,7 +229,7 @@ const [Grid] = useVbenVxeGrid({ formOptions, gridOptions, gridEvents });
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Page auto-content-height class="h-[800px]">
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #empty>
|
||||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;">
|
||||
|
||||
@ -173,7 +173,7 @@ const [Grid] = useVbenVxeGrid({ formOptions, gridOptions, gridEvents });
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Page auto-content-height class="h-[800px]">
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #time_header>
|
||||
时间 <span style="color: red">(UTC+8)</span>
|
||||
|
||||
@ -155,7 +155,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height class="h-[800px]">
|
||||
<Page auto-content-height>
|
||||
<Grid />
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@ -239,7 +239,7 @@ const [Grid] = useVbenVxeGrid({ formOptions, gridOptions, gridEvents });
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Page auto-content-height class="h-[800px]">
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #empty>
|
||||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;">
|
||||
|
||||
@ -188,7 +188,7 @@ function getTagColor(tag:string){
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Page class="h-[800px]">
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #time_header>
|
||||
时间 <span style="color: red">(UTC+8)</span>
|
||||
|
||||
@ -146,7 +146,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height class="h-[800px]">
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #status="{ row }">
|
||||
<Tag color="gray" v-if="row.PayStatus === 0">未支付</Tag>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user