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
482 lines
15 KiB
Vue
482 lines
15 KiB
Vue
<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> |