Skip to content

feat(ui): 优化通知中心 #944

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 3, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion configure/etc/conf/i18n/messages_en.properties
Original file line number Diff line number Diff line change
@@ -729,4 +729,5 @@ notify.text.dingtalk=DingTalk Notification
notify.text.created=Created
notify.text.updated=Updated
notify.text.deleted=Deleted
notify.text.syncData=Sync Data
notify.text.syncData=Sync Data
plugin.text.requiredJsonConvert=The JsonConvert plugin has not been installed and cannot be converted, please try again after installation
3 changes: 2 additions & 1 deletion configure/etc/conf/i18n/messages_zh-cn.properties
Original file line number Diff line number Diff line change
@@ -729,4 +729,5 @@ notify.text.dingtalk=\u9489\u9489\u901A\u77E5
notify.text.created=\u521B\u5EFA
notify.text.updated=\u66F4\u65B0
notify.text.deleted=\u5220\u9664
notify.text.syncData=\u540C\u6B65\u6570\u636E
notify.text.syncData=\u540C\u6B65\u6570\u636E
plugin.text.requiredJsonConvert=\u5C1A\u672A\u5B89\u88C5 JsonConvert \u63D2\u4EF6\uFF0C\u65E0\u6CD5\u8FDB\u884C Json \u8F6C\u6362\uFF0C\u8BF7\u5B89\u88C5\u540E\u91CD\u8BD5
Original file line number Diff line number Diff line change
@@ -20,6 +20,7 @@ public class PluginMetadata
private PluginType type;
private String loaderName;
private String key;
private Object configure;

@JsonIgnore
private ClassLoader classLoader;
Original file line number Diff line number Diff line change
@@ -4,33 +4,49 @@
import io.edurt.datacap.common.response.CommonResponse;
import io.edurt.datacap.plugin.PluginManager;
import io.edurt.datacap.plugin.PluginMetadata;
import io.edurt.datacap.plugin.PluginType;
import io.edurt.datacap.service.common.PluginUtils;
import lombok.Data;
import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.nio.file.Path;
import java.util.List;
import java.util.stream.Collectors;

@RestController
@RequestMapping(value = "/api/v1/plugin")
@SuppressFBWarnings(value = {"EI_EXPOSE_REP2"})
public class PluginController
{
private final PluginManager pluginManager;
private final Environment environment;

public PluginController(PluginManager pluginManager)
public PluginController(PluginManager pluginManager, Environment environment)
{
this.pluginManager = pluginManager;
this.environment = environment;
}

@GetMapping
public CommonResponse<List<PluginMetadata>> getPlugins()
public CommonResponse<List<PluginMetadata>> getPlugins(@RequestParam(value = "hasConfigure", required = false) boolean hasConfigure)
{
List<PluginMetadata> plugins = pluginManager.getPluginInfos()
.stream()
.filter(v -> v.getType().equals(PluginType.CONNECTOR))
.collect(Collectors.toList());

if (hasConfigure) {
plugins.forEach(plugin -> plugin.setConfigure(PluginUtils.loadYamlConfigure("JDBC", plugin.getName(), plugin.getName(), environment)));
}

return CommonResponse.success(pluginManager.getPluginInfos());
}

Original file line number Diff line number Diff line change
@@ -28,14 +28,18 @@ private PluginUtils() {}

public static Optional<PluginService> getPluginByName(Injector injector, String pluginName)
{
Optional<PluginService> pluginOptional = injector.getInstance(Key.get(new TypeLiteral<Set<PluginService>>() {})).stream().filter(plugin -> plugin.name().equalsIgnoreCase(pluginName)).findFirst();
return pluginOptional;
return injector.getInstance(Key.get(new TypeLiteral<Set<PluginService>>() {}))
.stream()
.filter(plugin -> plugin.name().equalsIgnoreCase(pluginName))
.findFirst();
}

public static Optional<PluginService> getPluginByNameAndType(Injector injector, String pluginName, String pluginType)
{
Optional<PluginService> pluginOptional = injector.getInstance(Key.get(new TypeLiteral<Set<PluginService>>() {})).stream().filter(plugin -> plugin.name().equalsIgnoreCase(pluginName) && plugin.type().name().equalsIgnoreCase(pluginType)).findFirst();
return pluginOptional;
return injector.getInstance(Key.get(new TypeLiteral<Set<PluginService>>() {}))
.stream()
.filter(plugin -> plugin.name().equalsIgnoreCase(pluginName) && plugin.type().name().equalsIgnoreCase(pluginType))
.findFirst();
}

@Deprecated
Original file line number Diff line number Diff line change
@@ -176,14 +176,6 @@ else if (type().equals(PluginType.NATIVE)) {

default Response execute(Configure configure, String content)
{
if (!this.isSupportMeta()) {
return Response.builder()
.isSuccessful(false)
.isConnected(false)
.message("This plugin does not support metadata management")
.build();
}

this.connect(configure);
return this.execute(content);
}
2 changes: 1 addition & 1 deletion core/datacap-ui/package.json
Original file line number Diff line number Diff line change
@@ -28,7 +28,7 @@
"md-editor-v3": "^4.12.1",
"pinia": "^3.0.1",
"uuid": "^9.0.1",
"view-shadcn-ui": "^2025.1.2",
"view-shadcn-ui": "^2025.1.3",
"vue": "^3.4.21",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^9.10.1",
16 changes: 11 additions & 5 deletions core/datacap-ui/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 0 additions & 8 deletions core/datacap-ui/src/router/default.ts
Original file line number Diff line number Diff line change
@@ -338,14 +338,6 @@ const createAdminRouter = (router: any) => {
isRoot: false
},
component: () => import('@/views/pages/admin/wofkflow/WorkflowInfo.vue')
},
{
path: 'notify',
meta: {
title: 'common.notify',
isRoot: false
},
component: () => import('@/views/pages/admin/notify/NotifyHome.vue')
}
]
}
4 changes: 2 additions & 2 deletions core/datacap-ui/src/services/plugin.ts
Original file line number Diff line number Diff line change
@@ -5,9 +5,9 @@ const DEFAULT_PATH = '/api/v1/plugin'

class PluginService
{
getPlugins(): Promise<ResponseModel>
getPlugins(hasConfigure?: boolean): Promise<ResponseModel>
{
return new HttpUtils().get(`${ DEFAULT_PATH }`)
return new HttpUtils().get(`${ DEFAULT_PATH }`, { hasConfigure })
}

install(configure: { name: string, url: string }): Promise<ResponseModel>
177 changes: 135 additions & 42 deletions core/datacap-ui/src/views/layouts/common/components/LayoutHeader.vue
Original file line number Diff line number Diff line change
@@ -60,20 +60,54 @@
</ShadcnTooltip>
</div>
<div class="mt-1">
<LanguageSwitcher @changeLanguage="onChangeLanguage($event)"/>
<LanguageSwitcher @changeLanguage="onChangeLanguage"/>
</div>

<div v-if="userInfo" class="mt-2.5">
<ShadcnLink link="/admin/notify">
<template v-if="userInfo?.unreadCount > 0">
<ShadcnBadge dot>
<ShadcnIcon icon="Bell" class="hover:text-blue-400" :size="20"/>
<ShadcnNotification :height="300" :loadData="loadMoreNotifications">
<template #trigger>
<ShadcnBadge v-if="userInfo?.unreadCount > 0" dot :text="userInfo?.unreadCount">
<ShadcnIcon icon="Bell" class="hover:text-blue-400 cursor-pointer" :size="20"/>
</ShadcnBadge>
<ShadcnIcon v-else icon="Bell" class="hover:text-blue-400 cursor-pointer" :size="20"/>
</template>
<template v-else>
<ShadcnIcon icon="Bell" class="hover:text-blue-400" :size="20"/>

<template #actions>
<span></span>
</template>
</ShadcnLink>

<ShadcnNotificationItem v-for="(item, index) in messages"
:key="index"
:item="item"
@on-click="handleNotificationClick">
<template #title>
<div class="mt-1 text-sm text-gray-600">
<div v-if="item.entityExists" class="flex space-x-1">
<span>{{ $t(`common.${ item.entityType?.toLowerCase() || '' }`) }}</span>

<template v-if="item.entityType === 'DATASET'">
<RouterLink :to="`/admin/dataset/info/${item.entityCode}`" target="_blank" class="hover:text-blue-400 flex items-center">
[ {{ item.entityName }} ]
</RouterLink>
</template>

<template v-else>
<ShadcnLink class="hover:text-blue-400" :to="'/' + item.entityType + '/' + item.entityCode">[ {{ item.entityName }} ]</ShadcnLink>
</template>

<span>{{ $t(`common.${ item.type?.toLowerCase() || '' }`) }}</span>
</div>
<div v-else>
{{ $t(`common.${ item.entityType?.toLowerCase() || '' }`) }} [ {{ item.entityName }} ] {{ $t(`common.${ item.type?.toLowerCase() || '' }`) }}
</div>
</div>
</template>

<template #time>
<ShadcnTime relative :reference-time="item.createTime"/>
</template>
</ShadcnNotificationItem>
</ShadcnNotification>
</div>

<!-- User Info -->
@@ -125,49 +159,108 @@
</div>
</template>

<script lang="ts">
import { defineComponent, onMounted } from 'vue'
<script setup lang="ts">
import { getCurrentInstance, onMounted, ref } from 'vue'
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
import { TokenUtils } from '@/utils/token'
import router from '@/router'
import { createDefaultRouter } from '@/router/default'
import LanguageSwitcher from '@/views/layouts/common/components/components/LanguageSwitcher.vue'
import NotificationService from '@/services/notification'
import { FilterModel } from '@/model/filter.ts'
import { cloneDeep } from 'lodash'
export default defineComponent({
name: 'LayoutHeader',
setup()
{
const userStore = useUserStore()
const { userInfo, isLoggedIn, menu: activeMenus } = storeToRefs(userStore)
onMounted(async () => {
if (TokenUtils.getAuthUser()) {
await userStore.fetchUserInfo()
}
})
const logout = () => {
userStore.logout()
createDefaultRouter(router)
router.push('/auth/signin')
}
const userStore = useUserStore()
const { proxy } = getCurrentInstance()!
const filter: FilterModel = new FilterModel()
const messages = ref<any[]>([])
const pageIndex = ref<number>(1)
const hasMoreData = ref(true)
const loading = ref(false)
const { userInfo, isLoggedIn, menu: activeMenus } = storeToRefs(userStore)
return {
userInfo,
isLoggedIn,
activeMenus,
logout
const emit = defineEmits<{
changeLanguage: [language: string]
}>()
onMounted(async () => {
if (TokenUtils.getAuthUser()) {
await userStore.fetchUserInfo()
await fetchMessages()
}
})
const logout = () => {
userStore.logout()
createDefaultRouter(router)
router.push('/auth/signin')
}
const onChangeLanguage = (language: string) => {
emit('changeLanguage', language)
}
const handleNotificationClick = (message: any) => {
const { id, code } = message
const payload = { id, code, isRead: true }
NotificationService.saveOrUpdate(payload)
.then(response => {
if (response.status && response.data) {
fetchMessages()
userStore.fetchUserInfo()
}
else {
// @ts-ignore
proxy.$Message.error({
content: response.message,
showIcon: true
})
}
})
}
const fetchMessages = async (value: number = 1) => {
filter.page = value
filter.orders = [{ column: 'createTime', order: 'desc' }]
loading.value = true
try {
const response = await NotificationService.getAll(filter)
if (response.status && response.data) {
messages.value = response.data.content.map((item: any) => {
item.read = item.isRead
return item
})
pageIndex.value = response.data.page
hasMoreData.value = response.data.page < response.data.totalPage
}
},
components: {
LanguageSwitcher
},
methods: {
onChangeLanguage(language: string)
{
this.$emit('changeLanguage', language)
else {
// @ts-ignore
proxy.$Message.error({
content: response.message,
showIcon: true
})
}
}
})
finally {
loading.value = false
}
}
const loadMoreNotifications = async (callback: (items: any[]) => void) => {
if (loading.value || !hasMoreData.value) {
callback([])
return
}
const oldData = cloneDeep(messages.value)
loading.value = true
pageIndex.value++
await fetchMessages(pageIndex.value)
const newItems = messages.value
messages.value = [...oldData, ...newItems]
callback(newItems)
loading.value = false
}
</script>
177 changes: 0 additions & 177 deletions core/datacap-ui/src/views/pages/admin/notify/NotifyHome.vue

This file was deleted.

This file was deleted.

55 changes: 27 additions & 28 deletions core/datacap-ui/src/views/pages/admin/source/SourceInfo.vue
Original file line number Diff line number Diff line change
@@ -10,10 +10,8 @@
<ShadcnTab v-model="activeTab" :key="`tab-${configureTabs.length}`" @on-change="onChangeTab">
<ShadcnTabItem value="source" :label="$t('source.common.source')" :key="'source-tab'">
<ShadcnFormItem name="type" :label="$t('source.common.type')" :rules="[{ required: true, message: $t('function.tip.selectPluginHolder') }]">
<ShadcnToggleGroup v-model="formState.type" class="flex flex-wrap gap-3" name="plugin">
<ShadcnToggle v-for="plugin in plugins" class="p-1"
:key="plugin.name"
:value="plugin.name">
<ShadcnToggleGroup v-model="formState.type" class="flex flex-wrap gap-3" name="plugin" :disabled="!hasJsonConvert">
<ShadcnToggle v-for="plugin in plugins" class="p-1" :key="plugin.name" :value="plugin.name">
<ShadcnTooltip :content="plugin.name" class="p-1">
<img class="h-16 w-16 object-contain" :src="'/static/images/plugin/' + plugin.name.toLowerCase() + '.svg'" :alt="plugin.name">
</ShadcnTooltip>
@@ -26,6 +24,7 @@
class="space-y-4"
:key="tab"
:value="tab"
:disabled="!hasJsonConvert"
:label="$t(`source.common.${ tab }`)">
<ShadcnFormItem v-for="configure in pluginTabConfigure"
:name="configure.field"
@@ -119,6 +118,7 @@ import { defineComponent } from 'vue'
import { SourceModel, SourceRequest } from '@/model/source'
import { cloneDeep, pick } from 'lodash'
import SourceService from '@/services/source'
import PluginService from '@/services/plugin'
import { TokenUtils } from '@/utils/token'
import { ResponseModel } from '@/model/response'
@@ -172,7 +172,8 @@ export default defineComponent({
pluginConfigure: null as unknown as any,
pluginTabConfigure: null as unknown as any,
applyConfigure: null as unknown as any,
originalSchema: null as unknown as any
originalSchema: null as unknown as any,
hasJsonConvert: false
}
},
created()
@@ -246,23 +247,30 @@ export default defineComponent({
// 获取插件列表的 Promise
// Get plugins promise
const getPluginsPromise = SourceService.getPlugins()
const getPluginsPromise = PluginService.getPlugins(true)
const pluginsResponse = await getPluginsPromise
this.hasJsonConvert = pluginsResponse.data.some(value =>
value.type?.toLowerCase() === 'convert' &&
value.name === 'JsonConvert'
)
if (!this.hasJsonConvert) {
this.testInfo.message = this.$t('plugin.text.requiredJsonConvert')
}
if (pluginsResponse.status) {
this.plugins = pluginsResponse.data.filter((plugin: { type: string }) =>
plugin.type?.toLowerCase() === 'connector'
)
}
if (this.info) {
this.title = `${ this.$t('source.common.modify').replace('$NAME', String(this.info.name)) }`
this.title = `${ this.$t('source.common.modify').replace('$NAME', String(this.info.name || '...')) }`
// 同时执行获取插件列表和源代码信息的请求
// Simultaneously execute requests to get plugin list and source code
const [pluginsResponse, sourceResponse] = await Promise.all([
getPluginsPromise,
SourceService.getByCode(this.info.code)
])
// 处理插件列表响应
// Handle plugin list response
if (pluginsResponse.status) {
this.plugins = pluginsResponse.data
}
const sourceResponse = await SourceService.getByCode(this.info.code)
// 处理源代码信息响应
// Handle source code response
@@ -280,19 +288,10 @@ export default defineComponent({
this.updatePluginTabConfigure('source')
}
}
console.log(this.plugins)
}
else {
// 如果没有 info,只需要获取插件列表
// If there is no info, only get plugin list
const pluginsResponse = await getPluginsPromise
if (pluginsResponse.status) {
this.plugins = pluginsResponse.data
const hasJsonConvert = this.plugins.filter(value => value.type === 'Convert' && value.name === 'JsonConvert').length > 0
if (!hasJsonConvert) {
this.testInfo.message = 'JsonConvert plugin is required'
}
}
this.formState = SourceRequest.of()
}
}
20 changes: 19 additions & 1 deletion core/datacap-ui/src/views/pages/store/StoreHome.vue
Original file line number Diff line number Diff line change
@@ -40,7 +40,9 @@
<ShadcnAvatar class="bg-transparent"
size="large"
:src="plugin.logo"
:alt="plugin.i18nFormat ? $t(plugin.label) : plugin.label"/>
:alt="plugin.i18nFormat ? $t(plugin.label) : plugin.label"
@click="onVisibleInfo(plugin, true)">
</ShadcnAvatar>

<ShadcnText type="h6">
{{ plugin.i18nFormat ? $t(plugin.label) : plugin.label }}
@@ -126,13 +128,20 @@
</ShadcnTabItem>
</ShadcnTab>
</div>

<PluginInfo v-if="infoVisible && info"
:info="info"
:is-visible="infoVisible"
@close="onVisibleInfo(null, false)">
</PluginInfo>
</template>

<script setup lang="ts">
import { computed, getCurrentInstance, onBeforeMount, ref, watch } from 'vue'
import { useI18nHandler } from '@/i18n/I18n'
import { PackageUtils } from '@/utils/package.ts'
import PluginService from '@/services/plugin.ts'
import PluginInfo from '@/views/pages/store/components/PluginInfo.vue'
interface MetadataItem
{
@@ -181,6 +190,8 @@ const { loadingState } = useI18nHandler()
const loading = ref(false)
const metadata = ref<Metadata>(null)
const version = ref(PackageUtils.get('version'))
const info = ref<MetadataItem>(null)
const infoVisible = ref(false)
// Default to first plugin type found
const activeTab = ref('')
@@ -381,6 +392,13 @@ const onSave = async () => {
}
}
const onVisibleInfo = (item: MetadataItem, opened?: boolean) => {
infoVisible.value = opened
if (opened) {
info.value = item
}
}
onBeforeMount(() => {
if (!loadingState.value) {
loadMetadata()
119 changes: 119 additions & 0 deletions core/datacap-ui/src/views/pages/store/components/PluginInfo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<template>
<ShadcnModal v-model="visible"
width="50%"
height="60%"
:title="title"
@on-close="onCancel">
<div v-if="info" class="relative w-full h-full flex flex-col items-center p-6">
<div class="flex flex-col items-center mb-6">
<ShadcnAvatar class="mb-4 shadow-lg border-4 border-gray-100"
style="width: 5rem; height: 5rem;"
:src="info.logo || '/static/images/plugin.png'"
:alt="info.label">
</ShadcnAvatar>

<h3 class="text-xl font-semibold text-gray-800 mb-2">{{ info.label }}</h3>
</div>

<div class="w-full max-w-md mb-6">
<div class="bg-gray-50 rounded-lg p-4 text-center">
<ShadcnText class="text-sm text-gray-600 leading-relaxed" type="small">
{{ info.i18nFormat ? $t(info.description) : info.description }}
</ShadcnText>
</div>
</div>

<div class="w-full max-w-md space-y-4">
<div class="flex items-center justify-between p-3 bg-white rounded-lg border border-gray-200">
<span class="text-sm font-medium text-gray-700">{{ $t('common.plugin.version') }}</span>
<ShadcnTag>{{ info.version }}</ShadcnTag>
</div>

<div v-if="info.installed" class="flex items-center justify-between p-3 bg-white rounded-lg border border-gray-200">
<span class="text-sm font-medium text-gray-700">{{ $t('common.installVersion') }}</span>
<ShadcnTag color="#00BFFF">{{ info.installVersion }}</ShadcnTag>
</div>

<div class="flex items-center justify-between p-3 bg-white rounded-lg border border-gray-200">
<span class="text-sm font-medium text-gray-700">{{ $t('common.author') }}</span>
<span class="text-sm text-gray-600">{{ info.author }}</span>
</div>

<div class="flex items-center justify-between p-3 bg-white rounded-lg border border-gray-200">
<span class="text-sm font-medium text-gray-700">{{ $t('common.releasedTime') }}</span>
<span class="text-sm text-gray-600">{{ info.released }}</span>
</div>

<div class="p-3 bg-white rounded-lg border border-gray-200" style="margin-bottom: 1rem;">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">{{ $t('common.plugin.list.supportVersion') }}</span>
</div>

<div class="flex flex-wrap gap-2">
<ShadcnTag v-for="version in info.supportVersion" type="success" :key="version">
{{ version }}
</ShadcnTag>
</div>
</div>
</div>
</div>

<template #footer>
<ShadcnButton type="default" @click="onCancel">
{{ $t('common.cancel') }}
</ShadcnButton>
</template>
</ShadcnModal>
</template>

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
interface Props
{
isVisible: boolean
info: any | null
}
interface Emits
{
(e: 'close', value: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
isVisible: false,
info: null
})
const emit = defineEmits<Emits>()
const title = ref<string | null>(null)
const visible = computed({
get(): boolean
{
return props.isVisible
},
set(value: boolean)
{
emit('close', value)
}
})
const handleInitialize = () => {
if (props.info) {
title.value = props.info.label
}
}
const onCancel = () => {
visible.value = false
emit('close', false)
}
onMounted(() => {
handleInitialize()
})
watch(() => props.info, () => {
handleInitialize()
}, { immediate: true })
</script>