feat: 路由动态权限控制、图片广场优化

This commit is contained in:
Gsh
2026-01-03 17:03:42 +08:00
parent 3892ff1937
commit 42edd4c230
11 changed files with 660 additions and 145 deletions

View File

@@ -12,10 +12,17 @@ import {
ZoomIn,
} from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { getSelectableTokenInfo } from '@/api';
import { generateImage, getImageModels, getTaskStatus } from '@/api/aiImage';
const props = defineProps({
isActive: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['task-created']);
// State
@@ -41,6 +48,19 @@ const canGenerate = computed(() => {
return selectedModelId.value && prompt.value && !generating.value;
});
// Watch isActive to manage polling
watch(() => props.isActive, (active) => {
if (active) {
// Resume polling if we have a processing task
if (currentTaskId.value && currentTask.value?.taskStatus === 'Processing') {
startPolling(currentTaskId.value);
}
}
else {
stopPolling();
}
});
// Methods
async function fetchTokens() {
tokenLoading.value = true;
@@ -197,6 +217,12 @@ function startPolling(taskId: string) {
}
async function pollStatus(taskId: string) {
// Double check active status before polling (though timer should be cleared)
if (!props.isActive) {
stopPolling();
return;
}
try {
const res = await getTaskStatus(taskId);
// Handle response structure if needed
@@ -257,7 +283,8 @@ async function downloadImage() {
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (e) {
}
catch (e) {
console.error('Download failed', e);
// Fallback
window.open(currentTask.value.storeUrl, '_blank');
@@ -324,14 +351,15 @@ onUnmounted(() => {
配置
</h2>
<!-- Token & Model -->
<div class="bg-gray-50 p-4 rounded-lg space-y-4">
<el-form-item label="API密钥" class="mb-0">
<el-form label-position="top" class="space-y-2" label-width="auto">
<!-- Token -->
<el-form-item label="API密钥 (可选)">
<el-select
v-model="selectedTokenId"
placeholder="请选择API密钥"
class="w-full"
:loading="tokenLoading"
clearable
>
<el-option
v-for="token in tokenOptions"
@@ -343,7 +371,8 @@ onUnmounted(() => {
</el-select>
</el-form-item>
<el-form-item label="模型" class="mb-0">
<!-- Model -->
<el-form-item label="模型" required>
<el-select
v-model="selectedModelId"
placeholder="请选择模型"
@@ -363,57 +392,59 @@ onUnmounted(() => {
</el-option>
</el-select>
</el-form-item>
</div>
<!-- Prompt -->
<div class="space-y-2">
<div class="flex justify-between items-center">
<label class="text-sm font-medium text-gray-700">提示词</label>
<el-button link type="primary" size="small" @click="clearPrompt">
<el-icon class="mr-1">
<Delete />
</el-icon>清空
</el-button>
</div>
<el-input
v-model="prompt"
type="textarea"
:autosize="{ minRows: 8, maxRows: 15 }"
placeholder="描述你想要生成的画面,例如:一只在太空中飞行的赛博朋克风格的猫..."
maxlength="2000"
show-word-limit
class="custom-textarea"
/>
</div>
<!-- Reference Image -->
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">参考图 (可选)</label>
<div class="bg-gray-50 p-4 rounded-lg border border-dashed border-gray-300 hover:border-blue-400 transition-colors">
<el-upload
v-model:file-list="fileList"
action="#"
list-type="picture-card"
:auto-upload="false"
:limit="2"
:on-change="handleFileChange"
:on-remove="handleRemove"
accept=".jpg,.jpeg,.png,.bmp,.webp"
:class="{ 'hide-upload-btn': fileList.length >= 2 }"
>
<div class="flex flex-col items-center justify-center text-gray-400">
<el-icon class="text-2xl mb-2">
<Plus />
</el-icon>
<span class="text-xs">点击上传</span>
<!-- Prompt -->
<el-form-item label="提示词" required class="prompt-form-item ">
<template #label>
<div class="flex justify-between items-center w-full ">
<span>提示词</span>
<el-button link type="primary" size="small" @click="clearPrompt">
<el-icon class="mr-1">
<Delete />
</el-icon>清空
</el-button>
</div>
</template>
<el-input
v-model="prompt"
type="textarea"
:autosize="{ minRows: 8, maxRows: 8 }"
placeholder="描述你想要生成的画面,例如:一只在太空中飞行的赛博朋克风格的猫..."
maxlength="2000"
show-word-limit
class="custom-textarea"
resize="vertical"
/>
</el-form-item>
<!-- Reference Image -->
<el-form-item label="参考图 (可选)">
<div class="w-full bg-gray-50 p-4 rounded-lg border border-dashed border-gray-300 hover:border-blue-400 transition-colors">
<el-upload
v-model:file-list="fileList"
action="#"
list-type="picture-card"
:auto-upload="false"
:limit="2"
:on-change="handleFileChange"
:on-remove="handleRemove"
accept=".jpg,.jpeg,.png,.bmp,.webp"
:class="{ 'hide-upload-btn': fileList.length >= 2 }"
>
<div class="flex flex-col items-center justify-center text-gray-400">
<el-icon class="text-2xl mb-2">
<Plus />
</el-icon>
<span class="text-xs">点击上传</span>
</div>
</el-upload>
<div class="text-xs text-gray-400 mt-2 flex justify-between items-center flex-wrap gap-2">
<span>最多2张< 5MB (支持 JPG/PNG/WEBP)</span>
<el-checkbox v-model="compressImage" label="压缩图片" size="small" />
</div>
</el-upload>
<div class="text-xs text-gray-400 mt-2 flex justify-between items-center flex-wrap gap-2">
<span>最多2张< 5MB (支持 JPG/PNG/WEBP)</span>
<el-checkbox v-model="compressImage" label="压缩图片" size="small" />
</div>
</div>
</div>
</el-form-item>
</el-form>
</div>
<div class="mt-auto pt-4">
@@ -450,7 +481,7 @@ onUnmounted(() => {
<div class="absolute bottom-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2 z-10">
<el-button circle type="primary" :icon="ZoomIn" @click="showViewer = true" />
<el-button circle type="primary" :icon="Download" @click="downloadImage" />
<el-button circle type="info" :icon="Refresh" title="新任务" @click="currentTask = null" />
<el-button circle type="success" :icon="Refresh" title="重新生成" @click="handleGenerate" />
</div>
<el-image-viewer
@@ -563,4 +594,8 @@ onUnmounted(() => {
:deep(.hide-upload-btn .el-upload--picture-card) {
display: none;
}
/* 隐藏默认的标签 */
:deep(.prompt-form-item .el-form-item__label){
display: flex;
}
</style>

View File

@@ -55,18 +55,22 @@
class="w-full h-full"
:preview-src-list="[currentTask.storeUrl]"
/>
<!-- Download Button Overlay -->
<div class="absolute bottom-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<el-button circle type="primary" :icon="Download" @click="downloadImage(currentTask.storeUrl)" />
</div>
</div>
<!-- Right Info -->
<div class="w-full md:w-[300px] flex flex-col gap-4 overflow-y-auto">
<div>
<h3 class="font-bold text-gray-800 mb-2 flex items-center gap-2">
<div class="w-full md:w-[300px] flex flex-col gap-4 overflow-hidden">
<div class="flex-1 flex flex-col min-h-0">
<h3 class="font-bold text-gray-800 mb-2 flex items-center gap-2 shrink-0">
<el-icon><MagicStick /></el-icon> 提示词
</h3>
<div class="bg-gray-50 p-4 rounded-lg border border-gray-100 text-sm text-gray-600 leading-relaxed relative group/prompt">
<div class="bg-gray-50 p-4 rounded-lg border border-gray-100 text-sm text-gray-600 leading-relaxed relative group/prompt overflow-y-auto custom-scrollbar flex-1">
{{ currentTask.prompt }}
<el-button
class="absolute top-2 right-2 opacity-0 group-hover/prompt:opacity-100 transition-opacity shadow-sm"
class="absolute top-2 right-2 opacity-0 group-hover/prompt:opacity-100 transition-opacity shadow-sm z-10"
size="small"
circle
:icon="CopyDocument"
@@ -76,7 +80,7 @@
</div>
</div>
<div class="mt-auto space-y-3 pt-4 border-t border-gray-100">
<div class="mt-auto space-y-3 pt-4 border-t border-gray-100 shrink-0">
<div class="flex justify-between text-sm">
<span class="text-gray-500">创建时间</span>
<span class="text-gray-800">{{ formatTime(currentTask.creationTime) }}</span>
@@ -118,7 +122,7 @@ import TaskCard from './TaskCard.vue';
import { format } from 'date-fns';
import { ElMessage } from 'element-plus';
import { useClipboard } from '@vueuse/core';
import { Picture, Loading, MagicStick, CopyDocument } from '@element-plus/icons-vue';
import { Picture, Loading, MagicStick, CopyDocument, Download } from '@element-plus/icons-vue';
const emit = defineEmits(['use-prompt', 'use-reference']);
@@ -197,6 +201,24 @@ const copyPrompt = async (text: string) => {
await copy(text);
ElMessage.success('提示词已复制');
};
const downloadImage = async (url: string) => {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = `image-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch (e) {
console.error('Download failed', e);
window.open(url, '_blank');
}
};
</script>
<style scoped>

View File

@@ -15,7 +15,7 @@
@click="handleCardClick(task)"
@use-prompt="$emit('use-prompt', $event)"
@use-reference="$emit('use-reference', $event)"
@publish="handlePublish($event)"
@publish="openPublishDialog(task)"
/>
</div>
@@ -50,7 +50,7 @@
>
<div v-if="currentTask" class="flex flex-col md:flex-row gap-6 h-[600px]">
<!-- Left Image -->
<div class="flex-1 bg-black/5 rounded-lg flex items-center justify-center overflow-hidden relative">
<div class="flex-1 bg-black/5 rounded-lg flex items-center justify-center overflow-hidden relative group">
<el-image
v-if="currentTask.storeUrl"
:src="currentTask.storeUrl"
@@ -66,6 +66,11 @@
<span class="text-sm opacity-80">{{ currentTask.errorInfo || '未知错误' }}</span>
</div>
</div>
<!-- Download Button Overlay -->
<div v-if="currentTask.storeUrl" class="absolute bottom-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<el-button circle type="primary" :icon="Download" @click="downloadImage(currentTask.storeUrl)" />
</div>
</div>
<!-- Right Info -->
@@ -127,7 +132,7 @@
type="success"
class="w-full"
:icon="Share"
@click="handlePublish(currentTask)"
@click="openPublishDialog(currentTask)"
>
发布到广场
</el-button>
@@ -139,18 +144,64 @@
</div>
</div>
</el-dialog>
<!-- Publish Dialog -->
<el-dialog
v-model="publishDialogVisible"
title="发布到广场"
width="500px"
append-to-body
align-center
>
<el-form label-position="top">
<el-form-item label="标签 (回车添加)">
<div class="flex gap-2 flex-wrap w-full p-2 border border-gray-200 rounded-md min-h-[40px]">
<el-tag
v-for="tag in publishTags"
:key="tag"
closable
:disable-transitions="false"
@close="handleCloseTag(tag)"
>
{{ tag }}
</el-tag>
<el-input
v-if="inputVisible"
ref="InputRef"
v-model="inputValue"
class="w-24"
size="small"
@keyup.enter="handleInputConfirm"
@blur="handleInputConfirm"
/>
<el-button v-else class="button-new-tag" size="small" @click="showInput">
+ New Tag
</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="publishDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmPublish" :loading="publishing">
发布
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { ref, computed, nextTick } from 'vue';
import { getMyTasks, publishImage } from '@/api/aiImage';
import type { TaskItem } from '@/api/aiImage/types';
import TaskCard from './TaskCard.vue';
import { format } from 'date-fns';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { InputInstance } from 'element-plus';
import { useClipboard } from '@vueuse/core';
import { Picture, Loading, MagicStick, CopyDocument, Share } from '@element-plus/icons-vue';
import { Picture, Loading, MagicStick, CopyDocument, Share, CircleCloseFilled, Download } from '@element-plus/icons-vue';
const emit = defineEmits(['use-prompt', 'use-reference']);
@@ -162,6 +213,15 @@ const noMore = ref(false);
const dialogVisible = ref(false);
const currentTask = ref<TaskItem | null>(null);
// Publish Dialog State
const publishDialogVisible = ref(false);
const publishing = ref(false);
const publishTags = ref<string[]>([]);
const inputValue = ref('');
const inputVisible = ref(false);
const InputRef = ref<InputInstance>();
const taskToPublish = ref<TaskItem | null>(null);
const { copy } = useClipboard();
const disabled = computed(() => loading.value || noMore.value);
@@ -230,23 +290,71 @@ const copyPrompt = async (text: string) => {
ElMessage.success('提示词已复制');
};
const handlePublish = async (task: TaskItem) => {
const openPublishDialog = (task: TaskItem) => {
taskToPublish.value = task;
publishTags.value = [];
inputValue.value = '';
inputVisible.value = false;
publishDialogVisible.value = true;
};
const handleCloseTag = (tag: string) => {
publishTags.value.splice(publishTags.value.indexOf(tag), 1);
};
const showInput = () => {
inputVisible.value = true;
nextTick(() => {
InputRef.value!.input!.focus();
});
};
const handleInputConfirm = () => {
if (inputValue.value) {
if (!publishTags.value.includes(inputValue.value)) {
publishTags.value.push(inputValue.value);
}
}
inputVisible.value = false;
inputValue.value = '';
};
const confirmPublish = async () => {
if (!taskToPublish.value) return;
publishing.value = true;
try {
await ElMessageBox.confirm('确定要发布这张图片到广场吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info',
});
await publishImage({
taskId: task.id,
categories: []
taskId: taskToPublish.value.id,
categories: publishTags.value
});
ElMessage.success('发布成功');
task.publishStatus = 'Published';
taskToPublish.value.publishStatus = 'Published';
publishDialogVisible.value = false;
} catch (e) {
// Cancelled or error
console.error(e);
ElMessage.error('发布失败');
} finally {
publishing.value = false;
}
};
const downloadImage = async (url: string) => {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = `image-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch (e) {
console.error('Download failed', e);
window.open(url, '_blank');
}
};

View File

@@ -1,63 +1,35 @@
<template>
<div class="image-page-container h-full flex flex-col">
<el-tabs v-model="activeTab" class="flex-1 flex flex-col" type="border-card">
<el-tab-pane label="提示词广场" name="plaza" class="h-full">
<ImagePlaza
v-if="activeTab === 'plaza'"
@use-prompt="handleUsePrompt"
@use-reference="handleUseReference"
/>
</el-tab-pane>
<el-tab-pane label="图片生成" name="generate" class="h-full">
<ImageGenerator
ref="imageGeneratorRef"
@task-created="handleTaskCreated"
/>
</el-tab-pane>
<el-tab-pane label="我的图库" name="my-images" class="h-full">
<MyImages
ref="myImagesRef"
v-if="activeTab === 'my-images'"
@use-prompt="handleUsePrompt"
@use-reference="handleUseReference"
/>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue';
import ImagePlaza from './components/ImagePlaza.vue';
import { nextTick, ref, watch } from 'vue';
import ImageGenerator from './components/ImageGenerator.vue';
import ImagePlaza from './components/ImagePlaza.vue';
import MyImages from './components/MyImages.vue';
const activeTab = ref('plaza');
const myImagesRef = ref();
const imageGeneratorRef = ref();
const handleTaskCreated = () => {
function handleTaskCreated() {
// Optional: Switch to My Images or just notify
// For now, we stay on Generator page to see the result.
};
}
const handleUsePrompt = (prompt: string) => {
function handleUsePrompt(prompt: string) {
activeTab.value = 'generate';
nextTick(() => {
if (imageGeneratorRef.value) {
imageGeneratorRef.value.setPrompt(prompt);
}
});
};
}
const handleUseReference = (url: string) => {
function handleUseReference(url: string) {
activeTab.value = 'generate';
nextTick(() => {
if (imageGeneratorRef.value && url) {
imageGeneratorRef.value.addReferenceImage(url);
}
});
};
}
// Refresh My Images when tab is activated
watch(activeTab, (val) => {
@@ -67,17 +39,47 @@ watch(activeTab, (val) => {
});
</script>
<template>
<div class="image-page-container h-full flex flex-col">
<el-tabs v-model="activeTab" class="flex-1 flex flex-col" type="border-card">
<el-tab-pane label="图片广场" name="plaza" class="h-full">
<ImagePlaza
v-if="activeTab === 'plaza'"
@use-prompt="handleUsePrompt"
@use-reference="handleUseReference"
/>
</el-tab-pane>
<el-tab-pane label="图片生成" name="generate" class="h-full">
<ImageGenerator
ref="imageGeneratorRef"
:is-active="activeTab === 'generate'"
@task-created="handleTaskCreated"
/>
</el-tab-pane>
<el-tab-pane label="我的图库" name="my-images" class="h-full">
<MyImages
v-if="activeTab === 'my-images'"
ref="myImagesRef"
@use-prompt="handleUsePrompt"
@use-reference="handleUseReference"
/>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.image-page-container {
height: 100%;
padding: 10px;
box-sizing: border-box;
}
:deep(.el-tabs__content) {
flex: 1;
overflow: hidden;
padding: 0;
height: calc(100% - 40px);
}
:deep(.el-tab-pane) {
height: 100%;

View File

@@ -2,6 +2,8 @@
import { Expand, Fold } from '@element-plus/icons-vue';
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { checkPagePermission } from '@/config/permission.ts';
import { useUserStore } from '@/stores';
const route = useRoute();
const router = useRouter();
@@ -13,9 +15,16 @@ const inviteCodeFromUrl = computed(() => {
// 控制侧边栏折叠状态
const isCollapsed = ref(false);
const userStore = useUserStore();
const userName = userStore.userInfo?.user?.userName;
const hasPermission = checkPagePermission('/console/channel', userName);
// 菜单项配置
const navItems = [
// 基础菜单项
const baseNavItems = [
{ name: 'user', label: '用户信息', icon: 'User', path: '/console/user' },
{ name: 'apikey', label: 'API密钥', icon: 'Key', path: '/console/apikey' },
{ name: 'recharge-log', label: '充值记录', icon: 'Document', path: '/console/recharge-log' },
@@ -24,9 +33,13 @@ const navItems = [
{ name: 'daily-task', label: '每日任务(限时)', icon: 'Trophy', path: '/console/daily-task' },
{ name: 'invite', label: '每周邀请(限时)', icon: 'Present', path: '/console/invite' },
{ name: 'activation', label: '激活码兑换', icon: 'MagicStick', path: '/console/activation' },
{ name: 'channel', label: '渠道商管理', icon: 'Setting', path: '/console/channel' },
];
// 根据权限动态添加渠道商管理
const navItems = hasPermission
? [...baseNavItems, { name: 'channel', label: '渠道商管理', icon: 'Setting', path: '/console/channel' }]
: baseNavItems;
// 当前激活的菜单
const activeNav = computed(() => {
const path = route.path;

View File

@@ -1,4 +1,7 @@
<script setup lang="ts">
// 页面效果查看
// https://ai.ccnetcore.com/pay-result?charset=UTF-8&out_trade_no=YI_20251015232040_3316&method=alipay.trade.page.pay.return&total_amount=215.90&sign=htfny1D%2B8wcLZzK7StevZG%2BD441RLXvksoAR%2BzOq%2B1WHMwfJdVkzyZF2bBmvbU%2FsHBB2HMl8TT3KoaHaf8UfWZgtDGbMoQC%2F1O%2BRcEw8jljlpW3XLMdKGx6dytqZkhq9lRD6tR3ofBiuviv2PmxVd1l%2Bcqs7nwNWwKJWonWI0c5UOE%2BYWgg3hjEJnMYVQjUb6FvrVLfANEU0YyTO%2Bi6vL55Gwug6GIXvGqUPZc3GbwXc%2FUHnu1qv4Yi6tc1dtUoLUNHVfTKrC2N55T84AALZteIK0m7suzrkvBPcKdpn4NGVDtv5cCBCHPjtD3COrNISrNUf3sQXpTvqJGw6dWag6g%3D%3D&trade_no=2025101522001438971445003566&auth_app_id=2021005182687851&version=1.0&app_id=2021005182687851&sign_type=RSA2&seller_id=2088870286599802&timestamp=2025-10-15+23%3A21%3A01
import { ElButton, ElDivider, ElMessage } from 'element-plus';
import { computed, onMounted, ref } from 'vue';