mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 16:16:42 +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 gradientStyle = ref('')
|
||||
const gradientPalettes = [
|
||||
['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 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 gradientStyle = computed(() => {
|
||||
const seed = String(props.workflow?.id || Math.random())
|
||||
const hash = seed.split('').reduce((a, b) => {
|
||||
a = (a << 5) - a + b.charCodeAt(0)
|
||||
return a & a
|
||||
}, 0)
|
||||
const [startRgb, endRgb] = gradientPalettes[Math.abs(hash) % gradientPalettes.length]
|
||||
|
||||
const index = Math.abs(hash) % gradients.length
|
||||
return gradients[index]
|
||||
}
|
||||
|
||||
// 初始化渐变背景
|
||||
onMounted(() => {
|
||||
gradientStyle.value = generateRandomGradient()
|
||||
return {
|
||||
'--workflow-share-gradient-start-rgb': startRgb,
|
||||
'--workflow-share-gradient-end-rgb': endRgb,
|
||||
backgroundImage: `linear-gradient(135deg, rgb(${startRgb}) 0%, rgb(${endRgb}) 100%)`,
|
||||
}
|
||||
})
|
||||
|
||||
// 复用工作流
|
||||
@@ -104,7 +99,7 @@ function doDelete() {
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
:style="{ background: gradientStyle }"
|
||||
:style="gradientStyle"
|
||||
@click="showForkWorkflow"
|
||||
>
|
||||
<div class="h-full flex flex-col">
|
||||
|
||||
@@ -188,8 +188,10 @@ onMounted(() => {
|
||||
<VCard>
|
||||
<VCardText>
|
||||
<VCol>
|
||||
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
||||
<div class="ma-auto">
|
||||
<div
|
||||
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
|
||||
width="10rem"
|
||||
aspect-ratio="2/3"
|
||||
@@ -205,52 +207,52 @@ onMounted(() => {
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<VCardItem>
|
||||
<div class="flex-grow subscribe-share-detail">
|
||||
<VCardItem class="subscribe-share-detail__header pa-0">
|
||||
<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 }}
|
||||
</VCardTitle>
|
||||
<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 }}
|
||||
</VCardSubtitle>
|
||||
<VList lines="one" class="border-0">
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('subscribe.sharer') }}:</span>
|
||||
<span class="text-body-1"> {{ media?.share_user }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0" v-if="media?.keyword">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('subscribe.keyword') }}:</span>
|
||||
<span class="text-body-1"> {{ media?.keyword }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0" v-if="media?.custom_words" @click.stop="toggleExpand">
|
||||
<VListItemTitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces"
|
||||
<dl class="subscribe-share-detail__metadata">
|
||||
<div class="subscribe-share-detail__metadata-row">
|
||||
<dt>{{ t('subscribe.sharer') }}:</dt>
|
||||
<dd>{{ media?.share_user }}</dd>
|
||||
</div>
|
||||
<div v-if="media?.keyword" class="subscribe-share-detail__metadata-row">
|
||||
<dt>{{ t('subscribe.keyword') }}:</dt>
|
||||
<dd>{{ media?.keyword }}</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="media?.custom_words"
|
||||
class="subscribe-share-detail__recognition"
|
||||
@click.stop="toggleExpand"
|
||||
>
|
||||
<dt>{{ t('subscribe.recognitionWords') }}:</dt>
|
||||
<dd
|
||||
class="break-words"
|
||||
:class="{
|
||||
'line-clamp-4 overflow-hidden text-ellipsis': !isExpanded,
|
||||
}"
|
||||
>
|
||||
<span class="font-weight-medium">{{ t('subscribe.recognitionWords') }}:</span>
|
||||
<span class="text-body-1"> {{ media?.custom_words }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<div class="text-center text-md-left">
|
||||
<div>
|
||||
{{ media?.custom_words }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="subscribe-share-detail__actions">
|
||||
<div class="subscribe-share-detail__buttons">
|
||||
<VBtn
|
||||
color="primary"
|
||||
:disabled="processing"
|
||||
@click="doFork"
|
||||
prepend-icon="mdi-heart"
|
||||
:loading="processing"
|
||||
class="mb-2 me-2"
|
||||
class="subscribe-share-detail__button"
|
||||
>
|
||||
{{ t('subscribe.normalSub') }}
|
||||
</VBtn>
|
||||
@@ -259,7 +261,7 @@ onMounted(() => {
|
||||
color="warning"
|
||||
@click="unfollowUser"
|
||||
prepend-icon="mdi-account-remove"
|
||||
class="mb-2 me-2"
|
||||
class="subscribe-share-detail__button"
|
||||
>
|
||||
{{ t('subscribe.unfollow') }}
|
||||
</VBtn>
|
||||
@@ -268,7 +270,7 @@ onMounted(() => {
|
||||
@click="followUser"
|
||||
color="info"
|
||||
prepend-icon="mdi-account-plus"
|
||||
class="mb-2 me-2"
|
||||
class="subscribe-share-detail__button"
|
||||
>
|
||||
{{ t('subscribe.follow') }}
|
||||
</VBtn>
|
||||
@@ -282,15 +284,14 @@ onMounted(() => {
|
||||
@click="doDelete"
|
||||
prepend-icon="mdi-delete"
|
||||
:loading="deleting"
|
||||
class="mb-2 me-2"
|
||||
class="subscribe-share-detail__button"
|
||||
>
|
||||
{{ t('subscribe.cancelShare') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<div class="text-xs mt-2" v-if="props.media?.count">
|
||||
<VIcon icon="mdi-fire" />{{
|
||||
t('subscribe.usageCount', { count: props.media?.count?.toLocaleString() })
|
||||
}}
|
||||
<div class="subscribe-share-detail__usage" v-if="props.media?.count">
|
||||
<VIcon icon="mdi-fire" size="18" />
|
||||
<span>{{ t('subscribe.usageCount', { count: props.media?.count?.toLocaleString() }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
@@ -302,3 +303,124 @@ onMounted(() => {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</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">
|
||||
import api from '@/api'
|
||||
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 { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { VueFlow, useVueFlow } from '@vue-flow/core'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -42,35 +42,6 @@ const getEventTypeText = (eventTypeValue: string) => {
|
||||
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(() => {
|
||||
if (!props.workflow) return null
|
||||
@@ -95,13 +66,10 @@ const parsedWorkflow = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化流程图数据
|
||||
onMounted(() => {
|
||||
if (parsedWorkflow.value) {
|
||||
nodes.value = parsedWorkflow.value.actions ?? []
|
||||
edges.value = parsedWorkflow.value.flows ?? []
|
||||
}
|
||||
})
|
||||
const previewActions = computed(() =>
|
||||
Array.isArray(parsedWorkflow.value?.actions) ? parsedWorkflow.value.actions : [],
|
||||
)
|
||||
const previewFlows = computed(() => (Array.isArray(parsedWorkflow.value?.flows) ? parsedWorkflow.value.flows : []))
|
||||
|
||||
// 复用工作流
|
||||
async function doFork() {
|
||||
@@ -160,81 +128,63 @@ async function doDelete() {
|
||||
<VCard>
|
||||
<VCardText>
|
||||
<VCol>
|
||||
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
||||
<div class="ma-auto mt-5">
|
||||
<div class="workflow-preview">
|
||||
<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 class="workflow-share-layout">
|
||||
<div class="workflow-share-preview">
|
||||
<WorkflowSummaryPreview :actions="previewActions" :flows="previewFlows" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容 -->
|
||||
<div class="flex-grow">
|
||||
<VCardItem>
|
||||
<div class="flex-grow workflow-share-detail">
|
||||
<VCardItem class="workflow-share-detail__header pa-0">
|
||||
<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 }}
|
||||
</VCardTitle>
|
||||
<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 }}
|
||||
</VCardSubtitle>
|
||||
<VList lines="one" class="border-0">
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('workflow.sharer') }}:</span>
|
||||
<span class="text-body-1"> {{ props.workflow?.share_user }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0" v-if="props.workflow?.trigger_type || props.workflow?.timer">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('workflow.trigger') }}:</span>
|
||||
<span class="text-body-1">
|
||||
<span v-if="props.workflow?.trigger_type === 'timer' || !props.workflow?.trigger_type">
|
||||
<VIcon icon="mdi-clock-outline" size="small" class="me-1" />
|
||||
{{ 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>
|
||||
<dl class="workflow-share-detail__metadata">
|
||||
<div class="workflow-share-detail__metadata-row">
|
||||
<dt>{{ t('workflow.sharer') }}:</dt>
|
||||
<dd>{{ props.workflow?.share_user }}</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.workflow?.trigger_type || props.workflow?.timer"
|
||||
class="workflow-share-detail__metadata-row"
|
||||
>
|
||||
<dt>{{ t('workflow.trigger') }}:</dt>
|
||||
<dd>
|
||||
<span v-if="props.workflow?.trigger_type === 'timer' || !props.workflow?.trigger_type">
|
||||
<VIcon icon="mdi-clock-outline" size="small" class="me-1" />
|
||||
{{ props.workflow?.timer }}
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0" v-if="parsedWorkflow?.actions">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('workflow.actionCount') }}:</span>
|
||||
<span class="text-body-1"> {{ parsedWorkflow?.actions?.length }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<div class="text-center text-md-left">
|
||||
<div>
|
||||
<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>
|
||||
</dd>
|
||||
</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
|
||||
color="primary"
|
||||
:disabled="processing"
|
||||
@click="doFork"
|
||||
prepend-icon="mdi-heart"
|
||||
:loading="processing"
|
||||
class="mb-2 me-2"
|
||||
class="workflow-share-detail__button"
|
||||
>
|
||||
{{ t('workflow.normalFork') }}
|
||||
</VBtn>
|
||||
@@ -248,15 +198,14 @@ async function doDelete() {
|
||||
@click="doDelete"
|
||||
prepend-icon="mdi-delete"
|
||||
:loading="deleting"
|
||||
class="mb-2 me-2"
|
||||
class="workflow-share-detail__button"
|
||||
>
|
||||
{{ t('workflow.cancelShare') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<div class="text-xs mt-2" v-if="props.workflow?.count">
|
||||
<VIcon icon="mdi-fire" />{{
|
||||
t('workflow.usageCount', { count: props.workflow?.count?.toLocaleString() })
|
||||
}}
|
||||
<div class="workflow-share-detail__usage" v-if="props.workflow?.count">
|
||||
<VIcon icon="mdi-fire" size="18" />
|
||||
<span>{{ t('workflow.usageCount', { count: props.workflow?.count?.toLocaleString() }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
@@ -270,65 +219,102 @@ async function doDelete() {
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@vue-flow/core/dist/style.css';
|
||||
@import '@vue-flow/core/dist/theme-default.css';
|
||||
@import '@vue-flow/minimap/dist/style.css';
|
||||
|
||||
.workflow-preview {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: rgba(var(--v-theme-surface), 0.8);
|
||||
block-size: 280px;
|
||||
inline-size: 240px;
|
||||
.workflow-share-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 13.5rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.workflow-preview-flow {
|
||||
block-size: 100%;
|
||||
.workflow-share-preview {
|
||||
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%;
|
||||
}
|
||||
|
||||
.vue-flow__node {
|
||||
font-size: 10px;
|
||||
.workflow-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;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
box-shadow: none;
|
||||
}
|
||||
@media (width < 360px) {
|
||||
.workflow-share-detail__buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vue-flow__edge-path,
|
||||
.vue-flow__connection-path {
|
||||
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;
|
||||
}
|
||||
}
|
||||
.workflow-share-detail__button {
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
.workflow-preview {
|
||||
block-size: 240px;
|
||||
inline-size: 240px;
|
||||
.workflow-share-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
padding-block-start: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -142,6 +142,21 @@ describe('ForkSubscribeDialog follow behavior', () => {
|
||||
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 () => {
|
||||
const media = createSubscribeShare({ share_uid: 'new-follow-user' })
|
||||
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',
|
||||
manualTrigger: 'Manual Trigger',
|
||||
actionCount: 'Action Count',
|
||||
preview: {
|
||||
title: 'Flow Overview',
|
||||
actionCount: '{count} actions',
|
||||
flowCount: '{count} connections',
|
||||
moreActions: '{count} more',
|
||||
hasBranches: 'Branched',
|
||||
unknownAction: 'Unknown action',
|
||||
},
|
||||
normalFork: 'Fork Workflow',
|
||||
cancelShare: 'Cancel Share',
|
||||
cancelSuccess: 'Share cancelled successfully',
|
||||
|
||||
@@ -992,6 +992,14 @@ export default {
|
||||
timer: '定时器',
|
||||
manualTrigger: '手动触发',
|
||||
actionCount: '动作数量',
|
||||
preview: {
|
||||
title: '流程概览',
|
||||
actionCount: '{count} 个动作',
|
||||
flowCount: '{count} 条连接',
|
||||
moreActions: '另有 {count} 个',
|
||||
hasBranches: '含分支',
|
||||
unknownAction: '未知动作',
|
||||
},
|
||||
normalFork: '复用工作流',
|
||||
cancelShare: '取消分享',
|
||||
cancelSuccess: '取消分享成功',
|
||||
|
||||
@@ -992,6 +992,14 @@ export default {
|
||||
timer: '定時器',
|
||||
manualTrigger: '手動觸發',
|
||||
actionCount: '動作數量',
|
||||
preview: {
|
||||
title: '流程概覽',
|
||||
actionCount: '{count} 個動作',
|
||||
flowCount: '{count} 條連接',
|
||||
moreActions: '另有 {count} 個',
|
||||
hasBranches: '含分支',
|
||||
unknownAction: '未知動作',
|
||||
},
|
||||
normalFork: '復用工作流',
|
||||
cancelShare: '取消分享',
|
||||
cancelSuccess: '取消分享成功',
|
||||
|
||||
@@ -44,6 +44,19 @@ describe('glass overlay material styles', () => {
|
||||
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', () => {
|
||||
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;
|
||||
}
|
||||
|
||||
// 左右布局的单行字段将值放在右栏中央;整行字段会在移动端规则中恢复左对齐。
|
||||
.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 {
|
||||
overflow: hidden;
|
||||
@@ -612,6 +618,19 @@ html[data-theme-radius='extra'] {
|
||||
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)
|
||||
.v-field__input {
|
||||
overflow: visible;
|
||||
@@ -663,12 +682,13 @@ html[data-theme-radius='extra'] {
|
||||
.app-responsive-input--multiline .v-field__input {
|
||||
min-block-size: 5.5rem;
|
||||
align-items: start;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.app-responsive-input--choice .app-responsive-input__control,
|
||||
.app-responsive-input--choice .v-input__control,
|
||||
.app-responsive-input--group .v-selection-control-group {
|
||||
justify-content: end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.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-'])
|
||||
)
|
||||
> :is(.app-responsive-input--field, .app-responsive-input--range) {
|
||||
--app-responsive-input-value-alignment: start;
|
||||
|
||||
row-gap: 0.5rem;
|
||||
align-items: start;
|
||||
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-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 {
|
||||
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 {
|
||||
--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 {
|
||||
opacity: calc(0.34 + var(--glass-surface-density, 0.62) * 0.145);
|
||||
|
||||
Reference in New Issue
Block a user