admin_web/apps/web-antd/src/views/notification/index.vue
hahwu 39195fb2e6
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
活动优化
2026-04-29 10:38:33 +08:00

482 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 = 621355968000000000;
const TICKS_PER_MILLISECOND = 10000;
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 = Number(String(value));
const unixMilliseconds = (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>