Compare commits

...

11 Commits

Author SHA1 Message Date
jxxghp
0a7d53b5c7 修复消息中心滚动显示 2026-06-14 21:32:14 +08:00
jxxghp
da0cd14af8 调整未读消息入口提示 2026-06-14 21:11:36 +08:00
jxxghp
342c62c085 docs: update README files to include Module Federation documentation 2026-06-14 16:35:57 +08:00
jxxghp
891274cc0e refactor(dialogs): unify button styles and layout in dialog components
- Updated VCardActions in multiple dialog components to use a consistent class `app-dialog-actions` for styling.
- Changed button variants to `flat` for primary actions and `tonal` for secondary actions across various dialogs.
- Adjusted padding and spacing for buttons to improve layout consistency.
- Enhanced responsiveness for dialog actions in mobile views.
2026-06-14 14:47:06 +08:00
jxxghp
889a4b744a chore: update version to 2.13.9 in package.json 2026-06-14 13:28:51 +08:00
jxxghp
7fc5b74851 feat: add wiki sync to plugin market settings 2026-06-14 12:57:40 +08:00
jxxghp
785cbcf81d fix manual transfer default auto target 2026-06-13 22:21:54 +08:00
InfinityPacer
364b660390 feat: add log download actions (#489) 2026-06-13 20:35:55 +08:00
jxxghp
599ca912f4 feat: 增强仪表板布局响应式支持,添加布局档位管理和本地存储功能 2026-06-13 20:34:42 +08:00
jxxghp
2f66f0f1fc feat: 增加仪表板配置的归一化和本地存储支持,优化用户配置加载 2026-06-13 20:00:02 +08:00
jxxghp
cd2f561194 chore: update version to 2.13.8 in package.json 2026-06-13 18:40:14 +08:00
57 changed files with 974 additions and 480 deletions

View File

@@ -11,15 +11,6 @@
- 支持多语言(中文/英文)
- 完整的插件系统支持,包括远程组件动态加载
## 模块联邦功能
MoviePilot 现已支持模块联邦Module Federation功能允许插件开发者创建可动态加载的远程组件实现更丰富的插件用户界面。
### 相关文档
- [模块联邦开发指南](docs/module-federation-guide.md) - 如何开发远程组件插件
- [模块联邦问题排查指南](docs/federation-troubleshooting.md) - 常见问题和解决方案
- [插件远程组件示例](examples/plugin-component/) - 开发插件组件的完整示例项目
## 开发部署
@@ -58,3 +49,12 @@ yarn build
```shell
node dist/service.js
```
### 模块联邦功能
MoviePilot 现已支持模块联邦Module Federation功能允许插件开发者创建可动态加载的远程组件实现更丰富的插件用户界面。
- [模块联邦开发指南](docs/module-federation-guide.md) - 如何开发远程组件插件
- [模块联邦问题排查指南](docs/federation-troubleshooting.md) - 常见问题和解决方案
- [插件远程组件示例](examples/plugin-component/) - 开发插件组件的完整示例项目

View File

@@ -11,15 +11,6 @@ Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS
- Multi-language support (Chinese/English)
- Complete plugin system with dynamic remote component loading
## Module Federation
MoviePilot now supports Module Federation, allowing plugin developers to create dynamically loadable remote components for richer plugin user interfaces.
### Documentation
- [Module Federation Troubleshooting Guide](docs/federation-troubleshooting.md) - Common issues and solutions
- [Plugin Remote Component Example](examples/plugin-component/) - Complete example project for developing plugin components
## Development
### Recommended IDE Setup
@@ -57,3 +48,10 @@ yarn build
```shell
node dist/service.js
```
### Module Federation
MoviePilot now supports Module Federation, allowing plugin developers to create dynamically loadable remote components for richer plugin user interfaces.
- [Module Federation Troubleshooting Guide](docs/federation-troubleshooting.md) - Common issues and solutions
- [Plugin Remote Component Example](examples/plugin-component/) - Complete example project for developing plugin components

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.13.7",
"version": "2.13.9",
"private": true,
"type": "module",
"bin": "dist/service.js",

View File

@@ -163,9 +163,9 @@ const instructions = computed(() => {
</VAlert>
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="text" @click="showInstructions = false">
<VBtn color="primary" variant="flat" class="px-5" @click="showInstructions = false">
{{ t('pwa.gotIt') }}
</VBtn>
</VCardActions>

View File

@@ -133,12 +133,12 @@ async function savaAlistConfig() {
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
<VCardActions class="app-dialog-actions">
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
{{ t('dialog.alistConfig.reset') }}
</VBtn>
<VSpacer />
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
{{ t('dialog.alistConfig.complete') }}
</VBtn>
</VCardActions>

View File

@@ -138,12 +138,12 @@ onUnmounted(() => {
</VAlert>
</div>
</VCardText>
<VCardActions>
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
<VCardActions class="app-dialog-actions">
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
{{ t('dialog.aliyunAuth.reset') }}
</VBtn>
<VSpacer />
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
{{ t('dialog.aliyunAuth.complete') }}
</VBtn>
</VCardActions>

View File

@@ -84,9 +84,16 @@ function submitReidentify() {
</VAlert>
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" :loading="props.loading" prepend-icon="mdi-check" @click="submitReidentify">
<VBtn
color="primary"
variant="flat"
:loading="props.loading"
prepend-icon="mdi-check"
class="px-5"
@click="submitReidentify"
>
{{ t('setting.cache.reidentifyDialog.confirm') }}
</VBtn>
</VCardActions>

View File

@@ -383,7 +383,7 @@ onMounted(() => {
</VTab>
</VTabs>
<div v-if="loading" class="d-flex justify-center align-center" style="min-height: 300px">
<div v-if="loading" class="d-flex justify-center align-center" style="min-block-size: 300px">
<VProgressCircular indeterminate color="primary" size="64" />
</div>
@@ -610,12 +610,16 @@ onMounted(() => {
</VWindow>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn variant="text" @click="emit('close')">
{{ t('common.cancel') }}
</VBtn>
<VBtn color="primary" :loading="saving" prepend-icon="mdi-content-save" class="px-5" @click="saveConfig">
<VBtn
color="primary"
variant="flat"
:loading="saving"
prepend-icon="mdi-content-save"
class="px-5"
@click="saveConfig"
>
{{ t('common.save') }}
</VBtn>
</VCardActions>

View File

@@ -153,15 +153,15 @@ function submitSettings() {
<VSwitch v-model="elevatedValue" :label="props.switchLabel" />
</p>
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(true)">
<VCardActions class="app-dialog-actions">
<VBtn v-if="props.showBulkActions" color="success" variant="tonal" @click="setAllItems(true)">
{{ props.selectAllText }}
</VBtn>
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(false)">
<VBtn v-if="props.showBulkActions" color="warning" variant="tonal" @click="setAllItems(false)">
{{ props.selectNoneText }}
</VBtn>
<VSpacer />
<VBtn color="primary" class="px-5" @click="submitSettings">
<VBtn color="primary" variant="flat" class="px-5" @click="submitSettings">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>

View File

@@ -86,8 +86,9 @@ function submitCustomCSS() {
class="custom-css-editor"
/>
</div>
<VCardActions class="custom-css-actions">
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
<VCardActions class="app-dialog-actions custom-css-actions">
<VSpacer />
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
{{ t('common.save') }}
</VBtn>
</VCardActions>

View File

@@ -199,8 +199,9 @@ onMounted(() => {
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="flat" @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('customRule.action.confirm') }}
</VBtn>
</VCardActions>

View File

@@ -88,9 +88,9 @@ function submitOrder() {
</template>
</draggable>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn @click="submitOrder">
<VBtn color="primary" variant="flat" class="px-5" @click="submitOrder">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>

View File

@@ -536,8 +536,9 @@ onMounted(() => {
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="flat" @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>

View File

@@ -52,9 +52,16 @@ function closeDialog() {
<VCardText>
<VTextField v-model="folderName" :label="t('common.name')" prepend-inner-icon="mdi-format-text" />
</VCardText>
<VCardActions>
<div class="flex-grow-1" />
<VBtn :disabled="!folderName" prepend-icon="mdi-folder-plus" class="px-5 me-3" @click="emit('create')">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
:disabled="!folderName"
prepend-icon="mdi-folder-plus"
class="px-5"
@click="emit('create')"
>
{{ t('common.create') }}
</VBtn>
</VCardActions>

View File

@@ -81,11 +81,19 @@ function closeDialog() {
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn color="success" prepend-icon="mdi-magic" class="px-5 me-3" @click="emit('auto-name')">
<VCardActions class="app-dialog-actions">
<VBtn color="success" variant="tonal" prepend-icon="mdi-magic" @click="emit('auto-name')">
{{ t('file.autoRecognizeName') }}
</VBtn>
<VBtn :disabled="!renameName" prepend-icon="mdi-check" class="px-5 me-3" @click="emit('rename')">
<VSpacer />
<VBtn
color="primary"
variant="flat"
:disabled="!renameName"
prepend-icon="mdi-check"
class="px-5"
@click="emit('rename')"
>
{{ t('common.confirm') }}
</VBtn>
</VCardActions>

View File

@@ -294,18 +294,23 @@ onMounted(() => {
</Draggable>
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
</VCardText>
<VCardActions class="pt-3">
<VBtn color="primary" @click="addFilterCard">
<VCardActions class="app-dialog-actions">
<VBtn color="primary" variant="tonal" class="app-dialog-actions__icon-btn" @click="addFilterCard">
<VIcon icon="mdi-plus" />
</VBtn>
<VBtn color="success" @click="importRules('priority')">
<VBtn
color="success"
variant="tonal"
class="app-dialog-actions__icon-btn"
@click="importRules('priority')"
>
<VIcon icon="mdi-import" />
</VBtn>
<VBtn color="info" @click="shareRules">
<VBtn color="info" variant="tonal" class="app-dialog-actions__icon-btn" @click="shareRules">
<VIcon icon="mdi-share" />
</VBtn>
<VSpacer />
<VBtn @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
<VBtn color="primary" variant="flat" @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>

View File

@@ -36,9 +36,9 @@ function handleImport() {
<VCardText class="pt-2">
<VTextarea v-model="codeString" prepend-inner-icon="mdi-code-json" />
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn @click="handleImport" prepend-icon="mdi-import" class="px-5 me-3">
<VBtn color="primary" variant="flat" @click="handleImport" prepend-icon="mdi-import" class="px-5">
{{ t('dialog.importCode.import') }}
</VBtn>
</VCardActions>

View File

@@ -43,7 +43,10 @@ function closeDialog() {
<template>
<VDialog v-if="visible" v-model="visible" max-width="560">
<VCard>
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
<VCardItem>
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
</VCardItem>
<VDivider />
<VCardText class="d-flex flex-column ga-4">
<VAlert v-if="props.authSession?.instructions" type="info" variant="tonal">
{{ props.authSession.instructions }}
@@ -71,9 +74,9 @@ function closeDialog() {
</VBtn>
</div>
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn variant="text" @click="closeDialog">
<VBtn color="primary" variant="flat" class="px-5" @click="closeDialog">
{{ t('common.close') }}
</VBtn>
</VCardActions>

View File

@@ -591,8 +591,15 @@ onMounted(() => {
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveMediaServerInfo" prepend-icon="mdi-content-save" class="px-5">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
@click="saveMediaServerInfo"
prepend-icon="mdi-content-save"
class="px-5"
>
{{ t('common.confirm') }}
</VBtn>
</VCardActions>

View File

@@ -1171,8 +1171,15 @@ onMounted(() => {
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveNotificationInfo" prepend-icon="mdi-content-save" class="px-5">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
@click="saveNotificationInfo"
prepend-icon="mdi-content-save"
class="px-5"
>
{{ t('common.confirm') }}
</VBtn>
</VCardActions>

View File

@@ -92,8 +92,9 @@ function submitTemplate() {
class="template-ace-editor"
/>
</div>
<VCardActions class="template-editor-actions">
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitTemplate">
<VCardActions class="app-dialog-actions template-editor-actions">
<VSpacer />
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="submitTemplate">
{{ t('common.save') }}
</VBtn>
</VCardActions>

View File

@@ -299,8 +299,9 @@ watch(
</VAlert>
</VCardText>
<VCardActions class="justify-end px-6 pb-4">
<VBtn variant="outlined" @click="show = false">{{ t('common.close') }}</VBtn>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="flat" class="px-5" @click="show = false">{{ t('common.close') }}</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -154,10 +154,11 @@ onMounted(() => {
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
@click="submitClone"
prepend-icon="mdi-content-copy"
class="px-5"

View File

@@ -160,13 +160,26 @@ onBeforeMount(async () => {
<div v-if="!pluginFormItems || pluginFormItems.length === 0">此插件没有可配置项</div>
</div>
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" color="info">
<VCardActions class="app-dialog-actions">
<VBtn
v-if="props.plugin?.has_page"
color="info"
variant="tonal"
prepend-icon="mdi-database-eye-outline"
@click="emit('switch')"
>
{{ t('dialog.pluginConfig.viewData') }}
</VBtn>
<VSpacer />
<!-- 只有Vuetify模式显示默认保存按钮Vue模式由组件内部控制 -->
<VBtn v-if="renderMode === 'vuetify'" @click="savePluginConf" prepend-icon="mdi-content-save" class="px-5">
<VBtn
v-if="renderMode === 'vuetify'"
color="primary"
variant="flat"
@click="savePluginConf"
prepend-icon="mdi-content-save"
class="px-5"
>
保存
</VBtn>
</VCardActions>

View File

@@ -54,9 +54,9 @@ function closeDialog() {
@keyup.enter="emit('create')"
/>
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-folder-plus" class="px-5" @click="emit('create')">
<VBtn color="primary" variant="flat" prepend-icon="mdi-folder-plus" class="px-5" @click="emit('create')">
{{ t('plugin.create') }}
</VBtn>
</VCardActions>

View File

@@ -57,9 +57,9 @@ function confirmRename() {
@keyup.enter="confirmRename"
/>
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
<VBtn color="primary" variant="flat" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -201,9 +201,11 @@ onMounted(() => {
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">保存</VBtn>
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">
保存
</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -42,6 +42,12 @@ function openLoggerWindow() {
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
window.open(url, '_blank')
}
/** 下载当前插件日志压缩包。 */
function downloadLogger() {
const url = `${import.meta.env.VITE_API_BASE_URL}system/logging/download/${props.plugin?.id?.toLowerCase()}`
window.open(url, '_blank')
}
</script>
<template>
@@ -52,12 +58,20 @@ function openLoggerWindow() {
<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>
<span class="ms-4 d-inline-flex align-center ga-1">
<a class="d-inline-flex align-center cursor-pointer" @click="downloadLogger">
<VChip color="grey-darken-1" size="small">
<VIcon icon="mdi-download" size="small" start />
{{ t('common.download') }}
</VChip>
</a>
<a class="d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
<VChip color="grey-darken-1" size="small">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
</span>
</VCardTitle>
</VCardItem>
<VDivider />

View File

@@ -24,11 +24,14 @@ const repoText = ref('')
const newRepoUrl = ref('')
const editingIndex = ref<number | null>(null)
const editingUrl = ref('')
const syncingWiki = ref(false)
const emit = defineEmits(['save', 'close'])
const parsedTextRepos = computed(() => parseRepoInput(repoText.value))
const activeRepoCount = computed(() => (editorMode.value === 'text' ? parsedTextRepos.value.repos.length : repoList.value.length))
const activeRepoCount = computed(() =>
editorMode.value === 'text' ? parsedTextRepos.value.repos.length : repoList.value.length,
)
const saveDisabled = computed(
() => activeRepoCount.value === 0 || (editorMode.value === 'text' && parsedTextRepos.value.invalidRepos.length > 0),
)
@@ -136,6 +139,35 @@ async function saveHandle() {
}
}
/** 从 Wiki 同步公开插件仓库清单并写入配置。 */
async function syncWikiRepos() {
try {
syncingWiki.value = true
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET/sync-wiki', {})
if (result.success) {
const repos = Array.isArray(result.data?.repos)
? result.data.repos
: parseRepoInput(result.data?.value || '').repos
repoList.value = repos
syncTextFromList()
$toast.success(
t('dialog.pluginMarketSetting.syncSuccess', {
added: result.data?.added_count ?? 0,
total: result.data?.total_count ?? repos.length,
}),
)
} else {
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: result?.message }))
}
} catch (error) {
console.log(error)
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: error instanceof Error ? error.message : '' }))
} finally {
syncingWiki.value = false
}
}
/** 获取当前维护模式下可保存的仓库地址。 */
function normalizeCurrentRepos() {
if (editorMode.value === 'text') {
@@ -224,8 +256,8 @@ function formatRepoDisplay(url: string) {
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean)
if (
['github.com', 'www.github.com', 'raw.githubusercontent.com'].includes(parsedUrl.hostname)
&& pathSegments.length >= 2
['github.com', 'www.github.com', 'raw.githubusercontent.com'].includes(parsedUrl.hostname) &&
pathSegments.length >= 2
) {
return `${pathSegments[0]}/${pathSegments[1].replace(/\.git$/, '')}`
}
@@ -258,25 +290,47 @@ onMounted(() => {
</div>
<VDialogCloseBtn @click="emit('close')" />
</VCardItem>
<VDivider />
<VCardText class="plugin-market-dialog-body pt-4">
<div class="plugin-market-toolbar">
<VBtnToggle
:model-value="editorMode"
mandatory
color="primary"
density="comfortable"
variant="tonal"
class="plugin-market-mode-toggle"
@update:model-value="switchEditorMode"
>
<VBtn value="list" prepend-icon="mdi-format-list-bulleted">
{{ t('dialog.pluginMarketSetting.listMode') }}
</VBtn>
<VBtn value="text" prepend-icon="mdi-text-box-edit-outline">
{{ t('dialog.pluginMarketSetting.textMode') }}
</VBtn>
</VBtnToggle>
<div class="plugin-market-toolbar-hint">
<VIcon icon="mdi-information-outline" size="18" />
<span>{{ t('dialog.pluginMarketSetting.repoCountHint', { count: activeRepoCount }) }}</span>
</div>
<div class="plugin-market-mode-switch" role="tablist" :aria-label="t('dialog.pluginMarketSetting.title')">
<VTooltip :text="t('dialog.pluginMarketSetting.listMode')" location="top">
<template #activator="{ props }">
<button
v-bind="props"
type="button"
class="plugin-market-mode-button"
:class="{ 'is-active': editorMode === 'list' }"
role="tab"
:aria-label="t('dialog.pluginMarketSetting.listMode')"
:aria-selected="editorMode === 'list'"
@click="switchEditorMode('list')"
>
<VIcon icon="mdi-format-list-bulleted" size="20" />
</button>
</template>
</VTooltip>
<VTooltip :text="t('dialog.pluginMarketSetting.textMode')" location="top">
<template #activator="{ props }">
<button
v-bind="props"
type="button"
class="plugin-market-mode-button"
:class="{ 'is-active': editorMode === 'text' }"
role="tab"
:aria-label="t('dialog.pluginMarketSetting.textMode')"
:aria-selected="editorMode === 'text'"
@click="switchEditorMode('text')"
>
<VIcon icon="mdi-text-box-edit-outline" size="20" />
</button>
</template>
</VTooltip>
</div>
</div>
<div v-if="editorMode === 'list'" class="plugin-market-list-panel">
@@ -424,7 +478,17 @@ onMounted(() => {
</div>
</VCardText>
<VCardActions class="plugin-market-actions">
<VCardActions class="app-dialog-actions">
<VBtn
color="success"
variant="tonal"
prepend-icon="mdi-cloud-sync-outline"
:loading="syncingWiki"
:disabled="syncingWiki"
@click="syncWikiRepos"
>
{{ t('dialog.pluginMarketSetting.syncWiki') }}
</VBtn>
<VSpacer />
<VBtn
color="primary"
@@ -478,14 +542,70 @@ onMounted(() => {
.plugin-market-toolbar {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-block-size: 2.25rem;
}
.plugin-market-mode-toggle {
inline-size: 100%;
.plugin-market-toolbar-hint {
display: flex;
align-items: center;
border-radius: 0.375rem;
background: rgba(var(--v-theme-info), 0.08);
color: rgb(var(--v-theme-info));
font-size: 0.875rem;
gap: 0.5rem;
min-inline-size: 0;
padding-block: 0.5rem;
padding-inline: 1rem;
:deep(.v-btn) {
flex: 1;
min-inline-size: 0;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.plugin-market-mode-switch {
display: inline-flex;
padding: 0.125rem;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 0.375rem;
background: rgba(var(--v-theme-surface), 0.72);
gap: 0.125rem;
}
.plugin-market-mode-button {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 0.375rem;
background: transparent;
block-size: 2.25rem;
color: rgba(var(--v-theme-on-surface), 0.68);
cursor: pointer;
font: inherit;
inline-size: 2.25rem;
transition:
background-color 0.16s ease,
color 0.16s ease;
&:hover {
background: rgba(var(--v-theme-primary), 0.07);
color: rgb(var(--v-theme-on-surface));
}
&:focus-visible {
outline: 2px solid rgba(var(--v-theme-primary), 0.48);
outline-offset: 2px;
}
&.is-active {
background: rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary));
}
}
@@ -529,8 +649,8 @@ onMounted(() => {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-break: anywhere;
-webkit-line-clamp: 2;
overflow-wrap: anywhere;
white-space: normal;
word-break: break-word;
@@ -550,20 +670,22 @@ onMounted(() => {
.plugin-market-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-direction: column;
min-block-size: 14rem;
}
.plugin-market-textarea-field {
position: relative;
display: flex;
overflow: hidden;
flex: 1;
background: rgba(var(--v-theme-surface), 0.72);
min-block-size: 0;
overflow: hidden;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
&:focus-within {
border-color: rgb(var(--v-theme-primary));
@@ -586,13 +708,14 @@ onMounted(() => {
background: transparent;
block-size: 100%;
color: rgb(var(--v-theme-on-surface));
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
font-size: 1rem;
line-height: 1.6;
min-block-size: 0;
outline: none;
overflow-y: auto;
padding: 1rem 1rem 1rem 3.25rem;
padding-block: 1rem;
padding-inline: 3.25rem 1rem;
resize: none;
white-space: pre-wrap;
word-break: break-word;
@@ -612,19 +735,14 @@ onMounted(() => {
}
}
.plugin-market-actions {
flex: 0 0 auto;
gap: 0.5rem;
padding: 0.75rem 1.5rem 1rem;
}
@media (max-width: 600px) {
@media (width <= 600px) {
.plugin-market-dialog-card {
block-size: 100dvh;
}
.plugin-market-card-item {
padding: 0.75rem 1rem 0.625rem;
padding-block: 0.75rem 0.625rem;
padding-inline: 1rem;
}
.plugin-market-header {
@@ -640,16 +758,22 @@ onMounted(() => {
.plugin-market-dialog-body {
gap: 0.625rem;
padding: 0.75rem 1rem !important;
padding-block: 0.75rem !important;
padding-inline: 1rem !important;
}
.plugin-market-mode-toggle {
inline-size: 100%;
.plugin-market-toolbar {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
:deep(.v-btn) {
flex: 1;
min-inline-size: 0;
}
.plugin-market-mode-switch {
flex: 0 0 auto;
}
.plugin-market-toolbar-hint {
flex: 1 1 auto;
}
.plugin-market-list-panel,
@@ -664,9 +788,5 @@ onMounted(() => {
.plugin-market-empty {
min-block-size: 10rem;
}
.plugin-market-actions {
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
}
}
</style>

View File

@@ -89,12 +89,12 @@ async function handleReset() {
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
<VCardActions class="app-dialog-actions">
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
{{ t('dialog.rcloneConfig.reset') }}
</VBtn>
<VSpacer />
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
{{ t('dialog.rcloneConfig.complete') }}
</VBtn>
</VCardActions>

View File

@@ -10,7 +10,6 @@ import {
ManualTransferPayload,
ManualTransferPreviewData,
ManualTransferPreviewItem,
ManualTransferTargetPathData,
StorageConf,
TransferDirectoryConf,
TransferForm,
@@ -118,14 +117,6 @@ const episodeFormatRecommendState = reactive<{
const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined)
interface ManualTransferTargetPathRequest {
fileitem?: FileItem
fileitems?: FileItem[]
logid?: number
logids?: number[]
target_storage?: string | null
}
interface TargetDirectoryOption {
title: string
value: string
@@ -297,16 +288,20 @@ const disableEpisodeDetail = computed(() => {
}
})
const initialTargetPath = normalizeTargetPath(props.target_path)
// 表单
const transferForm = reactive<TransferForm>({
fileitem: {} as FileItem,
logid: 0,
target_storage: props.target_storage ?? 'local',
target_path: normalizeTargetPath(props.target_path),
target_storage: initialTargetPath ? (props.target_storage ?? 'local') : null,
target_path: initialTargetPath,
transfer_type: null,
min_filesize: 0,
scrape: false,
scrape: initialTargetPath ? false : null,
from_history: false,
library_type_folder: null,
library_category_folder: null,
episode_group: null,
})
@@ -354,51 +349,6 @@ const targetPathSelection = computed({
},
})
// 构造目的路径自动匹配请求,只传用户真实上下文,避免用默认存储误导后端匹配。
function createTargetPathMatchRequest(): ManualTransferTargetPathRequest | undefined {
const payload: ManualTransferTargetPathRequest = {}
if (props.target_storage) {
payload.target_storage = props.target_storage
}
if (normalizedItems.value.length === 1) {
payload.fileitem = normalizedItems.value[0]
return payload
}
if (normalizedItems.value.length > 1) {
payload.fileitems = normalizedItems.value
return payload
}
if (props.logids?.length) {
if (props.logids.length > 1) {
payload.logids = props.logids
return payload
}
payload.logid = props.logids[0]
return payload
}
}
// 应用后端匹配到的目的路径配置,未匹配时保持 null 等待用户手工选择。
function applyMatchedTargetPath(data?: ManualTransferTargetPathData) {
const matchedTargetPath = normalizeTargetPath(data?.target_path)
if (!matchedTargetPath) {
resetAutomaticTargetConfig()
return
}
transferForm.target_storage = data?.target_storage || transferForm.target_storage || 'local'
transferForm.transfer_type = data?.transfer_type || transferForm.transfer_type
transferForm.scrape = data?.scrape ?? false
transferForm.library_type_folder = data?.library_type_folder ?? false
transferForm.library_category_folder = data?.library_category_folder ?? false
transferForm.target_path = matchedTargetPath
}
// 重置为完全自动匹配状态,提交时不携带目标路径及其派生配置。
function resetAutomaticTargetConfig() {
transferForm.target_storage = null
@@ -409,34 +359,6 @@ function resetAutomaticTargetConfig() {
transferForm.library_category_folder = null
}
// 请求后端按源目录匹配最合适的手动整理目的路径。
async function autoSelectTargetPath() {
if (normalizeTargetPath(props.target_path) || transferForm.target_path) return
const payload = createTargetPathMatchRequest()
if (!payload) {
resetAutomaticTargetConfig()
return
}
try {
const result = await api.post<ApiResponse<ManualTransferTargetPathData>, ApiResponse<ManualTransferTargetPathData>>(
'transfer/manual/target-path',
payload,
)
if (!result.success) {
resetAutomaticTargetConfig()
return
}
applyMatchedTargetPath(result.data)
} catch (error) {
console.log(error)
resetAutomaticTargetConfig()
}
}
// 监听目的路径变化,配置默认值
watch(
() => transferForm.target_path,
@@ -1374,7 +1296,6 @@ async function transfer(background: boolean = false) {
onMounted(async () => {
await loadDirectories()
await autoSelectTargetPath()
loadStorages()
loadEpisodeFormatRuleConfiguration()
})
@@ -1629,35 +1550,39 @@ onUnmounted(() => {
</VCol>
</VRow>
</VForm>
<VCardActions class="reorganize-form-pane__actions pt-3 px-0 pb-0">
<VBtn
color="info"
:variant="previewVisible ? 'tonal' : 'text'"
@click="togglePreview"
:prepend-icon="previewToggleIcon"
class="reorganize-action-btn reorganize-action-btn--preview"
:class="{ 'reorganize-action-btn--active': previewVisible }"
:loading="previewLoading"
>
{{ t('dialog.reorganize.previewResult') }}
</VBtn>
<VBtn
color="success"
@click="transfer(true)"
prepend-icon="mdi-plus"
class="reorganize-action-btn reorganize-action-btn--queue"
>
{{ t('dialog.reorganize.addToQueue') }}
</VBtn>
<VBtn
@click="transfer(false)"
prepend-icon="mdi-arrow-right-bold"
class="reorganize-action-btn reorganize-action-btn--primary"
>
{{ t('dialog.reorganize.reorganizeNow') }}
</VBtn>
</VCardActions>
</div>
<VCardActions class="app-dialog-actions reorganize-form-pane__actions">
<VBtn
color="info"
variant="tonal"
@click="togglePreview"
:prepend-icon="previewToggleIcon"
class="reorganize-action-btn reorganize-action-btn--preview"
:class="{ 'reorganize-action-btn--active': previewVisible }"
:loading="previewLoading"
>
{{ t('dialog.reorganize.previewResult') }}
</VBtn>
<VBtn
color="success"
variant="tonal"
@click="transfer(true)"
prepend-icon="mdi-plus"
class="reorganize-action-btn reorganize-action-btn--queue"
>
{{ t('dialog.reorganize.addToQueue') }}
</VBtn>
<VSpacer />
<VBtn
color="primary"
variant="flat"
@click="transfer(false)"
prepend-icon="mdi-arrow-right-bold"
class="reorganize-action-btn reorganize-action-btn--primary"
>
{{ t('dialog.reorganize.reorganizeNow') }}
</VBtn>
</VCardActions>
</div>
<div v-show="previewVisible" class="reorganize-preview-pane">
<div class="reorganize-preview-pane__header">
@@ -1890,17 +1815,9 @@ onUnmounted(() => {
}
.reorganize-form-pane__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.75rem;
margin-block-start: auto;
}
.reorganize-action-btn {
min-inline-size: 0;
}
.reorganize-action-btn--active {
background: rgba(var(--v-theme-info), 0.12);
}
@@ -2240,15 +2157,9 @@ onUnmounted(() => {
border-inline-end: none;
}
.reorganize-form-pane__actions {
display: grid;
justify-content: stretch;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.reorganize-action-btn {
inline-size: 100%;
min-block-size: 2.75rem;
padding-inline: 1rem;
}
.reorganize-preview-pane__summary {
@@ -2263,14 +2174,10 @@ onUnmounted(() => {
@media (width <= 640px) {
.reorganize-form-pane__actions {
justify-content: stretch;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.reorganize-action-btn {
min-inline-size: 0;
}
.reorganize-action-btn--primary {
grid-column: 1 / -1;
}

View File

@@ -175,10 +175,11 @@ const filteredSites = computed(() => {
</div>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
:disabled="selectedSites.length === 0"
@click="confirmSearch"
prepend-icon="mdi-magnify"

View File

@@ -34,6 +34,11 @@ const visible = computed({
function allLoggingUrl() {
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
}
/** 拼接主程序日志下载 URL。 */
function allLoggingDownloadUrl() {
return `${import.meta.env.VITE_API_BASE_URL}system/logging/download/moviepilot`
}
</script>
<template>
@@ -44,12 +49,20 @@ function allLoggingUrl() {
<VCardTitle class="d-inline-flex">
<VIcon icon="mdi-file-document" class="me-2" />
{{ t('shortcut.log.subtitle') }}
<a class="mx-2 d-inline-flex align-center" :href="allLoggingUrl()" target="_blank">
<VChip color="grey-darken-1" size="small" class="ml-2">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
<span class="ms-4 d-inline-flex align-center ga-1">
<a class="d-inline-flex align-center" :href="allLoggingDownloadUrl()" target="_blank">
<VChip color="grey-darken-1" size="small">
<VIcon icon="mdi-download" size="small" start />
{{ t('common.download') }}
</VChip>
</a>
<a class="d-inline-flex align-center" :href="allLoggingUrl()" target="_blank">
<VChip color="grey-darken-1" size="small">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
</span>
</VCardTitle>
</VCardItem>
<VDivider />

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import api from '@/api'
import { clearAppBadge } from '@/utils/badge'
import { clearUnreadMessages } from '@/utils/badge'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
@@ -67,15 +67,19 @@ async function sendMessage() {
}
}
/** 清除未读消息计数和桌面角标。 */
function clearUnreadMessageState() {
window.setTimeout(() => {
void clearUnreadMessages()
}, 500)
}
watch(visible, async newValue => {
if (newValue) {
await nextTick()
messageViewRef.value?.resumeSSE?.()
messageViewRef.value?.forceScrollToEnd?.()
window.setTimeout(() => {
void clearAppBadge()
}, 500)
clearUnreadMessageState()
return
}
@@ -85,12 +89,8 @@ watch(visible, async newValue => {
onMounted(async () => {
await nextTick()
messageViewRef.value?.resumeSSE?.()
messageViewRef.value?.forceScrollToEnd?.()
window.setTimeout(() => {
void clearAppBadge()
}, 500)
clearUnreadMessageState()
})
onUnmounted(() => {

View File

@@ -340,12 +340,26 @@ onMounted(async () => {
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn v-if="props.oper === 'add'" color="primary" @click="addSite" prepend-icon="mdi-plus" class="px-5">
<VBtn
v-if="props.oper === 'add'"
color="primary"
variant="flat"
@click="addSite"
prepend-icon="mdi-plus"
class="px-5"
>
{{ t('site.actions.add') }}
</VBtn>
<VBtn v-else color="primary" @click="updateSiteInfo" prepend-icon="mdi-content-save" class="px-5">
<VBtn
v-else
color="primary"
variant="flat"
@click="updateSiteInfo"
prepend-icon="mdi-content-save"
class="px-5"
>
{{ t('common.save') }}
</VBtn>
</VCardActions>

View File

@@ -110,9 +110,11 @@ async function updateSiteCookie() {
</VRow>
</VForm>
</VCardText>
<VCardActions class="mx-auto">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
size="large"
color="primary"
variant="flat"
@click="updateSiteCookie"
:disabled="updateButtonDisable"
:loading="updateButtonDisable"

View File

@@ -117,12 +117,12 @@ async function saveSmbConfig() {
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
<VCardActions class="app-dialog-actions">
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
{{ t('dialog.smbConfig.reset') }}
</VBtn>
<VSpacer />
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
{{ t('dialog.smbConfig.complete') }}
</VBtn>
</VCardActions>

View File

@@ -89,8 +89,9 @@ function handleDone() {
</VCol>
</VRow>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>

View File

@@ -559,12 +559,14 @@ onMounted(() => {
</VWindow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="!props.default" color="error" @click="removeSubscribe" class="me-3">
<VCardActions class="app-dialog-actions">
<VBtn v-if="!props.default" color="error" variant="tonal" @click="removeSubscribe">
{{ t('dialog.subscribeEdit.cancelSubscribe') }}
</VBtn>
<VSpacer />
<VBtn
color="primary"
variant="flat"
@click=";`${props.default ? saveDefaultSubscribeConfig() : updateSubscribeInfo()}`"
prepend-icon="mdi-content-save"
class="px-5"

View File

@@ -105,9 +105,17 @@ const $toast = useToast()
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn :disabled="shareDoing" @click="doShare" prepend-icon="mdi-share" class="px-5" :loading="shareDoing">
<VBtn
color="primary"
variant="flat"
:disabled="shareDoing"
@click="doShare"
prepend-icon="mdi-share"
class="px-5"
:loading="shareDoing"
>
{{ t('dialog.subscribeShare.confirmShare') }}
</VBtn>
</VCardActions>

View File

@@ -97,9 +97,9 @@ function updateFilter(values: string[]) {
</VChip>
</VChipGroup>
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="visible = false">
<VBtn color="primary" variant="flat" prepend-icon="mdi-check" class="px-5" @click="visible = false">
{{ t('torrent.confirm') }}
</VBtn>
</VCardActions>

View File

@@ -225,11 +225,11 @@ onUnmounted(() => {
</div>
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VBtn
color="error"
variant="tonal"
prepend-icon="mdi-restore"
class="px-5 me-3"
@click="handleReset"
>
{{ t('dialog.u115Auth.reset') }}
@@ -238,8 +238,10 @@ onUnmounted(() => {
<VSpacer />
<VBtn
color="primary"
variant="flat"
prepend-icon="mdi-check"
class="px-5 me-3"
class="px-5"
@click="handleDone"
>
{{ t('dialog.u115Auth.complete') }}

View File

@@ -612,12 +612,13 @@ onMounted(() => {
</div>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
v-if="props.oper === 'add'"
:disabled="isAdding"
color="primary"
variant="flat"
@click="addUser"
prepend-icon="mdi-plus"
class="px-5"
@@ -629,6 +630,7 @@ onMounted(() => {
v-else
:disabled="isUpdating"
color="primary"
variant="flat"
@click="updateUser"
prepend-icon="mdi-content-save"
class="px-5"

View File

@@ -312,12 +312,19 @@ onMounted(() => {
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn v-if="workflow" color="primary" @click="editWorkflow" prepend-icon="mdi-content-save" class="px-5">
<VBtn
v-if="workflow"
color="primary"
variant="flat"
@click="editWorkflow"
prepend-icon="mdi-content-save"
class="px-5"
>
{{ t('dialog.workflowAddEdit.confirm') }}
</VBtn>
<VBtn v-else color="primary" @click="addWorkflow" prepend-icon="mdi-plus" class="px-5">
<VBtn v-else color="primary" variant="flat" @click="addWorkflow" prepend-icon="mdi-plus" class="px-5">
{{ t('dialog.workflowAddEdit.confirm') }}
</VBtn>
</VCardActions>

View File

@@ -125,9 +125,17 @@ const $toast = useToast()
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn :disabled="shareDoing" @click="doShare" prepend-icon="mdi-share" class="px-5" :loading="shareDoing">
<VBtn
color="primary"
variant="flat"
:disabled="shareDoing"
@click="doShare"
prepend-icon="mdi-share"
class="px-5"
:loading="shareDoing"
>
{{ t('dialog.workflowShare.confirmShare') }}
</VBtn>
</VCardActions>

View File

@@ -24,7 +24,6 @@ import {
hasPermission,
type UserPermissionKey,
} from '@/utils/permission'
import { onUnreadMessage } from '@/utils/badge'
import { usePullDownGesture } from '@/composables/usePullDownGesture'
import { usePWA } from '@/composables/usePWA'
import OfflinePage from '@/layouts/components/OfflinePage.vue'
@@ -48,9 +47,6 @@ const themeLayout = ref(readThemeCustomizerSettings().layout)
const userStore = useUserStore()
const pluginSidebarNavStore = usePluginSidebarNavStore()
// ShortcutBar 引用
const shortcutBarRef = ref<InstanceType<typeof ShortcutBar> | null>(null)
// 获取用户权限信息
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
@@ -382,18 +378,6 @@ function applyPendingHorizontalTab() {
pendingHorizontalTab.value = null
}
// 处理未读消息事件
function handleUnreadMessage(count: number) {
if (canAdmin.value && count > 0) {
// 延迟一点时间确保组件已渲染
setTimeout(() => {
if (shortcutBarRef.value && typeof shortcutBarRef.value.openMessageDialog === 'function') {
shortcutBarRef.value.openMessageDialog()
}
}, 500)
}
}
// 关闭插件快速访问
function handleClosePluginQuickAccess() {
showPluginQuickAccess.value = false
@@ -442,9 +426,6 @@ onMounted(async () => {
await pluginSidebarNavStore.ensureSidebarNav()
appendPluginSidebarMenus()
// 监听全局未读消息事件
const unsubscribe = onUnreadMessage(handleUnreadMessage)
// 监听Service Worker消息
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
@@ -454,7 +435,6 @@ onMounted(async () => {
// 组件卸载时清理监听
onBeforeUnmount(() => {
unsubscribe()
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
@@ -521,7 +501,7 @@ onMounted(async () => {
<!-- 👉 Horizontal Search Icon -->
<SearchBar v-if="showHorizontalThemeNav" icon-only />
<!-- 👉 Shortcuts -->
<ShortcutBar v-if="canAdmin" ref="shortcutBarRef" />
<ShortcutBar v-if="canAdmin" />
<!-- 👉 Notification -->
<UserNofification />
<!-- 👉 UserProfile -->

View File

@@ -5,6 +5,7 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, filterItemsByPermission, hasItemPermission, type PermissionProtectedItem } from '@/utils/permission'
import { clearUnreadMessages, getUnreadCount, onUnreadMessage } from '@/utils/badge'
// 国际化
const { t } = useI18n()
@@ -43,6 +44,12 @@ const appsMenu = ref(false)
// 菜单最大宽度
const menuMaxWidth = ref(420)
// 未读消息数量,用于控制消息捷径卡片上的红点。
const unreadMessageCount = ref(0)
const hasUnreadMessages = computed(() => unreadMessageCount.value > 0)
let unreadStateRevision = 0
let stopUnreadMessageListener: (() => void) | null = null
// 定义捷径列表
const shortcuts: ShortcutItem[] = [
{
@@ -127,12 +134,44 @@ const shortcuts: ShortcutItem[] = [
const visibleShortcuts = computed(() => filterItemsByPermission(shortcuts, userPermissions.value))
/** 设置消息捷径卡片的未读数量。 */
function setUnreadMessageCount(count: number) {
unreadMessageCount.value = Math.max(0, count)
}
/** 同步全局未读消息数量到消息捷径卡片。 */
function handleUnreadMessage(count: number) {
unreadStateRevision += 1
setUnreadMessageCount(count)
}
/** 从 Service Worker 读取当前未读数量,避免错过启动早期事件。 */
async function syncUnreadMessageStateFromBadge() {
const revision = unreadStateRevision
const count = await getUnreadCount()
if (revision === unreadStateRevision) {
setUnreadMessageCount(count)
}
}
/** 清空未读消息数量和 PWA 桌面角标。 */
function clearUnreadMessageState() {
unreadStateRevision += 1
setUnreadMessageCount(0)
void clearUnreadMessages()
}
/** 打开快捷工具对应的共享弹窗。 */
function openShortcutDialog(item: (typeof shortcuts)[number]) {
if (!hasItemPermission(item, userPermissions.value)) return
appsMenu.value = false
if (item.dialog === 'message') {
clearUnreadMessageState()
}
if (item.customDialog) {
openSharedDialog(item.customDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
return
@@ -168,6 +207,9 @@ defineExpose({
})
onMounted(() => {
stopUnreadMessageListener = onUnreadMessage(handleUnreadMessage)
void syncUnreadMessageStateFromBadge()
const shortcut = getQueryValue('shortcut')
if (shortcut) {
const found = visibleShortcuts.value.find(item => item.dialog === shortcut)
@@ -176,6 +218,10 @@ onMounted(() => {
}
}
})
onBeforeUnmount(() => {
stopUnreadMessageListener?.()
})
</script>
<template>
@@ -211,20 +257,30 @@ onMounted(() => {
<div class="grid grid-cols-2 gap-3">
<!-- 循环渲染快捷方式 -->
<div v-for="(item, index) in visibleShortcuts" :key="index">
<VCard
flat
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full"
hover
@click="openShortcutDialog(item)"
<VBadge
:model-value="item.dialog === 'message' && hasUnreadMessages"
dot
color="error"
location="top end"
offset-x="8"
offset-y="8"
class="d-block h-full w-100"
>
<VAvatar variant="text" size="48" rounded="lg">
<VIcon color="primary" :icon="item.icon" size="24" />
</VAvatar>
<div>
<div class="text-body-1 text-high-emphasis font-weight-medium">{{ item.title }}</div>
<div class="text-caption text-medium-emphasis">{{ item.subtitle }}</div>
</div>
</VCard>
<VCard
flat
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full w-100"
hover
@click="openShortcutDialog(item)"
>
<VAvatar variant="text" size="48" rounded="lg">
<VIcon color="primary" :icon="item.icon" size="24" />
</VAvatar>
<div>
<div class="text-body-1 text-high-emphasis font-weight-medium">{{ item.title }}</div>
<div class="text-caption text-medium-emphasis">{{ item.subtitle }}</div>
</div>
</VCard>
</VBadge>
</div>
</div>
</div>

View File

@@ -14,6 +14,7 @@ export default {
success: 'Success',
error: 'Error',
openInNewWindow: 'Open in new window',
download: 'Download',
inputMessage: 'Enter message or command',
send: 'Send',
noData: 'No data',
@@ -2581,6 +2582,7 @@ export default {
repoHint: 'Separate multiple URLs with new lines or commas',
urlPlaceholder: 'Enter plugin repository URL',
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
repoCountHint: '{count} plugin repository URLs maintained',
listMode: 'List',
textMode: 'Text',
textHint: 'Paste repository URLs one per line or separated by commas.',
@@ -2591,6 +2593,9 @@ export default {
invalidText: 'There are {count} invalid URLs in the text. Fix them before saving.',
invalidTextIgnored: '{count} invalid URLs ignored',
duplicateTextIgnored: 'Duplicate URLs will be removed automatically when saving.',
syncWiki: 'Sync Wiki',
syncSuccess: 'Plugin repositories synced from Wiki. {added} added, {total} total.',
syncFailed: 'Failed to sync Wiki: {message}!',
close: 'Close',
save: 'Save',
saveSuccess: 'Plugin repository saved successfully',

View File

@@ -14,6 +14,7 @@ export default {
success: '成功',
error: '错误',
openInNewWindow: '在新窗口中打开',
download: '下载',
inputMessage: '输入消息或命令',
send: '发送',
noData: '暂无数据',
@@ -2533,6 +2534,7 @@ export default {
repoHint: '多个地址可使用换行或英文逗号分隔',
urlPlaceholder: '输入插件仓库地址',
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
repoCountHint: '当前已维护 {count} 个插件仓库地址',
listMode: '列表维护',
textMode: '文本维护',
textHint: '直接粘贴仓库地址串,一行一个或使用英文逗号分隔。',
@@ -2543,6 +2545,9 @@ export default {
invalidText: '文本中有 {count} 个无效地址,请修正后保存。',
invalidTextIgnored: '已忽略 {count} 个无效地址',
duplicateTextIgnored: '重复地址会在保存时自动去重。',
syncWiki: '同步 Wiki',
syncSuccess: '已从 Wiki 同步插件仓库,新增 {added} 个,共 {total} 个',
syncFailed: '同步 Wiki 失败:{message}',
close: '关闭',
save: '保存',
saveSuccess: '插件仓库保存成功',

View File

@@ -14,6 +14,7 @@ export default {
success: '成功',
error: '錯誤',
openInNewWindow: '在新窗口中打開',
download: '下載',
inputMessage: '輸入消息或命令',
send: '發送',
noData: '暫無數據',
@@ -2534,6 +2535,7 @@ export default {
repoHint: '多個地址可使用換行或英文逗號分隔',
urlPlaceholder: '輸入插件倉庫地址',
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
repoCountHint: '目前已維護 {count} 個插件倉庫地址',
listMode: '列表維護',
textMode: '文字維護',
textHint: '直接貼上倉庫地址串,一行一個或使用英文逗號分隔。',
@@ -2544,6 +2546,9 @@ export default {
invalidText: '文字中有 {count} 個無效地址,請修正後儲存。',
invalidTextIgnored: '已忽略 {count} 個無效地址',
duplicateTextIgnored: '重複地址會在儲存時自動去重。',
syncWiki: '同步 Wiki',
syncSuccess: '已從 Wiki 同步插件倉庫,新增 {added} 個,共 {total} 個',
syncFailed: '同步 Wiki 失敗:{message}',
close: '關閉',
save: '儲存',
saveSuccess: '插件倉庫儲存成功',

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { GridStack } from 'gridstack'
import type { GridItemHTMLElement, GridStackWidget } from 'gridstack'
import type { ColumnOptions, GridItemHTMLElement, GridStackWidget } from 'gridstack'
import 'gridstack/dist/gridstack.min.css'
import api from '@/api'
import { isNullOrEmptyObject } from '@/@core/utils'
@@ -13,6 +13,7 @@ import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { useDisplay } from 'vuetify'
const ContentToggleSettingsDialog = defineAsyncComponent(
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
@@ -23,6 +24,7 @@ const { t } = useI18n()
// PWA模式检测
const { appMode } = usePWA()
const display = useDisplay()
const userStore = useUserStore()
const canAdmin = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
@@ -32,11 +34,27 @@ const canAdmin = computed(() =>
const route = useRoute()
const DASHBOARD_GRID_COLUMNS = 12
const DASHBOARD_GRID_DESKTOP_BREAKPOINT = 1280
const DASHBOARD_GRID_TABLET_BREAKPOINT = 960
const DASHBOARD_GRID_MOBILE_BREAKPOINT = 640
const DASHBOARD_GRID_CELL_HEIGHT = 16
const DASHBOARD_GRID_FALLBACK_ROWS = 4
const DASHBOARD_GRID_MARGIN = 8
const DASHBOARD_GRID_CONTENT_RESIZE_THRESHOLD = 4
const DASHBOARD_GRID_LAYOUT_STORAGE_KEY = 'MP_DASHBOARD_GRID_LAYOUT'
const DASHBOARD_ENABLE_STORAGE_KEY = 'MP_DASHBOARD'
const DASHBOARD_ORDER_STORAGE_KEY = 'MP_DASHBOARD_ORDER'
const DASHBOARD_GRID_LAYOUT_STORAGE_KEY_PREFIX = 'MP_DASHBOARD_GRID_LAYOUT'
const DASHBOARD_ENABLE_CONFIG_KEY = 'Dashboard'
const DASHBOARD_ORDER_CONFIG_KEY = 'DashboardOrder'
const DASHBOARD_GRID_LAYOUT_CONFIG_KEY = 'DashboardGridLayout'
const DASHBOARD_GRID_LAYOUT_CONFIG_KEY_PREFIX = 'DashboardGridLayout'
type DashboardEnableConfig = Record<string, boolean>
type DashboardOrderConfig = { id: string; key: string }[]
type DashboardGridLayoutConfig = Record<string, DashboardGridLayoutItem>
type DashboardConfigNormalizer<T> = (value: unknown) => T | undefined
type DashboardConfigRemoteValueBuilder<T> = (value: T) => unknown
type DashboardLayoutProfile = 'desktop' | 'tablet' | 'mobile'
interface DashboardGridLayoutItem {
x?: number
@@ -75,6 +93,9 @@ const isSyncingDashboardGrid = ref(false)
// 仪表板本地布局覆盖配置
const dashboardGridLayout = ref<Record<string, DashboardGridLayoutItem>>({})
// 当前仪表板布局档位,按 GridStack 响应式列数拆分跨端配置。
const dashboardLayoutProfile = ref<DashboardLayoutProfile>('desktop')
// 是否刚恢复过默认布局,用于避免退出编辑时立即把默认布局写回本地覆盖。
const isDashboardGridLayoutResetPending = ref(false)
@@ -309,42 +330,182 @@ function clampGridNumber(value: unknown, min: number, max: number, fallback: num
return Math.min(max, Math.max(min, Math.round(numericValue)))
}
// 读取并校验本地仪表板布局覆盖配置
function readDashboardGridLayout() {
const rawLayout = localStorage.getItem(DASHBOARD_GRID_LAYOUT_STORAGE_KEY)
if (!rawLayout) return {}
// 校验并归一化仪表板显示配置,避免异常用户配置影响页面渲染
function normalizeDashboardEnableConfig(value: unknown): DashboardEnableConfig | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
try {
const parsedLayout = JSON.parse(rawLayout) as Record<string, DashboardGridLayoutItem>
const normalizedLayout: Record<string, DashboardGridLayoutItem> = {}
return Object.entries(value).reduce<DashboardEnableConfig>((config, [key, enabled]) => {
config[key] = Boolean(enabled)
Object.entries(parsedLayout).forEach(([id, layout]) => {
if (!layout || typeof layout !== 'object') return
const width = clampGridNumber(layout.w, 1, DASHBOARD_GRID_COLUMNS, DASHBOARD_GRID_COLUMNS)
const normalizedItemLayout: DashboardGridLayoutItem = {
x: clampGridNumber(layout.x, 0, DASHBOARD_GRID_COLUMNS - width, 0),
y: clampGridNumber(layout.y, 0, 999, 0),
w: width,
}
return config
}, {})
}
if (layout.h !== undefined) {
normalizedItemLayout.h = clampGridNumber(layout.h, 1, 96, getDefaultDashboardGridRows())
}
// 校验并归一化仪表板顺序配置,只保留具备组件 ID 的项目。
function normalizeDashboardOrderConfig(value: unknown): DashboardOrderConfig | undefined {
if (!Array.isArray(value)) return undefined
normalizedLayout[id] = normalizedItemLayout
return value.reduce<DashboardOrderConfig>((config, item) => {
if (!item || typeof item !== 'object') return config
const rawItem = item as { id?: unknown; key?: unknown }
if (typeof rawItem.id !== 'string' || !rawItem.id) return config
config.push({
id: rawItem.id,
key: typeof rawItem.key === 'string' ? rawItem.key : '',
})
return normalizedLayout
return config
}, [])
}
// 校验并归一化仪表板 Grid 布局覆盖配置,兼容旧版裸布局和新版服务端包装结构。
function normalizeDashboardGridLayout(value: unknown): DashboardGridLayoutConfig | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const configValue = value as { items?: unknown }
const layoutValue = configValue.items && typeof configValue.items === 'object' ? configValue.items : value
const normalizedLayout: DashboardGridLayoutConfig = {}
Object.entries(layoutValue).forEach(([id, layout]) => {
if (!layout || typeof layout !== 'object') return
const rawLayout = layout as DashboardGridLayoutItem
const width = clampGridNumber(rawLayout.w, 1, DASHBOARD_GRID_COLUMNS, DASHBOARD_GRID_COLUMNS)
const normalizedItemLayout: DashboardGridLayoutItem = {
x: clampGridNumber(rawLayout.x, 0, DASHBOARD_GRID_COLUMNS - width, 0),
y: clampGridNumber(rawLayout.y, 0, 999, 0),
w: width,
}
if (rawLayout.h !== undefined) {
normalizedItemLayout.h = clampGridNumber(rawLayout.h, 1, 96, getDefaultDashboardGridRows())
}
normalizedLayout[id] = normalizedItemLayout
})
return normalizedLayout
}
// 构造服务端 Grid 布局配置,避免空布局被后端按空值删除后又被其他浏览器旧缓存回填。
function buildRemoteDashboardGridLayout(layout: DashboardGridLayoutConfig) {
return { items: layout }
}
// 根据当前视口判断仪表板布局档位,避免手机和桌面共用 Grid 坐标。
function resolveDashboardLayoutProfile(): DashboardLayoutProfile {
const width = display.width.value || (typeof window === 'undefined' ? DASHBOARD_GRID_DESKTOP_BREAKPOINT : window.innerWidth)
if (width <= DASHBOARD_GRID_MOBILE_BREAKPOINT) return 'mobile'
if (width <= DASHBOARD_GRID_TABLET_BREAKPOINT) return 'tablet'
return 'desktop'
}
// 获取当前布局档位对应的 GridStack 列数。
function getDashboardGridColumnsForProfile(profile: DashboardLayoutProfile) {
if (profile === 'mobile') return 1
if (profile === 'tablet') return 6
return DASHBOARD_GRID_COLUMNS
}
// 获取当前 Grid 实际列数,用于按布局档位保存当前坐标。
function getCurrentDashboardGridColumns() {
return dashboardGrid.value?.getColumn() ?? getDashboardGridColumnsForProfile(dashboardLayoutProfile.value)
}
// 获取当前布局档位的 GridStack 列变化策略。
function getDashboardGridColumnLayout(profile: DashboardLayoutProfile): ColumnOptions {
return profile === 'mobile' ? 'list' : 'moveScale'
}
// 获取布局档位对应的本地存储键,桌面沿用旧键以兼容已有配置。
function getDashboardGridLayoutStorageKey(profile: DashboardLayoutProfile) {
if (profile === 'desktop') return DASHBOARD_GRID_LAYOUT_STORAGE_KEY_PREFIX
return `${DASHBOARD_GRID_LAYOUT_STORAGE_KEY_PREFIX}_${profile.toUpperCase()}`
}
// 获取布局档位对应的用户配置键,桌面沿用旧键以兼容已同步配置。
function getDashboardGridLayoutConfigKey(profile: DashboardLayoutProfile) {
if (profile === 'desktop') return DASHBOARD_GRID_LAYOUT_CONFIG_KEY
return `${DASHBOARD_GRID_LAYOUT_CONFIG_KEY_PREFIX}${profile === 'mobile' ? 'Mobile' : 'Tablet'}`
}
// 加载指定布局档位的 Grid 布局配置。
async function loadDashboardGridLayoutConfig(profile: DashboardLayoutProfile) {
return await loadSharedDashboardConfig(
getDashboardGridLayoutConfigKey(profile),
getDashboardGridLayoutStorageKey(profile),
normalizeDashboardGridLayout,
buildRemoteDashboardGridLayout,
)
}
// 从本地存储读取并归一化指定的仪表板配置。
function readLocalDashboardConfig<T>(storageKey: string, normalize: DashboardConfigNormalizer<T>) {
const rawConfig = localStorage.getItem(storageKey)
if (!rawConfig) return undefined
try {
return normalize(JSON.parse(rawConfig))
} catch (error) {
console.error(error)
return {}
return undefined
}
}
// 将当前仪表板布局覆盖配置保存到本地
// 将仪表板配置写入本地存储,保留离线和接口失败时的兜底能力
function saveLocalDashboardConfig(storageKey: string, value: unknown) {
localStorage.setItem(storageKey, JSON.stringify(value))
}
// 将仪表板配置写入用户配置,用于跨浏览器共享。
async function saveUserDashboardConfig(configKey: string, value: unknown) {
await api.post(`/user/config/${configKey}`, value)
}
// 优先加载用户配置;服务端缺失时使用本地历史配置并回填到用户配置。
async function loadSharedDashboardConfig<T>(
configKey: string,
storageKey: string,
normalize: DashboardConfigNormalizer<T>,
buildRemoteValue: DashboardConfigRemoteValueBuilder<T> = value => value,
) {
const localConfig = readLocalDashboardConfig(storageKey, normalize)
try {
const response = await api.get(`/user/config/${configKey}`)
const remoteConfig = normalize(response?.data?.value)
if (remoteConfig !== undefined) {
saveLocalDashboardConfig(storageKey, remoteConfig)
return remoteConfig
}
if (localConfig !== undefined) {
await saveUserDashboardConfig(configKey, buildRemoteValue(localConfig))
}
} catch (error) {
console.error(error)
}
return localConfig
}
// 将当前仪表板布局覆盖配置保存到本地和用户配置。
function saveDashboardGridLayout(layout: Record<string, DashboardGridLayoutItem>) {
localStorage.setItem(DASHBOARD_GRID_LAYOUT_STORAGE_KEY, JSON.stringify(layout))
const profile = dashboardLayoutProfile.value
saveLocalDashboardConfig(getDashboardGridLayoutStorageKey(profile), layout)
void saveUserDashboardConfig(getDashboardGridLayoutConfigKey(profile), buildRemoteDashboardGridLayout(layout)).catch(
error => console.error(error),
)
}
// 获取仪表板组件的默认宽度,优先兼容插件旧版 cols.md / cols.cols 配置。
@@ -360,9 +521,10 @@ function getDefaultDashboardGridRows(item?: DashboardItem) {
// 合并插件/内置组件默认尺寸与用户本地布局覆盖。
function buildDashboardGridWidget(item: DashboardItem, id: string): GridStackWidget {
const savedLayout = dashboardGridLayout.value[id]
const gridColumns = getDashboardGridColumnsForProfile(dashboardLayoutProfile.value)
const width = savedLayout?.w ?? getDefaultDashboardGridWidth(item)
const height = savedLayout?.h ?? getDefaultDashboardGridRows(item)
const normalizedWidth = clampGridNumber(width, 1, DASHBOARD_GRID_COLUMNS, DASHBOARD_GRID_COLUMNS)
const normalizedWidth = clampGridNumber(width, 1, gridColumns, gridColumns)
const widget: GridStackWidget = {
id,
w: normalizedWidth,
@@ -372,7 +534,7 @@ function buildDashboardGridWidget(item: DashboardItem, id: string): GridStackWid
}
if (savedLayout?.x !== undefined && savedLayout?.y !== undefined) {
widget.x = clampGridNumber(savedLayout.x, 0, DASHBOARD_GRID_COLUMNS - normalizedWidth, 0)
widget.x = clampGridNumber(savedLayout.x, 0, gridColumns - normalizedWidth, 0)
widget.y = clampGridNumber(savedLayout.y, 0, 999, 0)
} else {
widget.autoPosition = true
@@ -434,7 +596,7 @@ function exitDashboardLayoutEditing() {
// 清除用户本地布局覆盖,并恢复内置组件和插件声明的默认占位,然后退出编辑模式。
async function resetDashboardGridLayout() {
dashboardGridLayout.value = {}
localStorage.removeItem(DASHBOARD_GRID_LAYOUT_STORAGE_KEY)
saveDashboardGridLayout({})
dashboardGrid.value?.removeAll(false, false)
isDashboardGridLayoutResetPending.value = true
await syncDashboardGrid()
@@ -497,32 +659,31 @@ function toggleDashboardLayoutEditing() {
nextTick(syncDashboardGrid)
}
// 加载用户监控面板配置(本地无配置时才加载)
// 加载用户监控面板配置,优先使用服务端用户配置以支持跨浏览器同步。
async function loadDashboardConfig() {
dashboardLayoutProfile.value = resolveDashboardLayoutProfile()
// 显示配置
const local_enable = localStorage.getItem('MP_DASHBOARD')
if (local_enable) {
enableConfig.value = JSON.parse(local_enable)
} else {
const response = await api.get('/user/config/Dashboard')
if (response && response.data && response.data.value) {
enableConfig.value = response.data.value
localStorage.setItem('MP_DASHBOARD', JSON.stringify(response.data.value))
}
const enable = await loadSharedDashboardConfig(
DASHBOARD_ENABLE_CONFIG_KEY,
DASHBOARD_ENABLE_STORAGE_KEY,
normalizeDashboardEnableConfig,
)
if (enable !== undefined) {
enableConfig.value = enable
}
// 顺序配置
const local_order = localStorage.getItem('MP_DASHBOARD_ORDER')
if (local_order) {
orderConfig.value = JSON.parse(local_order)
} else {
const response2 = await api.get('/user/config/DashboardOrder')
if (response2 && response2.data && response2.data.value) {
orderConfig.value = response2.data.value
localStorage.setItem('MP_DASHBOARD_ORDER', JSON.stringify(orderConfig.value))
}
const order = await loadSharedDashboardConfig(
DASHBOARD_ORDER_CONFIG_KEY,
DASHBOARD_ORDER_STORAGE_KEY,
normalizeDashboardOrderConfig,
)
if (order !== undefined) {
orderConfig.value = order
}
// 本地 Grid 布局覆盖
dashboardGridLayout.value = readDashboardGridLayout()
// Grid 布局覆盖
const gridLayoutProfile = dashboardLayoutProfile.value
const gridLayout = await loadDashboardGridLayoutConfig(gridLayoutProfile)
dashboardGridLayout.value = gridLayout ?? {}
// 排序
if (orderConfig.value) {
sortDashboardConfigs()
@@ -549,18 +710,16 @@ async function saveDashboardConfig(payload?: { enabled?: Record<string, boolean>
}
// 启用配置
const enableString = JSON.stringify(enableConfig.value)
localStorage.setItem('MP_DASHBOARD', enableString)
saveLocalDashboardConfig(DASHBOARD_ENABLE_STORAGE_KEY, enableConfig.value)
// 顺序配置从dashboardConfigs中提取
const orderObj = dashboardConfigs.value.map(item => ({ id: item.id, key: item.key }))
const orderString = JSON.stringify(orderObj)
localStorage.setItem('MP_DASHBOARD_ORDER', orderString)
saveLocalDashboardConfig(DASHBOARD_ORDER_STORAGE_KEY, orderObj)
// 保存到服务端
try {
await api.post('/user/config/Dashboard', enableConfig.value)
await api.post('/user/config/DashboardOrder', orderObj)
await saveUserDashboardConfig(DASHBOARD_ENABLE_CONFIG_KEY, enableConfig.value)
await saveUserDashboardConfig(DASHBOARD_ORDER_CONFIG_KEY, orderObj)
} catch (error) {
console.error(error)
}
@@ -676,9 +835,9 @@ function initializeDashboardGrid() {
column: DASHBOARD_GRID_COLUMNS,
columnOpts: {
breakpoints: [
{ w: 640, c: 1, layout: 'list' },
{ w: 960, c: 6, layout: 'moveScale' },
{ w: 1280, c: DASHBOARD_GRID_COLUMNS, layout: 'moveScale' },
{ w: DASHBOARD_GRID_MOBILE_BREAKPOINT, c: 1, layout: 'list' },
{ w: DASHBOARD_GRID_TABLET_BREAKPOINT, c: 6, layout: 'moveScale' },
{ w: DASHBOARD_GRID_DESKTOP_BREAKPOINT, c: DASHBOARD_GRID_COLUMNS, layout: 'moveScale' },
],
layout: 'moveScale',
},
@@ -893,7 +1052,8 @@ function notifyDashboardContentResize() {
function persistDashboardGridLayout(manualHeightId: string | false = false) {
if (!dashboardGrid.value || isSyncingDashboardGrid.value) return
const savedWidgets = dashboardGrid.value.save(false, false, undefined, DASHBOARD_GRID_COLUMNS)
const gridColumns = getCurrentDashboardGridColumns()
const savedWidgets = dashboardGrid.value.save(false, false, undefined, gridColumns)
const widgets = Array.isArray(savedWidgets) ? savedWidgets : (savedWidgets.children ?? [])
const nextLayout = { ...dashboardGridLayout.value }
@@ -901,10 +1061,10 @@ function persistDashboardGridLayout(manualHeightId: string | false = false) {
if (!widget.id) return
const id = String(widget.id)
const width = clampGridNumber(widget.w, 1, DASHBOARD_GRID_COLUMNS, getDefaultDashboardGridWidthById(id))
const width = clampGridNumber(widget.w, 1, gridColumns, getDefaultDashboardGridWidthById(id, gridColumns))
const previousLayout = dashboardGridLayout.value[id]
const nextItemLayout: DashboardGridLayoutItem = {
x: clampGridNumber(widget.x, 0, DASHBOARD_GRID_COLUMNS - width, 0),
x: clampGridNumber(widget.x, 0, gridColumns - width, 0),
y: clampGridNumber(widget.y, 0, 999, 0),
w: width,
}
@@ -922,10 +1082,10 @@ function persistDashboardGridLayout(manualHeightId: string | false = false) {
}
// 根据组件 ID 查找默认宽度,保存布局时用于兜底。
function getDefaultDashboardGridWidthById(id: string) {
function getDefaultDashboardGridWidthById(id: string, maxColumns = DASHBOARD_GRID_COLUMNS) {
const item = dashboardConfigs.value.find(config => buildPluginDashboardId(config.id, config.key) === id)
return item ? getDefaultDashboardGridWidth(item) : DASHBOARD_GRID_COLUMNS
return item ? Math.min(getDefaultDashboardGridWidth(item), maxColumns) : maxColumns
}
// 压实 GridStack 布局并保存本地占位信息。
@@ -951,6 +1111,25 @@ watch(
{ deep: true },
)
watch(
() => display.width.value,
async () => {
const nextProfile = resolveDashboardLayoutProfile()
if (nextProfile === dashboardLayoutProfile.value) return
if (dashboardGrid.value && !isSyncingDashboardGrid.value && !isDashboardGridLayoutResetPending.value) {
persistDashboardGridLayout(false)
}
dashboardLayoutProfile.value = nextProfile
dashboardGridLayout.value = (await loadDashboardGridLayoutConfig(nextProfile)) ?? {}
dashboardGrid.value?.column(getDashboardGridColumnsForProfile(nextProfile), getDashboardGridColumnLayout(nextProfile))
dashboardGrid.value?.removeAll(false, false)
await syncDashboardGrid()
notifyDashboardContentResize()
},
)
onBeforeMount(async () => {
await loadDashboardConfig()
initializeColors()

View File

@@ -297,15 +297,21 @@ async function updateBadge(count: number) {
}
}
// 清除桌面角标和本地未读计数,确保不支持 Badge API 时也能归零。
async function clearBadge() {
if ('clearAppBadge' in self.navigator) {
try {
await self.navigator.clearAppBadge()
await setStoredUnreadCount(0)
} catch (error) {
console.error('Failed to clear app badge:', error)
console.error('Failed to clear native app badge:', error)
}
}
try {
await setStoredUnreadCount(0)
} catch (error) {
console.error('Failed to clear unread count:', error)
}
}
// 监控缓存大小

View File

@@ -706,6 +706,53 @@ html[data-theme="transparent"] .app-card-colorful,
padding: 16px;
}
// 弹窗底部左右动作区:对齐插件仓库设置弹窗的按钮间距、主次样式和移动端铺排。
.app-dialog-actions {
flex: 0 0 auto;
gap: 0.5rem;
padding-block: 0.75rem 1rem !important;
padding-inline: 1.5rem !important;
> .v-btn {
flex: 0 0 auto;
}
> .app-dialog-actions__icon-btn {
inline-size: 2.5rem;
min-inline-size: 2.5rem;
padding-inline: 0;
}
}
@media (width <= 960px) {
.app-dialog-actions {
padding-block-end: calc(0.75rem + env(safe-area-inset-bottom)) !important;
}
}
@media (width <= 600px) {
.app-dialog-actions {
flex-wrap: wrap;
padding-block: 0.75rem calc(0.75rem + env(safe-area-inset-bottom)) !important;
padding-inline: 1rem !important;
> .v-btn:not(.app-dialog-actions__icon-btn) {
flex: 1 1 9rem;
min-inline-size: 0;
}
> .app-dialog-actions__icon-btn {
flex: 0 0 2.75rem;
inline-size: 2.75rem;
min-inline-size: 2.75rem;
}
> .v-spacer {
display: none;
}
}
}
// 路由过渡动画
.fade-slide-leave-active,
.fade-slide-enter-active {
@@ -1077,6 +1124,19 @@ html[data-theme="transparent"] .app-card-colorful,
.v-dialog--fullscreen > .v-overlay__content {
border-radius: 0 !important;
box-shadow: none !important;
display: flex;
inline-size: 100%;
inset-inline: 0 !important;
justify-content: center;
margin-inline: auto;
}
// 全屏弹窗触发后,未横向铺满的卡片仍保持居中。
.v-dialog--fullscreen > .v-overlay__content > .v-card,
.v-dialog--fullscreen > .v-overlay__content > .v-sheet,
.v-dialog--fullscreen > .v-overlay__content > form {
margin-inline: auto;
max-inline-size: 100%;
}
.v-dialog > .v-overlay__content {

View File

@@ -96,28 +96,41 @@ export async function checkAndEmitUnreadMessages() {
}
}
// 清除未读消息计数,并通知前端同步隐藏未读红点。
export async function clearUnreadMessages(): Promise<boolean> {
emitUnreadMessageEvent(0)
return clearAppBadge()
}
// 清除桌面图标徽章
export async function clearAppBadge(): Promise<boolean> {
try {
// 如果浏览器支持原生Badge API直接调用
if ('clearAppBadge' in navigator) {
await navigator.clearAppBadge()
}
let nativeBadgeCleared = true
// 如果浏览器支持原生Badge API直接调用
if ('clearAppBadge' in navigator) {
try {
await navigator.clearAppBadge()
} catch (error) {
nativeBadgeCleared = false
console.error('Failed to clear native app badge:', error)
}
}
try {
// 向service worker发送清除徽章消息
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
const messageChannel = new MessageChannel()
return new Promise(resolve => {
messageChannel.port1.onmessage = event => {
resolve(event.data.success)
resolve(Boolean(event.data.success) && nativeBadgeCleared)
}
navigator.serviceWorker.controller?.postMessage({ type: 'CLEAR_BADGE' }, [messageChannel.port2])
})
}
return true
return nativeBadgeCleared
} catch (error) {
console.error('Failed to clear app badge:', error)
return false

View File

@@ -2410,14 +2410,11 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
</VWindowItem>
</VWindow>
</VCardText>
<VCardActions class="pt-3">
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn color="primary" prepend-icon="mdi-content-save" @click="saveAdvancedSettings" class="px-5">
{{ t('common.save') }}
</VBtn>
</div>
</VForm>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" @click="saveAdvancedSettings" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -41,6 +41,7 @@ const MESSAGE_AUTO_SCROLL_THRESHOLD = 64
let scrollTimer: number | undefined
let scrollReleaseTimer: number | undefined
let boundScrollContainer: HTMLElement | null = null
// 生成消息去重签名
// SSE 消息只有 date 没有 reg_time数据库消息只有 reg_time 没有 date
@@ -83,10 +84,34 @@ function updateLastTime(message: Message) {
}
}
function getScrollContainer() {
const container = messageListRef.value?.$el ?? messageListRef.value
/** 判断元素自身是否是真正承载滚动的位置。 */
function isScrollableElement(element: HTMLElement) {
const { overflowY } = window.getComputedStyle(element)
const canScroll = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'
return container instanceof HTMLElement ? container : null
return canScroll && element.scrollHeight > element.clientHeight + 1
}
/** 获取消息列表所在的真实滚动容器。 */
function getScrollContainer() {
const element = messageListRef.value?.$el ?? messageListRef.value
if (!(element instanceof HTMLElement)) {
return null
}
let container: HTMLElement | null = element
while (container) {
if (isScrollableElement(container)) {
return container
}
container = container.parentElement
}
const dialogCardText = element.closest('.v-card-text')
return dialogCardText instanceof HTMLElement ? dialogCardText : element
}
function isNearBottom(container: HTMLElement) {
@@ -114,23 +139,31 @@ function bindScrollListener() {
return
}
if (boundScrollContainer && boundScrollContainer !== container) {
boundScrollContainer.removeEventListener('scroll', handleScroll)
}
container.removeEventListener('scroll', handleScroll)
container.addEventListener('scroll', handleScroll, { passive: true })
boundScrollContainer = container
updateAutoScrollState()
}
function unbindScrollListener() {
getScrollContainer()?.removeEventListener('scroll', handleScroll)
boundScrollContainer?.removeEventListener('scroll', handleScroll)
boundScrollContainer = null
}
function scrollContainerToEnd() {
/** 滚动到底部,并在布局稳定前连续几帧校正滚动位置。 */
function scrollContainerToEnd(retryCount = 1) {
const container = getScrollContainer()
if (!container) {
return
}
bindScrollListener()
isSyncingScroll.value = true
container.scrollTop = container.scrollHeight
container.scrollTop = Math.max(0, container.scrollHeight - container.clientHeight)
requestAnimationFrame(() => {
const latestContainer = getScrollContainer()
@@ -139,9 +172,14 @@ function scrollContainerToEnd() {
return
}
latestContainer.scrollTop = latestContainer.scrollHeight
latestContainer.scrollTop = Math.max(0, latestContainer.scrollHeight - latestContainer.clientHeight)
shouldAutoScroll.value = true
if (retryCount > 0) {
scrollContainerToEnd(retryCount - 1)
return
}
if (scrollReleaseTimer) {
window.clearTimeout(scrollReleaseTimer)
}
@@ -165,7 +203,7 @@ function requestScrollToEnd(force = false) {
scrollTimer = window.setTimeout(() => {
nextTick(() => {
requestAnimationFrame(() => {
scrollContainerToEnd()
scrollContainerToEnd(force ? 6 : 1)
})
})
}, force ? 0 : 80)
@@ -231,6 +269,8 @@ async function loadMessages({ done }: { done: any }) {
try {
// 设置加载中
loading.value = true
const isFirstPage = page.value === 1
currData.value = await api.get('message/web', {
params: {
page: page.value,
@@ -240,16 +280,17 @@ async function loadMessages({ done }: { done: any }) {
// 已加载过
isLoaded.value = true
if (currData.value.length > 0) {
const hasNewMessage = mergeMessages(currData.value)
mergeMessages(currData.value)
// 首次加载时滚动到底部
if (page.value === 1 && hasNewMessage) {
requestScrollToEnd(true)
}
// 页码+1
page.value++
// 完成
done('ok')
// 首次加载完成后再滚动,避免列表尚未完成布局时滚动失效。
if (isFirstPage) {
requestScrollToEnd(true)
}
} else {
// 没有新数据
done('empty')
@@ -341,7 +382,6 @@ defineExpose({
onMounted(() => {
nextTick(() => {
bindScrollListener()
requestScrollToEnd(true)
})
})
@@ -372,19 +412,15 @@ onBeforeUnmount(() => {
<LoadingBanner />
</template>
<template #empty> {{ t('message.noMoreData') }} </template>
<VVirtualScroll renderless :items="messages" :item-height="160">
<template #default="{ item, index, itemRef }">
<div
:ref="itemRef"
:key="getMessageKey(item) || index"
class="chat-group d-flex mt-5 mb-8"
:class="item.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
>
<div class="d-inline-flex flex-column" :class="item.action == 1 ? 'align-start' : 'align-end'">
<MessageCard :message="item" @imageload="handleImageLoad" />
</div>
</div>
</template>
</VVirtualScroll>
<div
v-for="(item, index) in messages"
:key="getMessageKey(item) || index"
class="chat-group d-flex mt-5 mb-8"
:class="item.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
>
<div class="d-inline-flex flex-column" :class="item.action == 1 ? 'align-start' : 'align-end'">
<MessageCard :message="item" @imageload="handleImageLoad" />
</div>
</div>
</VInfiniteScroll>
</template>

View File

@@ -3082,9 +3082,9 @@ camelcase@^5.0.0:
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
caniuse-lite@^1.0.30001688, caniuse-lite@^1.0.30001702:
version "1.0.30001761"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz"
integrity sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==
version "1.0.30001799"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz"
integrity sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==
chalk@^4.0.0, chalk@^4.0.2:
version "4.1.2"
@@ -7007,16 +7007,7 @@ std-env@^3.9.0:
resolved "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz"
integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -7101,14 +7092,7 @@ stringify-object@^3.3.0:
is-obj "^1.0.1"
is-regexp "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==