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:
98
.claude/skills/shadcn-theme/SKILL.md
Normal file
98
.claude/skills/shadcn-theme/SKILL.md
Normal 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 |
|
||||
109
.claude/skills/shadcn-theme/references/templates/theme-setup.md
Normal file
109
.claude/skills/shadcn-theme/references/templates/theme-setup.md
Normal 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>
|
||||
)
|
||||
}
|
||||
```
|
||||
90
.claude/skills/shadcn-theme/references/theming-guide.md
Normal file
90
.claude/skills/shadcn-theme/references/theming-guide.md
Normal 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
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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">支持 MP3、WAV、AAC、M4A、FLAC、OGG,最大 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">支持 MP3、WAV、AAC、M4A、FLAC、OGG,最大 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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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')))
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
906
scripts/gemini-image-generator.js
Normal file
906
scripts/gemini-image-generator.js
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user