mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-19 06:30:35 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a7d53b5c7 | ||
|
|
da0cd14af8 | ||
|
|
342c62c085 | ||
|
|
891274cc0e | ||
|
|
889a4b744a | ||
|
|
7fc5b74851 |
18
README.md
18
README.md
@@ -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/) - 开发插件组件的完整示例项目
|
||||
|
||||
16
README_EN.md
16
README_EN.md
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.13.8",
|
||||
"version": "2.13.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -294,7 +294,7 @@ const initialTargetPath = normalizeTargetPath(props.target_path)
|
||||
const transferForm = reactive<TransferForm>({
|
||||
fileitem: {} as FileItem,
|
||||
logid: 0,
|
||||
target_storage: initialTargetPath ? props.target_storage ?? 'local' : null,
|
||||
target_storage: initialTargetPath ? (props.target_storage ?? 'local') : null,
|
||||
target_path: initialTargetPath,
|
||||
transfer_type: null,
|
||||
min_filesize: 0,
|
||||
@@ -1550,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">
|
||||
@@ -1811,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);
|
||||
}
|
||||
@@ -2161,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 {
|
||||
@@ -2184,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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2582,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.',
|
||||
@@ -2592,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',
|
||||
|
||||
@@ -2534,6 +2534,7 @@ export default {
|
||||
repoHint: '多个地址可使用换行或英文逗号分隔',
|
||||
urlPlaceholder: '输入插件仓库地址',
|
||||
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
|
||||
repoCountHint: '当前已维护 {count} 个插件仓库地址',
|
||||
listMode: '列表维护',
|
||||
textMode: '文本维护',
|
||||
textHint: '直接粘贴仓库地址串,一行一个或使用英文逗号分隔。',
|
||||
@@ -2544,6 +2545,9 @@ export default {
|
||||
invalidText: '文本中有 {count} 个无效地址,请修正后保存。',
|
||||
invalidTextIgnored: '已忽略 {count} 个无效地址',
|
||||
duplicateTextIgnored: '重复地址会在保存时自动去重。',
|
||||
syncWiki: '同步 Wiki',
|
||||
syncSuccess: '已从 Wiki 同步插件仓库,新增 {added} 个,共 {total} 个',
|
||||
syncFailed: '同步 Wiki 失败:{message}!',
|
||||
close: '关闭',
|
||||
save: '保存',
|
||||
saveSuccess: '插件仓库保存成功',
|
||||
|
||||
@@ -2535,6 +2535,7 @@ export default {
|
||||
repoHint: '多個地址可使用換行或英文逗號分隔',
|
||||
urlPlaceholder: '輸入插件倉庫地址',
|
||||
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
|
||||
repoCountHint: '目前已維護 {count} 個插件倉庫地址',
|
||||
listMode: '列表維護',
|
||||
textMode: '文字維護',
|
||||
textHint: '直接貼上倉庫地址串,一行一個或使用英文逗號分隔。',
|
||||
@@ -2545,6 +2546,9 @@ export default {
|
||||
invalidText: '文字中有 {count} 個無效地址,請修正後儲存。',
|
||||
invalidTextIgnored: '已忽略 {count} 個無效地址',
|
||||
duplicateTextIgnored: '重複地址會在儲存時自動去重。',
|
||||
syncWiki: '同步 Wiki',
|
||||
syncSuccess: '已從 Wiki 同步插件倉庫,新增 {added} 個,共 {total} 個',
|
||||
syncFailed: '同步 Wiki 失敗:{message}!',
|
||||
close: '關閉',
|
||||
save: '儲存',
|
||||
saveSuccess: '插件倉庫儲存成功',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
// 监控缓存大小
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
26
yarn.lock
26
yarn.lock
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user