feat(ai): 支持双模型多视角AI设计生图与后台管理系统
- 实现AI多视角设计图生成功能,支持6个可选设计参数配置 - 集成SiliconFlow FLUX.1与火山引擎Seedream 4.5双模型切换 - 构建专业中文转英文prompt系统,提升AI生成质量 - 前端设计预览支持多视角切换与视角指示器展示 - 增加多视角设计图片DesignImage模型关联及存储 - 后端设计服务异步调用AI接口,失败时降级生成mock图 - 新增管理员后台管理路由及完整的权限校验机制 - 实现后台模块:仪表盘、系统配置、用户/品类/设计管理 - 配置数据库系统配置表,支持动态AI配置及热更新 - 增加用户管理员标识字段,管理后台登录鉴权支持 - 更新API接口支持多视角设计参数及后台管理接口 - 优化设计删除逻辑,删除多视角相关图片文件 - 前端新增管理后台页面与路由,布局样式独立分离 - 更新环境变量增加AI模型相关Key与参数配置说明 - 引入httpx异步HTTP客户端用于AI接口调用及图片下载 - README文档完善AI多视角生图与后台管理详细功能与流程说明
This commit is contained in:
@@ -1,19 +1,23 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<AppHeader />
|
||||
<main class="main-content">
|
||||
<AppHeader v-if="!isAdminRoute" />
|
||||
<main :class="isAdminRoute ? 'admin-main-content' : 'main-content'">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const isAdminRoute = computed(() => route.path.startsWith('/admin'))
|
||||
|
||||
// 应用初始化时恢复登录状态
|
||||
onMounted(() => {
|
||||
userStore.init()
|
||||
@@ -31,4 +35,8 @@ onMounted(() => {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-main-content {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
94
frontend/src/api/admin.ts
Normal file
94
frontend/src/api/admin.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import request from './request'
|
||||
|
||||
// ============ 仪表盘 ============
|
||||
export const getDashboard = () => request.get('/admin/dashboard')
|
||||
|
||||
// ============ 系统配置 ============
|
||||
export const getConfigs = (group?: string) =>
|
||||
request.get('/admin/configs', { params: group ? { group } : {} })
|
||||
|
||||
export const updateConfigs = (configs: Record<string, string>) =>
|
||||
request.post('/admin/configs', null, {
|
||||
// PUT 方法
|
||||
})
|
||||
|
||||
export const updateConfigsBatch = (configs: Record<string, string>) =>
|
||||
request.put('/admin/configs', { configs })
|
||||
|
||||
export const initDefaultConfigs = () =>
|
||||
request.post('/admin/configs/init')
|
||||
|
||||
// ============ 用户管理 ============
|
||||
export const getUsers = (params: { page?: number; page_size?: number; keyword?: string }) =>
|
||||
request.get('/admin/users', { params })
|
||||
|
||||
export const setUserAdmin = (userId: number, isAdmin: boolean) =>
|
||||
request.put(`/admin/users/${userId}/admin`, { is_admin: isAdmin })
|
||||
|
||||
export const deleteUser = (userId: number) =>
|
||||
request.delete(`/admin/users/${userId}`)
|
||||
|
||||
// ============ 品类管理 ============
|
||||
export const getAdminCategories = () =>
|
||||
request.get('/admin/categories')
|
||||
|
||||
export const createCategory = (data: { name: string; icon?: string; sort_order?: number; flow_type?: string }) =>
|
||||
request.post('/admin/categories', data)
|
||||
|
||||
export const updateCategory = (catId: number, data: { name?: string; icon?: string; sort_order?: number; flow_type?: string }) =>
|
||||
request.put(`/admin/categories/${catId}`, data)
|
||||
|
||||
export const deleteCategory = (catId: number) =>
|
||||
request.delete(`/admin/categories/${catId}`)
|
||||
|
||||
// -- 子类型 --
|
||||
export const createSubType = (data: { category_id: number; name: string; description?: string; preview_image?: string; sort_order?: number }) =>
|
||||
request.post('/admin/sub-types', data)
|
||||
|
||||
export const updateSubType = (stId: number, data: { name?: string; description?: string; preview_image?: string; sort_order?: number }) =>
|
||||
request.put(`/admin/sub-types/${stId}`, data)
|
||||
|
||||
export const deleteSubType = (stId: number) =>
|
||||
request.delete(`/admin/sub-types/${stId}`)
|
||||
|
||||
// -- 颜色 --
|
||||
export const createColor = (data: { category_id: number; name: string; hex_code?: string; sort_order?: number }) =>
|
||||
request.post('/admin/colors', data)
|
||||
|
||||
export const updateColor = (colorId: number, data: { name?: string; hex_code?: string; sort_order?: number }) =>
|
||||
request.put(`/admin/colors/${colorId}`, data)
|
||||
|
||||
export const deleteColor = (colorId: number) =>
|
||||
request.delete(`/admin/colors/${colorId}`)
|
||||
|
||||
// ============ 设计管理 ============
|
||||
export const getAdminDesigns = (params: { page?: number; page_size?: number; user_id?: number; status?: string }) =>
|
||||
request.get('/admin/designs', { params })
|
||||
|
||||
export const adminDeleteDesign = (designId: number) =>
|
||||
request.delete(`/admin/designs/${designId}`)
|
||||
|
||||
// ============ 提示词管理 ============
|
||||
export const getPromptTemplates = () =>
|
||||
request.get('/admin/prompt-templates')
|
||||
|
||||
export const updatePromptTemplate = (templateId: number, data: { template_value: string; description?: string }) =>
|
||||
request.put(`/admin/prompt-templates/${templateId}`, data)
|
||||
|
||||
export const getPromptMappings = (mappingType?: string) =>
|
||||
request.get('/admin/prompt-mappings', { params: mappingType ? { mapping_type: mappingType } : {} })
|
||||
|
||||
export const getMappingTypes = () =>
|
||||
request.get('/admin/prompt-mappings/types')
|
||||
|
||||
export const createPromptMapping = (data: { mapping_type: string; cn_key: string; en_value: string; sort_order?: number }) =>
|
||||
request.post('/admin/prompt-mappings', data)
|
||||
|
||||
export const updatePromptMapping = (mappingId: number, data: { cn_key?: string; en_value?: string; sort_order?: number }) =>
|
||||
request.put(`/admin/prompt-mappings/${mappingId}`, data)
|
||||
|
||||
export const deletePromptMapping = (mappingId: number) =>
|
||||
request.delete(`/admin/prompt-mappings/${mappingId}`)
|
||||
|
||||
export const previewPrompt = (params: Record<string, string>) =>
|
||||
request.post('/admin/prompt-preview', params)
|
||||
@@ -11,6 +11,15 @@ export interface SubType {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface DesignImage {
|
||||
id: number
|
||||
view_name: string
|
||||
image_url: string | null
|
||||
model_used: string | null
|
||||
prompt_used: string | null
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface Design {
|
||||
id: number
|
||||
user_id: number
|
||||
@@ -25,6 +34,7 @@ export interface Design {
|
||||
surface_finish: string | null
|
||||
usage_scene: string | null
|
||||
image_url: string | null
|
||||
images: DesignImage[]
|
||||
status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
177
frontend/src/components/AdminLayout.vue
Normal file
177
frontend/src/components/AdminLayout.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<aside class="admin-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<router-link to="/admin" class="admin-logo">
|
||||
<span class="logo-icon">⚙</span>
|
||||
<span class="logo-text">管理后台</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<router-link to="/admin" class="nav-item" exact-active-class="active">
|
||||
<el-icon><DataBoard /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</router-link>
|
||||
<router-link to="/admin/configs" class="nav-item" active-class="active">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>系统配置</span>
|
||||
</router-link>
|
||||
<router-link to="/admin/users" class="nav-item" active-class="active">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>用户管理</span>
|
||||
</router-link>
|
||||
<router-link to="/admin/categories" class="nav-item" active-class="active">
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>品类管理</span>
|
||||
</router-link>
|
||||
<router-link to="/admin/designs" class="nav-item" active-class="active">
|
||||
<el-icon><PictureFilled /></el-icon>
|
||||
<span>设计管理</span>
|
||||
</router-link>
|
||||
<router-link to="/admin/prompts" class="nav-item" active-class="active">
|
||||
<el-icon><ChatLineSquare /></el-icon>
|
||||
<span>提示词管理</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<router-link to="/" class="back-link">
|
||||
<el-icon><Back /></el-icon>
|
||||
<span>返回前台</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="admin-main">
|
||||
<header class="admin-header">
|
||||
<div class="header-title">{{ pageTitle }}</div>
|
||||
<div class="header-user">
|
||||
<span>{{ userStore.userInfo?.nickname || '管理员' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { DataBoard, Setting, User, Grid, PictureFilled, Back, ChatLineSquare } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const titles: Record<string, string> = {
|
||||
'AdminDashboard': '仪表盘',
|
||||
'AdminConfigs': '系统配置',
|
||||
'AdminUsers': '用户管理',
|
||||
'AdminCategories': '品类管理',
|
||||
'AdminDesigns': '设计管理',
|
||||
'AdminPrompts': '提示词管理',
|
||||
}
|
||||
return titles[route.name as string] || '管理后台'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: 220px;
|
||||
background: #1d1e2c;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
|
||||
.admin-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
|
||||
.logo-icon { font-size: 22px; }
|
||||
.logo-text { font-size: 18px; font-weight: 600; letter-spacing: 2px; }
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 12px 0;
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 24px;
|
||||
color: rgba(255,255,255,0.65);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover { color: #fff; background: rgba(255,255,255,0.06); }
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: #5B7E6B;
|
||||
border-right: 3px solid #A8D5BA;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
|
||||
.back-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover { color: #fff; background: rgba(255,255,255,0.06); }
|
||||
}
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
padding: 0 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
|
||||
.header-title { font-size: 16px; font-weight: 600; color: #1d1e2c; }
|
||||
.header-user { font-size: 14px; color: #666; }
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@
|
||||
<nav class="header-nav">
|
||||
<router-link to="/" class="nav-link">设计</router-link>
|
||||
<router-link to="/generate" class="nav-link">生成</router-link>
|
||||
<router-link to="/admin" class="nav-link admin-link" v-if="isAdmin">管理后台</router-link>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
<template v-if="isLoggedIn">
|
||||
@@ -43,6 +44,7 @@ const userStore = useUserStore()
|
||||
|
||||
const isLoggedIn = computed(() => !!userStore.token)
|
||||
const userNickname = computed(() => userStore.userInfo?.nickname || '用户')
|
||||
const isAdmin = computed(() => !!userStore.userInfo?.is_admin)
|
||||
|
||||
const handleCommand = (command: string) => {
|
||||
if (command === 'user') {
|
||||
@@ -94,6 +96,11 @@ const handleCommand = (command: string) => {
|
||||
color: #5B7E6B;
|
||||
border-bottom-color: #5B7E6B;
|
||||
}
|
||||
|
||||
&.admin-link {
|
||||
color: #E6A23C;
|
||||
&:hover, &.router-link-active { color: #E6A23C; border-bottom-color: #E6A23C; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
<template>
|
||||
<div class="design-preview">
|
||||
<!-- 视角 Tab 栏(多图时显示) -->
|
||||
<div class="view-tabs" v-if="hasMultipleViews">
|
||||
<button
|
||||
v-for="(img, idx) in design.images"
|
||||
:key="img.id"
|
||||
class="view-tab"
|
||||
:class="{ active: activeViewIndex === idx }"
|
||||
@click="activeViewIndex = idx"
|
||||
>
|
||||
{{ img.view_name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 图片预览区 -->
|
||||
<div class="preview-container">
|
||||
<div class="image-wrapper" :style="{ transform: `scale(${scale})` }">
|
||||
<el-image
|
||||
:src="imageUrl"
|
||||
:src="currentImageUrl"
|
||||
:alt="design.prompt"
|
||||
fit="contain"
|
||||
:preview-src-list="[imageUrl]"
|
||||
:initial-index="0"
|
||||
:preview-src-list="allImageUrls"
|
||||
:initial-index="activeViewIndex"
|
||||
preview-teleported
|
||||
class="design-image"
|
||||
>
|
||||
@@ -27,6 +40,11 @@
|
||||
</el-image>
|
||||
</div>
|
||||
|
||||
<!-- 视角指示器(多图时显示) -->
|
||||
<div class="view-indicator" v-if="hasMultipleViews">
|
||||
<span class="indicator-text">{{ activeViewName }} ({{ activeViewIndex + 1 }}/{{ design.images.length }})</span>
|
||||
</div>
|
||||
|
||||
<!-- 缩放控制 -->
|
||||
<div class="zoom-controls">
|
||||
<button class="zoom-btn" @click="zoomOut" :disabled="scale <= 0.5">
|
||||
@@ -84,7 +102,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Loading, PictureFilled, ZoomIn, ZoomOut, RefreshRight, Download, User } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -97,17 +115,48 @@ const props = defineProps<{
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 当前视角索引
|
||||
const activeViewIndex = ref(0)
|
||||
|
||||
// 缩放比例
|
||||
const scale = ref(1)
|
||||
|
||||
// 图片URL(添加API前缀)
|
||||
const imageUrl = computed(() => {
|
||||
if (!props.design.image_url) return ''
|
||||
// 如果已经是完整URL则直接使用,否则添加 /api 前缀
|
||||
if (props.design.image_url.startsWith('http')) {
|
||||
return props.design.image_url
|
||||
// 是否有多视角图片
|
||||
const hasMultipleViews = computed(() => {
|
||||
return props.design.images && props.design.images.length > 1
|
||||
})
|
||||
|
||||
// 当前视角名称
|
||||
const activeViewName = computed(() => {
|
||||
if (props.design.images && props.design.images.length > 0) {
|
||||
return props.design.images[activeViewIndex.value]?.view_name || ''
|
||||
}
|
||||
return `/api${props.design.image_url}`
|
||||
return ''
|
||||
})
|
||||
|
||||
// 获取图片URL(添加API前缀)
|
||||
const toImageUrl = (url: string | null): string => {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http')) return url
|
||||
return `/api${url}`
|
||||
}
|
||||
|
||||
// 当前显示的图片URL
|
||||
const currentImageUrl = computed(() => {
|
||||
// 优先用多视角图片
|
||||
if (props.design.images && props.design.images.length > 0) {
|
||||
return toImageUrl(props.design.images[activeViewIndex.value]?.image_url)
|
||||
}
|
||||
// 兼容旧数据,使用单图
|
||||
return toImageUrl(props.design.image_url)
|
||||
})
|
||||
|
||||
// 所有图片URL(用于大图预览)
|
||||
const allImageUrls = computed(() => {
|
||||
if (props.design.images && props.design.images.length > 0) {
|
||||
return props.design.images.map(img => toImageUrl(img.image_url))
|
||||
}
|
||||
return [toImageUrl(props.design.image_url)]
|
||||
})
|
||||
|
||||
// 下载URL
|
||||
@@ -117,7 +166,13 @@ const downloadUrl = computed(() => getDesignDownloadUrl(props.design.id))
|
||||
const downloadFilename = computed(() => {
|
||||
const category = props.design.category?.name || '设计'
|
||||
const subType = props.design.sub_type?.name || ''
|
||||
return `${category}${subType ? '-' + subType : ''}-${props.design.id}.png`
|
||||
const viewSuffix = hasMultipleViews.value ? `-${activeViewName.value}` : ''
|
||||
return `${category}${subType ? '-' + subType : ''}${viewSuffix}-${props.design.id}.png`
|
||||
})
|
||||
|
||||
// 切换视角时重置缩放
|
||||
watch(activeViewIndex, () => {
|
||||
scale.value = 1
|
||||
})
|
||||
|
||||
// 放大
|
||||
@@ -163,6 +218,54 @@ $text-light: #999999;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.view-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.view-tab {
|
||||
flex: 1;
|
||||
padding: 10px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: $text-secondary;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
letter-spacing: 1px;
|
||||
|
||||
&:hover {
|
||||
color: $primary-color;
|
||||
background: rgba($primary-color, 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: $primary-color;
|
||||
color: #fff;
|
||||
border-color: $primary-color;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.view-indicator {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.indicator-text {
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
|
||||
7
frontend/src/env.d.ts
vendored
Normal file
7
frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -30,6 +30,44 @@ const router = createRouter({
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/Register.vue')
|
||||
},
|
||||
// 管理后台路由
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('@/components/AdminLayout.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'AdminDashboard',
|
||||
component: () => import('@/views/admin/Dashboard.vue')
|
||||
},
|
||||
{
|
||||
path: 'configs',
|
||||
name: 'AdminConfigs',
|
||||
component: () => import('@/views/admin/ConfigManage.vue')
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'AdminUsers',
|
||||
component: () => import('@/views/admin/UserManage.vue')
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
name: 'AdminCategories',
|
||||
component: () => import('@/views/admin/CategoryManage.vue')
|
||||
},
|
||||
{
|
||||
path: 'designs',
|
||||
name: 'AdminDesigns',
|
||||
component: () => import('@/views/admin/DesignManage.vue')
|
||||
},
|
||||
{
|
||||
path: 'prompts',
|
||||
name: 'AdminPrompts',
|
||||
component: () => import('@/views/admin/PromptManage.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface UserInfo {
|
||||
nickname: string
|
||||
phone?: string | null
|
||||
avatar?: string | null
|
||||
is_admin?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
<div class="ink-drop"></div>
|
||||
<div class="ink-drop"></div>
|
||||
</div>
|
||||
<p class="loading-text">设计生成中,请稍候...</p>
|
||||
<p class="loading-hint">正在将您的创意转化为玉雕设计</p>
|
||||
<p class="loading-text">正在用 AI 生成多视角设计图...</p>
|
||||
<p class="loading-hint">根据品类自动生成 2~4 张不同视角的设计效果图,请耐心等候</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,8 +62,15 @@
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: carvingTechnique === opt }"
|
||||
@click="carvingTechnique = carvingTechnique === opt ? '' : opt"
|
||||
@click="selectTag('carvingTechnique', opt)"
|
||||
>{{ opt }}</span>
|
||||
<el-input
|
||||
v-model="customCarving"
|
||||
placeholder="自定义工艺"
|
||||
size="small"
|
||||
class="custom-input"
|
||||
@focus="carvingTechnique = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,8 +83,15 @@
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: designStyle === opt }"
|
||||
@click="designStyle = designStyle === opt ? '' : opt"
|
||||
@click="selectTag('designStyle', opt)"
|
||||
>{{ opt }}</span>
|
||||
<el-input
|
||||
v-model="customStyle"
|
||||
placeholder="自定义风格"
|
||||
size="small"
|
||||
class="custom-input"
|
||||
@focus="designStyle = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,8 +104,15 @@
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: motif === opt }"
|
||||
@click="motif = motif === opt ? '' : opt"
|
||||
@click="selectTag('motif', opt)"
|
||||
>{{ opt }}</span>
|
||||
<el-input
|
||||
v-model="customMotif"
|
||||
placeholder="自定义题材"
|
||||
size="small"
|
||||
class="custom-input"
|
||||
@focus="motif = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,13 +125,13 @@
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: sizeSpec === opt }"
|
||||
@click="sizeSpec = sizeSpec === opt ? '' : opt"
|
||||
@click="selectTag('sizeSpec', opt)"
|
||||
>{{ opt }}</span>
|
||||
<el-input
|
||||
v-model="customSize"
|
||||
placeholder="自定义尺寸"
|
||||
size="small"
|
||||
class="custom-size-input"
|
||||
class="custom-input"
|
||||
@focus="sizeSpec = ''"
|
||||
/>
|
||||
</div>
|
||||
@@ -125,8 +146,15 @@
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: surfaceFinish === opt }"
|
||||
@click="surfaceFinish = surfaceFinish === opt ? '' : opt"
|
||||
@click="selectTag('surfaceFinish', opt)"
|
||||
>{{ opt }}</span>
|
||||
<el-input
|
||||
v-model="customFinish"
|
||||
placeholder="自定义处理"
|
||||
size="small"
|
||||
class="custom-input"
|
||||
@focus="surfaceFinish = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -139,8 +167,15 @@
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: usageScene === opt }"
|
||||
@click="usageScene = usageScene === opt ? '' : opt"
|
||||
@click="selectTag('usageScene', opt)"
|
||||
>{{ opt }}</span>
|
||||
<el-input
|
||||
v-model="customScene"
|
||||
placeholder="自定义场景"
|
||||
size="small"
|
||||
class="custom-input"
|
||||
@focus="usageScene = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,7 +213,7 @@
|
||||
<section v-else class="preview-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">设计预览</h2>
|
||||
<p class="section-desc">您的设计已生成完成</p>
|
||||
<p class="section-desc">您的多视角设计已生成完成</p>
|
||||
</div>
|
||||
|
||||
<DesignPreview :design="currentDesign" />
|
||||
@@ -258,10 +293,38 @@ const carvingTechnique = ref('')
|
||||
const designStyle = ref('')
|
||||
const motif = ref('')
|
||||
const sizeSpec = ref('')
|
||||
const customSize = ref('')
|
||||
const surfaceFinish = ref('')
|
||||
const usageScene = ref('')
|
||||
|
||||
// 自定义输入状态
|
||||
const customCarving = ref('')
|
||||
const customStyle = ref('')
|
||||
const customMotif = ref('')
|
||||
const customSize = ref('')
|
||||
const customFinish = ref('')
|
||||
const customScene = ref('')
|
||||
|
||||
// 自定义输入与标签的关联 Map
|
||||
const tagRefs: Record<string, any> = {
|
||||
carvingTechnique, designStyle, motif, sizeSpec, surfaceFinish, usageScene
|
||||
}
|
||||
const customRefs: Record<string, any> = {
|
||||
carvingTechnique: customCarving, designStyle: customStyle, motif: customMotif,
|
||||
sizeSpec: customSize, surfaceFinish: customFinish, usageScene: customScene
|
||||
}
|
||||
|
||||
// 选择标签时清除对应自定义输入
|
||||
const selectTag = (field: string, opt: string) => {
|
||||
const tagRef = tagRefs[field]
|
||||
const customRef = customRefs[field]
|
||||
if (tagRef.value === opt) {
|
||||
tagRef.value = ''
|
||||
} else {
|
||||
tagRef.value = opt
|
||||
if (customRef) customRef.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 静态选项
|
||||
const carvingOptions = ['浮雕', '圆雕', '镂空雕', '阴刻', '线雕', '俏色雕', '薄意雕', '素面']
|
||||
const styleOptions = ['古典传统', '新中式', '写实', '抽象意境', '极简素面']
|
||||
@@ -315,12 +378,12 @@ const handleGenerate = async () => {
|
||||
sub_type_id: subTypeId.value || undefined,
|
||||
color_id: colorId.value || undefined,
|
||||
prompt: prompt.value.trim(),
|
||||
carving_technique: carvingTechnique.value || undefined,
|
||||
design_style: designStyle.value || undefined,
|
||||
motif: motif.value || undefined,
|
||||
carving_technique: carvingTechnique.value || customCarving.value || undefined,
|
||||
design_style: designStyle.value || customStyle.value || undefined,
|
||||
motif: motif.value || customMotif.value || undefined,
|
||||
size_spec: sizeSpec.value || customSize.value || undefined,
|
||||
surface_finish: surfaceFinish.value || undefined,
|
||||
usage_scene: usageScene.value || undefined,
|
||||
surface_finish: surfaceFinish.value || customFinish.value || undefined,
|
||||
usage_scene: usageScene.value || customScene.value || undefined,
|
||||
})
|
||||
ElMessage.success('设计生成成功!')
|
||||
} catch (error) {
|
||||
@@ -542,7 +605,7 @@ $text-light: #999999;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-size-input {
|
||||
.custom-input {
|
||||
width: 130px;
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
|
||||
299
frontend/src/views/admin/CategoryManage.vue
Normal file
299
frontend/src/views/admin/CategoryManage.vue
Normal file
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<div class="category-manage">
|
||||
<div class="page-actions">
|
||||
<el-button type="primary" @click="showAddCategory">新增品类</el-button>
|
||||
</div>
|
||||
|
||||
<div class="category-card" v-for="cat in categories" :key="cat.id" v-loading="loading">
|
||||
<div class="cat-header">
|
||||
<div class="cat-info">
|
||||
<span class="cat-icon">{{ cat.icon || '📦' }}</span>
|
||||
<span class="cat-name">{{ cat.name }}</span>
|
||||
<el-tag size="small" type="info">{{ cat.flow_type }}</el-tag>
|
||||
<el-tag size="small">排序: {{ cat.sort_order }}</el-tag>
|
||||
</div>
|
||||
<div class="cat-actions">
|
||||
<el-button size="small" @click="editCategory(cat)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="removeCategory(cat)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子类型 -->
|
||||
<div class="sub-section">
|
||||
<div class="sub-title">
|
||||
<span>子类型 ({{ cat.sub_types.length }})</span>
|
||||
<el-button size="small" @click="showAddSubType(cat.id)">添加</el-button>
|
||||
</div>
|
||||
<el-table :data="cat.sub_types" size="small" v-if="cat.sub_types.length > 0">
|
||||
<el-table-column prop="name" label="名称" width="120" />
|
||||
<el-table-column prop="description" label="描述" />
|
||||
<el-table-column prop="sort_order" label="排序" width="80" />
|
||||
<el-table-column label="操作" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editSubType(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="removeSubType(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 颜色 -->
|
||||
<div class="sub-section">
|
||||
<div class="sub-title">
|
||||
<span>颜色 ({{ cat.colors.length }})</span>
|
||||
<el-button size="small" @click="showAddColor(cat.id)">添加</el-button>
|
||||
</div>
|
||||
<div class="color-list" v-if="cat.colors.length > 0">
|
||||
<div class="color-chip" v-for="c in cat.colors" :key="c.id">
|
||||
<span class="color-dot" :style="{ backgroundColor: c.hex_code }"></span>
|
||||
<span class="color-name">{{ c.name }}</span>
|
||||
<span class="color-hex">{{ c.hex_code }}</span>
|
||||
<el-button size="small" text @click="editColor(c, cat.id)">编辑</el-button>
|
||||
<el-button size="small" text type="danger" @click="removeColor(c)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 品类弹窗 -->
|
||||
<el-dialog v-model="catDialogVisible" :title="catForm.id ? '编辑品类' : '新增品类'" width="460px">
|
||||
<el-form :model="catForm" label-width="80px">
|
||||
<el-form-item label="名称"><el-input v-model="catForm.name" /></el-form-item>
|
||||
<el-form-item label="图标"><el-input v-model="catForm.icon" placeholder="emoji 或图标名" /></el-form-item>
|
||||
<el-form-item label="流程类型">
|
||||
<el-select v-model="catForm.flow_type">
|
||||
<el-option label="full" value="full" />
|
||||
<el-option label="size_color" value="size_color" />
|
||||
<el-option label="simple" value="simple" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="catForm.sort_order" :min="0" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="catDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveCat">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 子类型弹窗 -->
|
||||
<el-dialog v-model="stDialogVisible" :title="stForm.id ? '编辑子类型' : '新增子类型'" width="460px">
|
||||
<el-form :model="stForm" label-width="80px">
|
||||
<el-form-item label="名称"><el-input v-model="stForm.name" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="stForm.description" /></el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="stForm.sort_order" :min="0" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="stDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveSt">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 颜色弹窗 -->
|
||||
<el-dialog v-model="colorDialogVisible" :title="colorForm.id ? '编辑颜色' : '新增颜色'" width="460px">
|
||||
<el-form :model="colorForm" label-width="80px">
|
||||
<el-form-item label="名称"><el-input v-model="colorForm.name" /></el-form-item>
|
||||
<el-form-item label="色值">
|
||||
<el-color-picker v-model="colorForm.hex_code" />
|
||||
<el-input v-model="colorForm.hex_code" style="width: 120px; margin-left: 12px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="colorForm.sort_order" :min="0" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="colorDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveColor">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getAdminCategories, createCategory, updateCategory, deleteCategory,
|
||||
createSubType, updateSubType, deleteSubType,
|
||||
createColor, updateColor, deleteColor
|
||||
} from '@/api/admin'
|
||||
|
||||
interface ColorItem { id: number; name: string; hex_code: string; sort_order: number }
|
||||
interface SubTypeItem { id: number; name: string; description: string; preview_image: string; sort_order: number }
|
||||
interface CategoryItem { id: number; name: string; icon: string; sort_order: number; flow_type: string; sub_types: SubTypeItem[]; colors: ColorItem[] }
|
||||
|
||||
const categories = ref<CategoryItem[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 品类表单
|
||||
const catDialogVisible = ref(false)
|
||||
const catForm = ref<any>({ id: 0, name: '', icon: '', sort_order: 0, flow_type: 'full' })
|
||||
|
||||
// 子类型表单
|
||||
const stDialogVisible = ref(false)
|
||||
const stForm = ref<any>({ id: 0, category_id: 0, name: '', description: '', sort_order: 0 })
|
||||
|
||||
// 颜色表单
|
||||
const colorDialogVisible = ref(false)
|
||||
const colorForm = ref<any>({ id: 0, category_id: 0, name: '', hex_code: '#8B7355', sort_order: 0 })
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
categories.value = (await getAdminCategories()) as any
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
// ---- 品类 ----
|
||||
const showAddCategory = () => {
|
||||
catForm.value = { id: 0, name: '', icon: '', sort_order: 0, flow_type: 'full' }
|
||||
catDialogVisible.value = true
|
||||
}
|
||||
const editCategory = (cat: CategoryItem) => {
|
||||
catForm.value = { ...cat }
|
||||
catDialogVisible.value = true
|
||||
}
|
||||
const saveCat = async () => {
|
||||
try {
|
||||
if (catForm.value.id) {
|
||||
await updateCategory(catForm.value.id, { name: catForm.value.name, icon: catForm.value.icon, sort_order: catForm.value.sort_order, flow_type: catForm.value.flow_type })
|
||||
} else {
|
||||
await createCategory({ name: catForm.value.name, icon: catForm.value.icon, sort_order: catForm.value.sort_order, flow_type: catForm.value.flow_type })
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
catDialogVisible.value = false
|
||||
await loadData()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
const removeCategory = async (cat: CategoryItem) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除品类「${cat.name}」?子类型和颜色也将被删除`, '警告', { type: 'warning' })
|
||||
await deleteCategory(cat.id)
|
||||
ElMessage.success('品类已删除')
|
||||
await loadData()
|
||||
} catch (e: any) { if (e !== 'cancel') console.error(e) }
|
||||
}
|
||||
|
||||
// ---- 子类型 ----
|
||||
const showAddSubType = (catId: number) => {
|
||||
stForm.value = { id: 0, category_id: catId, name: '', description: '', sort_order: 0 }
|
||||
stDialogVisible.value = true
|
||||
}
|
||||
const editSubType = (st: SubTypeItem) => {
|
||||
stForm.value = { ...st }
|
||||
stDialogVisible.value = true
|
||||
}
|
||||
const saveSt = async () => {
|
||||
try {
|
||||
if (stForm.value.id) {
|
||||
await updateSubType(stForm.value.id, { name: stForm.value.name, description: stForm.value.description, sort_order: stForm.value.sort_order })
|
||||
} else {
|
||||
await createSubType({ category_id: stForm.value.category_id, name: stForm.value.name, description: stForm.value.description, sort_order: stForm.value.sort_order })
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
stDialogVisible.value = false
|
||||
await loadData()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
const removeSubType = async (st: SubTypeItem) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除子类型「${st.name}」?`, '确认')
|
||||
await deleteSubType(st.id)
|
||||
ElMessage.success('子类型已删除')
|
||||
await loadData()
|
||||
} catch (e: any) { if (e !== 'cancel') console.error(e) }
|
||||
}
|
||||
|
||||
// ---- 颜色 ----
|
||||
const showAddColor = (catId: number) => {
|
||||
colorForm.value = { id: 0, category_id: catId, name: '', hex_code: '#8B7355', sort_order: 0 }
|
||||
colorDialogVisible.value = true
|
||||
}
|
||||
const editColor = (c: ColorItem, catId: number) => {
|
||||
colorForm.value = { ...c, category_id: catId }
|
||||
colorDialogVisible.value = true
|
||||
}
|
||||
const saveColor = async () => {
|
||||
try {
|
||||
if (colorForm.value.id) {
|
||||
await updateColor(colorForm.value.id, { name: colorForm.value.name, hex_code: colorForm.value.hex_code, sort_order: colorForm.value.sort_order })
|
||||
} else {
|
||||
await createColor({ category_id: colorForm.value.category_id, name: colorForm.value.name, hex_code: colorForm.value.hex_code, sort_order: colorForm.value.sort_order })
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
colorDialogVisible.value = false
|
||||
await loadData()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
const removeColor = async (c: ColorItem) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除颜色「${c.name}」?`, '确认')
|
||||
await deleteColor(c.id)
|
||||
ElMessage.success('颜色已删除')
|
||||
await loadData()
|
||||
} catch (e: any) { if (e !== 'cancel') console.error(e) }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-actions { margin-bottom: 20px; }
|
||||
|
||||
.category-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
|
||||
.cat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.cat-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
.cat-icon { font-size: 22px; }
|
||||
.cat-name { font-size: 16px; font-weight: 600; }
|
||||
}
|
||||
}
|
||||
|
||||
.sub-section {
|
||||
margin-top: 16px;
|
||||
.sub-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.color-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.color-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
|
||||
.color-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
.color-hex { color: #999; font-family: monospace; font-size: 12px; }
|
||||
}
|
||||
</style>
|
||||
381
frontend/src/views/admin/ConfigManage.vue
Normal file
381
frontend/src/views/admin/ConfigManage.vue
Normal file
@@ -0,0 +1,381 @@
|
||||
<template>
|
||||
<div class="config-page">
|
||||
<!-- 默认模型选择 -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">默认生图模型</h3>
|
||||
<div class="model-switch">
|
||||
<div
|
||||
class="model-option"
|
||||
:class="{ active: defaultModel === 'flux-dev' }"
|
||||
@click="setDefaultModel('flux-dev')"
|
||||
>
|
||||
<div class="model-badge">默认</div>
|
||||
<div class="model-name">SiliconFlow FLUX.1 [dev]</div>
|
||||
<div class="model-price">~0.13 元/张</div>
|
||||
<div class="model-tag">性价比高</div>
|
||||
</div>
|
||||
<div
|
||||
class="model-option"
|
||||
:class="{ active: defaultModel === 'seedream-4.5' }"
|
||||
@click="setDefaultModel('seedream-4.5')"
|
||||
>
|
||||
<div class="model-badge">备选</div>
|
||||
<div class="model-name">火山引擎 Seedream 4.5</div>
|
||||
<div class="model-price">~0.30 元/张</div>
|
||||
<div class="model-tag">高质量</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SiliconFlow 配置卡片 -->
|
||||
<div class="section-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title-row">
|
||||
<h3 class="section-title">SiliconFlow FLUX.1 [dev]</h3>
|
||||
<el-tag :type="siliconflowStatus" size="small">
|
||||
{{ siliconflowStatusText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p class="card-desc">硅基流动文生图 API,基于 FLUX.1 开源模型,性价比高</p>
|
||||
</div>
|
||||
<el-form label-width="120px" class="config-form">
|
||||
<el-form-item label="API Key">
|
||||
<el-input
|
||||
v-model="siliconflowKey"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入 SiliconFlow API Key"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="接口地址">
|
||||
<el-input v-model="siliconflowUrl" placeholder="https://api.siliconflow.cn/v1" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="testSiliconflow" :loading="testingSiliconflow">
|
||||
测试连接
|
||||
</el-button>
|
||||
<span v-if="siliconflowTestResult" :class="['test-result', siliconflowTestResult.ok ? 'success' : 'fail']">
|
||||
{{ siliconflowTestResult.msg }}
|
||||
</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 火山引擎配置卡片 -->
|
||||
<div class="section-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title-row">
|
||||
<h3 class="section-title">火山引擎 Seedream 4.5</h3>
|
||||
<el-tag :type="volcengineStatus" size="small">
|
||||
{{ volcengineStatusText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p class="card-desc">字节跳动火山引擎文生图 API,Seedream 4.5 模型,高质量输出</p>
|
||||
</div>
|
||||
<el-form label-width="120px" class="config-form">
|
||||
<el-form-item label="API Key">
|
||||
<el-input
|
||||
v-model="volcengineKey"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入火山引擎 API Key"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="接口地址">
|
||||
<el-input v-model="volcengineUrl" placeholder="https://ark.cn-beijing.volces.com/api/v3" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="testVolcengine" :loading="testingVolcengine">
|
||||
测试连接
|
||||
</el-button>
|
||||
<span v-if="volcengineTestResult" :class="['test-result', volcengineTestResult.ok ? 'success' : 'fail']">
|
||||
{{ volcengineTestResult.msg }}
|
||||
</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 通用设置 -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">通用设置</h3>
|
||||
<el-form label-width="120px" class="config-form">
|
||||
<el-form-item label="图片尺寸">
|
||||
<el-select v-model="imageSize" style="width: 200px">
|
||||
<el-option label="512 x 512" value="512" />
|
||||
<el-option label="768 x 768" value="768" />
|
||||
<el-option label="1024 x 1024" value="1024" />
|
||||
</el-select>
|
||||
<span class="form-tip">生成图片的宽高(像素)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<div class="save-bar">
|
||||
<el-button type="primary" size="large" @click="handleSave" :loading="saving">
|
||||
保存所有配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getConfigs, updateConfigsBatch } from '@/api/admin'
|
||||
import request from '@/api/request'
|
||||
|
||||
// 配置值
|
||||
const defaultModel = ref('flux-dev')
|
||||
const siliconflowKey = ref('')
|
||||
const siliconflowUrl = ref('https://api.siliconflow.cn/v1')
|
||||
const volcengineKey = ref('')
|
||||
const volcengineUrl = ref('https://ark.cn-beijing.volces.com/api/v3')
|
||||
const imageSize = ref('1024')
|
||||
const saving = ref(false)
|
||||
|
||||
// 测试状态
|
||||
const testingSiliconflow = ref(false)
|
||||
const testingVolcengine = ref(false)
|
||||
const siliconflowTestResult = ref<{ ok: boolean; msg: string } | null>(null)
|
||||
const volcengineTestResult = ref<{ ok: boolean; msg: string } | null>(null)
|
||||
|
||||
// 状态计算
|
||||
const siliconflowStatus = computed(() => siliconflowKey.value ? 'success' : 'info')
|
||||
const siliconflowStatusText = computed(() => siliconflowKey.value ? '已配置' : '未配置')
|
||||
const volcengineStatus = computed(() => volcengineKey.value ? 'success' : 'info')
|
||||
const volcengineStatusText = computed(() => volcengineKey.value ? '已配置' : '未配置')
|
||||
|
||||
// 加载配置
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const data = await getConfigs() as any
|
||||
const items: any[] = data.items || []
|
||||
const map: Record<string, string> = {}
|
||||
for (const item of items) {
|
||||
map[item.config_key] = item.config_value || ''
|
||||
}
|
||||
defaultModel.value = map['AI_IMAGE_MODEL'] || 'flux-dev'
|
||||
// 注意:API Key 是脱敏的(****),不回填到输入框
|
||||
// 只有完整值才回填
|
||||
if (map['SILICONFLOW_API_KEY'] && !map['SILICONFLOW_API_KEY'].includes('****')) {
|
||||
siliconflowKey.value = map['SILICONFLOW_API_KEY']
|
||||
}
|
||||
siliconflowUrl.value = map['SILICONFLOW_BASE_URL'] || 'https://api.siliconflow.cn/v1'
|
||||
if (map['VOLCENGINE_API_KEY'] && !map['VOLCENGINE_API_KEY'].includes('****')) {
|
||||
volcengineKey.value = map['VOLCENGINE_API_KEY']
|
||||
}
|
||||
volcengineUrl.value = map['VOLCENGINE_BASE_URL'] || 'https://ark.cn-beijing.volces.com/api/v3'
|
||||
imageSize.value = map['AI_IMAGE_SIZE'] || '1024'
|
||||
} catch (e) {
|
||||
console.error('加载配置失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换默认模型
|
||||
const setDefaultModel = (model: string) => {
|
||||
defaultModel.value = model
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const configs: Record<string, string> = {
|
||||
AI_IMAGE_MODEL: defaultModel.value,
|
||||
SILICONFLOW_BASE_URL: siliconflowUrl.value,
|
||||
VOLCENGINE_BASE_URL: volcengineUrl.value,
|
||||
AI_IMAGE_SIZE: imageSize.value,
|
||||
}
|
||||
// API Key 只有用户填写了才提交(留空不覆盖)
|
||||
if (siliconflowKey.value) {
|
||||
configs['SILICONFLOW_API_KEY'] = siliconflowKey.value
|
||||
}
|
||||
if (volcengineKey.value) {
|
||||
configs['VOLCENGINE_API_KEY'] = volcengineKey.value
|
||||
}
|
||||
await updateConfigsBatch(configs)
|
||||
ElMessage.success('配置已保存')
|
||||
await loadConfigs()
|
||||
} catch (e) {
|
||||
console.error('保存失败', e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 测试 SiliconFlow 连接
|
||||
const testSiliconflow = async () => {
|
||||
if (!siliconflowKey.value) {
|
||||
siliconflowTestResult.value = { ok: false, msg: '请先填写 API Key' }
|
||||
return
|
||||
}
|
||||
testingSiliconflow.value = true
|
||||
siliconflowTestResult.value = null
|
||||
try {
|
||||
const resp = await request.post('/admin/configs/test', {
|
||||
provider: 'siliconflow',
|
||||
api_key: siliconflowKey.value,
|
||||
base_url: siliconflowUrl.value,
|
||||
}) as any
|
||||
siliconflowTestResult.value = { ok: true, msg: resp.message || '连接成功' }
|
||||
} catch (e: any) {
|
||||
const msg = e.response?.data?.detail || '连接失败'
|
||||
siliconflowTestResult.value = { ok: false, msg }
|
||||
} finally {
|
||||
testingSiliconflow.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 测试火山引擎连接
|
||||
const testVolcengine = async () => {
|
||||
if (!volcengineKey.value) {
|
||||
volcengineTestResult.value = { ok: false, msg: '请先填写 API Key' }
|
||||
return
|
||||
}
|
||||
testingVolcengine.value = true
|
||||
volcengineTestResult.value = null
|
||||
try {
|
||||
const resp = await request.post('/admin/configs/test', {
|
||||
provider: 'volcengine',
|
||||
api_key: volcengineKey.value,
|
||||
base_url: volcengineUrl.value,
|
||||
}) as any
|
||||
volcengineTestResult.value = { ok: true, msg: resp.message || '连接成功' }
|
||||
} catch (e: any) {
|
||||
const msg = e.response?.data?.detail || '连接失败'
|
||||
volcengineTestResult.value = { ok: false, msg }
|
||||
} finally {
|
||||
testingVolcengine.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadConfigs)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.section-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1d1e2c;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.card-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 模型切换
|
||||
.model-switch {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.model-option {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
border: 2px solid #e8e8e8;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
border-color: #a8d5ba;
|
||||
box-shadow: 0 2px 12px rgba(91, 126, 107, 0.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #5B7E6B;
|
||||
background: linear-gradient(135deg, #f0f7f3 0%, #fff 100%);
|
||||
box-shadow: 0 4px 16px rgba(91, 126, 107, 0.15);
|
||||
|
||||
.model-badge {
|
||||
background: #5B7E6B;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.model-badge {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 16px;
|
||||
background: #ddd;
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.model-price {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #5B7E6B;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.model-tag {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
// 配置表单
|
||||
.config-form {
|
||||
margin-top: 16px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
// 测试结果
|
||||
.test-result {
|
||||
margin-left: 12px;
|
||||
font-size: 13px;
|
||||
|
||||
&.success { color: #67c23a; }
|
||||
&.fail { color: #f56c6c; }
|
||||
}
|
||||
|
||||
// 保存栏
|
||||
.save-bar {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
</style>
|
||||
75
frontend/src/views/admin/Dashboard.vue
Normal file
75
frontend/src/views/admin/Dashboard.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.total_users }}</div>
|
||||
<div class="stat-label">用户总数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.total_designs }}</div>
|
||||
<div class="stat-label">设计总数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.total_categories }}</div>
|
||||
<div class="stat-label">品类数量</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.today_designs }}</div>
|
||||
<div class="stat-label">今日新增设计</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.today_users }}</div>
|
||||
<div class="stat-label">今日新增用户</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getDashboard } from '@/api/admin'
|
||||
|
||||
const stats = ref({
|
||||
total_users: 0,
|
||||
total_designs: 0,
|
||||
total_categories: 0,
|
||||
today_designs: 0,
|
||||
today_users: 0,
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await getDashboard()
|
||||
stats.value = data as any
|
||||
} catch (e) {
|
||||
console.error('获取仪表盘数据失败', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
|
||||
.stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #5B7E6B;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
109
frontend/src/views/admin/DesignManage.vue
Normal file
109
frontend/src/views/admin/DesignManage.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="design-manage">
|
||||
<div class="page-actions">
|
||||
<el-select v-model="statusFilter" placeholder="状态筛选" clearable style="width: 140px" @change="loadDesigns">
|
||||
<el-option label="生成中" value="generating" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="失败" value="failed" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadDesigns">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="designs" stripe v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="username" label="用户" width="120">
|
||||
<template #default="{ row }">{{ row.username || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="category_name" label="品类" width="100" />
|
||||
<el-table-column prop="sub_type_name" label="子类型" width="100">
|
||||
<template #default="{ row }">{{ row.sub_type_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="color_name" label="颜色" width="100">
|
||||
<template #default="{ row }">{{ row.color_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="prompt" label="描述" show-overflow-tooltip />
|
||||
<el-table-column prop="image_url" label="图片" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-image v-if="row.image_url" :src="row.image_url" :preview-src-list="[row.image_url]"
|
||||
style="width: 40px; height: 40px; border-radius: 4px" fit="cover" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-if="total > pageSize"
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
style="margin-top: 20px; justify-content: center"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getAdminDesigns, adminDeleteDesign } from '@/api/admin'
|
||||
|
||||
const designs = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const statusFilter = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
const formatTime = (t: string) => t ? new Date(t).toLocaleString('zh-CN') : '-'
|
||||
const statusLabel = (s: string) => ({ generating: '生成中', completed: '已完成', failed: '失败' }[s] || s)
|
||||
const statusType = (s: string) => ({ generating: 'warning', completed: 'success', failed: 'danger' }[s] || 'info') as any
|
||||
|
||||
const loadDesigns = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getAdminDesigns({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
status: statusFilter.value || undefined
|
||||
}) as any
|
||||
designs.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handlePageChange = (p: number) => { page.value = p; loadDesigns() }
|
||||
|
||||
const handleDelete = async (design: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除设计 #${design.id}?`, '确认', { type: 'warning' })
|
||||
await adminDeleteDesign(design.id)
|
||||
ElMessage.success('设计已删除')
|
||||
await loadDesigns()
|
||||
} catch (e: any) { if (e !== 'cancel') console.error(e) }
|
||||
}
|
||||
|
||||
onMounted(loadDesigns)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-actions {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
363
frontend/src/views/admin/PromptManage.vue
Normal file
363
frontend/src/views/admin/PromptManage.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div class="prompt-manage">
|
||||
<!-- 提示词模板区 -->
|
||||
<el-card class="section-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>提示词模板</span>
|
||||
<el-tag type="info" size="small">修改后实时生效</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div v-loading="templateLoading">
|
||||
<div v-for="tpl in templates" :key="tpl.id" class="template-item">
|
||||
<div class="template-header">
|
||||
<el-tag>{{ tpl.template_key }}</el-tag>
|
||||
<span class="template-desc">{{ tpl.description }}</span>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="tpl.template_value"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 6 }"
|
||||
@blur="saveTemplate(tpl)"
|
||||
/>
|
||||
</div>
|
||||
<el-empty v-if="!templateLoading && templates.length === 0" description="暂无模板" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 映射管理区 -->
|
||||
<el-card class="section-card" shadow="never" style="margin-top: 16px">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>中英映射配置</span>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" size="small" @click="openAddMapping">新增映射</el-button>
|
||||
<el-button size="small" @click="openPreview">预览提示词</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 类型筛选标签 -->
|
||||
<div class="type-tabs">
|
||||
<el-tag
|
||||
v-for="t in mappingTypes"
|
||||
:key="t.type"
|
||||
:type="currentType === t.type ? '' : 'info'"
|
||||
:effect="currentType === t.type ? 'dark' : 'plain'"
|
||||
class="type-tag"
|
||||
@click="switchType(t.type)"
|
||||
>
|
||||
{{ t.label }} ({{ t.count }})
|
||||
</el-tag>
|
||||
<el-tag
|
||||
:type="!currentType ? '' : 'info'"
|
||||
:effect="!currentType ? 'dark' : 'plain'"
|
||||
class="type-tag"
|
||||
@click="switchType('')"
|
||||
>
|
||||
全部
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 映射表格 -->
|
||||
<el-table :data="mappings" v-loading="mappingLoading" stripe style="margin-top: 12px" max-height="500">
|
||||
<el-table-column prop="mapping_type" label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="info">{{ typeLabels[row.mapping_type] || row.mapping_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cn_key" label="中文" width="120" />
|
||||
<el-table-column prop="en_value" label="英文描述" show-overflow-tooltip />
|
||||
<el-table-column prop="sort_order" label="排序" width="70" />
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="openEditMapping(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除?" @confirm="handleDeleteMapping(row.id)">
|
||||
<template #reference>
|
||||
<el-button size="small" link type="danger">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增/编辑映射弹窗 -->
|
||||
<el-dialog v-model="mappingDialogVisible" :title="editingMapping ? '编辑映射' : '新增映射'" width="500px">
|
||||
<el-form :model="mappingForm" label-width="80px">
|
||||
<el-form-item label="映射类型">
|
||||
<el-select v-model="mappingForm.mapping_type" :disabled="!!editingMapping" style="width: 100%">
|
||||
<el-option v-for="t in allTypes" :key="t.value" :label="t.label" :value="t.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="中文">
|
||||
<el-input v-model="mappingForm.cn_key" placeholder="如:白玉" />
|
||||
</el-form-item>
|
||||
<el-form-item label="英文描述">
|
||||
<el-input v-model="mappingForm.en_value" type="textarea" :rows="3" placeholder="如:pure white nephrite jade..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="mappingForm.sort_order" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="mappingDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSaveMapping" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 预览弹窗 -->
|
||||
<el-dialog v-model="previewVisible" title="提示词预览" width="650px">
|
||||
<el-form :model="previewParams" label-width="80px" size="small">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="品类">
|
||||
<el-input v-model="previewParams.category_name" placeholder="牌子" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="视角">
|
||||
<el-input v-model="previewParams.view_name" placeholder="效果图" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="颜色">
|
||||
<el-input v-model="previewParams.color_name" placeholder="白玉" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="子类型">
|
||||
<el-input v-model="previewParams.sub_type_name" placeholder="平安扣" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="工艺">
|
||||
<el-input v-model="previewParams.carving_technique" placeholder="浮雕" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="风格">
|
||||
<el-input v-model="previewParams.design_style" placeholder="古典" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<el-button type="primary" size="small" @click="handlePreview" :loading="previewing" style="margin-bottom: 12px">
|
||||
生成预览
|
||||
</el-button>
|
||||
<div v-if="previewResult" class="preview-result">
|
||||
<div class="preview-label">生成的英文提示词:</div>
|
||||
<div class="preview-text">{{ previewResult }}</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getPromptTemplates, updatePromptTemplate,
|
||||
getPromptMappings, getMappingTypes,
|
||||
createPromptMapping, updatePromptMapping, deletePromptMapping,
|
||||
previewPrompt
|
||||
} from '@/api/admin'
|
||||
|
||||
interface Template {
|
||||
id: number
|
||||
template_key: string
|
||||
template_value: string
|
||||
description: string
|
||||
}
|
||||
interface MappingType {
|
||||
type: string
|
||||
count: number
|
||||
label: string
|
||||
}
|
||||
interface Mapping {
|
||||
id: number
|
||||
mapping_type: string
|
||||
cn_key: string
|
||||
en_value: string
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
category: '品类', color: '颜色', view: '视角',
|
||||
carving: '雕刻工艺', style: '设计风格', motif: '题材纹样',
|
||||
finish: '表面处理', scene: '用途场景', sub_type: '子类型'
|
||||
}
|
||||
|
||||
const allTypes = [
|
||||
{ value: 'category', label: '品类' },
|
||||
{ value: 'color', label: '颜色' },
|
||||
{ value: 'sub_type', label: '子类型' },
|
||||
{ value: 'view', label: '视角' },
|
||||
{ value: 'carving', label: '雕刻工艺' },
|
||||
{ value: 'style', label: '设计风格' },
|
||||
{ value: 'motif', label: '题材纹样' },
|
||||
{ value: 'finish', label: '表面处理' },
|
||||
{ value: 'scene', label: '用途场景' },
|
||||
]
|
||||
|
||||
// 模板
|
||||
const templates = ref<Template[]>([])
|
||||
const templateLoading = ref(false)
|
||||
|
||||
// 映射
|
||||
const mappingTypes = ref<MappingType[]>([])
|
||||
const mappings = ref<Mapping[]>([])
|
||||
const mappingLoading = ref(false)
|
||||
const currentType = ref('')
|
||||
|
||||
// 弹窗
|
||||
const mappingDialogVisible = ref(false)
|
||||
const editingMapping = ref<Mapping | null>(null)
|
||||
const mappingForm = ref({ mapping_type: 'category', cn_key: '', en_value: '', sort_order: 0 })
|
||||
const saving = ref(false)
|
||||
|
||||
// 预览
|
||||
const previewVisible = ref(false)
|
||||
const previewParams = ref<Record<string, string>>({ category_name: '牌子', view_name: '效果图' })
|
||||
const previewResult = ref('')
|
||||
const previewing = ref(false)
|
||||
|
||||
async function loadTemplates() {
|
||||
templateLoading.value = true
|
||||
try {
|
||||
const res = await getPromptTemplates()
|
||||
templates.value = res.data
|
||||
} catch { /* ignore */ } finally { templateLoading.value = false }
|
||||
}
|
||||
|
||||
async function saveTemplate(tpl: Template) {
|
||||
try {
|
||||
await updatePromptTemplate(tpl.id, { template_value: tpl.template_value, description: tpl.description })
|
||||
ElMessage.success('模板已保存')
|
||||
} catch { ElMessage.error('保存失败') }
|
||||
}
|
||||
|
||||
async function loadMappingTypes() {
|
||||
try {
|
||||
const res = await getMappingTypes()
|
||||
mappingTypes.value = res.data
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function loadMappings() {
|
||||
mappingLoading.value = true
|
||||
try {
|
||||
const res = await getPromptMappings(currentType.value || undefined)
|
||||
mappings.value = res.data
|
||||
} catch { /* ignore */ } finally { mappingLoading.value = false }
|
||||
}
|
||||
|
||||
function switchType(type: string) {
|
||||
currentType.value = type
|
||||
loadMappings()
|
||||
}
|
||||
|
||||
function openAddMapping() {
|
||||
editingMapping.value = null
|
||||
mappingForm.value = { mapping_type: currentType.value || 'category', cn_key: '', en_value: '', sort_order: 0 }
|
||||
mappingDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditMapping(row: Mapping) {
|
||||
editingMapping.value = row
|
||||
mappingForm.value = { mapping_type: row.mapping_type, cn_key: row.cn_key, en_value: row.en_value, sort_order: row.sort_order }
|
||||
mappingDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSaveMapping() {
|
||||
if (!mappingForm.value.cn_key || !mappingForm.value.en_value) {
|
||||
ElMessage.warning('请填写完整')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingMapping.value) {
|
||||
await updatePromptMapping(editingMapping.value.id, {
|
||||
cn_key: mappingForm.value.cn_key,
|
||||
en_value: mappingForm.value.en_value,
|
||||
sort_order: mappingForm.value.sort_order
|
||||
})
|
||||
} else {
|
||||
await createPromptMapping(mappingForm.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
mappingDialogVisible.value = false
|
||||
loadMappings()
|
||||
loadMappingTypes()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '保存失败')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function handleDeleteMapping(id: number) {
|
||||
try {
|
||||
await deletePromptMapping(id)
|
||||
ElMessage.success('已删除')
|
||||
loadMappings()
|
||||
loadMappingTypes()
|
||||
} catch { ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
function openPreview() {
|
||||
previewResult.value = ''
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
async function handlePreview() {
|
||||
previewing.value = true
|
||||
try {
|
||||
const res = await previewPrompt(previewParams.value)
|
||||
previewResult.value = res.data.prompt
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '预览失败')
|
||||
} finally { previewing.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTemplates()
|
||||
loadMappingTypes()
|
||||
loadMappings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.section-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
.template-item {
|
||||
margin-bottom: 16px;
|
||||
.template-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
.template-desc { color: #999; font-size: 13px; }
|
||||
}
|
||||
}
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
.type-tag { cursor: pointer; }
|
||||
}
|
||||
.preview-result {
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
.preview-label { color: #666; font-size: 13px; margin-bottom: 8px; }
|
||||
.preview-text { font-family: monospace; font-size: 13px; line-height: 1.6; word-break: break-all; color: #333; }
|
||||
}
|
||||
</style>
|
||||
129
frontend/src/views/admin/UserManage.vue
Normal file
129
frontend/src/views/admin/UserManage.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="user-manage">
|
||||
<div class="page-actions">
|
||||
<el-input v-model="keyword" placeholder="搜索用户名/昵称" clearable style="width: 260px" @keyup.enter="loadUsers" />
|
||||
<el-button type="primary" @click="loadUsers">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="users" stripe v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="username" label="用户名" width="150" />
|
||||
<el-table-column prop="nickname" label="昵称" width="150">
|
||||
<template #default="{ row }">{{ row.nickname || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phone" label="手机号" width="140">
|
||||
<template #default="{ row }">{{ row.phone || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="design_count" label="设计数" width="100" />
|
||||
<el-table-column prop="is_admin" label="管理员" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_admin ? 'success' : 'info'" size="small">
|
||||
{{ row.is_admin ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="注册时间" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" :type="row.is_admin ? 'warning' : 'primary'" @click="toggleAdmin(row)">
|
||||
{{ row.is_admin ? '取消管理员' : '设为管理员' }}
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-if="total > pageSize"
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
style="margin-top: 20px; justify-content: center"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getUsers, setUserAdmin, deleteUser } from '@/api/admin'
|
||||
|
||||
interface AdminUser {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string | null
|
||||
phone: string | null
|
||||
is_admin: boolean
|
||||
created_at: string
|
||||
design_count: number
|
||||
}
|
||||
|
||||
const users = ref<AdminUser[]>([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
const formatTime = (t: string) => {
|
||||
if (!t) return '-'
|
||||
return new Date(t).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const loadUsers = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getUsers({ page: page.value, page_size: pageSize.value, keyword: keyword.value || undefined }) as any
|
||||
users.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} catch (e) {
|
||||
console.error('加载用户列表失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageChange = (p: number) => {
|
||||
page.value = p
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
const toggleAdmin = async (user: AdminUser) => {
|
||||
try {
|
||||
const newStatus = !user.is_admin
|
||||
await ElMessageBox.confirm(
|
||||
`确定要${newStatus ? '设为管理员' : '取消管理员'}:${user.username}?`, '确认'
|
||||
)
|
||||
await setUserAdmin(user.id, newStatus)
|
||||
ElMessage.success('操作成功')
|
||||
await loadUsers()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (user: AdminUser) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除用户 ${user.username}?此操作不可恢复!`, '警告', { type: 'warning' })
|
||||
await deleteUser(user.id)
|
||||
ElMessage.success('用户已删除')
|
||||
await loadUsers()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-actions {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user