mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 00:36:41 +08:00
Refine shared workflow and subscription dialogs
This commit is contained in:
@@ -23,43 +23,38 @@ const workflowId = ref<string>()
|
|||||||
// 分享时间
|
// 分享时间
|
||||||
const dateText = ref(props.workflow && props.workflow?.date ? formatDateDifference(props.workflow.date) : '')
|
const dateText = ref(props.workflow && props.workflow?.date ? formatDateDifference(props.workflow.date) : '')
|
||||||
|
|
||||||
// 随机渐变背景
|
const gradientPalettes = [
|
||||||
const gradientStyle = ref('')
|
['74, 85, 104', '45, 55, 72'],
|
||||||
|
['85, 60, 154', '183, 148, 244'],
|
||||||
|
['44, 90, 160', '26, 54, 93'],
|
||||||
|
['47, 133, 90', '34, 84, 61'],
|
||||||
|
['197, 48, 48', '116, 42, 42'],
|
||||||
|
['214, 158, 46', '151, 90, 22'],
|
||||||
|
['128, 90, 213', '85, 60, 154'],
|
||||||
|
['49, 130, 206', '44, 82, 130'],
|
||||||
|
['56, 161, 105', '39, 103, 73'],
|
||||||
|
['229, 62, 62', '197, 48, 48'],
|
||||||
|
['221, 107, 32', '192, 86, 33'],
|
||||||
|
['107, 70, 193', '85, 60, 154'],
|
||||||
|
['43, 108, 176', '44, 82, 130'],
|
||||||
|
['56, 161, 105', '47, 133, 90'],
|
||||||
|
['213, 63, 140', '151, 38, 109'],
|
||||||
|
] as const
|
||||||
|
|
||||||
// 生成随机渐变背景
|
// 暴露渐变色通道,让材质主题能够保留色相并单独控制透光率。
|
||||||
function generateRandomGradient() {
|
const gradientStyle = computed(() => {
|
||||||
const gradients = [
|
|
||||||
'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
|
||||||
'linear-gradient(135deg, #553c9a 0%, #b794f4 100%)',
|
|
||||||
'linear-gradient(135deg, #2c5aa0 0%, #1a365d 100%)',
|
|
||||||
'linear-gradient(135deg, #2f855a 0%, #22543d 100%)',
|
|
||||||
'linear-gradient(135deg, #c53030 0%, #742a2a 100%)',
|
|
||||||
'linear-gradient(135deg, #d69e2e 0%, #975a16 100%)',
|
|
||||||
'linear-gradient(135deg, #805ad5 0%, #553c9a 100%)',
|
|
||||||
'linear-gradient(135deg, #3182ce 0%, #2c5282 100%)',
|
|
||||||
'linear-gradient(135deg, #38a169 0%, #276749 100%)',
|
|
||||||
'linear-gradient(135deg, #e53e3e 0%, #c53030 100%)',
|
|
||||||
'linear-gradient(135deg, #dd6b20 0%, #c05621 100%)',
|
|
||||||
'linear-gradient(135deg, #6b46c1 0%, #553c9a 100%)',
|
|
||||||
'linear-gradient(135deg, #2b6cb0 0%, #2c5282 100%)',
|
|
||||||
'linear-gradient(135deg, #38a169 0%, #2f855a 100%)',
|
|
||||||
'linear-gradient(135deg, #d53f8c 0%, #97266d 100%)',
|
|
||||||
]
|
|
||||||
|
|
||||||
// 基于工作流ID生成固定的随机数,确保同一工作流总是显示相同的渐变
|
|
||||||
const seed = String(props.workflow?.id || Math.random())
|
const seed = String(props.workflow?.id || Math.random())
|
||||||
const hash = seed.split('').reduce((a, b) => {
|
const hash = seed.split('').reduce((a, b) => {
|
||||||
a = (a << 5) - a + b.charCodeAt(0)
|
a = (a << 5) - a + b.charCodeAt(0)
|
||||||
return a & a
|
return a & a
|
||||||
}, 0)
|
}, 0)
|
||||||
|
const [startRgb, endRgb] = gradientPalettes[Math.abs(hash) % gradientPalettes.length]
|
||||||
|
|
||||||
const index = Math.abs(hash) % gradients.length
|
return {
|
||||||
return gradients[index]
|
'--workflow-share-gradient-start-rgb': startRgb,
|
||||||
}
|
'--workflow-share-gradient-end-rgb': endRgb,
|
||||||
|
backgroundImage: `linear-gradient(135deg, rgb(${startRgb}) 0%, rgb(${endRgb}) 100%)`,
|
||||||
// 初始化渐变背景
|
}
|
||||||
onMounted(() => {
|
|
||||||
gradientStyle.value = generateRandomGradient()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 复用工作流
|
// 复用工作流
|
||||||
@@ -104,7 +99,7 @@ function doDelete() {
|
|||||||
'app-hover-lift-card--hovering': hover.isHovering,
|
'app-hover-lift-card--hovering': hover.isHovering,
|
||||||
}"
|
}"
|
||||||
min-height="150"
|
min-height="150"
|
||||||
:style="{ background: gradientStyle }"
|
:style="gradientStyle"
|
||||||
@click="showForkWorkflow"
|
@click="showForkWorkflow"
|
||||||
>
|
>
|
||||||
<div class="h-full flex flex-col">
|
<div class="h-full flex flex-col">
|
||||||
|
|||||||
@@ -188,8 +188,10 @@ onMounted(() => {
|
|||||||
<VCard>
|
<VCard>
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<VCol>
|
<VCol>
|
||||||
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
<div
|
||||||
<div class="ma-auto">
|
class="subscribe-share-detail-layout d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
|
||||||
|
>
|
||||||
|
<div class="subscribe-share-detail__poster">
|
||||||
<VImg
|
<VImg
|
||||||
width="10rem"
|
width="10rem"
|
||||||
aspect-ratio="2/3"
|
aspect-ratio="2/3"
|
||||||
@@ -205,52 +207,52 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</VImg>
|
</VImg>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow">
|
<div class="flex-grow subscribe-share-detail">
|
||||||
<VCardItem>
|
<VCardItem class="subscribe-share-detail__header pa-0">
|
||||||
<VCardTitle
|
<VCardTitle
|
||||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis"
|
class="subscribe-share-detail__title break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis"
|
||||||
>
|
>
|
||||||
{{ props.media?.share_title }}
|
{{ props.media?.share_title }}
|
||||||
</VCardTitle>
|
</VCardTitle>
|
||||||
<VCardSubtitle
|
<VCardSubtitle
|
||||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis"
|
class="subscribe-share-detail__description break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis"
|
||||||
>
|
>
|
||||||
{{ props.media?.share_comment }}
|
{{ props.media?.share_comment }}
|
||||||
</VCardSubtitle>
|
</VCardSubtitle>
|
||||||
<VList lines="one" class="border-0">
|
<dl class="subscribe-share-detail__metadata">
|
||||||
<VListItem class="ps-0">
|
<div class="subscribe-share-detail__metadata-row">
|
||||||
<VListItemTitle class="text-center text-md-left">
|
<dt>{{ t('subscribe.sharer') }}:</dt>
|
||||||
<span class="font-weight-medium">{{ t('subscribe.sharer') }}:</span>
|
<dd>{{ media?.share_user }}</dd>
|
||||||
<span class="text-body-1"> {{ media?.share_user }}</span>
|
</div>
|
||||||
</VListItemTitle>
|
<div v-if="media?.keyword" class="subscribe-share-detail__metadata-row">
|
||||||
</VListItem>
|
<dt>{{ t('subscribe.keyword') }}:</dt>
|
||||||
<VListItem class="ps-0" v-if="media?.keyword">
|
<dd>{{ media?.keyword }}</dd>
|
||||||
<VListItemTitle class="text-center text-md-left">
|
</div>
|
||||||
<span class="font-weight-medium">{{ t('subscribe.keyword') }}:</span>
|
<div
|
||||||
<span class="text-body-1"> {{ media?.keyword }}</span>
|
v-if="media?.custom_words"
|
||||||
</VListItemTitle>
|
class="subscribe-share-detail__recognition"
|
||||||
</VListItem>
|
@click.stop="toggleExpand"
|
||||||
<VListItem class="ps-0" v-if="media?.custom_words" @click.stop="toggleExpand">
|
>
|
||||||
<VListItemTitle
|
<dt>{{ t('subscribe.recognitionWords') }}:</dt>
|
||||||
class="text-center text-md-left break-words whitespace-break-spaces"
|
<dd
|
||||||
|
class="break-words"
|
||||||
:class="{
|
:class="{
|
||||||
'line-clamp-4 overflow-hidden text-ellipsis': !isExpanded,
|
'line-clamp-4 overflow-hidden text-ellipsis': !isExpanded,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<span class="font-weight-medium">{{ t('subscribe.recognitionWords') }}:</span>
|
{{ media?.custom_words }}
|
||||||
<span class="text-body-1"> {{ media?.custom_words }}</span>
|
</dd>
|
||||||
</VListItemTitle>
|
</div>
|
||||||
</VListItem>
|
</dl>
|
||||||
</VList>
|
<div class="subscribe-share-detail__actions">
|
||||||
<div class="text-center text-md-left">
|
<div class="subscribe-share-detail__buttons">
|
||||||
<div>
|
|
||||||
<VBtn
|
<VBtn
|
||||||
color="primary"
|
color="primary"
|
||||||
:disabled="processing"
|
:disabled="processing"
|
||||||
@click="doFork"
|
@click="doFork"
|
||||||
prepend-icon="mdi-heart"
|
prepend-icon="mdi-heart"
|
||||||
:loading="processing"
|
:loading="processing"
|
||||||
class="mb-2 me-2"
|
class="subscribe-share-detail__button"
|
||||||
>
|
>
|
||||||
{{ t('subscribe.normalSub') }}
|
{{ t('subscribe.normalSub') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
@@ -259,7 +261,7 @@ onMounted(() => {
|
|||||||
color="warning"
|
color="warning"
|
||||||
@click="unfollowUser"
|
@click="unfollowUser"
|
||||||
prepend-icon="mdi-account-remove"
|
prepend-icon="mdi-account-remove"
|
||||||
class="mb-2 me-2"
|
class="subscribe-share-detail__button"
|
||||||
>
|
>
|
||||||
{{ t('subscribe.unfollow') }}
|
{{ t('subscribe.unfollow') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
@@ -268,7 +270,7 @@ onMounted(() => {
|
|||||||
@click="followUser"
|
@click="followUser"
|
||||||
color="info"
|
color="info"
|
||||||
prepend-icon="mdi-account-plus"
|
prepend-icon="mdi-account-plus"
|
||||||
class="mb-2 me-2"
|
class="subscribe-share-detail__button"
|
||||||
>
|
>
|
||||||
{{ t('subscribe.follow') }}
|
{{ t('subscribe.follow') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
@@ -282,15 +284,14 @@ onMounted(() => {
|
|||||||
@click="doDelete"
|
@click="doDelete"
|
||||||
prepend-icon="mdi-delete"
|
prepend-icon="mdi-delete"
|
||||||
:loading="deleting"
|
:loading="deleting"
|
||||||
class="mb-2 me-2"
|
class="subscribe-share-detail__button"
|
||||||
>
|
>
|
||||||
{{ t('subscribe.cancelShare') }}
|
{{ t('subscribe.cancelShare') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs mt-2" v-if="props.media?.count">
|
<div class="subscribe-share-detail__usage" v-if="props.media?.count">
|
||||||
<VIcon icon="mdi-fire" />{{
|
<VIcon icon="mdi-fire" size="18" />
|
||||||
t('subscribe.usageCount', { count: props.media?.count?.toLocaleString() })
|
<span>{{ t('subscribe.usageCount', { count: props.media?.count?.toLocaleString() }) }}</span>
|
||||||
}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
@@ -302,3 +303,124 @@ onMounted(() => {
|
|||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.subscribe-share-detail-layout {
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-share-detail__poster {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-share-detail {
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-share-detail__header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-share-detail__title,
|
||||||
|
.subscribe-share-detail__description {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-share-detail__metadata {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.625rem;
|
||||||
|
margin: 1.125rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-share-detail__metadata-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 0.625rem;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-share-detail__metadata dt {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: end;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-share-detail__metadata dd {
|
||||||
|
min-inline-size: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-share-detail__recognition {
|
||||||
|
min-inline-size: 0;
|
||||||
|
margin-block-start: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-share-detail__recognition dt {
|
||||||
|
text-align: center;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-share-detail__recognition dd {
|
||||||
|
inline-size: 100%;
|
||||||
|
margin-block-start: 0.35rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-share-detail__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-block-start: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-share-detail__buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-share-detail__usage {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width < 360px) {
|
||||||
|
.subscribe-share-detail__buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-share-detail__button {
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width < 960px) {
|
||||||
|
.subscribe-share-detail-layout {
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-share-detail__poster {
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||||
import { WorkflowShare } from '@/api/types'
|
import type { WorkflowShare } from '@/api/types'
|
||||||
|
import WorkflowSummaryPreview from '@/components/workflow/WorkflowSummaryPreview.vue'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
import { VueFlow, useVueFlow } from '@vue-flow/core'
|
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -42,35 +42,6 @@ const getEventTypeText = (eventTypeValue: string) => {
|
|||||||
return eventType ? eventType.title : eventTypeValue
|
return eventType ? eventType.title : eventTypeValue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 流程图相关
|
|
||||||
const { nodes, edges } = useVueFlow()
|
|
||||||
|
|
||||||
// 自定义节点类型
|
|
||||||
const nodeTypes: Record<string, any> = ref({})
|
|
||||||
|
|
||||||
// 自动扫描目录下所有的 .vue 文件
|
|
||||||
const components = import.meta.glob('../workflow/*Action.vue')
|
|
||||||
|
|
||||||
// 动态加载某个组件
|
|
||||||
const loadComponent = async (componentName: string) => {
|
|
||||||
const component = components[`../workflow/${componentName}.vue`]
|
|
||||||
if (component) {
|
|
||||||
return ((await component()) as any).default
|
|
||||||
}
|
|
||||||
throw new Error(t('dialog.workflowActions.componentNotFound', { component: componentName }))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将所有components中的组件加载到nodeTypes中
|
|
||||||
for (const path in components) {
|
|
||||||
const componentName = path.match(/\.\/workflow\/(.*).vue$/)?.[1]
|
|
||||||
if (!componentName) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
loadComponent(componentName).then(component => {
|
|
||||||
nodeTypes.value[componentName] = markRaw(component)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析工作流数据
|
// 解析工作流数据
|
||||||
const parsedWorkflow = computed(() => {
|
const parsedWorkflow = computed(() => {
|
||||||
if (!props.workflow) return null
|
if (!props.workflow) return null
|
||||||
@@ -95,13 +66,10 @@ const parsedWorkflow = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 初始化流程图数据
|
const previewActions = computed(() =>
|
||||||
onMounted(() => {
|
Array.isArray(parsedWorkflow.value?.actions) ? parsedWorkflow.value.actions : [],
|
||||||
if (parsedWorkflow.value) {
|
)
|
||||||
nodes.value = parsedWorkflow.value.actions ?? []
|
const previewFlows = computed(() => (Array.isArray(parsedWorkflow.value?.flows) ? parsedWorkflow.value.flows : []))
|
||||||
edges.value = parsedWorkflow.value.flows ?? []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 复用工作流
|
// 复用工作流
|
||||||
async function doFork() {
|
async function doFork() {
|
||||||
@@ -160,81 +128,63 @@ async function doDelete() {
|
|||||||
<VCard>
|
<VCard>
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<VCol>
|
<VCol>
|
||||||
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
<div class="workflow-share-layout">
|
||||||
<div class="ma-auto mt-5">
|
<div class="workflow-share-preview">
|
||||||
<div class="workflow-preview">
|
<WorkflowSummaryPreview :actions="previewActions" :flows="previewFlows" />
|
||||||
<VueFlow
|
|
||||||
:nodes="nodes"
|
|
||||||
:edges="edges"
|
|
||||||
:nodeTypes="nodeTypes"
|
|
||||||
:default-edge-options="{ type: 'animation', animated: true }"
|
|
||||||
:delete-key-code="null"
|
|
||||||
:select-nodes-on-drag="false"
|
|
||||||
:nodes-draggable="false"
|
|
||||||
:nodes-connectable="false"
|
|
||||||
:fit-view="true"
|
|
||||||
:fit-view-options="{ padding: 0.1, minZoom: 0.2, maxZoom: 1 }"
|
|
||||||
:default-viewport="{ x: 0, y: 0, zoom: 0.2 }"
|
|
||||||
class="workflow-preview-flow"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧内容 -->
|
<!-- 右侧内容 -->
|
||||||
<div class="flex-grow">
|
<div class="flex-grow workflow-share-detail">
|
||||||
<VCardItem>
|
<VCardItem class="workflow-share-detail__header pa-0">
|
||||||
<VCardTitle
|
<VCardTitle
|
||||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis"
|
class="workflow-share-detail__title break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis"
|
||||||
>
|
>
|
||||||
{{ props.workflow?.share_title }}
|
{{ props.workflow?.share_title }}
|
||||||
</VCardTitle>
|
</VCardTitle>
|
||||||
<VCardSubtitle
|
<VCardSubtitle
|
||||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis"
|
class="workflow-share-detail__description break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis"
|
||||||
>
|
>
|
||||||
{{ props.workflow?.share_comment }}
|
{{ props.workflow?.share_comment }}
|
||||||
</VCardSubtitle>
|
</VCardSubtitle>
|
||||||
<VList lines="one" class="border-0">
|
<dl class="workflow-share-detail__metadata">
|
||||||
<VListItem class="ps-0">
|
<div class="workflow-share-detail__metadata-row">
|
||||||
<VListItemTitle class="text-center text-md-left">
|
<dt>{{ t('workflow.sharer') }}:</dt>
|
||||||
<span class="font-weight-medium">{{ t('workflow.sharer') }}:</span>
|
<dd>{{ props.workflow?.share_user }}</dd>
|
||||||
<span class="text-body-1"> {{ props.workflow?.share_user }}</span>
|
</div>
|
||||||
</VListItemTitle>
|
<div
|
||||||
</VListItem>
|
v-if="props.workflow?.trigger_type || props.workflow?.timer"
|
||||||
<VListItem class="ps-0" v-if="props.workflow?.trigger_type || props.workflow?.timer">
|
class="workflow-share-detail__metadata-row"
|
||||||
<VListItemTitle class="text-center text-md-left">
|
>
|
||||||
<span class="font-weight-medium">{{ t('workflow.trigger') }}:</span>
|
<dt>{{ t('workflow.trigger') }}:</dt>
|
||||||
<span class="text-body-1">
|
<dd>
|
||||||
<span v-if="props.workflow?.trigger_type === 'timer' || !props.workflow?.trigger_type">
|
<span v-if="props.workflow?.trigger_type === 'timer' || !props.workflow?.trigger_type">
|
||||||
<VIcon icon="mdi-clock-outline" size="small" class="me-1" />
|
<VIcon icon="mdi-clock-outline" size="small" class="me-1" />
|
||||||
{{ props.workflow?.timer }}
|
{{ props.workflow?.timer }}
|
||||||
</span>
|
|
||||||
<span v-else-if="props.workflow?.trigger_type === 'event'">
|
|
||||||
<VIcon icon="mdi-calendar-check" size="small" class="me-1" />
|
|
||||||
{{ getEventTypeText(props.workflow?.event_type || '') }}
|
|
||||||
</span>
|
|
||||||
<span v-else-if="props.workflow?.trigger_type === 'manual'">
|
|
||||||
<VIcon icon="mdi-hand-pointing-up" size="small" class="me-1" />
|
|
||||||
{{ t('workflow.manualTrigger') }}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</VListItemTitle>
|
<span v-else-if="props.workflow?.trigger_type === 'event'">
|
||||||
</VListItem>
|
<VIcon icon="mdi-calendar-check" size="small" class="me-1" />
|
||||||
<VListItem class="ps-0" v-if="parsedWorkflow?.actions">
|
{{ getEventTypeText(props.workflow?.event_type || '') }}
|
||||||
<VListItemTitle class="text-center text-md-left">
|
</span>
|
||||||
<span class="font-weight-medium">{{ t('workflow.actionCount') }}:</span>
|
<span v-else-if="props.workflow?.trigger_type === 'manual'">
|
||||||
<span class="text-body-1"> {{ parsedWorkflow?.actions?.length }}</span>
|
<VIcon icon="mdi-hand-pointing-up" size="small" class="me-1" />
|
||||||
</VListItemTitle>
|
{{ t('workflow.manualTrigger') }}
|
||||||
</VListItem>
|
</span>
|
||||||
</VList>
|
</dd>
|
||||||
<div class="text-center text-md-left">
|
</div>
|
||||||
<div>
|
<div v-if="parsedWorkflow?.actions" class="workflow-share-detail__metadata-row">
|
||||||
|
<dt>{{ t('workflow.actionCount') }}:</dt>
|
||||||
|
<dd>{{ parsedWorkflow?.actions?.length }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div class="workflow-share-detail__actions">
|
||||||
|
<div class="workflow-share-detail__buttons">
|
||||||
<VBtn
|
<VBtn
|
||||||
color="primary"
|
color="primary"
|
||||||
:disabled="processing"
|
:disabled="processing"
|
||||||
@click="doFork"
|
@click="doFork"
|
||||||
prepend-icon="mdi-heart"
|
prepend-icon="mdi-heart"
|
||||||
:loading="processing"
|
:loading="processing"
|
||||||
class="mb-2 me-2"
|
class="workflow-share-detail__button"
|
||||||
>
|
>
|
||||||
{{ t('workflow.normalFork') }}
|
{{ t('workflow.normalFork') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
@@ -248,15 +198,14 @@ async function doDelete() {
|
|||||||
@click="doDelete"
|
@click="doDelete"
|
||||||
prepend-icon="mdi-delete"
|
prepend-icon="mdi-delete"
|
||||||
:loading="deleting"
|
:loading="deleting"
|
||||||
class="mb-2 me-2"
|
class="workflow-share-detail__button"
|
||||||
>
|
>
|
||||||
{{ t('workflow.cancelShare') }}
|
{{ t('workflow.cancelShare') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs mt-2" v-if="props.workflow?.count">
|
<div class="workflow-share-detail__usage" v-if="props.workflow?.count">
|
||||||
<VIcon icon="mdi-fire" />{{
|
<VIcon icon="mdi-fire" size="18" />
|
||||||
t('workflow.usageCount', { count: props.workflow?.count?.toLocaleString() })
|
<span>{{ t('workflow.usageCount', { count: props.workflow?.count?.toLocaleString() }) }}</span>
|
||||||
}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
@@ -270,65 +219,102 @@ async function doDelete() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import '@vue-flow/core/dist/style.css';
|
.workflow-share-layout {
|
||||||
@import '@vue-flow/core/dist/theme-default.css';
|
display: grid;
|
||||||
@import '@vue-flow/minimap/dist/style.css';
|
grid-template-columns: 13.5rem minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
.workflow-preview {
|
gap: 1.25rem;
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: rgba(var(--v-theme-surface), 0.8);
|
|
||||||
block-size: 280px;
|
|
||||||
inline-size: 240px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-preview-flow {
|
.workflow-share-preview {
|
||||||
block-size: 100%;
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-share-detail {
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-share-detail__header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-share-detail__title,
|
||||||
|
.workflow-share-detail__description {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-share-detail__metadata {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.625rem;
|
||||||
|
margin: 1.125rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-share-detail__metadata-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-share-detail__metadata dt {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: end;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-share-detail__metadata dd {
|
||||||
|
min-inline-size: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-share-detail__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-block-start: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-share-detail__buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.vue-flow__node {
|
.workflow-share-detail__usage {
|
||||||
font-size: 10px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
@media (width < 360px) {
|
||||||
box-shadow: none;
|
.workflow-share-detail__buttons {
|
||||||
transform: none;
|
flex-direction: column;
|
||||||
}
|
|
||||||
|
|
||||||
&.selected {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.vue-flow__edge-path,
|
.workflow-share-detail__button {
|
||||||
.vue-flow__connection-path {
|
inline-size: 100%;
|
||||||
stroke-width: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vue-flow__handle {
|
|
||||||
border-radius: 2px;
|
|
||||||
block-size: 12px;
|
|
||||||
inline-size: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自定义动作连线样式
|
|
||||||
.vue-flow__edge.animation {
|
|
||||||
.vue-flow__edge-path {
|
|
||||||
stroke: rgb(var(--v-theme-primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
&.selected {
|
|
||||||
.vue-flow__edge-path {
|
|
||||||
stroke: rgb(var(--v-theme-primary));
|
|
||||||
stroke-width: 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (width <= 600px) {
|
@media screen and (width <= 600px) {
|
||||||
.workflow-preview {
|
.workflow-share-layout {
|
||||||
block-size: 240px;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
inline-size: 240px;
|
gap: 1rem;
|
||||||
|
padding-block-start: 2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -142,6 +142,21 @@ describe('ForkSubscribeDialog follow behavior', () => {
|
|||||||
expect(requested).toHaveBeenCalledOnce()
|
expect(requested).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('places long recognition words in a full-width metadata row', async () => {
|
||||||
|
server.use(followSubscribersSettingHandler([]))
|
||||||
|
const { media } = await renderDialog(
|
||||||
|
createSubscribeShare({
|
||||||
|
custom_words: '#九门2026\n【ADWeb】\n^The.Mystic.Nine => 九门.The.Mystic.Nine',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const row = document.querySelector('.subscribe-share-detail__recognition')
|
||||||
|
|
||||||
|
expect(row).toBeInTheDocument()
|
||||||
|
expect(row?.querySelector(':scope > dt')).toHaveTextContent('识别词:')
|
||||||
|
expect(row?.querySelector(':scope > dd')).toHaveTextContent(media.custom_words!.replaceAll('\n', ' '))
|
||||||
|
})
|
||||||
|
|
||||||
it('follows a share user and refreshes the action from the server setting', async () => {
|
it('follows a share user and refreshes the action from the server setting', async () => {
|
||||||
const media = createSubscribeShare({ share_uid: 'new-follow-user' })
|
const media = createSubscribeShare({ share_uid: 'new-follow-user' })
|
||||||
const users: string[] = []
|
const users: string[] = []
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const dialogSource = readFileSync('src/components/dialog/ForkWorkflowDialog.vue', 'utf8')
|
||||||
|
|
||||||
|
describe('ForkWorkflowDialog preview contract', () => {
|
||||||
|
it('uses the static summary without loading an interactive VueFlow canvas', () => {
|
||||||
|
expect(dialogSource).toContain('WorkflowSummaryPreview')
|
||||||
|
expect(dialogSource).not.toContain('@vue-flow/core')
|
||||||
|
expect(dialogSource).not.toContain('import.meta.glob')
|
||||||
|
expect(dialogSource).not.toContain('<VueFlow')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserves the close-button safe area on small screens', () => {
|
||||||
|
expect(dialogSource).toMatch(
|
||||||
|
/@media screen and \(width <= 600px\)[\s\S]*?\.workflow-share-layout\s*\{[\s\S]*?padding-block-start:\s*2rem/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { actionStepDict } from '@/api/constants'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
interface WorkflowSummaryAction {
|
||||||
|
data?: {
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
id?: number | string
|
||||||
|
name?: string
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkflowSummaryFlow {
|
||||||
|
source?: number | string
|
||||||
|
target?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
actions?: WorkflowSummaryAction[]
|
||||||
|
flows?: WorkflowSummaryFlow[]
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
actions: () => [],
|
||||||
|
flows: () => [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const maxVisibleActions = 4
|
||||||
|
|
||||||
|
const actionIconMap: Record<string, string> = {
|
||||||
|
AddDownloadAction: 'mdi-download',
|
||||||
|
AddSubscribeAction: 'mdi-star-plus',
|
||||||
|
FetchDownloadsAction: 'mdi-progress-download',
|
||||||
|
FetchMediasAction: 'mdi-movie-search',
|
||||||
|
FetchRssAction: 'mdi-rss',
|
||||||
|
FetchTorrentsAction: 'mdi-search-web',
|
||||||
|
FilterMediasAction: 'mdi-filter-check',
|
||||||
|
FilterTorrentsAction: 'mdi-filter-multiple',
|
||||||
|
InvokePluginAction: 'mdi-run',
|
||||||
|
NoteAction: 'mdi-note-text',
|
||||||
|
ScanFileAction: 'mdi-folder-search',
|
||||||
|
ScrapeFileAction: 'mdi-file-find',
|
||||||
|
SendEventAction: 'mdi-send-check',
|
||||||
|
SendMessageAction: 'mdi-message-arrow-right',
|
||||||
|
TransferFileAction: 'mdi-file-move',
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedActions = computed(() => props.actions.filter(action => action && typeof action === 'object'))
|
||||||
|
const normalizedFlows = computed(() => props.flows.filter(flow => flow && typeof flow === 'object'))
|
||||||
|
|
||||||
|
function getActionKey(action: WorkflowSummaryAction, index: number) {
|
||||||
|
return action.id == null ? `action-${index}` : String(action.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared workflows are normally DAGs. Stable topological sorting keeps the summary useful
|
||||||
|
// even when stored node coordinates or array order no longer reflect the execution path.
|
||||||
|
const orderedActions = computed(() => {
|
||||||
|
const actions = normalizedActions.value
|
||||||
|
const actionRecords = actions.map((action, index) => ({ action, key: getActionKey(action, index) }))
|
||||||
|
const actionById = new Map(actionRecords.map(({ action, key }) => [key, action]))
|
||||||
|
const actionIndex = new Map(actionRecords.map(({ key }, index) => [key, index]))
|
||||||
|
const indegree = new Map(actionRecords.map(({ key }) => [key, 0]))
|
||||||
|
const targetsBySource = new Map<string, string[]>()
|
||||||
|
|
||||||
|
normalizedFlows.value.forEach(flow => {
|
||||||
|
const source = String(flow.source)
|
||||||
|
const target = String(flow.target)
|
||||||
|
if (!actionById.has(source) || !actionById.has(target) || source === target) return
|
||||||
|
|
||||||
|
const targets = targetsBySource.get(source) ?? []
|
||||||
|
if (targets.includes(target)) return
|
||||||
|
|
||||||
|
targets.push(target)
|
||||||
|
targetsBySource.set(source, targets)
|
||||||
|
indegree.set(target, (indegree.get(target) ?? 0) + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
const queue = actionRecords.filter(({ key }) => (indegree.get(key) ?? 0) === 0).map(({ key }) => key)
|
||||||
|
const result: WorkflowSummaryAction[] = []
|
||||||
|
const visited = new Set<string>()
|
||||||
|
|
||||||
|
while (queue.length) {
|
||||||
|
queue.sort((left, right) => (actionIndex.get(left) ?? 0) - (actionIndex.get(right) ?? 0))
|
||||||
|
const actionId = queue.shift()!
|
||||||
|
if (visited.has(actionId)) continue
|
||||||
|
|
||||||
|
visited.add(actionId)
|
||||||
|
result.push(actionById.get(actionId)!)
|
||||||
|
|
||||||
|
const targets = targetsBySource.get(actionId) ?? []
|
||||||
|
targets.forEach(targetId => {
|
||||||
|
indegree.set(targetId, (indegree.get(targetId) ?? 0) - 1)
|
||||||
|
if (indegree.get(targetId) === 0) queue.push(targetId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve malformed or cyclic nodes instead of silently hiding them from the summary.
|
||||||
|
actionRecords.forEach(({ action, key }) => {
|
||||||
|
if (!visited.has(key)) result.push(action)
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleActions = computed(() => orderedActions.value.slice(0, maxVisibleActions))
|
||||||
|
const hiddenActionCount = computed(() => Math.max(orderedActions.value.length - visibleActions.value.length, 0))
|
||||||
|
const summaryItemCount = computed(() => visibleActions.value.length + (hiddenActionCount.value ? 1 : 0))
|
||||||
|
|
||||||
|
const hasBranches = computed(() => {
|
||||||
|
const sourceCounts = new Map<string, number>()
|
||||||
|
const targetCounts = new Map<string, number>()
|
||||||
|
|
||||||
|
normalizedFlows.value.forEach(flow => {
|
||||||
|
const source = String(flow.source)
|
||||||
|
const target = String(flow.target)
|
||||||
|
sourceCounts.set(source, (sourceCounts.get(source) ?? 0) + 1)
|
||||||
|
targetCounts.set(target, (targetCounts.get(target) ?? 0) + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...sourceCounts.values(), ...targetCounts.values()].some(count => count > 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
function getActionIcon(type?: string) {
|
||||||
|
return (type && actionIconMap[type]) || 'mdi-puzzle-outline'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActionLabel(action: WorkflowSummaryAction) {
|
||||||
|
const name = action.name || action.data?.label
|
||||||
|
if (name) return actionStepDict[name] || name
|
||||||
|
return action.type?.replace(/Action$/, '') || t('workflow.preview.unknownAction')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="workflow-summary-preview"
|
||||||
|
:aria-label="t('workflow.preview.title')"
|
||||||
|
:style="{ '--workflow-summary-item-count': Math.max(summaryItemCount, 1) }"
|
||||||
|
>
|
||||||
|
<header class="workflow-summary-preview__header">
|
||||||
|
<span class="workflow-summary-preview__heading">
|
||||||
|
<VIcon icon="mdi-source-branch" size="18" />
|
||||||
|
{{ t('workflow.preview.title') }}
|
||||||
|
</span>
|
||||||
|
<span class="workflow-summary-preview__action-count">
|
||||||
|
{{ t('workflow.preview.actionCount', { count: orderedActions.length }) }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="visibleActions.length" class="workflow-summary-preview__steps" role="list">
|
||||||
|
<div
|
||||||
|
v-for="(action, index) in visibleActions"
|
||||||
|
:key="getActionKey(action, index)"
|
||||||
|
class="workflow-summary-preview__step"
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<span class="workflow-summary-preview__step-icon">
|
||||||
|
<VIcon :icon="getActionIcon(action.type)" size="17" />
|
||||||
|
</span>
|
||||||
|
<span class="workflow-summary-preview__step-name" :title="getActionLabel(action)">
|
||||||
|
{{ getActionLabel(action) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hiddenActionCount" class="workflow-summary-preview__step" role="listitem">
|
||||||
|
<span class="workflow-summary-preview__step-icon workflow-summary-preview__step-icon--more">
|
||||||
|
<VIcon icon="mdi-dots-horizontal" size="18" />
|
||||||
|
</span>
|
||||||
|
<span class="workflow-summary-preview__step-name">
|
||||||
|
{{ t('workflow.preview.moreActions', { count: hiddenActionCount }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="workflow-summary-preview__empty">
|
||||||
|
<VIcon icon="mdi-vector-polyline-remove" size="24" />
|
||||||
|
<span>{{ t('workflow.task.info.noActions') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="workflow-summary-preview__footer">
|
||||||
|
<span>{{ t('workflow.preview.flowCount', { count: normalizedFlows.length }) }}</span>
|
||||||
|
<span v-if="hasBranches" class="workflow-summary-preview__branch">
|
||||||
|
<VIcon icon="mdi-source-branch" size="14" />
|
||||||
|
{{ t('workflow.preview.hasBranches') }}
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.workflow-summary-preview {
|
||||||
|
--workflow-summary-line: rgba(var(--v-theme-on-surface), 0.16);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.875rem;
|
||||||
|
min-block-size: 9rem;
|
||||||
|
padding: 0.875rem;
|
||||||
|
border-radius: var(--app-control-radius);
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.035);
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||||
|
inline-size: 13.5rem;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-summary-preview__header,
|
||||||
|
.workflow-summary-preview__footer,
|
||||||
|
.workflow-summary-preview__heading,
|
||||||
|
.workflow-summary-preview__branch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-summary-preview__header,
|
||||||
|
.workflow-summary-preview__footer {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-summary-preview__heading {
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-summary-preview__action-count,
|
||||||
|
.workflow-summary-preview__footer {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-summary-preview__action-count {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-summary-preview__steps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--workflow-summary-item-count), minmax(0, 1fr));
|
||||||
|
align-items: start;
|
||||||
|
min-block-size: 3.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-summary-preview__step {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
min-inline-size: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-summary-preview__step:not(:last-child)::after {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
border-block-start: 1px solid var(--workflow-summary-line);
|
||||||
|
content: '';
|
||||||
|
inline-size: calc(100% - 2rem);
|
||||||
|
inset-block-start: 0.9375rem;
|
||||||
|
inset-inline-start: calc(50% + 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-summary-preview__step-icon {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(var(--v-theme-primary), 0.1);
|
||||||
|
block-size: 1.875rem;
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
inline-size: 1.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-summary-preview__step-icon--more {
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.07);
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-summary-preview__step-name {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
max-inline-size: 100%;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.625rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-summary-preview__empty {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-summary-preview__branch {
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (width <= 600px) {
|
||||||
|
.workflow-summary-preview {
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import WorkflowSummaryPreview from '@/components/workflow/WorkflowSummaryPreview.vue'
|
||||||
|
import { screen } from '@testing-library/vue'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
describe('WorkflowSummaryPreview', () => {
|
||||||
|
it('orders actions by their connections and collapses the remaining actions', async () => {
|
||||||
|
const { container } = await renderWithProviders(WorkflowSummaryPreview, {
|
||||||
|
props: {
|
||||||
|
actions: [
|
||||||
|
{ id: 'download', name: '创建下载', type: 'AddDownloadAction' },
|
||||||
|
{ id: 'rss', name: '读取源', type: 'FetchRssAction' },
|
||||||
|
{ id: 'filter', name: '筛选候选', type: 'FilterTorrentsAction' },
|
||||||
|
{ id: 'notify', name: '发送提醒', type: 'SendMessageAction' },
|
||||||
|
{ id: 'organize', name: '整理结果', type: 'TransferFileAction' },
|
||||||
|
],
|
||||||
|
flows: [
|
||||||
|
{ source: 'rss', target: 'filter' },
|
||||||
|
{ source: 'filter', target: 'download' },
|
||||||
|
{ source: 'download', target: 'organize' },
|
||||||
|
{ source: 'organize', target: 'notify' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('流程概览')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('5 个动作')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('另有 1 个')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('4 条连接')).toBeInTheDocument()
|
||||||
|
|
||||||
|
const steps = screen.getAllByRole('listitem')
|
||||||
|
expect(steps[0]).toHaveTextContent('读取源')
|
||||||
|
expect(steps[1]).toHaveTextContent('筛选候选')
|
||||||
|
expect(steps[2]).toHaveTextContent('创建下载')
|
||||||
|
expect(steps[3]).toHaveTextContent('整理结果')
|
||||||
|
expect(container.querySelector('.vue-flow')).not.toBeInTheDocument()
|
||||||
|
expect(container.querySelectorAll('button, input, textarea, select')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks branching workflows without exposing an interactive graph', async () => {
|
||||||
|
await renderWithProviders(WorkflowSummaryPreview, {
|
||||||
|
props: {
|
||||||
|
actions: [
|
||||||
|
{ id: 'source', name: '获取媒体', type: 'FetchMediasAction' },
|
||||||
|
{ id: 'left', name: '过滤媒体', type: 'FilterMediasAction' },
|
||||||
|
{ id: 'right', name: '发送事件', type: 'SendEventAction' },
|
||||||
|
],
|
||||||
|
flows: [
|
||||||
|
{ source: 'source', target: 'left' },
|
||||||
|
{ source: 'source', target: 'right' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('含分支')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('2 条连接')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a quiet empty state when the workflow has no actions', async () => {
|
||||||
|
const { container } = await renderWithProviders(WorkflowSummaryPreview)
|
||||||
|
|
||||||
|
expect(screen.getByText('暂无动作')).toBeInTheDocument()
|
||||||
|
expect(container.querySelector('.workflow-summary-preview__steps')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1003,6 +1003,14 @@ export default {
|
|||||||
timer: 'Timer',
|
timer: 'Timer',
|
||||||
manualTrigger: 'Manual Trigger',
|
manualTrigger: 'Manual Trigger',
|
||||||
actionCount: 'Action Count',
|
actionCount: 'Action Count',
|
||||||
|
preview: {
|
||||||
|
title: 'Flow Overview',
|
||||||
|
actionCount: '{count} actions',
|
||||||
|
flowCount: '{count} connections',
|
||||||
|
moreActions: '{count} more',
|
||||||
|
hasBranches: 'Branched',
|
||||||
|
unknownAction: 'Unknown action',
|
||||||
|
},
|
||||||
normalFork: 'Fork Workflow',
|
normalFork: 'Fork Workflow',
|
||||||
cancelShare: 'Cancel Share',
|
cancelShare: 'Cancel Share',
|
||||||
cancelSuccess: 'Share cancelled successfully',
|
cancelSuccess: 'Share cancelled successfully',
|
||||||
|
|||||||
@@ -992,6 +992,14 @@ export default {
|
|||||||
timer: '定时器',
|
timer: '定时器',
|
||||||
manualTrigger: '手动触发',
|
manualTrigger: '手动触发',
|
||||||
actionCount: '动作数量',
|
actionCount: '动作数量',
|
||||||
|
preview: {
|
||||||
|
title: '流程概览',
|
||||||
|
actionCount: '{count} 个动作',
|
||||||
|
flowCount: '{count} 条连接',
|
||||||
|
moreActions: '另有 {count} 个',
|
||||||
|
hasBranches: '含分支',
|
||||||
|
unknownAction: '未知动作',
|
||||||
|
},
|
||||||
normalFork: '复用工作流',
|
normalFork: '复用工作流',
|
||||||
cancelShare: '取消分享',
|
cancelShare: '取消分享',
|
||||||
cancelSuccess: '取消分享成功',
|
cancelSuccess: '取消分享成功',
|
||||||
|
|||||||
@@ -992,6 +992,14 @@ export default {
|
|||||||
timer: '定時器',
|
timer: '定時器',
|
||||||
manualTrigger: '手動觸發',
|
manualTrigger: '手動觸發',
|
||||||
actionCount: '動作數量',
|
actionCount: '動作數量',
|
||||||
|
preview: {
|
||||||
|
title: '流程概覽',
|
||||||
|
actionCount: '{count} 個動作',
|
||||||
|
flowCount: '{count} 條連接',
|
||||||
|
moreActions: '另有 {count} 個',
|
||||||
|
hasBranches: '含分支',
|
||||||
|
unknownAction: '未知動作',
|
||||||
|
},
|
||||||
normalFork: '復用工作流',
|
normalFork: '復用工作流',
|
||||||
cancelShare: '取消分享',
|
cancelShare: '取消分享',
|
||||||
cancelSuccess: '取消分享成功',
|
cancelSuccess: '取消分享成功',
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ describe('glass overlay material styles', () => {
|
|||||||
expect(styles).not.toMatch(/\.v-chip--variant-(?:outlined|text|plain)\s*\{/)
|
expect(styles).not.toMatch(/\.v-chip--variant-(?:outlined|text|plain)\s*\{/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps workflow share gradients as colored glass in every appearance', () => {
|
||||||
|
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||||
|
const card = readFileSync(resolve(cwd(), 'src/components/cards/WorkflowShareCard.vue'), 'utf8')
|
||||||
|
|
||||||
|
expect(card).toContain('--workflow-share-gradient-start-rgb')
|
||||||
|
expect(card).toContain('--workflow-share-gradient-end-rgb')
|
||||||
|
expect(styles).toMatch(
|
||||||
|
/\.workflow-share-card\s*\{[\s\S]*?background-image:\s*[\s\S]*?var\(--glass-sheen\),[\s\S]*?var\(--workflow-share-glass-scrim\),[\s\S]*?var\(--workflow-share-gradient-start-rgb,[\s\S]*?var\(--workflow-share-gradient-end-rgb,[\s\S]*?!important;/,
|
||||||
|
)
|
||||||
|
expect(styles).toContain("&[data-glass-appearance='frosted'] .workflow-share-card")
|
||||||
|
expect(styles).toContain("&[data-glass-appearance='tinted'] .workflow-share-card")
|
||||||
|
})
|
||||||
|
|
||||||
it('composites glass dialogs at their final geometry instead of resampling a scaled backdrop', () => {
|
it('composites glass dialogs at their final geometry instead of resampling a scaled backdrop', () => {
|
||||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||||
|
|
||||||
|
|||||||
+23
-1
@@ -589,6 +589,12 @@ html[data-theme-radius='extra'] {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 左右布局的单行字段将值放在右栏中央;整行字段会在移动端规则中恢复左对齐。
|
||||||
|
.app-responsive-input--field .v-field__input {
|
||||||
|
justify-content: var(--app-responsive-input-value-alignment, center);
|
||||||
|
text-align: var(--app-responsive-input-value-alignment, center);
|
||||||
|
}
|
||||||
|
|
||||||
// 移动端单行字段内容超出可视宽度时保持一行并显示省略号。
|
// 移动端单行字段内容超出可视宽度时保持一行并显示省略号。
|
||||||
.app-responsive-input--field .v-field__input {
|
.app-responsive-input--field .v-field__input {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -612,6 +618,19 @@ html[data-theme-radius='extra'] {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-responsive-input--field :is(input.v-field__input, .v-field__input > input),
|
||||||
|
.app-responsive-input--field :is(input.v-field__input, .v-field__input > input)::placeholder {
|
||||||
|
text-align: var(--app-responsive-input-value-alignment, center);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-responsive-input--field
|
||||||
|
.app-responsive-input__native:is(.v-select--single, .v-autocomplete--single, .v-combobox--single)
|
||||||
|
:is(.v-select__selection, .v-autocomplete__selection, .v-combobox__selection) {
|
||||||
|
inline-size: 100%;
|
||||||
|
justify-content: var(--app-responsive-input-value-alignment, center);
|
||||||
|
text-align: var(--app-responsive-input-value-alignment, center);
|
||||||
|
}
|
||||||
|
|
||||||
.app-responsive-input__native:is(.v-select--multiple, .v-autocomplete--multiple, .v-combobox--multiple)
|
.app-responsive-input__native:is(.v-select--multiple, .v-autocomplete--multiple, .v-combobox--multiple)
|
||||||
.v-field__input {
|
.v-field__input {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
@@ -663,12 +682,13 @@ html[data-theme-radius='extra'] {
|
|||||||
.app-responsive-input--multiline .v-field__input {
|
.app-responsive-input--multiline .v-field__input {
|
||||||
min-block-size: 5.5rem;
|
min-block-size: 5.5rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
text-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-responsive-input--choice .app-responsive-input__control,
|
.app-responsive-input--choice .app-responsive-input__control,
|
||||||
.app-responsive-input--choice .v-input__control,
|
.app-responsive-input--choice .v-input__control,
|
||||||
.app-responsive-input--group .v-selection-control-group {
|
.app-responsive-input--group .v-selection-control-group {
|
||||||
justify-content: end;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-responsive-input--choice .v-input__control {
|
.app-responsive-input--choice .v-input__control {
|
||||||
@@ -748,6 +768,8 @@ html[data-theme-radius='extra'] {
|
|||||||
.v-col-md-12:not([class*='v-col-lg-']):not([class*='v-col-xl-']):not([class*='v-col-xxl-'])
|
.v-col-md-12:not([class*='v-col-lg-']):not([class*='v-col-xl-']):not([class*='v-col-xxl-'])
|
||||||
)
|
)
|
||||||
> :is(.app-responsive-input--field, .app-responsive-input--range) {
|
> :is(.app-responsive-input--field, .app-responsive-input--range) {
|
||||||
|
--app-responsive-input-value-alignment: start;
|
||||||
|
|
||||||
row-gap: 0.5rem;
|
row-gap: 0.5rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
|||||||
@@ -993,6 +993,31 @@ html[data-theme='glass'] {
|
|||||||
--workflow-card-header-shadow: inset 0 1px 0 var(--glass-highlight), inset 0 -1px 0 rgba(2, 6, 16, 14%);
|
--workflow-card-header-shadow: inset 0 1px 0 var(--glass-highlight), inset 0 -1px 0 rgba(2, 6, 16, 14%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分享卡保留原随机渐变的色相,以半透明染色、吸光和镜面层组成有色玻璃。
|
||||||
|
.workflow-share-card {
|
||||||
|
--workflow-share-glass-start-opacity: calc(0.18 + var(--glass-tint-density, 0.65) * 0.38);
|
||||||
|
--workflow-share-glass-end-opacity: calc(0.24 + var(--glass-tint-density, 0.65) * 0.42);
|
||||||
|
--workflow-share-glass-scrim:
|
||||||
|
linear-gradient(
|
||||||
|
rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.62) * 0.1)),
|
||||||
|
rgba(11, 19, 34, calc(0.2 + var(--glass-surface-density, 0.62) * 0.14))
|
||||||
|
);
|
||||||
|
|
||||||
|
background-color: var(--glass-surface) !important;
|
||||||
|
background-image:
|
||||||
|
var(--glass-sheen),
|
||||||
|
var(--workflow-share-glass-scrim),
|
||||||
|
linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(
|
||||||
|
var(--workflow-share-gradient-start-rgb, 74, 85, 104),
|
||||||
|
var(--workflow-share-glass-start-opacity)
|
||||||
|
)
|
||||||
|
0%,
|
||||||
|
rgba(var(--workflow-share-gradient-end-rgb, 45, 55, 72), var(--workflow-share-glass-end-opacity)) 100%
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
|
||||||
// 插件卡片头部改为染色玻璃:图标主色只作为透光色相,压暗遮罩换成玻璃吸收与高光。
|
// 插件卡片头部改为染色玻璃:图标主色只作为透光色相,压暗遮罩换成玻璃吸收与高光。
|
||||||
.plugin-card__banner {
|
.plugin-card__banner {
|
||||||
border-block-end: 1px solid var(--glass-border);
|
border-block-end: 1px solid var(--glass-border);
|
||||||
@@ -1052,6 +1077,16 @@ html[data-theme='glass'] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[data-glass-appearance='frosted'] .workflow-share-card {
|
||||||
|
--workflow-share-glass-start-opacity: calc(0.12 + var(--glass-tint-density, 0.65) * 0.28);
|
||||||
|
--workflow-share-glass-end-opacity: calc(0.16 + var(--glass-tint-density, 0.65) * 0.34);
|
||||||
|
--workflow-share-glass-scrim:
|
||||||
|
linear-gradient(
|
||||||
|
rgba(11, 19, 34, calc(0.06 + var(--glass-surface-density, 0.86) * 0.07)),
|
||||||
|
rgba(11, 19, 34, calc(0.13 + var(--glass-surface-density, 0.86) * 0.1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 色调材质本身带主色语义,头部染色向主色收敛保持整页同一色温。
|
// 色调材质本身带主色语义,头部染色向主色收敛保持整页同一色温。
|
||||||
&[data-glass-appearance='tinted'] .plugin-card__banner {
|
&[data-glass-appearance='tinted'] .plugin-card__banner {
|
||||||
--plugin-card-banner-tint: linear-gradient(
|
--plugin-card-banner-tint: linear-gradient(
|
||||||
@@ -1097,6 +1132,16 @@ html[data-theme='glass'] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[data-glass-appearance='tinted'] .workflow-share-card {
|
||||||
|
--workflow-share-glass-start-opacity: calc(0.16 + var(--glass-tint-density, 0.65) * 0.32);
|
||||||
|
--workflow-share-glass-end-opacity: calc(0.21 + var(--glass-tint-density, 0.65) * 0.38);
|
||||||
|
--workflow-share-glass-scrim:
|
||||||
|
linear-gradient(
|
||||||
|
rgba(11, 19, 34, calc(0.09 + var(--glass-surface-density, 0.72) * 0.09)),
|
||||||
|
rgba(11, 19, 34, calc(0.18 + var(--glass-surface-density, 0.72) * 0.13))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 文件夹卡片保留用户自选渐变作为色相,只降低不透明度让卡片本体的玻璃透出来。
|
// 文件夹卡片保留用户自选渐变作为色相,只降低不透明度让卡片本体的玻璃透出来。
|
||||||
.plugin-folder-card__bg {
|
.plugin-folder-card__bg {
|
||||||
opacity: calc(0.34 + var(--glass-surface-density, 0.62) * 0.145);
|
opacity: calc(0.34 + var(--glass-surface-density, 0.62) * 0.145);
|
||||||
|
|||||||
Reference in New Issue
Block a user