Refine shared workflow and subscription dialogs

This commit is contained in:
jxxghp
2026-08-04 21:37:04 +08:00
parent 487e567d4c
commit b1f3d3eacb
13 changed files with 842 additions and 217 deletions
+26 -31
View File
@@ -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">
+159 -37
View File
@@ -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>
+134 -148
View File
@@ -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()
})
})
+8
View File
@@ -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',
+8
View File
@@ -992,6 +992,14 @@ export default {
timer: '定时器',
manualTrigger: '手动触发',
actionCount: '动作数量',
preview: {
title: '流程概览',
actionCount: '{count} 个动作',
flowCount: '{count} 条连接',
moreActions: '另有 {count} 个',
hasBranches: '含分支',
unknownAction: '未知动作',
},
normalFork: '复用工作流',
cancelShare: '取消分享',
cancelSuccess: '取消分享成功',
+8
View File
@@ -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
View File
@@ -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);
+45
View File
@@ -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);