feat: add Claude AI skills for shadcn theming and image generation tools

This commit introduces comprehensive Claude AI skill configurations for:
- shadcn/ui theming with OKLCH color space support
- Gemini API integration for image generation and chat capabilities
- Batch processing and multi-turn conversation features
- File handling utilities for image processing workflows
This commit is contained in:
2026-03-18 02:56:05 +08:00
parent 69e96412ff
commit 791a523101
16 changed files with 1967 additions and 796 deletions

View File

@@ -0,0 +1,98 @@
---
name: shadcn-theming
description: Design tokens and theming for shadcn/ui. Covers CSS variables, OKLCH colors, dark/light mode, and theme configuration for both Radix and Base UI primitives.
versions:
shadcn-ui: "2.x"
tailwindcss: "4.1"
user-invocable: true
allowed-tools: Read, Write, Edit, Glob, Grep
references: references/theming-guide.md, references/templates/theme-setup.md
related-skills: shadcn-registries, shadcn-components
---
# shadcn Theming
## Agent Workflow (MANDATORY)
Before theming work, use `TeamCreate`:
1. **fuse-ai-pilot:explore-codebase** - Find existing theme tokens
2. **fuse-ai-pilot:research-expert** - Verify OKLCH patterns via Context7
After: Run **fuse-ai-pilot:sniper** for validation.
## Overview
| Feature | Description |
|---------|-------------|
| **CSS Variables** | `--background`, `--foreground`, `--primary` |
| **OKLCH Colors** | Wide-gamut P3 color space |
| **Dark Mode** | `.dark` class or `prefers-color-scheme` |
| **Tailwind v4** | `@theme` directive integration |
## Critical Rules
1. **ALWAYS use OKLCH** color space for all tokens
2. **ALWAYS define dark mode** overrides for every token
3. **NEVER hard-code** hex or rgb in components
4. **USE @theme** directive for Tailwind v4 integration
5. **MAP semantic tokens** to primitive OKLCH values
## Architecture
```
app/
├── globals.css # :root + .dark token definitions
└── tailwind.config.ts # Optional (v3) or @theme (v4)
```
-> See [theme-setup.md](references/templates/theme-setup.md) for complete theme
## Token Hierarchy
```
Component: --card, --card-foreground, --button-*
Semantic: --primary, --secondary, --accent, --muted
Primitive: oklch(55% 0.20 260), oklch(98% 0.01 260)
```
## Validation Checklist
```
[ ] CSS variables defined in :root
[ ] Dark mode overrides in .dark
[ ] OKLCH color space used
[ ] Chart variables (--chart-1 to --chart-5)
[ ] Sidebar variables if applicable
[ ] No hard-coded hex in components
```
## Best Practices
### DO
- Use OKLCH for all colors
- Define semantic tokens mapped to primitives
- Provide dark mode overrides for all tokens
- Use `@theme` for Tailwind v4 integration
### DON'T
- Hard-code hex or rgb values
- Skip dark mode definitions
- Mix color spaces (hex + oklch)
- Define tokens only in Tailwind config
## Reference Guide
### Concepts
| Topic | Reference | When to Consult |
|-------|-----------|-----------------|
| **Theming Guide** | [theming-guide.md](references/theming-guide.md) | CSS variables and OKLCH setup |
### Templates
| Template | When to Use |
|----------|-------------|
| [theme-setup.md](references/templates/theme-setup.md) | Complete theme configuration |

View File

@@ -0,0 +1,109 @@
---
name: theme-setup
description: Complete shadcn/ui theme with CSS variables, dark mode, and Tailwind v4 integration
keywords: theme, setup, css, oklch, dark-mode, tailwind
---
# Theme Setup
## Complete Light + Dark Theme
```css
/* app/globals.css */
@import "tailwindcss";
:root {
--background: oklch(100% 0 0);
--foreground: oklch(14.1% 0.005 285.82);
--card: oklch(100% 0 0);
--card-foreground: oklch(14.1% 0.005 285.82);
--popover: oklch(100% 0 0);
--popover-foreground: oklch(14.1% 0.005 285.82);
--primary: oklch(20.5% 0.016 285.94);
--primary-foreground: oklch(98.5% 0 0);
--secondary: oklch(96.7% 0.001 286.38);
--secondary-foreground: oklch(20.5% 0.016 285.94);
--accent: oklch(96.7% 0.001 286.38);
--accent-foreground: oklch(20.5% 0.016 285.94);
--muted: oklch(96.7% 0.001 286.38);
--muted-foreground: oklch(55.6% 0.01 285.94);
--destructive: oklch(57.7% 0.245 27.33);
--destructive-foreground: oklch(98.5% 0 0);
--border: oklch(92.2% 0.004 286.32);
--input: oklch(92.2% 0.004 286.32);
--ring: oklch(87.1% 0.006 286.29);
--radius: 0.625rem;
--chart-1: oklch(64.6% 0.222 41.12);
--chart-2: oklch(60% 0.19 160);
--chart-3: oklch(55% 0.18 230);
--chart-4: oklch(70% 0.15 300);
--chart-5: oklch(75% 0.12 60);
--sidebar: oklch(98.5% 0 0);
--sidebar-foreground: oklch(14.1% 0.005 285.82);
--sidebar-primary: oklch(20.5% 0.016 285.94);
--sidebar-accent: oklch(96.7% 0.001 286.38);
--sidebar-border: oklch(92.2% 0.004 286.32);
--sidebar-ring: oklch(87.1% 0.006 286.29);
}
.dark {
--background: oklch(14.1% 0.005 285.82);
--foreground: oklch(98.5% 0 0);
--card: oklch(14.1% 0.005 285.82);
--card-foreground: oklch(98.5% 0 0);
--popover: oklch(14.1% 0.005 285.82);
--popover-foreground: oklch(98.5% 0 0);
--primary: oklch(92.2% 0 0);
--primary-foreground: oklch(20.5% 0.016 285.94);
--secondary: oklch(26.9% 0.006 286.03);
--secondary-foreground: oklch(98.5% 0 0);
--accent: oklch(26.9% 0.006 286.03);
--accent-foreground: oklch(98.5% 0 0);
--muted: oklch(26.9% 0.006 286.03);
--muted-foreground: oklch(71.1% 0.013 286.07);
--destructive: oklch(57.7% 0.245 27.33);
--border: oklch(26.9% 0.006 286.03);
--input: oklch(26.9% 0.006 286.03);
--ring: oklch(36.2% 0.014 285.88);
--sidebar: oklch(14.1% 0.005 285.82);
--sidebar-foreground: oklch(98.5% 0 0);
--sidebar-primary: oklch(48.8% 0.243 264.05);
--sidebar-accent: oklch(26.9% 0.006 286.03);
--sidebar-border: oklch(26.9% 0.006 286.03);
}
```
## Tailwind v4 @theme Bridge
```css
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-accent: var(--accent);
--color-muted: var(--muted);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-lg: var(--radius);
}
```
## Theme Switching (React)
```tsx
// components/theme-provider.tsx
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</NextThemesProvider>
)
}
```

View File

@@ -0,0 +1,90 @@
---
name: theming-guide
description: CSS variables, OKLCH colors, chart/sidebar tokens, and Tailwind integration
when-to-use: When configuring theme tokens or color system
keywords: theme, oklch, css-variables, dark-mode, tailwind, tokens, colors
priority: high
related: ../SKILL.md
---
# Theming Guide
## Overview
shadcn/ui uses CSS custom properties with OKLCH color space for wide-gamut P3 support and perceptual uniformity. Tokens follow a 3-level hierarchy: primitive -> semantic -> component.
---
## Key Concepts
| Concept | Description |
|---------|-------------|
| **OKLCH** | `oklch(L% C H)` - perceptually uniform, P3 gamut |
| **Semantic tokens** | `--primary`, `--secondary`, `--accent` |
| **Dark mode** | `.dark` class or `prefers-color-scheme` |
| **@theme** | Tailwind v4 custom property bridge |
## Layout Variables
| Variable | Purpose |
|----------|---------|
| `--background` | Page background |
| `--foreground` | Default text |
| `--card` / `--card-foreground` | Card surfaces |
| `--popover` / `--popover-foreground` | Popover surfaces |
## Interactive Variables
| Variable | Purpose |
|----------|---------|
| `--primary` / `--primary-foreground` | Primary buttons, links |
| `--secondary` | Secondary elements |
| `--accent` | Hover backgrounds |
| `--muted` / `--muted-foreground` | Muted backgrounds |
| `--destructive` | Danger/delete actions |
## Utility Variables
| Variable | Purpose |
|----------|---------|
| `--border` | Default border color |
| `--input` | Input border color |
| `--ring` | Focus ring color |
| `--radius` | Default border radius (0.625rem) |
## OKLCH Color Space
```
oklch(Lightness% Chroma Hue)
L: 0-100% (0 = black, 100 = white)
C: 0-0.4 (0 = gray, 0.4 = vivid)
H: 0-360 (hue angle)
```
Benefits: perceptual uniformity, P3 gamut, predictable contrast.
## Theme Switching
Use `.dark` class on `<html>` or CSS media query:
```tsx
<html className={theme === "dark" ? "dark" : ""}>
```
For full setup with next-themes, see [theme-setup.md](templates/theme-setup.md).
---
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Mixing hex and OKLCH | Use OKLCH consistently |
| Forgetting dark overrides | Every :root token needs .dark equivalent |
| Defining colors in Tailwind config | Use CSS variables with @theme bridge |
---
## Related Templates
- [theme-setup.md](templates/theme-setup.md) - Complete theme configuration

View File

@@ -12,7 +12,7 @@ import SidebarNav from '@/components/SidebarNav.vue'
<TopNav />
<div class="flex flex-1 pt-[70px]">
<SidebarNav />
<main class="flex-1 h-[calc(100vh-70px)] overflow-auto p-4">
<main class="flex-1 h-[calc(100vh-70px)] overflow-auto">
<RouterView v-slot="{ Component }">
<keep-alive>
<component :is="Component" />

View File

@@ -341,6 +341,28 @@
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
/* 品牌色阶 */
--color-primary-50: var(--color-primary-50);
--color-primary-100: var(--color-primary-100);
--color-primary-200: var(--color-primary-200);
--color-primary-300: var(--color-primary-300);
--color-primary-400: var(--color-primary-400);
--color-primary-500: var(--color-primary-500);
--color-primary-600: var(--color-primary-600);
--color-primary-700: var(--color-primary-700);
/* 灰色系 */
--color-gray-50: var(--color-gray-50);
--color-gray-100: var(--color-gray-100);
--color-gray-200: var(--color-gray-200);
--color-gray-300: var(--color-gray-300);
--color-gray-400: var(--color-gray-400);
--color-gray-500: var(--color-gray-500);
--color-gray-600: var(--color-gray-600);
--color-gray-700: var(--color-gray-700);
--color-gray-800: var(--color-gray-800);
--color-gray-900: var(--color-gray-900);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);

View File

@@ -43,7 +43,7 @@ function handleReset() {
</script>
<template>
<Card class="border-0 shadow-sm bg-white/80 backdrop-blur-sm">
<Card class="border-0 shadow-sm bg-white/80 backdrop-blur-sm p-0">
<CardContent class="p-5 space-y-5">
<!-- 平台选择 -->
<div class="space-y-2">
@@ -66,28 +66,6 @@ function handleReset() {
/>
</div>
<!-- 排序方式 -->
<div class="space-y-2">
<Label class="text-sm font-medium text-gray-700">排序方式</Label>
<RadioGroup v-model="form.sort_type" class="flex gap-2">
<div class="inline-flex items-center justify-center px-4 h-9 text-sm font-medium rounded-lg cursor-pointer transition-all"
:class="form.sort_type === 0 ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/80'">
<RadioGroupItem :value="0" id="sort-default" class="hidden" />
<label for="sort-default" class="cursor-pointer">综合排序</label>
</div>
<div class="inline-flex items-center justify-center px-4 h-9 text-sm font-medium rounded-lg cursor-pointer transition-all"
:class="form.sort_type === 1 ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/80'">
<RadioGroupItem :value="1" id="sort-likes" class="hidden" />
<label for="sort-likes" class="cursor-pointer">最多点赞</label>
</div>
<div class="inline-flex items-center justify-center px-4 h-9 text-sm font-medium rounded-lg cursor-pointer transition-all"
:class="form.sort_type === 2 ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/80'">
<RadioGroupItem :value="2" id="sort-latest" class="hidden" />
<label for="sort-latest" class="cursor-pointer">最新发布</label>
</div>
</RadioGroup>
</div>
<!-- 分析数量 -->
<div class="space-y-2">
<Label class="text-sm font-medium text-gray-700">分析数量</Label>

View File

@@ -46,6 +46,12 @@ const isAllSelected = computed(() => {
return props.data.length > 0 && props.selectedRowKeys.length === props.data.length
})
// 半选状态(部分选中)
const isIndeterminate = computed(() => {
const selectedLen = props.selectedRowKeys.length
return selectedLen > 0 && selectedLen < props.data.length
})
// 切换排序
function handleSort(key) {
if (sortKey.value === key) {
@@ -59,10 +65,10 @@ function handleSort(key) {
// 选择切换
function handleSelectAll(checked) {
if (isAllSelected.value) {
emit('update:selectedRowKeys', [])
} else {
if (checked) {
emit('update:selectedRowKeys', props.data.map(item => String(item.id)))
} else {
emit('update:selectedRowKeys', [])
}
}
@@ -133,6 +139,7 @@ function formatNumber(value) {
<div class="flex items-center gap-2">
<Checkbox
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@update:checked="handleSelectAll"
class="scale-110"
/>

View File

@@ -3,7 +3,6 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { toast } from 'vue-sonner'
import { Icon } from '@iconify/vue'
import dayjs from 'dayjs'
import BasicLayout from '@/layouts/components/BasicLayout.vue'
import { MaterialService } from '@/api/material'
import { VoiceService } from '@/api/voice'
import { useUpload } from '@/composables/useUpload'
@@ -42,6 +41,7 @@ import {
} from '@/components/ui/alert-dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Progress } from '@/components/ui/progress'
import TaskPageLayout from '@/views/system/task-management/components/TaskPageLayout.vue'
// ========== 常量 ==========
const MAX_FILE_SIZE = 5 * 1024 * 1024
@@ -369,32 +369,41 @@ onMounted(() => loadVoiceList())
</script>
<template>
<BasicLayout :show-back="false" :show-title="false">
<!-- 工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<Button @click="handleCreate">
新建配音
</Button>
<div class="p-4">
<TaskPageLayout
:loading="loading"
:current="pagination.current"
:page-size="pagination.pageSize"
:total="pagination.total"
@page-change="handlePageChange"
>
<!-- 筛选条件 -->
<template #filters>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<Button @click="handleCreate">
新建配音
</Button>
</div>
<div class="flex items-center gap-3">
<Input
v-model="searchParams.name"
placeholder="搜索配音名称..."
class="w-64"
@keypress.enter="handleSearch"
>
<template #prefix>
<Icon icon="lucide:search" class="size-4 text-muted-foreground" />
</template>
</Input>
<Button variant="outline" @click="handleSearch">搜索</Button>
<Button variant="ghost" @click="handleReset">重置</Button>
</div>
</div>
<div class="toolbar-right">
<Input
v-model="searchParams.name"
placeholder="搜索配音名称..."
class="w-64"
@keypress.enter="handleSearch"
>
<template #prefix>
<Icon icon="lucide:search" class="size-4 text-muted-foreground" />
</template>
</Input>
<Button variant="outline" @click="handleSearch">搜索</Button>
<Button variant="ghost" @click="handleReset">重置</Button>
</div>
</div>
</template>
<!-- 表格区域 -->
<div class="table-wrapper">
<!-- 表格 -->
<template #table>
<Table>
<TableHeader>
<TableRow class="hover:bg-transparent">
@@ -405,16 +414,9 @@ onMounted(() => loadVoiceList())
</TableRow>
</TableHeader>
<TableBody>
<!-- 加载状态 -->
<TableRow v-if="loading">
<TableCell :col-span="4" class="h-48 text-center">
<Spinner class="mx-auto size-6" />
</TableCell>
</TableRow>
<!-- 空状态 -->
<TableRow v-else-if="voiceList.length === 0">
<TableCell :col-span="4" class="h-48 text-center">
<TableRow v-if="voiceList.length === 0 && !loading">
<TableCell :colspan="4" class="h-48 text-center">
<div class="flex flex-col items-center gap-3 text-muted-foreground">
<Icon icon="lucide:mic-off" class="size-10 opacity-50" />
<span>暂无配音数据</span>
@@ -460,190 +462,137 @@ onMounted(() => loadVoiceList())
</TableRow>
</TableBody>
</Table>
</template>
<!-- 分页 -->
<div v-if="pagination.total > pagination.pageSize" class="pagination-bar">
<span class="text-sm text-muted-foreground">
{{ pagination.total }}
</span>
<div class="flex gap-1">
<Button
variant="outline"
size="sm"
:disabled="pagination.current === 1"
@click="handlePageChange(pagination.current - 1)"
>
上一页
</Button>
<Button
variant="outline"
size="sm"
:disabled="pagination.current * pagination.pageSize >= pagination.total"
@click="handlePageChange(pagination.current + 1)"
>
下一页
</Button>
</div>
</div>
</div>
<!-- 弹窗 -->
<template #modals>
<!-- 新建/编辑弹窗 -->
<Dialog :open="modalVisible" @update:open="(v) => modalVisible = v">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{{ isCreateMode ? '新建配音' : '编辑配音' }}</DialogTitle>
</DialogHeader>
<!-- 新建/编辑弹窗 -->
<Dialog :open="modalVisible" @update:open="(v) => modalVisible = v">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{{ isCreateMode ? '新建配音' : '编辑配音' }}</DialogTitle>
</DialogHeader>
<div class="space-y-5 py-2">
<!-- 名称 -->
<div class="space-y-2">
<Label for="name">配音名称 <span class="text-destructive">*</span></Label>
<Input id="name" v-model="formData.name" placeholder="请输入配音名称" />
</div>
<div class="space-y-5 py-2">
<!-- 名称 -->
<div class="space-y-2">
<Label for="name">配音名称 <span class="text-destructive">*</span></Label>
<Input id="name" v-model="formData.name" placeholder="请输入配音名称" />
<!-- 上传区域 -->
<div v-if="isCreateMode" class="space-y-2">
<Label>音频文件 <span class="text-destructive">*</span></Label>
<!-- 未上传状态 -->
<div
v-if="fileList.length === 0 && !uploadState.uploading && !extractingText"
class="upload-zone"
:class="{ 'upload-zone--dragging': isDragging }"
@click="triggerFileInput"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<div class="upload-zone__icon">
<Icon icon="lucide:cloud-upload" />
</div>
<p class="upload-zone__title">点击或拖拽音频文件到此区域</p>
<p class="upload-zone__hint">支持 MP3WAVAACM4AFLACOGG最大 5MB</p>
</div>
<!-- 上传中 -->
<div v-else-if="uploadState.uploading" class="upload-status">
<Progress :value="50" class="w-16" />
<p class="mt-3 text-sm text-muted-foreground">正在上传...</p>
</div>
<!-- 识别中 -->
<div v-else-if="extractingText" class="upload-status">
<Progress :value="50" class="w-16" />
<p class="mt-3 text-sm text-muted-foreground">正在识别语音...</p>
</div>
<!-- 已上传 -->
<div v-else class="upload-preview">
<div class="upload-preview__icon">
<Icon icon="lucide:file-audio" />
</div>
<div class="upload-preview__info">
<span class="upload-preview__name">{{ fileList[0]?.name || '音频文件' }}</span>
<Badge v-if="formData.text" variant="secondary" class="gap-1">
<Icon icon="lucide:check-circle" class="size-3" />
已识别语音
</Badge>
<Badge v-else variant="outline" class="gap-1 text-amber-600">
<Icon icon="lucide:alert-circle" class="size-3" />
未识别到语音
</Badge>
</div>
<Button variant="ghost" size="sm" class="text-destructive" @click="handleRemoveFile">
<Icon icon="lucide:x" class="size-4" />
</Button>
</div>
<input
ref="fileInputRef"
type="file"
accept="audio/*,.mp3,.wav,.aac,.m4a,.flac,.ogg"
class="hidden"
@change="handleFileSelect"
/>
</div>
<!-- 备注 -->
<div class="space-y-2">
<Label for="note">备注</Label>
<Textarea
id="note"
v-model="formData.note"
placeholder="备注信息(选填)"
:rows="2"
/>
</div>
</div>
<!-- 上传区域 -->
<div v-if="isCreateMode" class="space-y-2">
<Label>音频文件 <span class="text-destructive">*</span></Label>
<DialogFooter>
<Button variant="outline" @click="handleCancel">取消</Button>
<Button :disabled="isSubmitDisabled" :loading="submitting" @click="handleSubmit">
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 未上传状态 -->
<div
v-if="fileList.length === 0 && !uploadState.uploading && !extractingText"
class="upload-zone"
:class="{ 'upload-zone--dragging': isDragging }"
@click="triggerFileInput"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
<!-- 删除确认 -->
<AlertDialog :open="deleteDialogVisible" @update:open="(v) => deleteDialogVisible = v">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除</AlertDialogTitle>
<AlertDialogDescription>
确定要删除配音{{ deleteTarget?.name }}此操作不可恢复
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
@click="confirmDelete"
>
<div class="upload-zone__icon">
<Icon icon="lucide:cloud-upload" />
</div>
<p class="upload-zone__title">点击或拖拽音频文件到此区域</p>
<p class="upload-zone__hint">支持 MP3WAVAACM4AFLACOGG最大 5MB</p>
</div>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>
</TaskPageLayout>
</div>
<!-- 上传中 -->
<div v-else-if="uploadState.uploading" class="upload-status">
<Progress :value="50" class="w-16" />
<p class="mt-3 text-sm text-muted-foreground">正在上传...</p>
</div>
<!-- 识别中 -->
<div v-else-if="extractingText" class="upload-status">
<Progress :value="50" class="w-16" />
<p class="mt-3 text-sm text-muted-foreground">正在识别语音...</p>
</div>
<!-- 已上传 -->
<div v-else class="upload-preview">
<div class="upload-preview__icon">
<Icon icon="lucide:file-audio" />
</div>
<div class="upload-preview__info">
<span class="upload-preview__name">{{ fileList[0]?.name || '音频文件' }}</span>
<Badge v-if="formData.text" variant="secondary" class="gap-1">
<Icon icon="lucide:check-circle" class="size-3" />
已识别语音
</Badge>
<Badge v-else variant="outline" class="gap-1 text-amber-600">
<Icon icon="lucide:alert-circle" class="size-3" />
未识别到语音
</Badge>
</div>
<Button variant="ghost" size="sm" class="text-destructive" @click="handleRemoveFile">
<Icon icon="lucide:x" class="size-4" />
</Button>
</div>
<input
ref="fileInputRef"
type="file"
accept="audio/*,.mp3,.wav,.aac,.m4a,.flac,.ogg"
class="hidden"
@change="handleFileSelect"
/>
</div>
<!-- 备注 -->
<div class="space-y-2">
<Label for="note">备注</Label>
<Textarea
id="note"
v-model="formData.note"
placeholder="备注信息(选填)"
:rows="2"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="handleCancel">取消</Button>
<Button :disabled="isSubmitDisabled" :loading="submitting" @click="handleSubmit">
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 删除确认 -->
<AlertDialog :open="deleteDialogVisible" @update:open="(v) => deleteDialogVisible = v">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除</AlertDialogTitle>
<AlertDialogDescription>
确定要删除配音{{ deleteTarget?.name }}此操作不可恢复
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
@click="confirmDelete"
>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<audio ref="audioPlayer" class="hidden" />
</BasicLayout>
<audio ref="audioPlayer" class="hidden" />
</template>
<style scoped lang="less">
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4);
background: var(--card);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
box-shadow: var(--shadow-sm);
}
.toolbar-right {
display: flex;
align-items: center;
gap: var(--space-2);
}
.table-wrapper {
background: var(--card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.pagination-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border);
background: var(--card);
}
// 上传区域
.upload-zone {
display: flex;

View File

@@ -206,7 +206,7 @@
<label class="input-label">语速调节</label>
<div class="rate-control">
<Slider
v-model="store.speechRate"
v-model="speechRateArray"
:min="0.5"
:max="2.0"
:step="0.1"
@@ -283,6 +283,16 @@ const rateMarks = {
2.0: '2.0x',
}
// Slider 组件需要数组形式的 v-model
const speechRateArray = computed({
get: () => [store.speechRate],
set: (val: number[]) => {
if (val?.[0] !== undefined) {
store.speechRate = val[0]
}
}
})
function triggerFileSelect() {
fileInput.value?.click()
}
@@ -378,7 +388,6 @@ onMounted(async () => {
min-height: 100vh;
background: @bg-page;
padding: 48px 64px;
max-width: 1400px;
margin: 0 auto;
@media (max-width: 1024px) {

View File

@@ -28,7 +28,7 @@
<div class="flex items-center min-w-0 flex-1">
<Icon
icon="lucide:folder"
class="mr-2.5 text-base shrink-0"
class="mr-2.5 size-5 shrink-0"
:class="selectedGroupId === group.id ? 'text-primary' : 'text-muted-foreground'"
/>
<template v-if="editingGroupId !== group.id">
@@ -51,13 +51,13 @@
<span class="text-xs mr-1" :class="selectedGroupId === group.id ? 'text-primary/80' : 'text-muted-foreground'">{{ group.fileCount }}</span>
<Icon
icon="lucide:pencil"
class="opacity-0 group-hover:opacity-100 p-1 cursor-pointer text-xs transition-all"
class="opacity-0 group-hover:opacity-100 p-0.5 cursor-pointer size-4 transition-all"
:class="selectedGroupId === group.id ? 'text-primary/70 hover:text-primary' : 'text-muted-foreground hover:text-primary'"
@click.stop="handleEditGroup(group, $event)"
/>
<Icon
icon="lucide:trash-2"
class="opacity-0 group-hover:opacity-100 p-1 cursor-pointer text-xs transition-all"
class="opacity-0 group-hover:opacity-100 p-0.5 cursor-pointer size-4 transition-all"
:class="selectedGroupId === group.id ? 'text-primary/70 hover:text-destructive' : 'text-muted-foreground hover:text-destructive'"
@click.stop="handleDeleteGroup(group, $event)"
/>
@@ -831,7 +831,6 @@ onMounted(async () => {
display: flex;
height: 100%;
background: var(--background);
padding: var(--space-4);
gap: var(--space-4);
}

View File

@@ -0,0 +1,130 @@
<template>
<div class="task-page">
<!-- 筛选条件区域 -->
<div v-if="$slots.filters" class="task-page__filters">
<slot name="filters" />
</div>
<!-- 任务列表内容区域 -->
<div class="task-page__content">
<!-- 批量操作栏 -->
<div v-if="$slots['batch-actions']" class="batch-actions">
<slot name="batch-actions" />
</div>
<!-- 表格区域 -->
<div class="task-page__table">
<!-- 加载状态 -->
<div v-if="loading" class="task-page__loading">
<Spinner class="size-8" />
</div>
<!-- 表格内容 -->
<div class="task-page__table-wrapper">
<slot name="table" />
</div>
<!-- 分页 -->
<TablePagination
v-if="showPagination && total > 0"
:current="current"
:page-size="pageSize"
:total="total"
@change="$emit('page-change', $event)"
/>
</div>
</div>
<!-- 弹窗插槽 -->
<slot name="modals" />
</div>
</template>
<script setup>
import { Spinner } from '@/components/ui/spinner'
import { TablePagination } from '@/components/ui/pagination'
defineProps({
loading: {
type: Boolean,
default: false
},
showPagination: {
type: Boolean,
default: true
},
current: {
type: Number,
default: 1
},
pageSize: {
type: Number,
default: 10
},
total: {
type: Number,
default: 0
}
})
defineEmits(['page-change'])
</script>
<style scoped lang="less">
.task-page {
padding: 0;
height: 100%;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.task-page__filters {
flex-shrink: 0;
padding: var(--space-5);
background: var(--card);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
}
.task-page__content {
flex: 1;
overflow: hidden;
background: var(--card);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
padding: var(--space-5);
display: flex;
flex-direction: column;
}
.batch-actions {
flex-shrink: 0;
margin-bottom: var(--space-4);
}
.task-page__table {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
min-height: 0;
}
.task-page__loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--background);
opacity: 0.5;
z-index: 10;
}
.task-page__table-wrapper {
flex: 1;
overflow: auto;
min-height: 0;
}
</style>

View File

@@ -1,7 +1,13 @@
<template>
<div class="task-page">
<TaskPageLayout
:loading="loading"
:current="paginationConfig.current"
:page-size="paginationConfig.pageSize"
:total="paginationConfig.total"
@page-change="handlePageChange"
>
<!-- 筛选条件 -->
<div class="task-page__filters">
<template #filters>
<div class="flex flex-wrap items-center gap-3">
<!-- 状态筛选 -->
<Select v-model="filters.status" @update:model-value="handleFilterChange">
@@ -60,148 +66,135 @@
重置
</Button>
</div>
</div>
</template>
<!-- 任务列表 -->
<div class="task-page__content">
<!-- 批量操作栏 -->
<div v-if="selectedRowKeys.length > 0" class="batch-actions">
<Alert class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon icon="lucide:info" class="size-4" />
<span>已选中 {{ selectedRowKeys.length }} </span>
</div>
<Button variant="destructive" size="sm" @click="confirmBatchDelete">
<Icon icon="lucide:trash-2" class="mr-1 size-4" />
批量删除
</Button>
</Alert>
</div>
<div class="relative">
<div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-background/50 z-10">
<Spinner class="size-8" />
<!-- 批量操作栏 -->
<template #batch-actions>
<Alert v-if="selectedRowKeys.length > 0" class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon icon="lucide:info" class="size-4" />
<span>已选中 {{ selectedRowKeys.length }} </span>
</div>
<Button variant="destructive" size="sm" @click="confirmBatchDelete">
<Icon icon="lucide:trash-2" class="mr-1 size-4" />
批量删除
</Button>
</Alert>
</template>
<div class="overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[50px]">
<Checkbox
:checked="isAllSelected"
@update:checked="handleSelectAll"
/>
</TableHead>
<TableHead class="w-[80px]">ID</TableHead>
<TableHead class="min-w-[200px]">任务名称</TableHead>
<TableHead class="w-[100px]">状态</TableHead>
<TableHead class="w-[150px]">进度</TableHead>
<TableHead class="w-[180px]">创建时间</TableHead>
<TableHead class="w-[180px] sticky right-0 bg-background">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="record in list" :key="record.id">
<TableCell>
<Checkbox
:checked="selectedRowKeys.includes(record.id)"
@update:checked="handleSelectRow(record.id)"
/>
</TableCell>
<TableCell>{{ record.id }}</TableCell>
<TableCell>
<span class="font-medium">{{ record.taskName }}</span>
</TableCell>
<TableCell>
<TaskStatusTag :status="record.status" />
</TableCell>
<TableCell>
<div class="w-[120px]">
<Progress :model-value="record.progress" class="h-2" />
</div>
</TableCell>
<TableCell>{{ formatDate(record.createTime) }}</TableCell>
<TableCell class="sticky right-0 bg-background">
<div class="flex items-center gap-2">
<Button
v-if="isStatus(record.status, 'success')"
variant="ghost"
size="sm"
class="h-7 px-2 text-primary hover:text-primary/80"
@click="openVideoUrl(record)"
>
<Icon icon="lucide:play-circle" class="mr-1 size-4" />
预览
</Button>
<Button
v-if="isStatus(record.status, 'success')"
variant="ghost"
size="sm"
class="h-7 px-2 text-success hover:text-success/80"
@click="openVideoUrl(record)"
>
<Icon icon="lucide:download" class="mr-1 size-4" />
下载
</Button>
<Button
v-if="isStatus(record.status, 'running')"
variant="ghost"
size="sm"
class="h-7 px-2"
@click="handleCancel(record.id)"
>
取消
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-destructive hover:text-destructive/80 hover:bg-destructive/10"
@click="handleDelete(record.id)"
>
删除
</Button>
</div>
</TableCell>
</TableRow>
<TableRow v-if="list.length === 0 && !loading">
<TableCell colspan="7" class="h-32 text-center text-muted-foreground">
暂无数据
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- 表格 -->
<template #table>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[50px]">
<Checkbox
:checked="isAllSelected"
@update:checked="handleSelectAll"
/>
</TableHead>
<TableHead class="w-[80px]">ID</TableHead>
<TableHead class="min-w-[200px]">任务名称</TableHead>
<TableHead class="w-[100px]">状态</TableHead>
<TableHead class="w-[150px]">进度</TableHead>
<TableHead class="w-[180px]">创建时间</TableHead>
<TableHead class="w-[180px] sticky right-0 bg-background">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="record in list" :key="record.id">
<TableCell>
<Checkbox
:checked="selectedRowKeys.includes(record.id)"
@update:checked="handleSelectRow(record.id)"
/>
</TableCell>
<TableCell>{{ record.id }}</TableCell>
<TableCell>
<span class="font-medium">{{ record.taskName }}</span>
</TableCell>
<TableCell>
<TaskStatusTag :status="record.status" />
</TableCell>
<TableCell>
<div class="w-[120px]">
<Progress :model-value="record.progress" class="h-2" />
</div>
</TableCell>
<TableCell>{{ formatDate(record.createTime) }}</TableCell>
<TableCell class="sticky right-0 bg-background">
<div class="flex items-center gap-2">
<Button
v-if="isStatus(record.status, 'success')"
variant="ghost"
size="sm"
class="h-7 px-2 text-primary hover:text-primary/80"
@click="openVideoUrl(record)"
>
<Icon icon="lucide:play-circle" class="mr-1 size-4" />
预览
</Button>
<Button
v-if="isStatus(record.status, 'success')"
variant="ghost"
size="sm"
class="h-7 px-2 text-success hover:text-success/80"
@click="openVideoUrl(record)"
>
<Icon icon="lucide:download" class="mr-1 size-4" />
下载
</Button>
<Button
v-if="isStatus(record.status, 'running')"
variant="ghost"
size="sm"
class="h-7 px-2"
@click="handleCancel(record.id)"
>
取消
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-destructive hover:text-destructive/80 hover:bg-destructive/10"
@click="handleDelete(record.id)"
>
删除
</Button>
</div>
</TableCell>
</TableRow>
<TableRow v-if="list.length === 0 && !loading">
<TableCell colspan="7" class="h-32 text-center text-muted-foreground">
暂无数据
</TableCell>
</TableRow>
</TableBody>
</Table>
</template>
<!-- 分页 -->
<TablePagination
:current="paginationConfig.current"
:page-size="paginationConfig.pageSize"
:total="paginationConfig.total"
@change="handlePageChange"
/>
</div>
</div>
<!-- 确认删除对话框 -->
<AlertDialog :open="deleteDialogOpen" @update:open="deleteDialogOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除</AlertDialogTitle>
<AlertDialogDescription>
确定要删除选中的任务吗此操作无法撤销
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel @click="deleteDialogOpen = false">取消</AlertDialogCancel>
<AlertDialogAction @click="handleBatchDelete" :disabled="deleteLoading">
<Spinner v-if="deleteLoading" class="mr-2 size-4" />
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<!-- 弹窗 -->
<template #modals>
<!-- 确认删除对话框 -->
<AlertDialog :open="deleteDialogOpen" @update:open="deleteDialogOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除</AlertDialogTitle>
<AlertDialogDescription>
确定要删除选中的任务吗此操作无法撤销
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel @click="deleteDialogOpen = false">取消</AlertDialogCancel>
<AlertDialogAction @click="handleBatchDelete" :disabled="deleteLoading">
<Spinner v-if="deleteLoading" class="mr-2 size-4" />
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>
</TaskPageLayout>
</template>
<script setup>
@@ -218,7 +211,6 @@ import { Progress } from '@/components/ui/progress'
import { Alert } from '@/components/ui/alert'
import { Spinner } from '@/components/ui/spinner'
import { Checkbox } from '@/components/ui/checkbox'
import { TablePagination } from '@/components/ui/pagination'
import {
AlertDialog,
AlertDialogAction,
@@ -235,27 +227,20 @@ import { useTaskList } from '@/views/system/task-management/composables/useTaskL
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
import TaskStatusTag from '@/views/system/task-management/components/TaskStatusTag.vue'
import TaskPageLayout from '@/views/system/task-management/components/TaskPageLayout.vue'
// 日期选择器开关
const datePickerOpen = ref(false)
const selectedDateRange = ref(null)
// Composables
const { loading, list, filters, paginationConfig, fetchList, handleFilterChange, handleResetFilters, handleTableChange } = useTaskList(getDigitalHumanTaskPage)
const { loading, list, filters, paginationConfig, fetchList, handleFilterChange, handleResetFilters } = useTaskList(getDigitalHumanTaskPage)
// 初始化 filters.status 为 'all'
if (!filters.status) {
filters.status = 'all'
}
// 包装 handleFilterChange 处理 'all' 值
const wrappedHandleFilterChange = () => {
const params = { ...filters }
if (params.status === 'all') {
params.status = undefined
}
handleFilterChange()
}
const { handleDelete: deleteTaskById, handleCancel } = useTaskOperations({ deleteApi: deleteTask, cancelApi: cancelTask }, fetchList)
useTaskPolling(getDigitalHumanTaskPage, { onTaskUpdate: fetchList })
@@ -338,14 +323,14 @@ const handleDelete = (id) => {
// 处理日期选择
const handleDateSelect = (value) => {
if (value?.start && value?.end) {
const formatDate = (date) => {
const formatDateStr = (date) => {
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
filters.dateRange = [formatDate(value.start), formatDate(value.end)]
filters.dateRange = [formatDateStr(value.start), formatDateStr(value.end)]
datePickerOpen.value = false
handleFilterChange()
}
@@ -355,34 +340,4 @@ onMounted(fetchList)
</script>
<style scoped lang="less">
.task-page {
padding: 0;
height: 100%;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.task-page__filters {
flex-shrink: 0;
padding: var(--space-5);
background: var(--card);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
}
.task-page__content {
flex: 1;
overflow: hidden;
background: var(--card);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
padding: var(--space-5);
display: flex;
flex-direction: column;
}
.batch-actions {
margin-bottom: var(--space-4);
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="task-layout">
<div class="task-layout p-4">
<!-- 顶部Tab栏 -->
<div class="task-layout__header">
<div class="flex items-center justify-between">
@@ -59,7 +59,7 @@ const NAV_ITEMS = [
type: 'style-task',
label: '风格任务',
icon: 'lucide:palette',
component: markRaw(defineAsyncComponent(() => import('../../../task-center/BenchmarkTaskList.vue')))
component: markRaw(defineAsyncComponent(() => import('../task-center/BenchmarkTaskList.vue')))
}
]

View File

@@ -1,7 +1,13 @@
<template>
<div class="task-page">
<TaskPageLayout
:loading="loading"
:current="paginationConfig.current"
:page-size="paginationConfig.pageSize"
:total="paginationConfig.total"
@page-change="handlePageChange"
>
<!-- 筛选条件 -->
<div class="task-page__filters">
<template #filters>
<div class="flex flex-wrap items-center gap-3">
<!-- 状态筛选 -->
<Select v-model="filters.status" @update:model-value="handleFilterChange">
@@ -59,246 +65,236 @@
重置
</Button>
</div>
</div>
</template>
<!-- 任务列表 -->
<div class="task-page__content">
<!-- 批量操作栏 -->
<div v-if="selectedRowKeys.length > 0" class="batch-actions">
<Alert class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon icon="lucide:info" class="size-4" />
<span>已选中 {{ selectedRowKeys.length }} </span>
</div>
<div class="flex items-center gap-2">
<Button
:disabled="!hasDownloadableSelected"
:loading="batchDownloading"
size="sm"
@click="handleBatchDownloadSelected"
>
<Icon icon="lucide:download" class="mr-1 size-4" />
批量下载 ({{ downloadableCount }})
</Button>
<Button variant="destructive" size="sm" @click="handleBatchDeleteSelected">
<Icon icon="lucide:trash-2" class="mr-1 size-4" />
批量删除
</Button>
</div>
</Alert>
</div>
<div class="relative min-h-[200px]">
<Spinner v-if="loading" class="absolute inset-0 z-10 m-auto size-8" />
<div class="overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[50px]">
<Checkbox
:checked="isAllSelected"
@update:checked="handleSelectAll"
/>
</TableHead>
<TableHead class="w-[70px]">ID</TableHead>
<TableHead>标题</TableHead>
<TableHead class="w-[90px]">状态</TableHead>
<TableHead class="w-[100px]">生成结果</TableHead>
<TableHead class="w-[160px]">创建时间</TableHead>
<TableHead class="w-[160px]">完成时间</TableHead>
<TableHead class="w-[240px] sticky right-0 bg-background">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-for="record in list" :key="record.id">
<TableRow
class="cursor-pointer hover:bg-muted/50"
@click="toggleExpand(record.id)"
>
<TableCell>
<Checkbox
:checked="selectedRowKeys.includes(record.id)"
@update:checked.stop="handleSelectRow(record.id)"
/>
</TableCell>
<TableCell>{{ record.id }}</TableCell>
<TableCell>
<div class="flex items-center gap-2">
<Icon
:icon="expandedRowKeys.includes(record.id) ? 'lucide:chevron-down' : 'lucide:chevron-right'"
class="size-4 text-muted-foreground"
/>
<span class="font-medium">{{ record.title }}</span>
<Badge v-if="record.text" variant="secondary" class="text-xs">有文案</Badge>
</div>
</TableCell>
<TableCell>
<TaskStatusTag :status="record.status" />
</TableCell>
<TableCell>
<Badge v-if="record.outputUrls?.length" variant="success">
{{ record.outputUrls.length }} 个视频
</Badge>
<span v-else class="text-muted-foreground">-</span>
</TableCell>
<TableCell>{{ formatDate(record.createTime) }}</TableCell>
<TableCell>{{ record.finishTime ? formatDate(record.finishTime) : '-' }}</TableCell>
<TableCell @click.stop>
<div class="flex items-center gap-1">
<Button
v-if="canOperate(record, 'preview')"
variant="ghost"
size="sm"
class="h-7 px-2 text-primary hover:text-primary/80"
@click="openPreview(record)"
>
<Icon icon="lucide:play-circle" class="mr-1 size-4" />
预览
</Button>
<Button
v-if="canOperate(record, 'download')"
variant="ghost"
size="sm"
class="h-7 px-2 text-success hover:text-success/80"
@click="handleDownload(record)"
>
<Icon icon="lucide:download" class="mr-1 size-4" />
下载
</Button>
<Button
v-if="canOperate(record, 'cancel')"
variant="outline"
size="sm"
class="h-7 px-2"
@click="handleCancel(record.id)"
>
取消
</Button>
<Button
v-if="canOperate(record, 'retry')"
variant="outline"
size="sm"
class="h-7 px-2"
@click="handleRetry(record.id)"
>
重试
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-destructive hover:text-destructive/80 hover:bg-destructive/10"
@click="handleDeleteClick(record.id)"
>
删除
</Button>
</div>
</TableCell>
</TableRow>
<!-- 展开行 -->
<TableRow v-if="expandedRowKeys.includes(record.id)" class="bg-muted/30">
<TableCell colspan="8" class="p-0">
<div class="expanded-content">
<div v-if="record.text" class="task-text">
<strong>文案内容</strong>
<p>{{ record.text }}</p>
</div>
<div v-if="record.outputUrls?.length" class="task-results">
<div class="result-header">
<strong>生成结果</strong>
<span class="result-count">{{ record.outputUrls.length }} 个视频</span>
</div>
<div class="result-list">
<div v-for="(_, index) in record.outputUrls" :key="index" class="result-item">
<Button
v-if="isStatus(record.status, 'success')"
variant="ghost"
size="sm"
class="h-7 px-2"
@click="previewVideo(record, index)"
>
<Icon icon="lucide:play-circle" class="mr-1 size-4" />
视频 {{ index + 1 }}
</Button>
<Button
v-if="isStatus(record.status, 'success')"
variant="ghost"
size="sm"
class="h-7 px-2"
@click="downloadVideo(record.id, index)"
>
<Icon icon="lucide:download" class="size-4" />
</Button>
<span v-else class="text-muted-foreground text-sm">视频 {{ index + 1 }} (处理中...)</span>
</div>
</div>
</div>
<Alert v-if="record.errorMsg" variant="destructive" class="mt-3">
<Icon icon="lucide:alert-circle" class="size-4" />
<AlertDescription>{{ record.errorMsg }}</AlertDescription>
</Alert>
</div>
</TableCell>
</TableRow>
</template>
<TableRow v-if="list.length === 0 && !loading">
<TableCell colspan="8" class="h-32 text-center text-muted-foreground">
暂无数据
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 批量操作栏 -->
<template #batch-actions>
<Alert v-if="selectedRowKeys.length > 0" class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon icon="lucide:info" class="size-4" />
<span>已选中 {{ selectedRowKeys.length }} </span>
</div>
<!-- 分页 -->
<TablePagination
:current="paginationConfig.current"
:page-size="paginationConfig.pageSize"
:total="paginationConfig.total"
@change="handlePageChange"
/>
</div>
</div>
<!-- 预览模态框 -->
<Dialog v-model:open="preview.visible">
<DialogContent class="max-w-[800px]">
<DialogHeader>
<DialogTitle>{{ preview.title }}</DialogTitle>
</DialogHeader>
<div v-if="preview.url" class="preview-container">
<video :src="preview.url" controls autoplay class="preview-video">
您的浏览器不支持视频播放
</video>
</div>
<div v-else class="preview-loading">
<Spinner class="size-6" />
<span class="text-muted-foreground mt-2">正在加载预览...</span>
</div>
</DialogContent>
</Dialog>
<!-- 删除确认对话框 -->
<AlertDialog v-model:open="deleteDialogOpen">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认批量删除</AlertDialogTitle>
<AlertDialogDescription>
确定要删除选中的 {{ selectedRowKeys.length }} 个任务吗删除后无法恢复
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
:disabled="deleteLoading"
class="bg-destructive hover:bg-destructive/90"
@click="confirmBatchDelete"
<div class="flex items-center gap-2">
<Button
:disabled="!hasDownloadableSelected"
:loading="batchDownloading"
size="sm"
@click="handleBatchDownloadSelected"
>
确定删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Icon icon="lucide:download" class="mr-1 size-4" />
批量下载 ({{ downloadableCount }})
</Button>
<Button variant="destructive" size="sm" @click="handleBatchDeleteSelected">
<Icon icon="lucide:trash-2" class="mr-1 size-4" />
批量删除
</Button>
</div>
</Alert>
</template>
<!-- 表格 -->
<template #table>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[50px]">
<Checkbox
:checked="isAllSelected"
@update:checked="handleSelectAll"
/>
</TableHead>
<TableHead class="w-[70px]">ID</TableHead>
<TableHead>标题</TableHead>
<TableHead class="w-[90px]">状态</TableHead>
<TableHead class="w-[100px]">生成结果</TableHead>
<TableHead class="w-[160px]">创建时间</TableHead>
<TableHead class="w-[160px]">完成时间</TableHead>
<TableHead class="w-[240px] sticky right-0 bg-background">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-for="record in list" :key="record.id">
<TableRow
class="cursor-pointer hover:bg-muted/50"
@click="toggleExpand(record.id)"
>
<TableCell>
<Checkbox
:checked="selectedRowKeys.includes(record.id)"
@update:checked.stop="handleSelectRow(record.id)"
/>
</TableCell>
<TableCell>{{ record.id }}</TableCell>
<TableCell>
<div class="flex items-center gap-2">
<Icon
:icon="expandedRowKeys.includes(record.id) ? 'lucide:chevron-down' : 'lucide:chevron-right'"
class="size-4 text-muted-foreground"
/>
<span class="font-medium">{{ record.title }}</span>
<Badge v-if="record.text" variant="secondary" class="text-xs">有文案</Badge>
</div>
</TableCell>
<TableCell>
<TaskStatusTag :status="record.status" />
</TableCell>
<TableCell>
<Badge v-if="record.outputUrls?.length" variant="success">
{{ record.outputUrls.length }} 个视频
</Badge>
<span v-else class="text-muted-foreground">-</span>
</TableCell>
<TableCell>{{ formatDate(record.createTime) }}</TableCell>
<TableCell>{{ record.finishTime ? formatDate(record.finishTime) : '-' }}</TableCell>
<TableCell @click.stop>
<div class="flex items-center gap-1">
<Button
v-if="canOperate(record, 'preview')"
variant="ghost"
size="sm"
class="h-7 px-2 text-primary hover:text-primary/80"
@click="openPreview(record)"
>
<Icon icon="lucide:play-circle" class="mr-1 size-4" />
预览
</Button>
<Button
v-if="canOperate(record, 'download')"
variant="ghost"
size="sm"
class="h-7 px-2 text-success hover:text-success/80"
@click="handleDownload(record)"
>
<Icon icon="lucide:download" class="mr-1 size-4" />
下载
</Button>
<Button
v-if="canOperate(record, 'cancel')"
variant="outline"
size="sm"
class="h-7 px-2"
@click="handleCancel(record.id)"
>
取消
</Button>
<Button
v-if="canOperate(record, 'retry')"
variant="outline"
size="sm"
class="h-7 px-2"
@click="handleRetry(record.id)"
>
重试
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-destructive hover:text-destructive/80 hover:bg-destructive/10"
@click="handleDeleteClick(record.id)"
>
删除
</Button>
</div>
</TableCell>
</TableRow>
<!-- 展开行 -->
<TableRow v-if="expandedRowKeys.includes(record.id)" class="bg-muted/30">
<TableCell colspan="8" class="p-0">
<div class="expanded-content">
<div v-if="record.text" class="task-text">
<strong>文案内容</strong>
<p>{{ record.text }}</p>
</div>
<div v-if="record.outputUrls?.length" class="task-results">
<div class="result-header">
<strong>生成结果</strong>
<span class="result-count">{{ record.outputUrls.length }} 个视频</span>
</div>
<div class="result-list">
<div v-for="(_, index) in record.outputUrls" :key="index" class="result-item">
<Button
v-if="isStatus(record.status, 'success')"
variant="ghost"
size="sm"
class="h-7 px-2"
@click="previewVideo(record, index)"
>
<Icon icon="lucide:play-circle" class="mr-1 size-4" />
视频 {{ index + 1 }}
</Button>
<Button
v-if="isStatus(record.status, 'success')"
variant="ghost"
size="sm"
class="h-7 px-2"
@click="downloadVideo(record.id, index)"
>
<Icon icon="lucide:download" class="size-4" />
</Button>
<span v-else class="text-muted-foreground text-sm">视频 {{ index + 1 }} (处理中...)</span>
</div>
</div>
</div>
<Alert v-if="record.errorMsg" variant="destructive" class="mt-3">
<Icon icon="lucide:alert-circle" class="size-4" />
<AlertDescription>{{ record.errorMsg }}</AlertDescription>
</Alert>
</div>
</TableCell>
</TableRow>
</template>
<TableRow v-if="list.length === 0 && !loading">
<TableCell colspan="8" class="h-32 text-center text-muted-foreground">
暂无数据
</TableCell>
</TableRow>
</TableBody>
</Table>
</template>
<!-- 弹窗 -->
<template #modals>
<!-- 预览模态框 -->
<Dialog v-model:open="preview.visible">
<DialogContent class="max-w-[800px]">
<DialogHeader>
<DialogTitle>{{ preview.title }}</DialogTitle>
</DialogHeader>
<div v-if="preview.url" class="preview-container">
<video :src="preview.url" controls autoplay class="preview-video">
您的浏览器不支持视频播放
</video>
</div>
<div v-else class="preview-loading">
<Spinner class="size-6" />
<span class="text-muted-foreground mt-2">正在加载预览...</span>
</div>
</DialogContent>
</Dialog>
<!-- 删除确认对话框 -->
<AlertDialog v-model:open="deleteDialogOpen">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认批量删除</AlertDialogTitle>
<AlertDialogDescription>
确定要删除选中的 {{ selectedRowKeys.length }} 个任务吗删除后无法恢复
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
:disabled="deleteLoading"
class="bg-destructive hover:bg-destructive/90"
@click="confirmBatchDelete"
>
确定删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>
</TaskPageLayout>
</template>
<script setup>
@@ -331,26 +327,26 @@ import {
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { TablePagination } from '@/components/ui/pagination'
import { MixTaskService } from '@/api/mixTask'
import { formatDate } from '@/utils/file'
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
import TaskStatusTag from '@/views/system/task-management/components/TaskStatusTag.vue'
import TaskPageLayout from '@/views/system/task-management/components/TaskPageLayout.vue'
// 日期选择器开关
const datePickerOpen = ref(false)
const selectedDateRange = ref(null)
// Composables
const { loading, list, filters, paginationConfig, fetchList, handleFilterChange, handleResetFilters, handleTableChange } = useTaskList(MixTaskService.getTaskPage)
const { loading, list, filters, paginationConfig, fetchList, handleFilterChange, handleResetFilters } = useTaskList(MixTaskService.getTaskPage)
// 初始化 filters.status 为 'all'
if (!filters.status) {
filters.status = 'all'
}
const { handleDelete, handleCancel, handleRetry, handleBatchDownload } = useTaskOperations(
const { handleCancel, handleRetry, handleBatchDownload } = useTaskOperations(
{ deleteApi: MixTaskService.deleteTask, cancelApi: MixTaskService.cancelTask, retryApi: MixTaskService.retryTask, getSignedUrlsApi: MixTaskService.getSignedUrls },
fetchList
)
@@ -555,14 +551,14 @@ const handleDownload = (record) => {
// 处理日期选择
const handleDateSelect = (value) => {
if (value?.start && value?.end) {
const formatDate = (date) => {
const formatDateStr = (date) => {
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
filters.dateRange = [formatDate(value.start), formatDate(value.end)]
filters.dateRange = [formatDateStr(value.start), formatDateStr(value.end)]
datePickerOpen.value = false
handleFilterChange()
}
@@ -578,34 +574,6 @@ onMounted(fetchList)
</script>
<style scoped lang="less">
.task-page {
padding: var(--space-4);
height: 100%;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.task-page__filters {
padding: var(--space-4);
background: var(--color-bg-card);
border-radius: var(--radius-lg);
}
.task-page__content {
flex: 1;
overflow: hidden;
background: var(--color-bg-card);
border-radius: var(--radius-lg);
padding: var(--space-4);
display: flex;
flex-direction: column;
}
.batch-actions {
margin-bottom: var(--space-4);
}
.expanded-content {
padding: var(--space-5);
background: var(--muted);

View File

@@ -1,28 +1,36 @@
<template>
<div class="task-list-container">
<!-- 筛选 -->
<div class="filter-section">
<Select v-model="filterStatus" @update:model-value="handleFilterChange">
<SelectTrigger class="w-[150px]">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">全部状态</SelectItem>
<SelectItem :value="0">待处理</SelectItem>
<SelectItem :value="1">处理中</SelectItem>
<SelectItem :value="2">成功</SelectItem>
<SelectItem :value="3">失败</SelectItem>
</SelectContent>
</Select>
<Button @click="handleRefresh" :disabled="loading">
<Icon v-if="loading" icon="lucide:loader-2" class="size-4 animate-spin" />
<Icon v-else icon="lucide:refresh-cw" class="size-4" />
刷新
</Button>
</div>
<TaskPageLayout
:loading="loading"
:current="pagination.current"
:page-size="pagination.pageSize"
:total="pagination.total"
@page-change="handlePageChange"
>
<!-- 筛选条件 -->
<template #filters>
<div class="flex flex-wrap items-center gap-3">
<Select v-model="filterStatus" @update:model-value="handleFilterChange">
<SelectTrigger class="w-[150px]">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">全部状态</SelectItem>
<SelectItem :value="0">待处理</SelectItem>
<SelectItem :value="1">处理中</SelectItem>
<SelectItem :value="2">成功</SelectItem>
<SelectItem :value="3">失败</SelectItem>
</SelectContent>
</Select>
<Button @click="handleRefresh" :disabled="loading">
<Icon v-if="loading" icon="lucide:loader-2" class="size-4 animate-spin" />
<Icon v-else icon="lucide:refresh-cw" class="size-4" />
刷新
</Button>
</div>
</template>
<!-- -->
<div class="table-wrapper">
<!-- -->
<template #table>
<Table>
<TableHeader>
<TableRow>
@@ -102,49 +110,27 @@
</TableRow>
</TableBody>
</Table>
</div>
</template>
<!-- 分页 -->
<div v-if="pagination.total > 0" class="pagination-section">
<span class="text-sm text-muted-foreground">
{{ pagination.total }}
</span>
<div class="flex gap-1">
<Button
variant="outline"
size="sm"
:disabled="pagination.current === 1"
@click="handlePageChange(pagination.current - 1)"
>
上一页
</Button>
<Button
variant="outline"
size="sm"
:disabled="pagination.current * pagination.pageSize >= pagination.total"
@click="handlePageChange(pagination.current + 1)"
>
下一页
</Button>
</div>
</div>
<!-- 提示词弹窗 -->
<Dialog v-model:open="promptModalVisible">
<DialogContent class="sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>生成的提示词</DialogTitle>
</DialogHeader>
<div class="prompt-content">{{ currentPrompt }}</div>
<DialogFooter>
<Button @click="handleCopyCurrentPrompt">
<Icon icon="lucide:copy" class="size-4" />
复制到剪贴板
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<!-- 弹窗 -->
<template #modals>
<!-- 提示词弹窗 -->
<Dialog v-model:open="promptModalVisible">
<DialogContent class="sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>生成的提示词</DialogTitle>
</DialogHeader>
<div class="prompt-content">{{ currentPrompt }}</div>
<DialogFooter>
<Button @click="handleCopyCurrentPrompt">
<Icon icon="lucide:copy" class="size-4" />
复制到剪贴板
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
</TaskPageLayout>
</template>
<script setup>
@@ -186,6 +172,7 @@ import {
import { BenchmarkTaskApi } from '@/api/benchmarkTask'
import { copyToClipboard } from '@/utils/clipboard'
import TaskPageLayout from '@/views/system/task-management/components/TaskPageLayout.vue'
const loading = ref(false)
const taskList = ref([])
@@ -287,42 +274,6 @@ onMounted(() => {
</script>
<style scoped lang="less">
.task-list-container {
padding: 0;
height: 100%;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.filter-section {
flex-shrink: 0;
padding: var(--space-5);
background: var(--card);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
display: flex;
gap: var(--space-3);
margin-bottom: 0;
}
.table-wrapper {
flex: 1;
background: var(--card);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
overflow: hidden;
}
.pagination-section {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
border-top: 1px solid var(--border);
}
.prompt-content {
padding: var(--space-4);
background: var(--muted);

View File

@@ -0,0 +1,906 @@
/**
* Gemini Image Generator - 云雾API图片生成工具
*
* 功能:
* - 文生图Text-to-Image
* - 图生图Image-to-Image
* - 多种业务场景模板
* - 批量生成
* - 自定义输出目录
*
* 使用示例:
* node gemini-image-generator.js generate "A cute cat" -o ./output -r 16:9
* node gemini-image-generator.js edit "Add sunglasses" -i ./photo.jpg
* node gemini-image-generator.js template logo --text "MyBrand"
* node gemini-image-generator.js batch ./prompts.txt
*/
const fs = require('fs')
const path = require('path')
// ============================================================================
// 配置模块
// ============================================================================
const Config = {
// 云雾API配置 - 硬编码
api: {
baseUrl: 'https://yunwu.ai',
model: 'gemini-3.1-flash-image-preview',
endpoint: '/v1beta/models/gemini-3.1-flash-image-preview:generateContent',
key: 'sk-BjGv7Nf3KJHTBT8OB8LiGM0vHISl8yFcfCxZAWIZO4yogD7N'
},
// 默认输出配置
output: {
defaultDir: './output',
defaultFormat: 'png'
},
// 支持的宽高比
aspectRatios: ['1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9'],
// 支持的分辨率
imageSizes: ['512', '1K', '2K', '4K'],
// 默认分辨率
defaultImageSize: '2K',
// 响应模式
responseModalities: {
textAndImage: ['TEXT', 'IMAGE'],
imageOnly: ['IMAGE'],
textOnly: ['TEXT']
},
// 超时设置(毫秒)
timeout: {
default: 120000, // 默认2分钟
max: 300000 // 最大5分钟
}
}
// ============================================================================
// 文件处理模块
// ============================================================================
const FileUtils = {
/**
* 确保目录存在
*/
ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true })
}
return dirPath
},
/**
* 图片转Base64
*/
imageToBase64(imagePath) {
const buffer = fs.readFileSync(imagePath)
const ext = path.extname(imagePath).toLowerCase()
const mimeTypes = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp'
}
return {
mimeType: mimeTypes[ext] || 'image/png',
data: buffer.toString('base64')
}
},
/**
* Base64保存为图片
*/
base64ToImage(base64Data, outputPath) {
const buffer = Buffer.from(base64Data, 'base64')
fs.writeFileSync(outputPath, buffer)
return outputPath
},
/**
* 生成唯一文件名
*/
generateFilename(prefix = 'image', ext = 'png') {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const random = Math.random().toString(36).substring(2, 8)
return `${prefix}_${timestamp}_${random}.${ext}`
},
/**
* 读取提示词文件
*/
readPromptsFile(filePath) {
const content = fs.readFileSync(filePath, 'utf-8')
return content.split('\n').filter(line => line.trim()).map(line => line.trim())
}
}
// ============================================================================
// API调用模块
// ============================================================================
const GeminiAPI = {
/**
* 发送生成请求
*/
async generateContent(contents, options = {}) {
const {
aspectRatio = '1:1',
imageSize = Config.defaultImageSize,
responseModalities = Config.responseModalities.textAndImage,
timeout = Config.timeout.default
} = options
const url = `${Config.api.baseUrl}${Config.api.endpoint}?key=${Config.api.key}`
const body = {
contents: contents,
generationConfig: {
responseModalities: responseModalities,
imageConfig: {
aspectRatio: aspectRatio,
imageSize: imageSize
}
}
}
console.log(`\n📡 API请求: ${Config.api.baseUrl}${Config.api.endpoint}`)
console.log(`📋 模型: ${Config.api.model}`)
console.log(`⏱️ 超时: ${timeout / 1000}`)
// 使用 AbortController 实现超时
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${Config.api.key}`
},
body: JSON.stringify(body),
signal: controller.signal
})
if (!response.ok) {
const error = await response.text()
throw new Error(`API请求失败: ${response.status} - ${error}`)
}
return await response.json()
} finally {
clearTimeout(timeoutId)
}
},
/**
* 解析响应,提取图片和文本
*/
parseResponse(response) {
const result = {
text: '',
images: []
}
if (!response.candidates || !response.candidates[0]) {
return result
}
const parts = response.candidates[0].content?.parts || []
for (const part of parts) {
if (part.text) {
result.text += part.text
}
if (part.inlineData) {
result.images.push({
mimeType: part.inlineData.mimeType,
data: part.inlineData.data
})
}
}
return result
}
}
// ============================================================================
// 业务场景模板模块
// ============================================================================
const Templates = {
/**
* 写实照片模板
*/
photorealistic: {
name: '写实照片',
generate(subject, options = {}) {
const {
shotType = 'close-up portrait',
lighting = 'soft, natural golden hour light',
mood = 'serene',
environment = '',
cameraDetails = '85mm lens, shallow depth of field'
} = options
return `A photorealistic ${shotType} of ${subject}. ${environment ? `Set in ${environment}. ` : ''}The scene is illuminated by ${lighting}, creating a ${mood} atmosphere. Captured with ${cameraDetails}. Ultra-realistic, with sharp focus on key details.`
}
},
/**
* 贴纸/图标模板
*/
sticker: {
name: '贴纸/图标',
generate(subject, options = {}) {
const {
style = 'kawaii',
colorPalette = 'vibrant',
background = 'white'
} = options
return `A ${style}-style sticker of ${subject}. The design features bold, clean outlines, simple cel-shading, and a ${colorPalette} color palette. The background must be ${background}.`
}
},
/**
* Logo设计模板
*/
logo: {
name: 'Logo设计',
generate(text, options = {}) {
const {
style = 'modern, minimalist',
colorScheme = 'black and white',
shape = 'circle'
} = options
return `Create a ${style} logo${text ? ` with the text "${text}"` : ''}. The text should be in a clean, bold, sans-serif font. The color scheme is ${colorScheme}. Put the logo in a ${shape}.`
}
},
/**
* 产品图模板
*/
product: {
name: '产品图',
generate(product, options = {}) {
const {
surface = 'polished concrete surface',
lighting = 'three-point softbox setup',
angle = 'slightly elevated 45-degree shot',
background = 'minimalist'
} = options
return `A high-resolution, studio-lit product photograph of ${product}, presented on a ${surface}. The lighting is a ${lighting} designed to create soft, diffused highlights and eliminate harsh shadows. The camera angle is a ${angle} to showcase key features. Ultra-realistic. ${background} background.`
}
},
/**
* 极简设计模板
*/
minimalist: {
name: '极简设计',
generate(subject, options = {}) {
const {
position = 'bottom-right',
backgroundColor = 'off-white canvas',
lighting = 'soft, diffused lighting from the top left'
} = options
return `A minimalist composition featuring a single, ${subject} positioned in the ${position} of the frame. The background is a vast, empty ${backgroundColor}, creating significant negative space for text. ${lighting}.`
}
},
/**
* 漫画/故事板模板
*/
comic: {
name: '漫画/故事板',
generate(scene, options = {}) {
const {
style = 'gritty, noir',
panels = 3
} = options
return `Make a ${panels} panel comic in a ${style} art style with high-contrast black and white inks. ${scene}`
}
},
/**
* 风格转换模板
*/
styleTransfer: {
name: '风格转换',
generate(targetStyle, options = {}) {
const {
preserveElements = 'composition and key elements'
} = options
return `Transform the provided image into the artistic style of ${targetStyle}. Preserve the original ${preserveElements} but render with the new stylistic elements.`
}
},
/**
* 图像编辑模板
*/
edit: {
name: '图像编辑',
generate(instruction, options = {}) {
const {
preserve = 'Keep everything else unchanged, preserving the original style, lighting, and composition'
} = options
return `${instruction}. ${preserve}.`
}
},
/**
* 图像合成模板
*/
composite: {
name: '图像合成',
generate(description, options = {}) {
return `Create a new image by combining the elements from the provided images. ${description} Generate a realistic result with proper lighting and shadows.`
}
}
}
// ============================================================================
// 核心生成器类
// ============================================================================
class GeminiImageGenerator {
constructor(options = {}) {
this.outputDir = options.outputDir || Config.output.defaultDir
this.defaultAspectRatio = options.aspectRatio || '1:1'
this.defaultImageSize = options.imageSize || Config.defaultImageSize
if (!Config.api.key) {
console.warn('警告: 未设置API密钥')
}
}
/**
* 文生图
*/
async textToImage(prompt, options = {}) {
const {
aspectRatio = this.defaultAspectRatio,
imageSize = this.defaultImageSize,
outputDir = this.outputDir,
filename = null
} = options
console.log(`\n🎨 生成图片: "${prompt.substring(0, 50)}..."`)
console.log(`📐 宽高比: ${aspectRatio}`)
console.log(`📏 分辨率: ${imageSize}`)
const contents = [{
role: 'user',
parts: [{ text: prompt }]
}]
const response = await GeminiAPI.generateContent(contents, { aspectRatio, imageSize })
const result = GeminiAPI.parseResponse(response)
if (result.text) {
console.log(`📝 模型回复: ${result.text}`)
}
const savedFiles = []
FileUtils.ensureDir(outputDir)
for (let i = 0; i < result.images.length; i++) {
const img = result.images[i]
const ext = img.mimeType.split('/')[1] || 'png'
const outputFilename = filename || FileUtils.generateFilename('generated', ext)
const outputPath = path.join(outputDir, outputFilename)
FileUtils.base64ToImage(img.data, outputPath)
savedFiles.push(outputPath)
console.log(`✅ 已保存: ${outputPath}`)
}
return {
text: result.text,
images: result.images,
savedFiles
}
}
/**
* 图生图(带参考图编辑)
*/
async imageToImage(prompt, inputImages, options = {}) {
const {
aspectRatio = this.defaultAspectRatio,
imageSize = this.defaultImageSize,
outputDir = this.outputDir
} = options
console.log(`\n🖼️ 编辑图片: "${prompt.substring(0, 50)}..."`)
console.log(`📁 输入图片: ${Array.isArray(inputImages) ? inputImages.length : 1}`)
console.log(`📏 分辨率: ${imageSize}`)
const parts = [{ text: prompt }]
// 处理输入图片
const images = Array.isArray(inputImages) ? inputImages : [inputImages]
for (const imgPath of images) {
const { mimeType, data } = FileUtils.imageToBase64(imgPath)
parts.push({
inlineData: {
mime_type: mimeType,
data: data
}
})
}
const contents = [{
role: 'user',
parts: parts
}]
const response = await GeminiAPI.generateContent(contents, { aspectRatio, imageSize })
const result = GeminiAPI.parseResponse(response)
if (result.text) {
console.log(`📝 模型回复: ${result.text}`)
}
const savedFiles = []
FileUtils.ensureDir(outputDir)
for (let i = 0; i < result.images.length; i++) {
const img = result.images[i]
const ext = img.mimeType.split('/')[1] || 'png'
const outputFilename = FileUtils.generateFilename('edited', ext)
const outputPath = path.join(outputDir, outputFilename)
FileUtils.base64ToImage(img.data, outputPath)
savedFiles.push(outputPath)
console.log(`✅ 已保存: ${outputPath}`)
}
return {
text: result.text,
images: result.images,
savedFiles
}
}
/**
* 使用模板生成
*/
async generateFromTemplate(templateName, ...args) {
const template = Templates[templateName]
if (!template) {
throw new Error(`未知的模板: ${templateName}。可用模板: ${Object.keys(Templates).join(', ')}`)
}
const options = args[args.length - 1] || {}
const prompt = template.generate(...args)
console.log(`📋 使用模板: ${template.name}`)
return this.textToImage(prompt, options)
}
/**
* 批量生成
*/
async batchGenerate(prompts, options = {}) {
const results = []
const total = prompts.length
console.log(`\n🚀 开始批量生成,共 ${total} 个任务`)
for (let i = 0; i < prompts.length; i++) {
console.log(`\n[${i + 1}/${total}] 处理中...`)
try {
const result = await this.textToImage(prompts[i], {
...options,
filename: `batch_${i + 1}.png`
})
results.push({ success: true, prompt: prompts[i], result })
} catch (error) {
console.error(`❌ 失败: ${error.message}`)
results.push({ success: false, prompt: prompts[i], error: error.message })
}
}
const successCount = results.filter(r => r.success).length
console.log(`\n✨ 批量生成完成: ${successCount}/${total} 成功`)
return results
}
/**
* 多轮对话编辑
*/
createChatSession(options = {}) {
const history = []
return {
async send(message, inputImages = null) {
const parts = [{ text: message }]
// 如果有输入图片
if (inputImages) {
const images = Array.isArray(inputImages) ? inputImages : [inputImages]
for (const imgPath of images) {
const { mimeType, data } = FileUtils.imageToBase64(imgPath)
parts.push({
inlineData: {
mime_type: mimeType,
data: data
}
})
}
}
// 添加用户消息到历史
history.push({
role: 'user',
parts: parts
})
const response = await GeminiAPI.generateContent(history, options)
const result = GeminiAPI.parseResponse(response)
// 添加模型回复到历史(需要包含图片数据以便后续编辑)
const modelParts = []
if (result.text) {
modelParts.push({ text: result.text })
}
for (const img of result.images) {
modelParts.push({
inlineData: {
mime_type: img.mimeType,
data: img.data
}
})
}
if (modelParts.length > 0) {
history.push({
role: 'model',
parts: modelParts
})
}
// 保存图片
const savedFiles = []
FileUtils.ensureDir(options.outputDir || this.outputDir)
for (const img of result.images) {
const ext = img.mimeType.split('/')[1] || 'png'
const outputFilename = FileUtils.generateFilename('chat', ext)
const outputPath = path.join(options.outputDir || this.outputDir, outputFilename)
FileUtils.base64ToImage(img.data, outputPath)
savedFiles.push(outputPath)
console.log(`✅ 已保存: ${outputPath}`)
}
return {
text: result.text,
images: result.images,
savedFiles
}
},
getHistory() {
return history
}
}
}
}
// ============================================================================
// CLI接口模块
// ============================================================================
const CLI = {
/**
* 解析命令行参数
*/
parseArgs(args) {
const result = {
command: '',
params: [],
options: {}
}
let i = 0
while (i < args.length) {
const arg = args[i]
if (arg.startsWith('--')) {
const key = arg.substring(2)
const nextArg = args[i + 1]
if (nextArg && !nextArg.startsWith('-')) {
result.options[key] = nextArg
i += 2
} else {
result.options[key] = true
i++
}
} else if (arg.startsWith('-')) {
const key = arg.substring(1)
const shortOptions = {
'o': 'output',
'r': 'ratio',
's': 'size',
'i': 'input',
't': 'template',
'h': 'help'
}
const fullKey = shortOptions[key] || key
const nextArg = args[i + 1]
if (nextArg && !nextArg.startsWith('-')) {
result.options[fullKey] = nextArg
i += 2
} else {
result.options[fullKey] = true
i++
}
} else if (!result.command) {
result.command = arg
} else {
result.params.push(arg)
}
}
return result
},
/**
* 显示帮助信息
*/
showHelp() {
console.log(`
🎨 Gemini Image Generator - 云雾API图片生成工具
📦 模型: ${Config.api.model}
用法:
node gemini-image-generator.js <command> [options]
命令:
generate <prompt> 文生图
edit <prompt> 图生图(需要 -i 指定输入图片)
template <name> 使用模板生成
batch <file> 批量生成(从文件读取提示词)
list-templates 列出所有可用模板
选项:
-o, --output <dir> 输出目录 (默认: ./output)
-r, --ratio <ratio> 宽高比 (1:1, 16:9, 9:16, 3:2, 2:3 等)
-s, --size <size> 分辨率 (512, 1K, 2K, 4K默认: 2K)
-i, --input <file> 输入图片路径用于edit命令
-t, --template <name> 模板名称
--text <text> Logo文字用于logo模板
--subject <subject> 主题内容
--style <style> 风格
-h, --help 显示帮助信息
示例:
# 基础文生图 16:9 2K分辨率
node gemini-image-generator.js generate "A cute cat wearing a hat" -o ./my-images -r 16:9 -s 2K
# 高分辨率4K图片
node gemini-image-generator.js generate "A landscape photo" -r 16:9 -s 4K
# 图生图编辑
node gemini-image-generator.js edit "Add sunglasses to this person" -i ./photo.jpg
# 使用Logo模板
node gemini-image-generator.js template logo --text "MyBrand" --style minimalist
# 使用产品图模板
node gemini-image-generator.js template product --subject "a minimalist ceramic coffee mug"
# 批量生成
node gemini-image-generator.js batch ./prompts.txt -o ./batch-output
可用宽高比:
${Config.aspectRatios.join(', ')}
可用分辨率:
${Config.imageSizes.join(', ')}
可用模板:
${Object.entries(Templates).map(([k, v]) => `${k} (${v.name})`).join('\n ')}
`)
},
/**
* 列出模板
*/
listTemplates() {
console.log('\n📋 可用模板:\n')
for (const [key, template] of Object.entries(Templates)) {
console.log(` ${key.padEnd(15)} - ${template.name}`)
}
console.log('')
},
/**
* 执行命令
*/
async run(args) {
const { command, params, options } = this.parseArgs(args)
if (options.help || command === 'help' || !command) {
this.showHelp()
return
}
const generator = new GeminiImageGenerator({
outputDir: options.output || Config.output.defaultDir,
aspectRatio: options.ratio || '1:1',
imageSize: options.size || Config.defaultImageSize
})
switch (command) {
case 'generate': {
const prompt = params.join(' ')
if (!prompt) {
console.error('❌ 请提供生成提示词')
return
}
await generator.textToImage(prompt, {
aspectRatio: options.ratio,
imageSize: options.size,
outputDir: options.output
})
break
}
case 'edit': {
const prompt = params.join(' ')
const inputImages = options.input?.split(',').map(p => p.trim())
if (!prompt) {
console.error('❌ 请提供编辑指令')
return
}
if (!inputImages || inputImages.length === 0) {
console.error('❌ 请使用 -i 指定输入图片')
return
}
await generator.imageToImage(prompt, inputImages, {
aspectRatio: options.ratio,
imageSize: options.size,
outputDir: options.output
})
break
}
case 'template': {
const templateName = params[0] || options.template
if (!templateName) {
this.listTemplates()
return
}
const template = Templates[templateName]
if (!template) {
console.error(`❌ 未知的模板: ${templateName}`)
this.listTemplates()
return
}
// 根据模板类型处理参数
let templateOptions = { aspectRatio: options.ratio, outputDir: options.output }
switch (templateName) {
case 'logo':
await generator.generateFromTemplate('logo', options.text || '', {
style: options.style || 'modern, minimalist',
colorScheme: 'black and white'
}, templateOptions)
break
case 'product':
await generator.generateFromTemplate('product', options.subject || params.slice(1).join(' ') || 'a product', {
surface: 'polished concrete surface'
}, templateOptions)
break
case 'photorealistic':
await generator.generateFromTemplate('photorealistic', options.subject || params.slice(1).join(' ') || 'a person', {}, templateOptions)
break
case 'sticker':
await generator.generateFromTemplate('sticker', options.subject || params.slice(1).join(' ') || 'a cute character', {}, templateOptions)
break
default:
await generator.generateFromTemplate(templateName, params.slice(1).join(' ') || '', {}, templateOptions)
}
break
}
case 'batch': {
const filePath = params[0]
if (!filePath) {
console.error('❌ 请提供提示词文件路径')
return
}
const prompts = FileUtils.readPromptsFile(filePath)
await generator.batchGenerate(prompts, {
aspectRatio: options.ratio,
outputDir: options.output
})
break
}
case 'list-templates': {
this.listTemplates()
break
}
default:
console.error(`❌ 未知命令: ${command}`)
this.showHelp()
}
}
}
// ============================================================================
// 导出模块
// ============================================================================
module.exports = {
// 核心类
GeminiImageGenerator,
// 模块
Config,
FileUtils,
GeminiAPI,
Templates,
CLI,
// 便捷方法
generate: async (prompt, options) => {
const generator = new GeminiImageGenerator(options)
return generator.textToImage(prompt, options)
},
edit: async (prompt, images, options) => {
const generator = new GeminiImageGenerator(options)
return generator.imageToImage(prompt, images, options)
},
fromTemplate: async (templateName, ...args) => {
const generator = new GeminiImageGenerator(args[args.length - 1] || {})
return generator.generateFromTemplate(templateName, ...args)
}
}
// ============================================================================
// 主入口
// ============================================================================
// 如果直接运行此脚本
if (require.main === module) {
const args = process.argv.slice(2)
CLI.run(args).catch(error => {
console.error(`\n❌ 错误: ${error.message}`)
process.exit(1)
})
}