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:
2026-03-27 15:29:50 +08:00
parent e3ff55b4db
commit 032c43525a
41 changed files with 3756 additions and 81 deletions

View File

@@ -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
View 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)

View File

@@ -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

View 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">&#9881;</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>

View File

@@ -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; }
}
}
}

View File

@@ -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
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -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')
}
]
}
]
})

View File

@@ -8,6 +8,7 @@ export interface UserInfo {
nickname: string
phone?: string | null
avatar?: string | null
is_admin?: boolean
created_at?: string
}

View File

@@ -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) {

View 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>

View 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">字节跳动火山引擎文生图 APISeedream 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>

View 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>

View 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>

View 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>

View 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>