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

This commit is contained in:
hahwu 2026-04-24 17:23:21 +08:00
parent dbfb3c4bd9
commit 3c746fcea9
28 changed files with 1743 additions and 57 deletions

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

View File

@ -1,3 +1,5 @@
export * from './auth';
export * from './experiment';
export * from './menu';
export * from './notification';
export * from './user';

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

View File

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

View File

@ -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": {

View File

@ -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":"测试服",

View File

@ -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'),
},
},
],

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

View File

@ -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'],
},
},
}
],
},
];

View File

@ -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="配置操作">

View File

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

View File

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

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

View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import AbtestTable from './experiment-table.vue';
</script>
<template>
<AbtestTable />
</template>

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

View File

@ -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="实验体操作">

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

View File

@ -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%]" />

View File

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

View File

@ -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="邮件操作">

View File

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

View File

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

View File

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

View File

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

View File

@ -155,7 +155,7 @@ onMounted(async () => {
</script>
<template>
<Page auto-content-height class="h-[800px]">
<Page auto-content-height>
<Grid />
</Page>
</template>

View File

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

View File

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

View File

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