为 PluginCard 组件添加实时日志弹窗功能

This commit is contained in:
jxxghp
2025-05-26 12:37:49 +08:00
parent 6353d56beb
commit d81120ab8f
7 changed files with 263 additions and 177 deletions
+1 -1
View File
@@ -110,4 +110,4 @@
"i18n-ally.localesPaths": [ "i18n-ally.localesPaths": [
"src/locales" "src/locales"
] ]
} }
+194 -133
View File
@@ -58,6 +58,9 @@ const progressDialog = ref(false)
// 插件数据页面 // 插件数据页面
const pluginInfoDialog = ref(false) const pluginInfoDialog = ref(false)
// 实时日志弹窗
const loggingDialog = ref(false)
// 进度框文本 // 进度框文本
const progressText = ref('正在更新插件...') const progressText = ref('正在更新插件...')
@@ -82,7 +85,7 @@ const cloneForm = ref({
name: '', name: '',
description: '', description: '',
version: '', version: '',
icon: '' icon: '',
}) })
// 监听动作标识,如为true则打开详情 // 监听动作标识,如为true则打开详情
@@ -136,7 +139,12 @@ async function uninstallPlugin() {
// 通知父组件刷新 // 通知父组件刷新
emit('remove') emit('remove')
} else { } else {
$toast.error(t('plugin.uninstallFailed', { name: props.plugin?.plugin_name, message: result.message })) $toast.error(
t('plugin.uninstallFailed', {
name: props.plugin?.plugin_name,
message: result.message,
}),
)
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@@ -190,7 +198,12 @@ async function resetPlugin() {
// 通知父组件刷新 // 通知父组件刷新
emit('save') emit('save')
} else { } else {
$toast.error(t('plugin.resetFailed', { name: props.plugin?.plugin_name, message: result.message })) $toast.error(
t('plugin.resetFailed', {
name: props.plugin?.plugin_name,
message: result.message,
}),
)
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@@ -221,7 +234,12 @@ async function updatePlugin() {
// 通知父组件刷新 // 通知父组件刷新
emit('save') emit('save')
} else { } else {
$toast.error(t('plugin.updateFailed', { name: props.plugin?.plugin_name, message: result.message })) $toast.error(
t('plugin.updateFailed', {
name: props.plugin?.plugin_name,
message: result.message,
}),
)
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@@ -260,7 +278,7 @@ function showPluginClone() {
name: t('plugin.cloneDefaultName', { name: props.plugin?.plugin_name }), name: t('plugin.cloneDefaultName', { name: props.plugin?.plugin_name }),
description: t('plugin.cloneDefaultDescription', { description: props.plugin?.plugin_desc }), description: t('plugin.cloneDefaultDescription', { description: props.plugin?.plugin_desc }),
version: props.plugin?.plugin_version || '1.0', version: props.plugin?.plugin_version || '1.0',
icon: props.plugin?.plugin_icon || '' icon: props.plugin?.plugin_icon || '',
} }
pluginCloneDialog.value = true pluginCloneDialog.value = true
} }
@@ -281,7 +299,7 @@ async function executePluginClone() {
name: cloneForm.value.name.trim(), name: cloneForm.value.name.trim(),
description: cloneForm.value.description.trim(), description: cloneForm.value.description.trim(),
version: cloneForm.value.version.trim(), version: cloneForm.value.version.trim(),
icon: cloneForm.value.icon.trim() icon: cloneForm.value.icon.trim(),
}) })
progressDialog.value = false progressDialog.value = false
@@ -368,7 +386,7 @@ const dropdownItems = ref([
props: { props: {
prependIcon: 'mdi-file-document-outline', prependIcon: 'mdi-file-document-outline',
click: () => { click: () => {
openLoggerWindow() loggingDialog.value = true
}, },
}, },
}, },
@@ -541,135 +559,178 @@ watch(
</VCard> </VCard>
</VDialog> </VDialog>
<!-- 插件分身对话框 --> <!-- 实时日志弹窗 -->
<VDialog v-if="pluginCloneDialog" v-model="pluginCloneDialog" width="600" :fullscreen="!display.mdAndUp.value"> <VDialog
<VCard> v-if="loggingDialog"
<VCardTitle class="d-flex align-center pa-4"> v-model="loggingDialog"
<VIcon icon="mdi-content-copy" class="me-3" color="primary" /> scrollable
<div> max-width="60rem"
<div class="text-h6">🎭 {{ t('plugin.cloneTitle') }}</div> :fullscreen="!display.mdAndUp.value"
<div class="text-caption text-medium-emphasis">{{ t('plugin.cloneSubtitle', { name: props.plugin?.plugin_name }) }}</div> >
</div> <VCard>
</VCardTitle> <VDialogCloseBtn @click="loggingDialog = false" />
<VDialogCloseBtn @click="pluginCloneDialog = false" /> <VCardItem>
<VDivider /> <VCardTitle class="d-inline-flex">
<VIcon icon="mdi-file-document" class="me-2" />
{{ t('plugin.logTitle') }}
<a class="mx-2 d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
<VChip color="grey-darken-1" size="small" class="ml-2">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
</VCardText>
</VCard>
</VDialog>
<VCardText class="pa-4"> <!-- 插件分身对话框 -->
<!-- 功能说明 --> <VDialog v-if="pluginCloneDialog" v-model="pluginCloneDialog" width="600" :fullscreen="!display.mdAndUp.value">
<VAlert <VCard>
type="info" <VCardTitle class="d-flex align-center pa-4">
variant="tonal" <VIcon icon="mdi-content-copy" class="me-3" color="primary" />
density="compact" <div>
class="mb-4" <div class="text-h6">🎭 {{ t('plugin.cloneTitle') }}</div>
icon="mdi-information-outline" <div class="text-caption text-medium-emphasis">
> {{ t('plugin.cloneSubtitle', { name: props.plugin?.plugin_name }) }}
<div class="text-body-2"> </div>
<strong>{{ t('plugin.cloneFeature') }}</strong>{{ t('plugin.cloneDescription') }} </div>
</div> </VCardTitle>
</VAlert> <VDialogCloseBtn @click="pluginCloneDialog = false" />
<VDivider />
<VForm> <VCardText class="pa-4">
<VRow> <!-- 功能说明 -->
<VCol cols="12" md="6"> <VAlert type="info" variant="tonal" density="compact" class="mb-4" icon="mdi-information-outline">
<VTextField <div class="text-body-2">
v-model="cloneForm.suffix" <strong>{{ t('plugin.cloneFeature') }}</strong
:label="t('plugin.suffix') + ' *'" >{{ t('plugin.cloneDescription') }}
:placeholder="t('plugin.suffixPlaceholder')" </div>
:hint="t('plugin.suffixHint')" </VAlert>
persistent-hint
:rules="[
v => !!v || t('plugin.suffixRequired'),
v => /^[a-zA-Z0-9]+$/.test(v) || t('plugin.suffixFormatError'),
v => v.length <= 20 || t('plugin.suffixLengthError')
]"
required
prepend-inner-icon="mdi-tag"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.name"
:label="t('plugin.cloneName')"
:placeholder="t('plugin.cloneNamePlaceholder')"
:hint="t('plugin.cloneNameHint')"
persistent-hint
prepend-inner-icon="mdi-rename-box"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="cloneForm.description"
:label="t('plugin.cloneDescriptionLabel')"
:placeholder="t('plugin.cloneDescriptionPlaceholder')"
:hint="t('plugin.cloneDescriptionHint')"
persistent-hint
rows="2"
prepend-inner-icon="mdi-text"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.version"
:label="t('plugin.cloneVersion')"
:placeholder="t('plugin.cloneVersionPlaceholder')"
:hint="t('plugin.cloneVersionHint')"
persistent-hint
prepend-inner-icon="mdi-numeric"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.icon"
:label="t('plugin.cloneIcon')"
:placeholder="t('plugin.cloneIconPlaceholder')"
:hint="t('plugin.cloneIconHint')"
persistent-hint
prepend-inner-icon="mdi-image"
/>
</VCol>
<!-- 重要提醒 --> <VForm>
<VCol cols="12"> <VRow>
<VAlert <VCol cols="12" md="6">
type="warning" <VTextField
variant="tonal" v-model="cloneForm.suffix"
density="compact" :label="t('plugin.suffix') + ' *'"
class="mt-2" :placeholder="t('plugin.suffixPlaceholder')"
icon="mdi-alert-circle-outline" :hint="t('plugin.suffixHint')"
> persistent-hint
<div class="text-body-2"> :rules="[
<strong>{{ t('common.notice') }}</strong>{{ t('plugin.cloneNotice') }} v => !!v || t('plugin.suffixRequired'),
</div> v => /^[a-zA-Z0-9]+$/.test(v) || t('plugin.suffixFormatError'),
</VAlert> v => v.length <= 20 || t('plugin.suffixLengthError'),
</VCol> ]"
</VRow> required
</VForm> prepend-inner-icon="mdi-tag"
</VCardText> />
</VCol>
<VDivider />
<VCardActions class="pa-4"> <VCol cols="12" md="6">
<VSpacer /> <VTextField
<VBtn v-model="cloneForm.name"
@click="pluginCloneDialog = false" :label="t('plugin.cloneName')"
variant="outlined" :placeholder="t('plugin.cloneNamePlaceholder')"
> :hint="t('plugin.cloneNameHint')"
{{ t('common.cancel') }} persistent-hint
</VBtn> prepend-inner-icon="mdi-rename-box"
<VBtn />
color="primary" </VCol>
@click="executePluginClone"
:disabled="!cloneForm.suffix.trim()" <VCol cols="12">
> <VTextarea
<VIcon icon="mdi-content-copy" class="me-2" /> v-model="cloneForm.description"
{{ t('plugin.createClone') }} :label="t('plugin.cloneDescriptionLabel')"
</VBtn> :placeholder="t('plugin.cloneDescriptionPlaceholder')"
</VCardActions> :hint="t('plugin.cloneDescriptionHint')"
</VCard> persistent-hint
</VDialog> rows="2"
prepend-inner-icon="mdi-text"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.version"
:label="t('plugin.cloneVersion')"
:placeholder="t('plugin.cloneVersionPlaceholder')"
:hint="t('plugin.cloneVersionHint')"
persistent-hint
prepend-inner-icon="mdi-numeric"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.icon"
:label="t('plugin.cloneIcon')"
:placeholder="t('plugin.cloneIconPlaceholder')"
:hint="t('plugin.cloneIconHint')"
persistent-hint
prepend-inner-icon="mdi-image"
/>
</VCol>
<!-- 重要提醒 -->
<VCol cols="12">
<VAlert type="warning" variant="tonal" density="compact" class="mt-2" icon="mdi-alert-circle-outline">
<div class="text-body-2">
<strong>{{ t('common.notice') }}</strong
>{{ t('plugin.cloneNotice') }}
</div>
</VAlert>
</VCol>
</VRow>
</VForm>
</VCardText>
<VDivider />
<VCardActions class="pt-3">
<VSpacer />
<VBtn @click="pluginCloneDialog = false" variant="outlined">
{{ t('common.cancel') }}
</VBtn>
<VBtn color="primary" @click="executePluginClone" :disabled="!cloneForm.suffix.trim()">
<VIcon icon="mdi-content-copy" class="me-2" />
{{ t('plugin.createClone') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 实时日志弹窗 -->
<VDialog
v-if="loggingDialog"
v-model="loggingDialog"
scrollable
max-width="60rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="loggingDialog = false" />
<VCardItem>
<VCardTitle class="d-inline-flex">
<VIcon icon="mdi-file-document" class="me-2" />
{{ t('plugin.logTitle') }}
<a class="mx-2 d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
<VChip color="grey-darken-1" size="small" class="ml-2">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
</VCardText>
</VCard>
</VDialog>
</div> </div>
</template> </template>
+1 -1
View File
@@ -270,7 +270,7 @@ onMounted(() => {
</VCardItem> </VCardItem>
<VDivider /> <VDivider />
<VCardText> <VCardText>
<LoggingView /> <LoggingView logfile="moviepilot.log" />
</VCardText> </VCardText>
</VCard> </VCard>
</VDialog> </VDialog>
+5 -2
View File
@@ -2018,7 +2018,8 @@ export default {
cloneTitle: 'Create Plugin Clone', cloneTitle: 'Create Plugin Clone',
cloneSubtitle: 'Create an independent clone instance for {name}', cloneSubtitle: 'Create an independent clone instance for {name}',
cloneFeature: 'Plugin Clone Feature', cloneFeature: 'Plugin Clone Feature',
cloneDescription: 'Create an independent copy of the plugin with separate configuration and data, suitable for multi-account, testing environments, etc.', cloneDescription:
'Create an independent copy of the plugin with separate configuration and data, suitable for multi-account, testing environments, etc.',
suffix: 'Clone Suffix', suffix: 'Clone Suffix',
suffixPlaceholder: 'e.g.: Test, Backup, Site1', suffixPlaceholder: 'e.g.: Test, Backup, Site1',
suffixHint: 'Unique identifier to distinguish clones, only letters and numbers allowed', suffixHint: 'Unique identifier to distinguish clones, only letters and numbers allowed',
@@ -2039,12 +2040,14 @@ export default {
cloneIcon: 'Icon URL', cloneIcon: 'Icon URL',
cloneIconPlaceholder: 'https://example.com/icon.png', cloneIconPlaceholder: 'https://example.com/icon.png',
cloneIconHint: 'Custom icon for the clone plugin (optional)', cloneIconHint: 'Custom icon for the clone plugin (optional)',
cloneNotice: 'Clone plugins are disabled by default after creation and need to be manually configured and enabled. The clone suffix cannot be modified once set.', cloneNotice:
'Clone plugins are disabled by default after creation and need to be manually configured and enabled. The clone suffix cannot be modified once set.',
createClone: 'Create Clone', createClone: 'Create Clone',
cloning: 'Creating clone for {name}...', cloning: 'Creating clone for {name}...',
cloneSuccess: 'Plugin clone {name} created successfully!', cloneSuccess: 'Plugin clone {name} created successfully!',
cloneFailed: 'Plugin clone creation failed: {message}', cloneFailed: 'Plugin clone creation failed: {message}',
cloneFailedGeneral: 'Plugin clone creation failed', cloneFailedGeneral: 'Plugin clone creation failed',
logTitle: 'Plugin Logging',
}, },
profile: { profile: {
personalInfo: 'Personal Information', personalInfo: 'Personal Information',
+1
View File
@@ -2021,6 +2021,7 @@ export default {
cloneSuccess: '插件分身 {name} 创建成功!', cloneSuccess: '插件分身 {name} 创建成功!',
cloneFailed: '插件分身创建失败:{message}', cloneFailed: '插件分身创建失败:{message}',
cloneFailedGeneral: '插件分身创建失败', cloneFailedGeneral: '插件分身创建失败',
logTitle: '插件日志',
}, },
profile: { profile: {
personalInfo: '个人信息', personalInfo: '个人信息',
+1
View File
@@ -2022,6 +2022,7 @@ export default {
cloneSuccess: '插件分身 {name} 創建成功!', cloneSuccess: '插件分身 {name} 創建成功!',
cloneFailed: '插件分身創建失敗:{message}', cloneFailed: '插件分身創建失敗:{message}',
cloneFailedGeneral: '插件分身創建失敗', cloneFailedGeneral: '插件分身創建失敗',
logTitle: '插件日誌',
}, },
profile: { profile: {
personalInfo: '個人信息', personalInfo: '個人信息',
+60 -40
View File
@@ -1,84 +1,99 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useI18n } from 'vue-i18n' import { useI18n } from "vue-i18n";
// 定义输入变量
const props = defineProps<{
logfile: string;
}>();
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n();
// 已解析的日志列表 // 已解析的日志列表
const parsedLogs = ref<{ level: string; time: string; program: string; content: string }[]>([]) const parsedLogs = ref<
{ level: string; time: string; program: string; content: string }[]
>([]);
// 表头 // 表头
const headers = [ const headers = [
{ title: t('logging.level'), value: 'level' }, { title: t("logging.level"), value: "level" },
{ title: t('logging.time'), value: 'time' }, { title: t("logging.time"), value: "time" },
{ title: t('logging.program'), value: 'program' }, { title: t("logging.program"), value: "program" },
{ title: t('logging.content'), value: 'content' }, { title: t("logging.content"), value: "content" },
] ];
// SSE消息对象 // SSE消息对象
let eventSource: EventSource | null = null let eventSource: EventSource | null = null;
// 日志颜色映射表 // 日志颜色映射表
const logColorMap: Record<string, string> = { const logColorMap: Record<string, string> = {
DEBUG: 'secondary', DEBUG: "secondary",
INFO: 'info', INFO: "info",
WARNING: 'warning', WARNING: "warning",
ERROR: 'error', ERROR: "error",
} };
// 获取日志颜色 // 获取日志颜色
function getLogColor(level: string): string { function getLogColor(level: string): string {
return logColorMap[level] || 'secondary' return logColorMap[level] || "secondary";
} }
// SSE持续获取日志 // SSE持续获取日志
function startSSELogging() { function startSSELogging() {
eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/logging`) eventSource = new EventSource(
const buffer: string[] = [] `${import.meta.env.VITE_API_BASE_URL}system/logging?logfile=${
let timeoutId: number | null = null encodeURIComponent(props.logfile) ?? "moviepilot.log"
}`
);
const buffer: string[] = [];
let timeoutId: number | null = null;
eventSource.addEventListener('message', event => { eventSource.addEventListener("message", (event) => {
const message = event.data const message = event.data;
if (message) { if (message) {
buffer.push(message) buffer.push(message);
if (!timeoutId) { if (!timeoutId) {
timeoutId = window.setTimeout(() => { timeoutId = window.setTimeout(() => {
// 解析新日志 // 解析新日志
const newParsedLogs = buffer const newParsedLogs = buffer
.map(log => { .map((log) => {
const logPattern = /^【(.*?)】[0-9\-:]*\s(.*?)\s-\s(.*?)\s-\s(.*)$/ const logPattern = /^【(.*?)】[0-9\-:]*\s(.*?)\s-\s(.*?)\s-\s(.*)$/;
const matches = log.match(logPattern) const matches = log.match(logPattern);
if (matches) { if (matches) {
const [, level, time, program, content] = matches const [, level, time, program, content] = matches;
return { level, time, program, content } return { level, time, program, content };
} }
return null return null;
}) })
.filter(Boolean) .filter(Boolean);
// 倒序后插入parsedLogs顶部 // 倒序后插入parsedLogs顶部
parsedLogs.value.unshift(...(newParsedLogs.reverse() as any[])) parsedLogs.value.unshift(...(newParsedLogs.reverse() as any[]));
// 保留最新的200条日志 // 保留最新的200条日志
parsedLogs.value = parsedLogs.value.slice(0, 200) parsedLogs.value = parsedLogs.value.slice(0, 200);
// 重置buffer // 重置buffer
buffer.length = 0 buffer.length = 0;
timeoutId = null timeoutId = null;
}, 100) }, 100);
} }
} }
}) });
} }
onMounted(() => { onMounted(() => {
startSSELogging() startSSELogging();
}) });
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (eventSource) eventSource.close() if (eventSource) eventSource.close();
}) });
</script> </script>
<template> <template>
<LoadingBanner v-if="parsedLogs.length === 0" class="mt-12" :text="t('logging.refreshing') + ' ...'" /> <LoadingBanner
v-if="parsedLogs.length === 0"
class="mt-12"
:text="t('logging.refreshing') + ' ...'"
/>
<div v-else> <div v-else>
<VTable class="table-rounded" hide-default-footer disable-sort> <VTable class="table-rounded" hide-default-footer disable-sort>
<tbody> <tbody>
@@ -91,7 +106,12 @@ onBeforeUnmount(() => {
hide-default-header hide-default-header
> >
<template #item.level="{ item }"> <template #item.level="{ item }">
<VChip size="small" :color="getLogColor(item.level)" variant="elevated" v-text="item.level" /> <VChip
size="small"
:color="getLogColor(item.level)"
variant="elevated"
v-text="item.level"
/>
</template> </template>
<template #item.time="{ item }"> <template #item.time="{ item }">
<span class="text-sm">{{ item.time }}</span> <span class="text-sm">{{ item.time }}</span>