Files
Enterprise-Knowledge-Base/frontend/src/views/knowledge/settings/DataSourceSettings.vue
T
wizardchen 20b01a6cd5 feat(embed): session sidebar grouping and embed theme alignment (#1657)
Group embed sessions in the sidebar by channel, replace session source filter
with grouping buckets, and align shared chat components with embed theme colors.
2026-06-12 23:10:39 +08:00

658 lines
18 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { useI18n } from 'vue-i18n'
import {
listDataSources,
deleteDataSource,
triggerSync,
pauseDataSource,
resumeDataSource,
type DataSource,
} from '@/api/datasource'
import { humanizeCron, relativeTime } from '@/utils/cronHumanize'
import DataSourceEditorDialog from './DataSourceEditorDialog.vue'
import DataSourceSyncLogs from './DataSourceSyncLogs.vue'
import DataSourceTypeIcon from './DataSourceTypeIcon.vue'
import { useAuthStore } from '@/stores/auth'
const props = defineProps<{ kbId: string }>()
const emit = defineEmits<{ (e: 'count', value: number): void }>()
const { t } = useI18n()
const authStore = useAuthStore()
// 后端 /datasource 的 list/logs 是 Viewer+,但所有写操作(POST/PUT/DELETE
// 以及 sync/pause/resume/validate)都是 Admin+。低权限用户保留只读视图,
// 增删改和触发同步全部隐藏,而不是按下去再撞 403。
const canManageDataSource = computed(() => authStore.hasRole('admin'))
const dataSources = ref<DataSource[]>([])
const loading = ref(false)
const editorVisible = ref(false)
const editingDs = ref<DataSource | null>(null)
const logsVisible = ref(false)
const logsDsId = ref('')
const logsDsName = ref('')
const pollTimer = ref<number | null>(null)
function stopPolling() {
if (pollTimer.value !== null) {
window.clearTimeout(pollTimer.value)
pollTimer.value = null
}
}
function schedulePolling() {
stopPolling()
pollTimer.value = window.setTimeout(() => {
loadList(true)
}, 3000)
}
async function loadList(silent = false) {
if (!silent) loading.value = true
try {
const res = await listDataSources(props.kbId)
dataSources.value = res?.data || res || []
emit('count', dataSources.value.length)
const hasRunningSync = dataSources.value.some(ds => ds.latest_sync_log?.status === 'running')
if (hasRunningSync) {
schedulePolling()
} else {
stopPolling()
}
} catch (e: any) {
console.error(e)
} finally {
if (!silent) loading.value = false
}
}
function openCreate() {
editingDs.value = null
editorVisible.value = true
}
function openEdit(ds: DataSource) {
editingDs.value = ds
editorVisible.value = true
}
function openLogs(ds: DataSource) {
logsDsId.value = ds.id
logsDsName.value = ds.name
logsVisible.value = true
}
function handleDelete(ds: DataSource) {
const confirmDialog = DialogPlugin.confirm({
header: t('datasource.delete'),
body: t('datasource.deleteConfirm'),
confirmBtn: { content: t('datasource.delete'), theme: 'danger' },
cancelBtn: t('common.cancel'),
onConfirm: async () => {
try {
await deleteDataSource(ds.id)
MessagePlugin.success(t('datasource.deleteSuccess'))
await loadList()
confirmDialog.hide()
} catch (e: any) {
MessagePlugin.error(e?.message || e?.error || t('datasource.deleteFailed'))
}
},
})
}
async function handleSync(ds: DataSource) {
try {
await triggerSync(ds.id)
MessagePlugin.success(t('datasource.syncTriggered'))
await loadList(true)
} catch (e: any) {
MessagePlugin.error(e?.message || e?.error || t('datasource.syncFailed'))
}
}
async function handlePause(ds: DataSource) {
try {
await pauseDataSource(ds.id)
MessagePlugin.success(t('datasource.paused'))
loadList()
} catch (e: any) {
MessagePlugin.error(e?.message || e?.error || t('datasource.pauseFailed'))
}
}
async function handleResume(ds: DataSource) {
try {
await resumeDataSource(ds.id)
MessagePlugin.success(t('datasource.resumed'))
loadList()
} catch (e: any) {
MessagePlugin.error(e?.message || e?.error || t('datasource.resumeFailed'))
}
}
function statusTheme(status: string): 'success' | 'danger' | 'default' | 'warning' {
if (status === 'active') return 'success'
if (status === 'error') return 'danger'
if (status === 'paused') return 'warning'
return 'default'
}
function statusLabel(status: string) {
return t(`datasource.status.${status}`)
}
function syncModeLabel(mode: string) {
return t(`datasource.syncMode.${mode}`)
}
function connectorLabel(type: string) {
return t(`datasource.connector.${type}`) || type
}
function scheduleLabel(cron: string) {
return humanizeCron(cron, t)
}
function lastSyncTime(ds: DataSource) {
return relativeTime(ds.last_sync_at, t)
}
function lastSyncFullTime(ds: DataSource) {
if (!ds.last_sync_at) return ''
return new Date(ds.last_sync_at).toLocaleString()
}
function syncResultPills(ds: DataSource) {
const log = ds.latest_sync_log
if (!log) return []
const pills: { text: string; cls: string }[] = []
if (log.items_created > 0) pills.push({ text: `+${log.items_created}`, cls: 'created' })
if (log.items_updated > 0) pills.push({ text: `~${log.items_updated}`, cls: 'updated' })
if (log.items_deleted > 0) pills.push({ text: `-${log.items_deleted}`, cls: 'deleted' })
if (log.items_failed > 0) pills.push({ text: `${log.items_failed} ${t('datasource.logMetric.failed')}`, cls: 'failed' })
if (log.items_skipped > 0) pills.push({ text: `${log.items_skipped} ${t('datasource.logMetric.skipped')}`, cls: 'skipped' })
return pills
}
function lastSyncStatusLabel(ds: DataSource) {
const log = ds.latest_sync_log
if (!log) return '--'
return t(`datasource.logStatus.${log.status}`)
}
function lastSyncStatusColor(ds: DataSource) {
const log = ds.latest_sync_log
if (!log) return ''
switch (log.status) {
case 'success': return 'var(--td-success-color)'
case 'failed': return 'var(--td-error-color)'
case 'running': return 'var(--td-brand-color)'
case 'partial': return 'var(--td-warning-color)'
default: return ''
}
}
function isSyncRunning(ds: DataSource) {
return ds.latest_sync_log?.status === 'running'
}
function onEditorSaved() {
editorVisible.value = false
loadList()
}
onMounted(loadList)
onBeforeUnmount(stopPolling)
</script>
<template>
<div class="ds-settings">
<div class="section-header">
<h2 class="section-title">{{ t('datasource.title') }}</h2>
<p class="section-desc">{{ t('datasource.description') }}</p>
</div>
<div class="channels-section">
<div class="channels-header">
<span class="channels-title">{{ t('datasource.channelsTitle') }}</span>
<span class="channels-count">{{ dataSources.length }}</span>
</div>
<t-loading :loading="loading" size="small" class="channels-loading-wrap">
<div
v-if="!loading && dataSources.length === 0 && !canManageDataSource"
class="channels-empty"
>
<t-empty :description="t('datasource.empty')" />
</div>
<div v-else-if="!loading" class="channel-grid">
<component
:is="canManageDataSource ? 'button' : 'div'"
v-for="ds in dataSources"
:key="ds.id"
:type="canManageDataSource ? 'button' : undefined"
:class="['channel-card', { 'channel-card--clickable': canManageDataSource }]"
@click="canManageDataSource ? openEdit(ds) : undefined"
>
<div class="channel-card__badge">
<DataSourceTypeIcon :type="ds.type" :size="22" />
</div>
<div class="channel-card__body">
<div class="channel-card__header">
<h3 class="channel-card__title" :title="ds.name">{{ ds.name }}</h3>
<t-tag size="small" :theme="statusTheme(ds.status)" variant="light">
{{ statusLabel(ds.status) }}
</t-tag>
</div>
<p class="channel-card__subtitle">
{{ connectorLabel(ds.type) }} · {{ syncModeLabel(ds.sync_mode) }}
</p>
<div class="channel-card__meta">
<div class="channel-card__meta-item">
<span class="channel-card__meta-label">{{ t('datasource.schedule') }}</span>
<span class="channel-card__meta-value">{{ scheduleLabel(ds.sync_schedule) }}</span>
</div>
<div class="channel-card__meta-item">
<span class="channel-card__meta-label">{{ t('datasource.lastSync') }}</span>
<t-tooltip :content="lastSyncFullTime(ds)" :disabled="!lastSyncFullTime(ds)">
<span class="channel-card__meta-value">{{ lastSyncTime(ds) }}</span>
</t-tooltip>
</div>
<div class="channel-card__meta-item channel-card__meta-item--wide">
<span class="channel-card__meta-label">{{ t('datasource.lastStatus') }}</span>
<span class="channel-card__meta-value">
<template v-if="ds.latest_sync_log">
<span :style="{ color: lastSyncStatusColor(ds), fontWeight: 500 }">
{{ lastSyncStatusLabel(ds) }}
</span>
<span
v-for="pill in syncResultPills(ds)"
:key="pill.cls"
:class="['ds-pill', pill.cls]"
>{{ pill.text }}</span>
</template>
<span v-else class="channel-card__meta-placeholder">--</span>
</span>
</div>
</div>
<div v-if="ds.error_message" class="channel-card__error">
<t-icon name="error-circle-filled" size="14px" />
<span>{{ ds.error_message }}</span>
</div>
</div>
<div class="channel-card__actions" @click.stop>
<t-tooltip
v-if="canManageDataSource"
:content="isSyncRunning(ds) ? t('datasource.logStatus.running') : t('datasource.syncNow')"
>
<t-button
size="small"
variant="text"
theme="primary"
:disabled="isSyncRunning(ds)"
@click="handleSync(ds)"
>
<template #icon>
<t-icon name="refresh" :class="{ 'ds-icon-spin': isSyncRunning(ds) }" />
</template>
</t-button>
</t-tooltip>
<t-tooltip :content="t('datasource.logs')">
<t-button size="small" variant="text" @click="openLogs(ds)">
<template #icon><t-icon name="root-list" /></template>
</t-button>
</t-tooltip>
<t-dropdown v-if="canManageDataSource" trigger="click" :min-column-width="120">
<t-button
variant="text"
shape="square"
size="small"
class="channel-card__more"
@click.stop
>
<template #icon><t-icon name="ellipsis" /></template>
</t-button>
<template #dropdown>
<t-dropdown-menu>
<t-dropdown-item @click="openEdit(ds)">
<t-icon name="edit" /> {{ t('datasource.edit') }}
</t-dropdown-item>
<t-dropdown-item
v-if="ds.status === 'active'"
@click="handlePause(ds)"
>
<t-icon name="pause-circle" /> {{ t('datasource.pause') }}
</t-dropdown-item>
<t-dropdown-item
v-else-if="ds.status === 'paused'"
@click="handleResume(ds)"
>
<t-icon name="play-circle" /> {{ t('datasource.resume') }}
</t-dropdown-item>
<t-dropdown-item theme="error" @click="handleDelete(ds)">
<t-icon name="delete" /> {{ t('datasource.delete') }}
</t-dropdown-item>
</t-dropdown-menu>
</template>
</t-dropdown>
</div>
</component>
<button
v-if="canManageDataSource"
type="button"
class="channel-card channel-card--add"
@click="openCreate"
>
<span class="channel-card--add__icon" aria-hidden="true">
<t-icon name="add" />
</span>
<span class="channel-card--add__label">{{ t('datasource.add') }}</span>
</button>
</div>
</t-loading>
</div>
<DataSourceEditorDialog
v-model:visible="editorVisible"
:kb-id="kbId"
:data-source="editingDs"
@saved="onEditorSaved"
/>
<DataSourceSyncLogs
v-model:visible="logsVisible"
:data-source-id="logsDsId"
:data-source-name="logsDsName"
/>
</div>
</template>
<style scoped lang="less">
.ds-settings {
width: 100%;
}
.section-header {
margin-bottom: 20px;
.section-title {
margin: 0 0 6px;
font-size: 20px;
font-weight: 600;
color: var(--td-text-color-primary);
}
.section-desc {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: var(--td-text-color-secondary);
}
}
.channels-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
.channels-title {
font-size: 14px;
font-weight: 500;
color: var(--td-text-color-primary);
}
.channels-count {
padding: 2px 8px;
background: var(--td-bg-color-secondarycontainer);
border-radius: 10px;
font-size: 12px;
color: var(--td-text-color-disabled);
}
}
.channels-loading-wrap {
min-height: 80px;
}
.channels-empty {
padding: 32px 0;
}
.channel-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.channel-card {
position: relative;
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border: 1px solid var(--td-component-stroke);
border-radius: 10px;
background: var(--td-bg-color-container);
text-align: left;
font: inherit;
color: inherit;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
&--clickable {
cursor: pointer;
width: 100%;
&:hover,
&:focus-visible {
border-color: var(--td-brand-color-3, var(--td-brand-color));
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
outline: none;
}
&:focus-visible {
outline: 2px solid var(--td-brand-color);
outline-offset: 2px;
}
}
&--add {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 68px;
border-style: dashed;
background: transparent;
color: var(--td-text-color-placeholder);
cursor: pointer;
width: 100%;
&:hover,
&:focus-visible {
color: var(--td-brand-color);
border-color: var(--td-brand-color);
background: color-mix(in srgb, var(--td-brand-color) 6%, transparent);
box-shadow: none;
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: color-mix(in srgb, var(--td-brand-color) 10%, transparent);
color: var(--td-brand-color);
font-size: 18px;
}
&__label {
font-size: 13px;
font-weight: 500;
line-height: 1.4;
}
}
&__badge {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--td-brand-color) 10%, transparent);
overflow: hidden;
}
&__body {
flex: 1;
min-width: 0;
}
&__header {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
&__title {
flex: 1;
min-width: 0;
margin: 0;
font-size: 14px;
font-weight: 600;
line-height: 1.4;
color: var(--td-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__subtitle {
margin: 2px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--td-text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--td-component-stroke);
}
&__meta-item {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
&--wide {
grid-column: 1 / -1;
}
}
&__meta-label {
font-size: 11px;
line-height: 1.4;
color: var(--td-text-color-placeholder);
}
&__meta-value {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
font-size: 12px;
line-height: 1.4;
color: var(--td-text-color-primary);
}
&__meta-placeholder {
color: var(--td-text-color-disabled);
}
&__error {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
padding: 8px 10px;
border-radius: 6px;
background: var(--td-error-color-1);
color: var(--td-error-color);
font-size: 12px;
line-height: 1.45;
text-align: left;
}
&__actions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 2px;
padding-top: 2px;
}
&__more {
flex-shrink: 0;
padding: 2px;
opacity: 0;
color: var(--td-text-color-placeholder);
transition: opacity 0.15s ease;
&:hover,
&:focus-visible {
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-primary);
}
}
&:hover .channel-card__more,
&:focus-within .channel-card__more,
&__actions:focus-within .channel-card__more {
opacity: 1;
}
}
.ds-pill {
font-size: 10px;
padding: 1px 5px;
border-radius: 4px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 16px;
&.created { background: var(--td-success-color-1); color: var(--td-success-color); }
&.updated { background: var(--td-brand-color-light); color: var(--td-brand-color); }
&.deleted { background: var(--td-warning-color-1); color: var(--td-warning-color); }
&.skipped { background: var(--td-bg-color-component); color: var(--td-text-color-placeholder); }
&.failed { background: var(--td-error-color-1); color: var(--td-error-color); }
}
.ds-icon-spin {
animation: ds-spin 1s linear infinite;
}
@keyframes ds-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>