feat: NAS 部署与 Harbor 推送流程
Some checks failed
CI Check / check (push) Has been cancelled

This commit is contained in:
WuYongJie 2025-12-03 14:39:06 +08:00
parent 8705b9846a
commit d1c6efc79d
244 changed files with 19944 additions and 7017 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -188,10 +188,10 @@ powershell -ExecutionPolicy Bypass -File "d:\sucaiku\flnxi_sucai\scripts\deploy\
```
鎵ц杩滅▼鍛戒护: cd /root/youyidian-3d && docker compose down --timeout 30 || true
Container youyidian-3d-material Stopping
Container youyidian-3d-material Stopped
Container youyidian-3d-material Removing
Container youyidian-3d-material Removed
Container flnxi-material Stopping
Container flnxi-material Stopped
Container flnxi-material Removing
Container flnxi-material Removed
Network youyidian-3d_default Removing
Network youyidian-3d_default Removed
鉁?瀹屾垚
@ -320,10 +320,10 @@ powershell -ExecutionPolicy Bypass -File "d:\sucaiku\flnxi_sucai\scripts\deploy\
youyidian-3d-app Built
Network youyidian-3d_default Creating
Network youyidian-3d_default Created
Container youyidian-3d-material Creating
Container youyidian-3d-material Created
Container youyidian-3d-material Starting
Container youyidian-3d-material Started
Container flnxi-material Creating
Container flnxi-material Created
Container flnxi-material Starting
Container flnxi-material Started
鉁?瀹屾垚
鎵ц杩滅▼鍛戒护: sleep 20
@ -345,16 +345,16 @@ npm notice
鎵ц杩滅▼鍛戒护: cd /root/youyidian-3d && docker compose ps app || docker-compose ps app
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
youyidian-3d-material youyidian-3d-app "docker-entrypoint.s…" app About a minute ago Up About a minute 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp
flnxi-material youyidian-3d-app "docker-entrypoint.s…" app About a minute ago Up About a minute 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp
鉁?瀹屾垚
鎵ц杩滅▼鍛戒护: cd /root/youyidian-3d && docker compose logs app --tail=50 || docker-compose logs app --tail=50
youyidian-3d-material | ▲ Next.js 14.2.33
youyidian-3d-material | - Local: http://localhost:3000
youyidian-3d-material | - Network: http://0.0.0.0:3000
youyidian-3d-material |
youyidian-3d-material | ✓ Starting...
youyidian-3d-material | ✓ Ready in 210ms
flnxi-material | ▲ Next.js 14.2.33
flnxi-material | - Local: http://localhost:3000
flnxi-material | - Network: http://0.0.0.0:3000
flnxi-material |
flnxi-material | ✓ Starting...
flnxi-material | ✓ Ready in 210ms
鉁?瀹屾垚
@ -377,7 +377,7 @@ youyidian-3d-material | ✓ Ready in 210ms
3. **等待容器启动** - 等待 20 秒完成
4. **数据库迁移** - Prisma 数据库已与 schema 同步
5. **检查容器状态** - 容器正在运行
- 容器名:`youyidian-3d-material`
- 容器名:`flnxi-material`
- 端口映射:`0.0.0.0:4320->3000/tcp`
- 状态:运行中(约 1 分钟前启动)
6. **查看应用日志** - Next.js 应用已启动
@ -4576,3 +4576,4 @@ _**Agent (model default, mode Agent)**_
---

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,11 @@
我已经完成了对项目的深入研究,并编写了一份详细的优化分析与建议文档。文档包括:
1. 项目概述:明确了项目的最终目的和核心原则
2. 优化点分析:从性能、稳定性、部署友好性和可维护性四个方面进行了详细分析
3. 优化优先级排序:将优化点分为三个优先级,便于实施
4. 具体优化建议:包括数据库索引优化、全局错误处理机制、结构化日志系统、图片懒加载和Docker镜像优化等
5. 预期优化效果:列出了各项指标的预期改善情况
6. 优化实施计划:制定了详细的实施时间表
7. 监控与评估:说明了如何监控和评估优化效果
文档已经保存到根目录下,文件名为"项目优化分析与建议.md"。

View File

View File

@ -172,11 +172,13 @@ RUN if [ ! -d "node_modules/.prisma" ]; then \
else \
unset PRISMA_SCHEMA_ENGINE_BINARY; \
fi && \
npm run build || (echo "构建完成,但部分页面预渲染失败(这是预期的,这些页面将在运行时动态渲染)" && exit 0)
npm run build || (echo "构建完成,但部分页面预渲染失败(这是预期的,这些页面将在运行时动态渲染)" && true)
# 验证构建产物
# 验证构建产物(允许 ESLint 警告,但不允许致命错误)
RUN if [ ! -f ".next/standalone/server.js" ]; then \
echo "错误: standalone 输出未生成,请检查 next.config.js 中的 output: 'standalone'" >&2 && exit 1; \
echo "错误: standalone 输出未生成,请检查 next.config.js 中的 output: 'standalone'" >&2 && \
echo "提示: 如果构建过程中有 React Hooks 或其他编译错误,请先修复后再构建" >&2 && \
exit 1; \
fi
# 为可选模块创建占位符(确保 COPY 不会失败)
@ -217,7 +219,11 @@ RUN apk add --no-cache \
pixman \
# 字体支持(canvas 渲染中文时需要)
fontconfig \
ttf-dejavu
ttf-dejavu \
# FFmpeg(视频转码功能,可选)
# 如果不需要视频转码功能,可以注释掉以下两行以减小镜像大小(约节省 30-45MB)
ffmpeg \
ffmpeg-libs
# 创建非 root 用户
RUN addgroup --system --gid 1001 nodejs && \

View File

@ -1,4 +1,4 @@
# 由壹点3D素材
# FLNXI素材库
一个遵循苹果设计语言的 3D 素材协作平台,聚焦企业/团队的私有化部署场景,内置 AI 辅助、部门权限、模板化元数据与可移植部署工具链。
@ -190,4 +190,4 @@
## 📝 License
MIT
MIT

View File

@ -1,4 +1,4 @@
由壹点3D素材管理系统
FLNXI素材库管理系统
版本历史
============================================
@ -48,3 +48,4 @@ Version 1.0.0 (2024-10-26)
- [ ] 更多素材类型支持
- [ ] 协作功能增强

View File

@ -31,7 +31,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
const [siteName, setSiteName] = useState('由壹点3D素材')
const [siteName, setSiteName] = useState('FLNXI素材库')
const [headerLogoUrl, setHeaderLogoUrl] = useState('')
const [headerLogoPreview, setHeaderLogoPreview] = useState('')
const [loginLogoUrl, setLoginLogoUrl] = useState('')
@ -50,6 +50,9 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
const [enableRegistration, setEnableRegistration] = useState(true)
const [enableAiImageEdit, setEnableAiImageEdit] = useState(false)
const [enableTunnelService, setEnableTunnelService] = useState(false)
// 首页删除按钮:true = 仅管理员可见,false = 所有人可见
const [homeDeleteButtonAdminOnly, setHomeDeleteButtonAdminOnly] = useState(true)
const [premiumRatio, setPremiumRatio] = useState(10)
const [toast, setToast] = useState<ToastState | null>(null)
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
@ -150,7 +153,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
if (response.ok) {
const data = await response.json()
setHeaderBgColor(data.headerBgColor || '#1f2937')
setSiteName(data.siteName || '由壹点3D素材')
setSiteName(data.siteName || 'FLNXI素材库')
setHeaderLogoUrl(data.headerLogoUrl || '')
setHeaderLogoPreview(data.headerLogoUrl || '')
setLoginLogoUrl(data.loginLogoUrl || '')
@ -165,6 +168,8 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
setEnableRegistration(data.enableRegistration ?? true)
setEnableAiImageEdit(data.enableAiImageEdit ?? false)
setEnableTunnelService(data.enableTunnelService ?? false)
setHomeDeleteButtonAdminOnly((data.homeDeleteButtonMode ?? 'ADMIN_ONLY') !== 'ALL')
setPremiumRatio(typeof data.premiumRatio === 'number' ? data.premiumRatio : 10)
setLastSavedAt(data.updatedAt ? new Date(data.updatedAt) : null)
}
} catch (error) {
@ -184,6 +189,14 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
const handleSave = async () => {
setSaving(true)
try {
const numericRatio = Number(premiumRatio)
if (!Number.isFinite(numericRatio)) {
showToast({ type: 'error', message: '精品比例必须为数字' })
setSaving(false)
return
}
const normalizedRatio = Math.min(100, Math.max(0, Number(numericRatio.toFixed(2))))
const response = await fetch('/api/system-settings', {
method: 'PUT',
headers: {
@ -204,6 +217,8 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
enableRegistration,
enableAiImageEdit,
enableTunnelService,
homeDeleteButtonMode: homeDeleteButtonAdminOnly ? 'ADMIN_ONLY' : 'ALL',
premiumRatio: normalizedRatio,
}),
})
@ -399,17 +414,19 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
<div className="flex flex-wrap gap-3">
{variant === 'standalone' && (
<button
onClick={() => scrollToSection('brand')}
type="button"
onClick={fetchSettings}
disabled={saving}
className={cn(
'inline-flex items-center gap-2 rounded-xl border px-5 py-3 text-sm font-medium transition-all',
theme === 'dark'
? 'border-white/20 bg-white/5 text-white/80 hover:bg-white/10 hover:bg-white/20'
: 'border-slate-300/50 bg-slate-50/80 text-slate-700 hover:bg-slate-100/80'
? 'border-white/20 bg-white/5 text-white/80 hover:bg-white/10 hover:bg-white/20 disabled:opacity-60'
: 'border-slate-300/50 bg-slate-50/80 text-slate-700 hover:bg-slate-100/80 disabled:opacity-60'
)}
>
快速定位
重新拉取
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 5l7 7-7 7" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
)}
@ -545,7 +562,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
type="text"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="由壹点3D素材"
placeholder="FLNXI素材库"
className={styles.inputStyles.base}
/>
<p className={cn('text-xs', theme === 'dark' ? 'text-slate-500' : 'text-slate-400')}>将同步至后台导航、登录页和浏览器标题。</p>
@ -721,6 +738,13 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
onChange: setEnableRegistration,
disabled: false,
},
{
title: '首页删除按钮仅管理员可见',
desc: '开启后首页素材卡片的删除按钮仅管理员可见;关闭后所有用户都能看到(实际删除权限仍受角色控制)。',
value: homeDeleteButtonAdminOnly,
onChange: setHomeDeleteButtonAdminOnly,
disabled: false,
},
{
title: 'AI 改图能力(预留)',
desc: '控制素材详情页的 AI 改图入口(功能预留)。',
@ -764,6 +788,46 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
</label>
</div>
))}
<div
className={cn(
'rounded-2xl border px-4 py-2.5 flex items-center justify-between gap-4',
theme === 'dark' ? 'border-slate-700/80 bg-slate-800/60' : 'border-slate-200/80 bg-slate-50/80'
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-0.5">
<p className={cn('text-sm font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>
精品作品比例
</p>
<div className="flex items-center gap-2 flex-shrink-0">
<input
type="number"
min={0}
max={100}
step={0.5}
value={premiumRatio}
onChange={(event) => {
const value = Number(event.target.value)
setPremiumRatio(Number.isFinite(value) ? value : 0)
}}
disabled={!sessionUser?.isSuperAdmin}
className={cn(
'!w-16 text-center font-semibold text-sm px-2 py-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:border-indigo-500 dark:focus:border-indigo-400 focus:ring-2 focus:ring-indigo-200 dark:focus:ring-indigo-800 outline-none transition-all',
'[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
!sessionUser?.isSuperAdmin && 'opacity-60 cursor-not-allowed'
)}
/>
<span className={cn('text-sm font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>%</span>
</div>
</div>
<p className={cn('text-[11px] leading-tight', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
系统会根据素材总量 × 该比例取整作为允许加精的数量,默认 10%。举例:总素材 320 条且比例 10%,则加精上限为 ⌊320 × 10%⌋ = 32 条
</p>
{!sessionUser?.isSuperAdmin && (
<p className="text-[10px] text-amber-500/90 mt-0.5">仅超级管理员可修改</p>
)}
</div>
</div>
</div>
</div>
@ -864,41 +928,10 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
</div>
</div>
<div className={cn(styles.cardStyles.base, 'sticky bottom-4 backdrop-blur px-6 py-4 flex flex-wrap items-center gap-3 justify-end')}>
{toast && (
<span
className={`text-sm font-medium px-3 py-1 rounded-full ${
toast.type === 'success'
? 'bg-emerald-50 text-emerald-600 border border-emerald-200'
: 'bg-rose-50 text-rose-600 border border-rose-200'
}`}
>
{toast.message}
</span>
)}
<button
type="button"
onClick={fetchSettings}
disabled={saving}
className={cn(styles.buttonStyles.secondary, 'px-5 py-2.5 text-sm')}
>
重新拉取
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className={cn(
'px-5 py-2.5 rounded-xl bg-gradient-to-r from-indigo-500 to-purple-500 text-white text-sm font-semibold shadow-lg shadow-indigo-500/40 hover:shadow-xl transition-all duration-200',
'disabled:opacity-60 disabled:cursor-not-allowed'
)}
>
{saving ? '保存中...' : '保存全部修改'}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@ -17,14 +17,28 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback, useRef, useMemo, type CSSProperties } from 'react'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
import { useDialog } from '@/components/ui/DialogProvider'
import { cardAnimations, buttonAnimations, scrollAnimations, scrollToElementWithSpring } from '@/lib/ui-animations'
import {
DndContext,
DragEndEvent,
DragStartEvent,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DraggableAttributes,
} from '@dnd-kit/core'
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
interface TemplateCategory {
fieldKey: string
fieldLabel: string
fieldDefaultLabel: string
filterOrder: number
options: string[]
}
@ -40,10 +54,71 @@ interface CategoriesTabProps {
onDataChange?: () => void
}
const sanitizeOptionLabel = (value: string): string => {
let cleaned = value?.trim?.() ?? ''
if (!cleaned) return ''
cleaned = cleaned.replace(/["'`“”]/g, '').trim()
let safeGuard = 0
while (cleaned.startsWith('[[') && cleaned.endsWith(']]') && safeGuard < 5) {
const next = cleaned.slice(1, -1).trim()
if (!next || next === cleaned) {
break
}
cleaned = next
safeGuard += 1
}
if (
safeGuard > 0 &&
cleaned.startsWith('[') &&
cleaned.endsWith(']') &&
!/[,\n,]/.test(cleaned.slice(1, -1))
) {
cleaned = cleaned.slice(1, -1).trim()
}
return cleaned || value.trim()
}
const expandOptionSegments = (value: string): string[] => {
const trimmed = value?.trim?.() ?? ''
if (!trimmed) return []
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
const inner = trimmed.slice(1, -1).trim()
if (!inner) return []
return inner
.split(/[,,]/)
.map((segment) => sanitizeOptionLabel(segment))
.filter(Boolean)
}
const sanitized = sanitizeOptionLabel(trimmed)
return sanitized ? [sanitized] : []
}
const normalizeOptionArray = (values: string[]): string[] => {
const seen = new Set<string>()
const normalized: string[] = []
values.forEach((value) => {
const segments = expandOptionSegments(value)
segments.forEach((segment) => {
if (!segment || seen.has(segment)) {
return
}
seen.add(segment)
normalized.push(segment)
})
})
return normalized
}
export default function CategoriesTab({ showToast, onDataChange }: CategoriesTabProps) {
const styles = useThemeStyles()
const theme = useTheme()
const { confirm } = useDialog()
const { confirm, prompt } = useDialog()
const [templateCategories, setTemplateCategories] = useState<TemplateCategory[]>([])
const [selectedFieldKey, setSelectedFieldKey] = useState<string | null>(null)
const [editingOptions, setEditingOptions] = useState<Map<string, string[]>>(new Map())
@ -54,16 +129,58 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
const [categoryFieldLoading, setCategoryFieldLoading] = useState(false)
const [savingFieldKey, setSavingFieldKey] = useState<string | null>(null)
const [isEditingFields, setIsEditingFields] = useState(false)
const [categoryOrder, setCategoryOrder] = useState<string[]>([])
const [categoryLabelDrafts, setCategoryLabelDrafts] = useState<Map<string, string>>(new Map())
const [categoryDefaultLabels, setCategoryDefaultLabels] = useState<Map<string, string>>(new Map())
const [categoryConfigSaving, setCategoryConfigSaving] = useState(false)
const categoryRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const shouldScrollRef = useRef<boolean>(false) // 控制是否应该滚动,只有用户点击左侧按钮时才滚动
const reorderSensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 6,
},
}),
)
const textTitle = theme === 'dark' ? 'text-white' : 'text-slate-900'
const textDesc = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
const textLabel = theme === 'dark' ? 'text-slate-200' : 'text-slate-700'
const textHint = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
const enabledFieldMeta = categoryFieldOptions.filter(field => field.enabled)
const disabledFieldMeta = categoryFieldOptions.filter(field => !field.enabled)
const enabledFieldMeta = categoryFieldOptions.filter((field) => field.enabled)
const disabledFieldMeta = categoryFieldOptions.filter((field) => !field.enabled)
const categoryMap = useMemo(() => {
const map = new Map<string, TemplateCategory>()
templateCategories.forEach((cat) => {
map.set(cat.fieldKey, cat)
})
return map
}, [templateCategories])
const orderedCategories = useMemo(() => {
if (!categoryOrder.length) {
return templateCategories
}
const ordered = categoryOrder.map((key) => categoryMap.get(key)).filter((cat): cat is TemplateCategory => !!cat)
const missing = templateCategories.filter((cat) => !categoryOrder.includes(cat.fieldKey))
return [...ordered, ...missing]
}, [categoryMap, categoryOrder, templateCategories])
const enabledFieldMap = useMemo(() => {
const map = new Map<string, CategoryFieldMeta>()
enabledFieldMeta.forEach((field) => {
map.set(field.key, field)
})
return map
}, [enabledFieldMeta])
const orderedEnabledFields = useMemo(() => {
const ordered = categoryOrder
.map((key) => enabledFieldMap.get(key))
.filter((field): field is CategoryFieldMeta => !!field)
const remaining = enabledFieldMeta.filter((field) => !categoryOrder.includes(field.key))
return [...ordered, ...remaining]
}, [categoryOrder, enabledFieldMeta, enabledFieldMap])
const fetchData = useCallback(async () => {
try {
@ -72,10 +189,38 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
if (response.ok) {
const data = await response.json()
console.log('[CategoriesTab] Received template categories:', data)
setTemplateCategories(data)
const sortedData = [...data].sort(
(a: TemplateCategory, b: TemplateCategory) => (a.filterOrder ?? 0) - (b.filterOrder ?? 0),
)
const sanitizedCategories = sortedData.map((cat: TemplateCategory) => ({
...cat,
options: normalizeOptionArray(cat.options),
}))
setTemplateCategories(sanitizedCategories)
setCategoryOrder((prev) => {
if (!prev || !prev.length) {
return sanitizedCategories.map((cat) => cat.fieldKey)
}
const validKeys = new Set(sanitizedCategories.map((cat) => cat.fieldKey))
const preserved = prev.filter((key) => validKeys.has(key))
sanitizedCategories.forEach((cat) => {
if (!preserved.includes(cat.fieldKey)) {
preserved.push(cat.fieldKey)
}
})
return preserved
})
setCategoryLabelDrafts((prev) => {
const next = new Map<string, string>()
sanitizedCategories.forEach((cat) => {
next.set(cat.fieldKey, prev.get(cat.fieldKey) ?? cat.fieldLabel)
})
return next
})
setCategoryDefaultLabels(new Map(sanitizedCategories.map((cat) => [cat.fieldKey, cat.fieldDefaultLabel || cat.fieldLabel])))
// 初始化编辑状态
const editingMap = new Map<string, string[]>()
data.forEach((cat: TemplateCategory) => {
sanitizedCategories.forEach((cat: TemplateCategory) => {
editingMap.set(cat.fieldKey, [...cat.options])
})
setEditingOptions(editingMap)
@ -123,6 +268,32 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
fetchCategoryFieldOptions()
}, [fetchCategoryFieldOptions])
const handleCategoryLabelChange = useCallback((fieldKey: string, value: string) => {
setCategoryLabelDrafts((prev) => {
const next = new Map(prev)
next.set(fieldKey, value)
return next
})
}, [])
const handleCategoryDragStart = useCallback((_event: DragStartEvent) => {
// reserved for future visual feedback
}, [])
const handleCategoryDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
setCategoryOrder((order) => {
const oldIndex = order.indexOf(String(active.id))
const newIndex = order.indexOf(String(over.id))
if (oldIndex === -1 || newIndex === -1) return order
return arrayMove(order, oldIndex, newIndex)
})
},
[],
)
// 滚动到指定分类的函数(使用苹果风格的缓入缓出滚动,页面级滚动)
const scrollToCategory = useCallback((fieldKey: string) => {
const categoryElement = categoryRefs.current.get(fieldKey)
@ -140,6 +311,35 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
})
}, [])
const handleFieldRenamePrompt = useCallback(async (fieldKey: string) => {
const currentLabel =
(categoryLabelDrafts.get(fieldKey) ??
categoryDefaultLabels.get(fieldKey) ??
templateCategories.find((cat) => cat.fieldKey === fieldKey)?.fieldLabel ??
fieldKey) || ''
const result = await prompt({
title: '重命名分类',
message: '请输入该分类的新显示名称',
defaultValue: currentLabel,
placeholder: '请输入显示名称',
confirmText: '保存',
cancelText: '取消',
})
if (result === null) {
return
}
const trimmed = result.trim()
if (!trimmed) {
showToast('分类名称不能为空', 'error')
return
}
handleCategoryLabelChange(fieldKey, trimmed)
}, [categoryDefaultLabels, categoryLabelDrafts, handleCategoryLabelChange, prompt, showToast, templateCategories])
// 当选中字段变化时,只有在用户点击左侧按钮时才自动滚动到对应的卡片
useEffect(() => {
if (!selectedFieldKey || !shouldScrollRef.current) {
@ -166,7 +366,8 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
}, [selectedFieldKey, scrollToCategory])
const handleUpdateOptions = async (fieldKey: string, options: string[]) => {
if (options.length === 0) {
const normalizedOptions = normalizeOptionArray(options)
if (normalizedOptions.length === 0) {
showToast('选项不能为空', 'error')
return
}
@ -177,7 +378,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ options }),
body: JSON.stringify({ options: normalizedOptions }),
})
if (response.ok) {
@ -194,21 +395,64 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
}
}
const handleSaveCategoryConfig = useCallback(async () => {
if (!categoryOrder.length) {
setIsEditingFields(false)
return
}
try {
setCategoryConfigSaving(true)
const updates = categoryOrder.map((fieldKey, index) => {
const labelDraft = (categoryLabelDrafts.get(fieldKey) ?? '').trim()
const defaultLabel = (categoryDefaultLabels.get(fieldKey) ?? '').trim()
if (!labelDraft) {
throw new Error('分类名称不能为空')
}
return {
fieldKey,
filterOrder: index,
filterDisplayName: labelDraft === (defaultLabel || labelDraft) ? null : labelDraft,
}
})
const response = await fetch('/api/category-fields', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ updates }),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || '保存失败')
}
showToast('分类字段已保存')
setIsEditingFields(false)
await Promise.all([fetchCategoryFieldOptions(), fetchData()])
onDataChange?.()
} catch (error: any) {
console.error('[CategoriesTab] Failed to save category config:', error)
showToast(error?.message || '保存失败', 'error')
} finally {
setCategoryConfigSaving(false)
}
}, [categoryDefaultLabels, categoryLabelDrafts, categoryOrder, fetchCategoryFieldOptions, fetchData, onDataChange, showToast])
const handleAddOption = async (fieldKey: string) => {
const currentOptions = editingOptions.get(fieldKey) || []
const newValue = newOptionValue.get(fieldKey)?.trim() || ''
const safeValue = sanitizeOptionLabel(newValue)
if (!newValue) {
if (!safeValue) {
showToast('选项不能为空', 'error')
return
}
if (currentOptions.includes(newValue)) {
if (currentOptions.includes(safeValue)) {
showToast('选项已存在', 'error')
return
}
const updatedOptions = [...currentOptions, newValue]
const updatedOptions = normalizeOptionArray([...currentOptions, safeValue])
setEditingOptions(new Map(editingOptions).set(fieldKey, updatedOptions))
setNewOptionValue(new Map(newOptionValue).set(fieldKey, ''))
setAddingOption(new Map(addingOption).set(fieldKey, false))
@ -233,24 +477,25 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
setRenamingOption(new Map(renamingOption).set(`${fieldKey}-${oldValue}`, {
fieldKey,
oldValue,
newValue: oldValue,
newValue: sanitizeOptionLabel(oldValue),
}))
}
const handleRenameOption = async (fieldKey: string, oldValue: string, newValue: string) => {
const trimmed = newValue.trim()
const trimmed = sanitizeOptionLabel(newValue)
if (!trimmed) {
showToast('选项不能为空', 'error')
return
}
const currentOptions = editingOptions.get(fieldKey) || []
if (trimmed !== oldValue && currentOptions.includes(trimmed)) {
const originalValue = sanitizeOptionLabel(oldValue)
if (trimmed !== originalValue && currentOptions.includes(trimmed)) {
showToast('选项已存在', 'error')
return
}
const updatedOptions = currentOptions.map(opt => opt === oldValue ? trimmed : opt)
const updatedOptions = currentOptions.map(opt => opt === originalValue ? trimmed : opt)
setEditingOptions(new Map(editingOptions).set(fieldKey, updatedOptions))
setRenamingOption(new Map(renamingOption))
await handleUpdateOptions(fieldKey, updatedOptions)
@ -329,7 +574,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
{/* 按钮移动到底部,顶部仅保留标题 */}
<div className="flex-1 overflow-y-auto min-h-0 space-y-3 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{!isEditingFields &&
templateCategories.map((category) => {
orderedCategories.map((category) => {
const isSelected = selectedFieldKey === category.fieldKey
return (
<button
@ -373,42 +618,94 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
<>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-indigo-500 mb-2">已启用字段</p>
<div className="space-y-2">
{enabledFieldMeta.length === 0 && (
<div className={cn('text-xs text-slate-500 dark:text-slate-400')}>
暂无启用字段
</div>
)}
{enabledFieldMeta.map((field) => (
<div
key={`${field.key}-enabled`}
className="rounded-2xl border border-slate-200 dark:border-slate-700 px-4 py-3 flex items-center justify-between gap-3"
{orderedEnabledFields.length === 0 ? (
<div className={cn('text-xs text-slate-500 dark:text-slate-400')}>暂无启用字段</div>
) : (
<DndContext
sensors={reorderSensors}
collisionDetection={closestCenter}
onDragStart={handleCategoryDragStart}
onDragEnd={handleCategoryDragEnd}
>
<SortableContext
items={orderedEnabledFields.map((field) => field.key)}
strategy={verticalListSortingStrategy}
>
<div>
<p className="text-sm font-semibold text-slate-900 dark:text-white">{field.label}</p>
<p className="text-xs text-slate-400 dark:text-slate-500">
{field.key} · {field.source === 'standard' ? '标准字段' : '模板字段'}
</p>
<div className="space-y-3">
{orderedEnabledFields.map((field) => {
const displayLabel =
(categoryLabelDrafts.get(field.key) ?? field.label)?.trim() ||
categoryDefaultLabels.get(field.key) ||
field.label
return (
<SortableItemWrapper
key={field.key}
id={field.key}
disabled={false}
className="w-full"
>
{({ setNodeRef, attributes, listeners, isDragging }) => (
<div
ref={setNodeRef}
className={cn(
'rounded-2xl border border-slate-200 dark:border-slate-700 px-4 py-3 flex items-center gap-3 bg-white/90 dark:bg-slate-900/40',
isDragging ? 'ring-2 ring-indigo-200 shadow-xl' : '',
)}
>
<button
type="button"
className="w-8 h-8 flex items-center justify-center rounded-full border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-indigo-500 bg-white/80 dark:bg-slate-900/60 cursor-grab active:cursor-grabbing"
aria-label="拖动排序"
title="拖动排序"
{...attributes}
{...listeners}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M7 5a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zM7 10a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zM7 15a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0z" />
</svg>
</button>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-slate-900 dark:text-white truncate">
{displayLabel}
</p>
<p className="text-xs text-slate-400 dark:text-slate-500">
{field.key} · {field.source === 'standard' ? '标准字段' : '模板字段'}
</p>
</div>
<button
type="button"
className="w-8 h-8 rounded-full border border-slate-200 dark:border-slate-700 bg-white text-slate-500 flex items-center justify-center hover:text-indigo-500 transition"
title="重命名"
onClick={() => void handleFieldRenamePrompt(field.key)}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
<path d="M5 6a3 3 0 00-3 3v7a1 1 0 001 1h7a3 3 0 003-3v-1h-2v1a1 1 0 01-1 1H5z" />
</svg>
</button>
<button
type="button"
className={cn(styles.buttonStyles.danger, styles.buttonModifiers.compactXs)}
disabled={savingFieldKey === field.key}
onClick={() => handleToggleCategoryField(field.key, false)}
>
{savingFieldKey === field.key ? '处理中…' : '禁用'}
</button>
</div>
)}
</SortableItemWrapper>
)
})}
</div>
<button
type="button"
className={cn(styles.buttonStyles.danger, styles.buttonModifiers.compactXs)}
disabled={savingFieldKey === field.key}
onClick={() => handleToggleCategoryField(field.key, false)}
>
{savingFieldKey === field.key ? '处理中…' : '移除'}
</button>
</div>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-400 mb-2">可添加字段</p>
<div className="space-y-2">
{disabledFieldMeta.length === 0 && (
<div className={cn('text-xs text-slate-500 dark:text-slate-400')}>
暂无可添加字段
</div>
<div className={cn('text-xs text-slate-500 dark:text-slate-400')}>暂无可添加字段</div>
)}
{disabledFieldMeta.map((field) => (
<div
@ -427,7 +724,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
disabled={savingFieldKey === field.key}
onClick={() => handleToggleCategoryField(field.key, true)}
>
{savingFieldKey === field.key ? '处理中…' : '添加'}
{savingFieldKey === field.key ? '处理中…' : '启用'}
</button>
</div>
))}
@ -442,8 +739,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
className={cn(styles.buttonStyles.secondary, 'flex-1')}
onClick={async () => {
if (isEditingFields) {
setIsEditingFields(false)
await Promise.all([fetchCategoryFieldOptions(), fetchData()])
await handleSaveCategoryConfig()
} else {
if (categoryFieldOptions.length === 0) {
await fetchCategoryFieldOptions()
@ -451,9 +747,9 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
setIsEditingFields(true)
}
}}
disabled={categoryFieldLoading}
disabled={categoryFieldLoading || categoryConfigSaving}
>
{isEditingFields ? '保存' : '编辑'}
{isEditingFields ? (categoryConfigSaving ? '保存中…' : '保存') : '编辑'}
</button>
<button
type="button"
@ -464,7 +760,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
}
await Promise.all([fetchCategoryFieldOptions(), fetchData()])
}}
disabled={categoryFieldLoading}
disabled={categoryFieldLoading || categoryConfigSaving}
>
{isEditingFields ? '取消' : '刷新'}
</button>
@ -475,16 +771,19 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
{/* 绿色框框:右侧选项卡片列表(页面级滚动,显示所有字段) */}
<div className="flex-1 min-w-0">
<div className="space-y-6">
{templateCategories.map((category) => {
{orderedCategories.map((category) => {
const rawOptions = editingOptions.get(category.fieldKey) || category.options
// 去重:使用 Set 去除重复选项,保持顺序
const categoryOptions = Array.from(new Set(rawOptions))
const categoryOptions = normalizeOptionArray(rawOptions)
const isAddingCategory = addingOption.get(category.fieldKey) || false
const newValueCategory = newOptionValue.get(category.fieldKey) || ''
const displayLabel =
(categoryLabelDrafts.get(category.fieldKey) ?? category.fieldLabel)?.trim() ||
category.fieldDefaultLabel ||
category.fieldLabel
return (
<div
key={category.fieldKey}
<div
key={category.fieldKey}
ref={(el) => {
if (el) {
categoryRefs.current.set(category.fieldKey, el)
@ -495,10 +794,9 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
className={cn(styles.cardStyles.base, 'space-y-4')}
>
<div className="pb-2 border-b border-slate-200 dark:border-slate-800">
<h3 className={cn('text-base font-semibold', textTitle)}>{category.fieldLabel}</h3>
<h3 className={cn('text-base font-semibold', textTitle)}>{displayLabel}</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{/* 选项卡片 */}
{categoryOptions.map((option, index) => {
const renameKey = `${category.fieldKey}-${option}`
const isRenaming = renamingOption.has(renameKey)
@ -509,7 +807,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
key={`${category.fieldKey}-${option}-${index}`}
className={cn(
styles.cardStyles.flat,
'p-2 space-y-1.5 hover:shadow-md transition-all duration-300 hover:-translate-y-0.5'
'p-2 space-y-1.5 hover:shadow-md transition-all duration-300 hover:-translate-y-0.5',
)}
>
{isRenaming ? (
@ -518,11 +816,13 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
type="text"
value={renameData?.newValue || option}
onChange={(e) => {
setRenamingOption(new Map(renamingOption).set(renameKey, {
fieldKey: category.fieldKey,
oldValue: option,
newValue: e.target.value,
}))
setRenamingOption(
new Map(renamingOption).set(renameKey, {
fieldKey: category.fieldKey,
oldValue: option,
newValue: e.target.value,
}),
)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
@ -531,7 +831,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
handleCancelRename()
}
}}
className={cn(styles.inputStyles.base, 'w-full text-sm max-w-xs')} // 限制最大宽度,避免输入框太宽
className={cn(styles.inputStyles.base, 'w-full text-sm max-w-xs')}
autoFocus
/>
<div className="flex gap-2">
@ -566,7 +866,6 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
styles.buttonModifiers.compactXs,
'flex-1',
buttonAnimations.base,
// 统一UI样式已包含颜色,不需要额外添加
)}
>
删除
@ -578,7 +877,6 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
)
})}
{/* 新增卡片 */}
{isAddingCategory ? (
<div className={cn(styles.cardStyles.flat, 'p-2 space-y-1.5')}>
<input
@ -629,7 +927,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
cardAnimations.lift,
theme === 'dark'
? 'border-slate-600/50 text-slate-400 hover:border-slate-500/50 hover:text-slate-300 hover:bg-slate-800/30'
: 'border-slate-300/50 text-slate-500 hover:border-slate-400/50 hover:text-slate-600 hover:bg-slate-50/50'
: 'border-slate-300/50 text-slate-500 hover:border-slate-400/50 hover:text-slate-600 hover:bg-slate-50/50',
)}
>
<svg className="w-5 h-5 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -642,11 +940,11 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
</div>
)
})}
{templateCategories.length === 0 && (
{orderedCategories.length === 0 && (
<div className={cn(styles.cardStyles.base, 'flex items-center justify-center py-12')}>
<div className="text-center">
<p className={cn('text-sm', textDesc)}>
暂无模板分类,请在模板管理中为目标字段勾选&ldquo;添加到分类&rdquo;
暂无模板分类,请在模板管理中为目标字段勾选&ldquo;添加到分类&rdquo;
</p>
</div>
</div>
@ -657,3 +955,34 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
</div>
)
}
type SortableItemWrapperProps = {
id: string
disabled: boolean
className?: string
children: (props: {
setNodeRef: (element: HTMLElement | null) => void
attributes: DraggableAttributes
listeners: Record<string, unknown> | undefined
isDragging: boolean
}) => JSX.Element
}
function SortableItemWrapper({ id, disabled, className, children }: SortableItemWrapperProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
disabled,
})
const style: CSSProperties = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
transition,
zIndex: isDragging ? 40 : undefined,
}
return (
<div style={style} className={className}>
{children({ setNodeRef, attributes, listeners, isDragging })}
</div>
)
}

View File

@ -15,6 +15,7 @@
import { useState, useEffect, useCallback } from 'react'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
import { useDialog } from '@/components/ui/DialogProvider'
import Modal from '@/components/ui/Modal'
import { formatDateTimeLocale } from '../_utils/formatters'
import type { Invitation, ToastFn } from './types'
@ -278,67 +279,74 @@ export default function InvitationsTab({ showToast }: InvitationsTabProps) {
</div>
{/* Create Invitation Modal */}
{showCreateInvitationModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className={cn(styles.cardStyles.base, 'w-full max-w-md')}>
<h3 className={cn('text-lg font-semibold mb-4', textTitle)}>
创建邀请链接
</h3>
<div className="space-y-4">
<div>
<label className={cn('block text-sm font-medium mb-2', textLabel)}>
最大使用次数
</label>
<input
type="number"
min="1"
value={newInvitationMaxUses}
onChange={(e) => setNewInvitationMaxUses(parseInt(e.target.value) || 1)}
className={cn(styles.inputStyles.base)}
placeholder="请输入最大使用次数"
/>
<p className={cn('text-xs mt-1', textDesc)}>
邀请链接最多可以被使用多少次
</p>
</div>
<div>
<label className={cn('block text-sm font-medium mb-2', textLabel)}>
过期天数(可选)
</label>
<input
type="number"
min="1"
value={newInvitationExpiresInDays || ''}
onChange={(e) => setNewInvitationExpiresInDays(e.target.value ? parseInt(e.target.value) : null)}
className={cn(styles.inputStyles.base)}
placeholder="留空表示永不过期"
/>
<p className={cn('text-xs mt-1', textDesc)}>
邀请链接在多少天后过期,留空表示永不过期
</p>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => {
setShowCreateInvitationModal(false)
setNewInvitationMaxUses(1)
setNewInvitationExpiresInDays(null)
}}
className={cn(styles.buttonStyles.secondary)}
>
取消
</button>
<button
onClick={handleCreateInvitation}
className={cn(styles.buttonStyles.primary)}
>
创建
</button>
</div>
</div>
<Modal
isOpen={showCreateInvitationModal}
onClose={() => {
setShowCreateInvitationModal(false)
setNewInvitationMaxUses(1)
setNewInvitationExpiresInDays(null)
}}
title="创建邀请链接"
size="small"
footer={
<div className="flex gap-3 justify-end">
<button
onClick={() => {
setShowCreateInvitationModal(false)
setNewInvitationMaxUses(1)
setNewInvitationExpiresInDays(null)
}}
className={cn(
styles.buttonStyles.secondary,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200'
)}
>
取消
</button>
<button
onClick={handleCreateInvitation}
className={cn(styles.buttonStyles.primary)}
>
创建
</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className={cn('block text-sm font-medium mb-2', textLabel)}>
最大使用次数
</label>
<input
type="number"
min="1"
value={newInvitationMaxUses}
onChange={(e) => setNewInvitationMaxUses(parseInt(e.target.value) || 1)}
className={cn(styles.inputStyles.base)}
placeholder="请输入最大使用次数"
/>
<p className={cn('text-xs mt-1', textDesc)}>
邀请链接最多可以被使用多少次
</p>
</div>
<div>
<label className={cn('block text-sm font-medium mb-2', textLabel)}>
过期天数(可选)
</label>
<input
type="number"
min="1"
value={newInvitationExpiresInDays || ''}
onChange={(e) => setNewInvitationExpiresInDays(e.target.value ? parseInt(e.target.value) : null)}
className={cn(styles.inputStyles.base)}
placeholder="留空表示永不过期"
/>
<p className={cn('text-xs mt-1', textDesc)}>
邀请链接在多少天后过期,留空表示永不过期
</p>
</div>
</div>
)}
</Modal>
</>
)
}

View File

@ -12,65 +12,175 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import Image from 'next/image'
import EditMaterialModal from '@/components/EditMaterialModal'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
import { useDialog } from '@/components/ui/DialogProvider'
import type { Material, ToastFn } from './types'
import type { Material, ToastFn, User } from './types'
import LoadingState from './ui/LoadingState'
const PAGE_SIZE = 40
interface MaterialsTabProps {
showToast: ToastFn
}
export default function MaterialsTab({ showToast }: MaterialsTabProps) {
const styles = useThemeStyles()
const { confirm } = useDialog()
const { confirm, prompt } = useDialog()
const theme = useTheme()
const [materials, setMaterials] = useState<Material[]>([])
const [loading, setLoading] = useState(true)
const [editingMaterial, setEditingMaterial] = useState<Material | null>(null)
const [materialSearchTerm, setMaterialSearchTerm] = useState('')
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('')
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showDeleted, setShowDeleted] = useState(false) // 是否显示回收站
const [currentPage, setCurrentPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const loadMoreRef = useRef<HTMLDivElement | null>(null)
const [isUserSelectOpen, setIsUserSelectOpen] = useState(false)
const [userSearchTerm, setUserSearchTerm] = useState('')
const [userSearchLoading, setUserSearchLoading] = useState(false)
const [userSearchResults, setUserSearchResults] = useState<User[]>([])
const [selectedTargetUser, setSelectedTargetUser] = useState<User | null>(null)
const fetchMaterials = useCallback(async () => {
try {
setLoading(true)
const response = await fetch('/api/materials?pageSize=1000')
if (response.ok) {
const data = await response.json()
setMaterials(data.materials || data)
} else {
showToast('获取素材列表失败', 'error')
const handleSearchUsers = useCallback(
async (keyword: string) => {
const trimmed = keyword.trim()
setUserSearchTerm(keyword)
if (!trimmed) {
setUserSearchResults([])
return
}
} catch (error) {
console.error('Error fetching materials:', error)
showToast('获取素材列表失败', 'error')
} finally {
setLoading(false)
}
}, [showToast])
setUserSearchLoading(true)
try {
const params = new URLSearchParams()
params.set('pageSize', '20')
params.set('search', trimmed)
const response = await fetch(`/api/admin/users?${params.toString()}`)
if (!response.ok) {
showToast('搜索用户失败', 'error')
return
}
const data = await response.json()
const users = Array.isArray(data.users) ? (data.users as User[]) : []
setUserSearchResults(users)
} catch (error) {
console.error('Search users error:', error)
showToast('搜索用户失败', 'error')
} finally {
setUserSearchLoading(false)
}
},
[showToast],
)
useEffect(() => {
fetchMaterials()
const handler = setTimeout(() => {
setDebouncedSearchTerm(materialSearchTerm.trim())
}, 300)
return () => clearTimeout(handler)
}, [materialSearchTerm])
const fetchMaterials = useCallback(
async (page = 1, append = false) => {
try {
if (append) {
setIsLoadingMore(true)
} else {
setLoading(true)
setHasMore(true)
}
const params = new URLSearchParams()
params.set('page', page.toString())
params.set('pageSize', PAGE_SIZE.toString())
if (showDeleted) {
params.set('showDeleted', 'true')
}
if (debouncedSearchTerm) {
params.set('search', debouncedSearchTerm)
}
const response = await fetch(`/api/materials?${params.toString()}`)
if (response.ok) {
const data = await response.json()
const fetchedMaterials = (Array.isArray(data.materials)
? data.materials
: Array.isArray(data)
? data
: []) as Material[]
if (append) {
setMaterials((prev) => {
const existingIds = new Set(prev.map((item) => item.id))
const deduped = fetchedMaterials.filter((item) => !existingIds.has(item.id))
if (deduped.length === 0) return prev
return [...prev, ...deduped]
})
} else {
setMaterials(fetchedMaterials)
}
const appendedCount = fetchedMaterials.length
const totalPages = typeof data?.totalPages === 'number' ? data.totalPages : undefined
setCurrentPage(page)
if (totalPages) {
setHasMore(page < totalPages)
} else {
setHasMore(appendedCount === PAGE_SIZE)
}
return appendedCount
}
showToast('获取素材列表失败', 'error')
} catch (error) {
console.error('Error fetching materials:', error)
showToast('获取素材列表失败', 'error')
} finally {
if (append) {
setIsLoadingMore(false)
} else {
setLoading(false)
}
}
setHasMore(false)
return 0
},
[debouncedSearchTerm, showDeleted, showToast]
)
useEffect(() => {
fetchMaterials(1, false)
}, [fetchMaterials])
const handleDeleteMaterial = async (id: string) => {
const handleDeleteMaterial = async (id: string, permanent = false) => {
const confirmed = await confirm({
title: '确认删除',
message: '确定要删除这个素材吗?',
title: permanent ? '确认永久删除' : '确认删除',
message: permanent
? '确定要永久删除这个素材吗?此操作不可恢复!'
: '确定要删除这个素材吗?素材将移至回收站,7天后自动清除。',
variant: 'danger',
})
if (!confirmed) return
try {
const response = await fetch(`/api/materials?id=${id}`, {
const url = permanent
? `/api/materials?id=${id}&permanent=true`
: `/api/materials?id=${id}`
const response = await fetch(url, {
method: 'DELETE',
})
if (response.ok) {
setMaterials(prevMaterials => prevMaterials.filter(m => m.id !== id))
showToast('删除成功')
showToast(permanent ? '永久删除成功' : '删除成功')
fetchMaterials()
} else {
showToast('删除失败', 'error')
}
@ -80,20 +190,216 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) {
}
}
const filteredMaterials = useMemo(() => {
if (!materialSearchTerm.trim()) return materials
const term = materialSearchTerm.toLowerCase()
return materials.filter((material) => {
return (
material.productModel?.toLowerCase().includes(term) ||
material.brandName?.toLowerCase().includes(term) ||
material.categoryName?.toLowerCase().includes(term) ||
material.sceneName?.toLowerCase().includes(term) ||
material.materialType?.toLowerCase().includes(term) ||
material.user.username?.toLowerCase().includes(term)
)
const handleBulkRestore = useCallback(async () => {
const ids = Array.from(selectedIds)
if (!ids.length) return
const confirmed = await confirm({
title: '批量恢复素材',
message: `确定要恢复选中的 ${ids.length} 条素材吗?`,
variant: 'default',
})
}, [materials, materialSearchTerm])
if (!confirmed) return
try {
for (const id of ids) {
// eslint-disable-next-line no-await-in-loop
await fetch(`/api/materials/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
}
setMaterials((prev) => prev.filter((material) => !selectedIds.has(material.id)))
setSelectedIds(new Set())
showToast('批量恢复完成')
fetchMaterials()
} catch (error) {
console.error('Bulk restore materials error:', error)
showToast('批量恢复失败,请稍后重试', 'error')
}
}, [selectedIds, confirm, showToast, fetchMaterials])
const handleRestoreMaterial = async (id: string) => {
try {
const response = await fetch(`/api/materials/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
if (response.ok) {
showToast('恢复成功')
fetchMaterials()
} else {
showToast('恢复失败', 'error')
}
} catch (error) {
console.error('Error restoring material:', error)
showToast('恢复失败', 'error')
}
}
const toggleSelectMaterial = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const clearSelection = useCallback(() => {
setSelectedIds(new Set())
}, [])
const selectAllVisible = useCallback((list: Material[]) => {
setSelectedIds(new Set(list.map((material) => material.id)))
}, [])
const handleBulkDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
if (!ids.length) return
if (showDeleted) {
// 回收站中:批量永久删除
const confirmed = await confirm({
title: '批量永久删除素材',
message: `确定要永久删除选中的 ${ids.length} 条素材吗?此操作不可恢复!`,
variant: 'danger',
})
if (!confirmed) return
try {
for (const id of ids) {
// eslint-disable-next-line no-await-in-loop
await fetch(`/api/materials?id=${id}&permanent=true`, { method: 'DELETE' })
}
setMaterials((prev) => prev.filter((material) => !selectedIds.has(material.id)))
setSelectedIds(new Set())
showToast('批量永久删除完成')
} catch (error) {
console.error('Bulk permanent delete materials error:', error)
showToast('批量永久删除失败,请稍后重试', 'error')
}
} else {
// 正常列表:批量删除(软删除)
const confirmed = await confirm({
title: '批量删除素材',
message: `确定要删除选中的 ${ids.length} 条素材吗?素材将移至回收站,7天后自动清除。`,
variant: 'danger',
})
if (!confirmed) return
try {
for (const id of ids) {
// eslint-disable-next-line no-await-in-loop
await fetch(`/api/materials?id=${id}`, { method: 'DELETE' })
}
setMaterials((prev) => prev.filter((material) => !selectedIds.has(material.id)))
setSelectedIds(new Set())
showToast('批量删除完成')
} catch (error) {
console.error('Bulk delete materials error:', error)
showToast('批量删除失败,请稍后重试', 'error')
}
}
}, [selectedIds, confirm, showToast, showDeleted])
useEffect(() => {
// 移除已被删除的选择项
setSelectedIds((prev) => {
if (prev.size === 0) return prev
const availableIds = new Set(materials.map((material) => material.id))
const next = new Set<string>()
prev.forEach((id) => {
if (availableIds.has(id)) {
next.add(id)
}
})
return next
})
}, [materials])
const handleLoadMore = useCallback(async () => {
if (loading || isLoadingMore || !hasMore) return
const nextPage = currentPage + 1
await fetchMaterials(nextPage, true)
}, [currentPage, fetchMaterials, hasMore, isLoadingMore, loading])
useEffect(() => {
if (!hasMore) return
const sentinel = loadMoreRef.current
if (!sentinel) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
handleLoadMore()
}
},
{ threshold: 0.2 }
)
observer.observe(sentinel)
return () => {
observer.disconnect()
}
}, [handleLoadMore, hasMore])
const selectionCount = selectedIds.size
const isAllVisibleSelected =
materials.length > 0 && materials.every((material) => selectedIds.has(material.id))
const handleBulkTransferToTeam = useCallback(async () => {
const ids = Array.from(selectedIds)
if (!ids.length) return
const confirmed = await confirm({
title: '移交到团队空间',
message: `确定要将选中的 ${ids.length} 条素材的归属移交到团队空间吗?`,
variant: 'default',
})
if (!confirmed) return
try {
const response = await fetch('/api/admin/materials/transfer-owner', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialIds: ids,
toTeam: true,
}),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
showToast(data.error || '移交失败', 'error')
return
}
showToast('已将选中素材移交到团队空间', 'success')
setSelectedIds(new Set())
fetchMaterials(1, false)
} catch (error) {
console.error('Bulk transfer to team error:', error)
showToast('移交失败,请稍后重试', 'error')
}
}, [selectedIds, confirm, showToast, fetchMaterials])
const handleBulkTransferToUser = useCallback(async () => {
const ids = Array.from(selectedIds)
if (!ids.length) return
// 打开「选择用户」弹窗
setIsUserSelectOpen(true)
setUserSearchTerm('')
setUserSearchResults([])
setSelectedTargetUser(null)
}, [selectedIds])
if (loading) {
return <LoadingState message="加载素材列表..." />
@ -124,14 +430,28 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) {
<div className="p-6">
{/* 搜索框 */}
<div className="mb-6">
<div className="relative">
<input
type="text"
placeholder="搜索素材(型号、品牌、分类、场景、类型、上传者)..."
value={materialSearchTerm}
onChange={(e) => setMaterialSearchTerm(e.target.value)}
className={cn(styles.inputStyles.base, 'pl-11 pr-10')}
/>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setShowDeleted(!showDeleted)}
className={cn(
'px-4 py-2 rounded-lg font-medium transition-colors',
'border border-slate-300 dark:border-slate-600',
showDeleted
? 'bg-indigo-600 text-white hover:bg-indigo-700 border-indigo-600 dark:border-indigo-600'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'
)}
>
{showDeleted ? '返回素材列表' : '回收站'}
</button>
<div className="relative flex-1">
<input
type="text"
placeholder="搜索素材(型号、品牌、分类、场景、类型、上传者)..."
value={materialSearchTerm}
onChange={(e) => setMaterialSearchTerm(e.target.value)}
className={cn(styles.inputStyles.base, 'pl-11 pr-10')}
/>
<svg className={cn('absolute left-4 top-3 w-5 h-5', textIcon)} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
@ -152,21 +472,123 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) {
</svg>
</button>
)}
</div>
</div>
</div>
{/* 批量操作 */}
{materials.length > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div className="text-sm">
已选 <span className="font-semibold">{selectionCount}</span> 项
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() =>
isAllVisibleSelected ? clearSelection() : selectAllVisible(materials)
}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compactXs)}
>
{isAllVisibleSelected ? '取消全选' : '全选当前结果'}
</button>
<button
type="button"
onClick={clearSelection}
className={cn(
styles.buttonStyles.ghost,
styles.buttonModifiers.compactXs,
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
disabled={!selectionCount}
>
清空选择
</button>
<button
type="button"
onClick={handleBulkTransferToTeam}
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compactXs,
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
disabled={!selectionCount}
>
移交到团队空间
</button>
<button
type="button"
onClick={handleBulkTransferToUser}
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compactXs,
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
disabled={!selectionCount}
>
移交给指定用户
</button>
{showDeleted && (
<button
type="button"
onClick={handleBulkRestore}
className={cn(
styles.buttonStyles.success,
styles.buttonModifiers.compactXs,
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
disabled={!selectionCount}
>
批量恢复
</button>
)}
<button
type="button"
onClick={handleBulkDelete}
className={cn(
styles.buttonStyles.danger,
styles.buttonModifiers.compactXs,
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
disabled={!selectionCount}
>
{showDeleted ? '批量永久删除' : '批量删除'}
</button>
</div>
</div>
)}
{/* 素材列表 */}
{filteredMaterials.length === 0 ? (
{materials.length === 0 ? (
<div className={cn('text-center py-12', textDesc)}>
{materialSearchTerm ? '暂无匹配的素材' : '暂无素材'}
{debouncedSearchTerm ? '暂无匹配的素材' : '暂无素材'}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredMaterials.map((material) => (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{materials.map((material) => (
<div
key={material.id}
className={cn(styles.cardStyles.hover, 'overflow-hidden')}
className={cn(
styles.cardStyles.hover,
'overflow-hidden relative',
selectedIds.has(material.id) && 'ring-2 ring-offset-2 ring-indigo-500'
)}
>
<button
type="button"
onClick={() => toggleSelectMaterial(material.id)}
className={cn(
'absolute z-10 top-3 left-3 w-7 h-7 rounded-full border-2 flex items-center justify-center text-xs font-semibold transition-colors',
selectedIds.has(material.id)
? 'bg-indigo-600 border-indigo-600 text-white'
: 'bg-white/80 border-slate-300 text-transparent hover:text-slate-400'
)}
aria-pressed={selectedIds.has(material.id)}
aria-label={selectedIds.has(material.id) ? '取消选择素材' : '选择素材'}
>
✓
</button>
<div className={cn('aspect-square relative overflow-hidden', bgImage)}>
{material.previewUrl ? (
<Image
@ -193,23 +615,54 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) {
<div>分类: {material.categoryName}</div>
<div>上传者: {material.user.username}</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setEditingMaterial(material)}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compactXs, 'flex-1')}
>
编辑
</button>
<button
onClick={() => handleDeleteMaterial(material.id)}
className={cn(styles.buttonStyles.danger, styles.buttonModifiers.compactXs, 'flex-1')}
>
删除
</button>
<div className="flex gap-2 mt-3">
{!showDeleted ? (
<>
<button
onClick={() => setEditingMaterial(material)}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compactXs, 'flex-1')}
>
编辑
</button>
<button
onClick={() => handleDeleteMaterial(material.id)}
className={cn(styles.buttonStyles.danger, styles.buttonModifiers.compactXs, 'flex-1')}
>
删除
</button>
</>
) : (
<>
<button
onClick={() => handleRestoreMaterial(material.id)}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compactXs, 'flex-1')}
>
恢复
</button>
<button
onClick={() => handleDeleteMaterial(material.id, true)}
className={cn(styles.buttonStyles.danger, styles.buttonModifiers.compactXs, 'flex-1')}
>
永久删除
</button>
</>
)}
</div>
</div>
</div>
))}
</div>
{(hasMore || isLoadingMore) && (
<div
ref={hasMore ? loadMoreRef : null}
className="flex items-center justify-center py-4 text-sm text-slate-500 dark:text-slate-400"
>
{isLoadingMore ? '加载中...' : '继续向下滚动加载更多'}
</div>
)}
{!hasMore && materials.length > 0 && (
<div className="text-center py-4 text-sm text-slate-400 dark:text-slate-500">已经到底啦~</div>
)}
</div>
)}
</div>
@ -218,7 +671,7 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) {
{/* Edit Material Modal */}
{editingMaterial && (
<EditMaterialModal
material={editingMaterial}
material={editingMaterial as any}
onClose={() => setEditingMaterial(null)}
onSuccess={async () => {
setEditingMaterial(null)
@ -227,6 +680,210 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) {
}}
/>
)}
{/* 选择目标用户弹窗 */}
{isUserSelectOpen && (
<div className="fixed inset-0 z-40 flex items-center justify-center px-4 sm:px-6">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setIsUserSelectOpen(false)} />
<div
className={cn(
'relative z-50 w-full max-w-xl rounded-2xl border shadow-xl',
theme === 'dark'
? 'bg-slate-900/95 border-slate-700/80 shadow-black/60'
: 'bg-white/95 border-slate-200 shadow-slate-900/5',
)}
>
<div className="px-5 pt-4 pb-3 border-b border-slate-200/70 dark:border-slate-700/80 flex items-center justify-between">
<div>
<div className="text-base font-semibold text-slate-900 dark:text-slate-50">选择目标用户</div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
支持按用户名、邮箱或部门关键字搜索,然后点选要接收素材的用户。
</div>
</div>
<button
type="button"
onClick={() => setIsUserSelectOpen(false)}
className="ml-2 inline-flex h-7 w-7 items-center justify-center rounded-full text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 dark:hover:text-slate-200"
>
<span className="sr-only">关闭</span>
×
</button>
</div>
<div className="px-5 pt-3 pb-4 space-y-3">
<div>
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1.5">
搜索用户
</label>
<div className="flex gap-2">
<input
type="text"
value={userSearchTerm}
onChange={(e) => handleSearchUsers(e.target.value)}
placeholder="输入用户名、邮箱或部门关键字,例如:zhangsan / 设计部"
className={cn(
'flex-1 rounded-lg border px-3 py-1.5 text-sm outline-none',
'placeholder:text-slate-400',
theme === 'dark'
? 'bg-slate-900 border-slate-700 text-slate-100 focus:border-indigo-400 focus:ring-1 focus:ring-indigo-500/70'
: 'bg-white border-slate-300 text-slate-900 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40',
)}
/>
</div>
<p className="mt-1 text-[11px] text-slate-400 dark:text-slate-500">
小提示:可以直接输入邮箱或完整用户名,结果会自动高亮匹配。
</p>
</div>
<div className="border rounded-xl max-h-72 overflow-y-auto border-slate-200 dark:border-slate-700/80">
{userSearchLoading ? (
<div className="px-4 py-6 text-sm text-slate-500 dark:text-slate-400 text-center">搜索中...</div>
) : userSearchResults.length === 0 ? (
<div className="px-4 py-6 text-sm text-slate-400 dark:text-slate-500 text-center">
{userSearchTerm.trim()
? '未找到匹配的用户,请尝试调整关键字。'
: '请输入关键字开始搜索用户。'}
</div>
) : (
<ul className="divide-y divide-slate-100 dark:divide-slate-800/80">
{userSearchResults.map((user) => {
const isSelected = selectedTargetUser?.id === user.id
const label = user.username || user.email || user.id
const subLabel = user.email && user.username ? user.email : user.email || user.department || ''
const dept = user.department || user.managedDepartment || ''
return (
<li key={user.id}>
<button
type="button"
onClick={() => setSelectedTargetUser(user)}
className={cn(
'w-full px-4 py-2.5 flex items-center justify-between text-left text-sm transition-colors',
isSelected
? theme === 'dark'
? 'bg-indigo-700/40 text-slate-50'
: 'bg-indigo-50 text-slate-900'
: theme === 'dark'
? 'hover:bg-slate-800/70 text-slate-100'
: 'hover:bg-slate-50 text-slate-800',
)}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate max-w-[180px] font-medium">{label}</span>
{user.isSuperAdmin && (
<span className="inline-flex items-center rounded-full bg-amber-100 text-amber-700 px-1.5 py-0.5 text-[10px] font-medium dark:bg-amber-500/15 dark:text-amber-300">
超级管理员
</span>
)}
{user.role === 'ADMIN' && !user.isSuperAdmin && (
<span className="inline-flex items-center rounded-full bg-indigo-100 text-indigo-700 px-1.5 py-0.5 text-[10px] font-medium dark:bg-indigo-500/15 dark:text-indigo-300">
管理员
</span>
)}
</div>
{subLabel && (
<div className="mt-0.5 text-[11px] text-slate-500 dark:text-slate-400 truncate max-w-[220px]">
{subLabel}
</div>
)}
{dept && (
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500 truncate max-w-[220px]">
部门:{dept}
</div>
)}
</div>
{isSelected && (
<span className="ml-3 inline-flex h-5 w-5 items-center justify-center rounded-full bg-indigo-500 text-white text-xs">
✓
</span>
)}
</button>
</li>
)
})}
</ul>
)}
</div>
</div>
<div className="px-5 pb-4 pt-3 border-t border-slate-200/70 dark:border-slate-700/80 flex items-center justify-between">
<div className="text-[11px] text-slate-400 dark:text-slate-500">
当前已选中素材:<span className="font-semibold">{selectedIds.size}</span> 条
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setIsUserSelectOpen(false)
setSelectedTargetUser(null)
}}
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compactXs,
'min-w-[80px]',
)}
>
取消
</button>
<button
type="button"
disabled={!selectedTargetUser}
onClick={async () => {
if (!selectedTargetUser) return
const ids = Array.from(selectedIds)
if (!ids.length) {
showToast('请先勾选要移交的素材', 'error')
return
}
const confirmed = await confirm({
title: '确认移交',
message: `确定要将选中的 ${ids.length} 条素材的归属移交给用户「${
selectedTargetUser.username || selectedTargetUser.email || selectedTargetUser.id
}」吗?`,
variant: 'default',
})
if (!confirmed) return
try {
const response = await fetch('/api/admin/materials/transfer-owner', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialIds: ids,
targetUserId: selectedTargetUser.id,
}),
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
showToast(data.error || '移交失败', 'error')
return
}
showToast('已将选中素材移交给指定用户', 'success')
setIsUserSelectOpen(false)
setSelectedTargetUser(null)
setSelectedIds(new Set())
fetchMaterials(1, false)
} catch (error) {
console.error('Bulk transfer to user error:', error)
showToast('移交失败,请稍后重试', 'error')
}
}}
className={cn(
styles.buttonStyles.primary,
styles.buttonModifiers.compactXs,
'min-w-[100px] disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
确认移交
</button>
</div>
</div>
</div>
</div>
)}
</>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -341,6 +341,7 @@ const BUILTIN_TEMPLATES: BuiltinTemplateConfig[] = [
name: '3DMAX 素材模板',
description: '适用于 3D 模型类素材,覆盖渲染器、SKU 与品牌等核心字段。',
materialType: '3DMAX',
materialKind: 'IMAGE',
getFields: () => {
const keys = ['productModel', 'categoryName', 'sceneName', 'renderer']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
@ -352,6 +353,7 @@ const BUILTIN_TEMPLATES: BuiltinTemplateConfig[] = [
name: 'PSD 素材模板',
description: '针对平面设计 PSD 素材,包含品牌、颜色模式和输出分辨率。',
materialType: 'PSD',
materialKind: 'IMAGE',
getFields: () => {
const keys = ['productModel', 'categoryName', 'sceneName']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
@ -363,6 +365,7 @@ const BUILTIN_TEMPLATES: BuiltinTemplateConfig[] = [
name: 'AI 工作流',
description: '面向 AI 生成/矢量工作流,仅保留必要的基础字段。',
materialType: 'AI 工作流',
materialKind: 'IMAGE',
getFields: () => {
const keys = ['productModel', 'categoryName', 'sceneName']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
@ -374,6 +377,7 @@ const BUILTIN_TEMPLATES: BuiltinTemplateConfig[] = [
name: '视频素材模板',
description: '适用于视频文件素材。',
materialType: '视频',
materialKind: 'VIDEO',
getFields: () => {
const keys = ['productModel', 'categoryName', 'sceneName']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
@ -385,6 +389,7 @@ const BUILTIN_TEMPLATES: BuiltinTemplateConfig[] = [
name: '模特素材模板',
description: '用于模特/人像素材,关注拍摄场景与负责作者。',
materialType: '模特',
materialKind: 'IMAGE',
getFields: () => {
const keys = ['productModel', 'categoryName', 'sceneName']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
@ -396,6 +401,7 @@ const BUILTIN_TEMPLATES: BuiltinTemplateConfig[] = [
name: '其他素材模板',
description: '适用于其他类型的素材。',
materialType: '其他',
materialKind: 'OTHER',
getFields: () => {
const keys = ['productModel', 'categoryName', 'sceneName']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
@ -744,6 +750,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
description: preset ? preset.description : '',
status: 'draft',
materialType: preset?.materialType || MATERIAL_TYPE_OPTIONS[0],
materialKind: preset?.materialKind || 'IMAGE',
fieldGroups: '',
displayConfig: '',
fields: initialFields,
@ -767,6 +774,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
description: tpl.description || '',
status: tpl.status || 'draft',
materialType: tpl.materialType || MATERIAL_TYPE_OPTIONS[0],
materialKind: (tpl as any).materialKind || 'IMAGE',
fieldGroups: safeJsonStringify(tpl.fieldGroups),
displayConfig: safeJsonStringify(tpl.displayConfig),
fields: tpl.fields.map((field) => ({
@ -804,6 +812,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
description: tpl.description || '',
status: 'draft',
materialType: tpl.materialType || MATERIAL_TYPE_OPTIONS[0],
materialKind: (tpl as any).materialKind || 'IMAGE',
fieldGroups: safeJsonStringify(tpl.fieldGroups),
displayConfig: safeJsonStringify(tpl.displayConfig),
fields: tpl.fields.map((field) => ({
@ -858,6 +867,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
description: preset.description,
status: 'draft',
materialType: preset.materialType,
materialKind: preset.materialKind,
fieldGroups: '',
displayConfig: '',
fields: initialFields,
@ -881,6 +891,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
description: tpl.description || '',
status: tpl.status || 'draft',
materialType: tpl.materialType || MATERIAL_TYPE_OPTIONS[0],
materialKind: (tpl as any).materialKind || 'IMAGE',
fieldGroups: safeJsonStringify(tpl.fieldGroups),
displayConfig: safeJsonStringify(tpl.displayConfig),
fields: tpl.fields.map((field) => ({
@ -947,6 +958,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
name: defaultName,
description: preset.description, // 总是更新描述
materialType: preset.materialType,
materialKind: preset.materialKind,
fields: preset.getFields(),
}
})
@ -1076,6 +1088,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
name: preset.name,
description: preset.description,
status,
materialType: preset.materialType,
materialKind: preset.materialKind,
fieldGroups: null,
displayConfig: null,
fields: fields.map((field, index) => {
@ -1305,6 +1319,10 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
description: form.description.trim() || null,
status: form.status,
materialType: form.materialType || MATERIAL_TYPE_OPTIONS[0],
// 如果没有显式设置 materialKind,则按照 materialType 粗略推断一个
materialKind:
form.materialKind ||
(form.materialType === '视频' ? 'VIDEO' : form.materialType === '其他' ? 'OTHER' : 'IMAGE'),
}
try {
@ -1485,6 +1503,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
description: template.description,
status: 'active',
version: template.version,
materialType: template.materialType,
materialKind: (template as any).materialKind || 'IMAGE',
fieldGroups: template.fieldGroups,
displayConfig: template.displayConfig,
fields: template.fields.map((field, index) => ({
@ -1530,6 +1550,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
description: template.description,
status: 'draft',
version: template.version,
materialType: template.materialType,
materialKind: (template as any).materialKind || 'IMAGE',
fieldGroups: template.fieldGroups,
displayConfig: template.displayConfig,
fields: template.fields.map((field, index) => ({
@ -1633,7 +1655,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
onDuplicate={openDuplicate}
onDelete={handleDelete}
/>
</div>
</div>
{editorOpen && form && (
<Modal
@ -1663,8 +1685,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
: '预览预制模板的实际效果(预览模式不可编辑)'
: '左侧配置字段,右侧实时预览上传面板效果'}
</p>
</div>
<button
</div>
<button
onClick={closeEditor}
className="p-2 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all duration-200"
>
@ -1753,8 +1775,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
onSave={handleSave}
onExitPreview={exitPreviewToEditor}
onCreateFromPreview={createFromPreview}
/>
</div>
/>
</div>
</Modal>
)}
</div>

View File

@ -79,20 +79,29 @@ export default function UsersTab({
return map
}, [users])
// 过滤掉admin账户(如果有其他最高权限账户)
const filteredUsers = useMemo(() => {
const hasOtherSuperAdmin = users.some((u) => u.isSuperAdmin && u.email !== 'admin')
if (hasOtherSuperAdmin) {
return users.filter((u) => u.email !== 'admin')
}
return users
}, [users])
const { treeRoots } = useMemo(() => {
if (!users.length) {
if (!filteredUsers.length) {
return { treeRoots: [] as UserTreeNode[] }
}
const childMap = new Map<string, string[]>()
users.forEach((user) => {
filteredUsers.forEach((user) => {
if (user?.id) {
childMap.set(user.id, [])
}
})
const rootIds: string[] = []
users.forEach((user) => {
filteredUsers.forEach((user) => {
if (!user?.id) return
const parentId = user.parentId
if (parentId && childMap.has(parentId)) {
@ -126,7 +135,7 @@ export default function UsersTab({
.sort((a, b) => compareUsers(a.user, b.user))
return { treeRoots: buildNodes(rootIds) }
}, [userLookup, users])
}, [userLookup, filteredUsers])
const toggleUserNodeCollapse = useCallback((nodeId: string) => {
setCollapsedUserNodeIds((prev) => {
@ -251,7 +260,7 @@ export default function UsersTab({
const filterNodes = (nodes: UserTreeNode[]): UserTreeNode[] =>
nodes
.map((node) => {
const filteredChildren = filterNodes(node.children)
const filteredChildren = filterNodes(node.children)
return matchUser(node.user) || filteredChildren.length
? { user: node.user, children: filteredChildren }
: null
@ -283,7 +292,7 @@ export default function UsersTab({
return labelA.localeCompare(labelB, 'zh-CN')
}
users.forEach((user) => {
filteredUsers.forEach((user) => {
const deptName = (user.department && user.department.trim()) || '未分配部门'
const entry = ensureEntry(deptName)
if (user.isSuperAdmin || user.managedDepartment || user.role === 'ADMIN') {
@ -302,17 +311,17 @@ export default function UsersTab({
})
return map
}, [users, departments])
}, [filteredUsers, departments])
const departmentMaterialCounts = useMemo(() => {
const counts = new Map<string, number>()
users.forEach((user) => {
filteredUsers.forEach((user) => {
const deptName = (user.department && user.department.trim()) || '未分配部门'
const current = counts.get(deptName) || 0
counts.set(deptName, current + (user._count?.materials || 0))
})
return counts
}, [users])
}, [filteredUsers])
const hasDepartments = departments.length > 0
const editingUserDepartment = editingUser?.department ?? ''
@ -326,71 +335,71 @@ export default function UsersTab({
}, [editingUserDepartment, users])
const renderStatusBadges = (user: User) => (
<>
<span
<>
<span
className={cn(
'px-2 py-0.5 text-xs rounded-full',
user.isActive
user.isActive
? badgeActive
: badgeInactive,
)}
>
{user.isActive ? '已激活' : '未激活'}
</span>
<span
>
{user.isActive ? '已激活' : '未激活'}
</span>
<span
className={cn(
'px-2 py-0.5 text-xs rounded-full',
user.canDownload
user.canDownload
? badgeRole
: badgeNoRole,
)}
>
{user.canDownload ? '可下载' : '禁止下载'}
</span>
</>
)
>
{user.canDownload ? '可下载' : '禁止下载'}
</span>
</>
)
const renderActionButtons = useCallback((user: User, options?: { showRoleToggle?: boolean }) => {
const allowed = canManageUser(user)
if (!allowed && user.id !== currentUserId) {
const allowed = canManageUser(user)
if (!allowed && user.id !== currentUserId) {
return <span className={cn('text-[11px]', textDesc)}>无权限修改</span>
}
}
const actionButton = (
label: string,
className: string,
onClick: () => void,
const actionButton = (
label: string,
className: string,
onClick: () => void,
visible: boolean = true,
) => {
if (!visible) return null
return (
<button
type="button"
disabled={!allowed}
) => {
if (!visible) return null
return (
<button
type="button"
disabled={!allowed}
className={cn(
className,
styles.buttonModifiers.compactXs,
!allowed && 'opacity-40 cursor-not-allowed pointer-events-none',
)}
onClick={() => {
if (!allowed) return
onClick()
}}
>
{label}
</button>
)
}
onClick={() => {
if (!allowed) return
onClick()
}}
>
{label}
</button>
)
}
return (
<div className="flex flex-wrap items-center gap-2">
{actionButton(
'编辑',
return (
<div className="flex flex-wrap items-center gap-2">
{actionButton(
'编辑',
styles.buttonStyles.secondary,
() => handleEditUser(user),
)}
{actionButton(
user.isActive ? '禁用' : '激活',
)}
{actionButton(
user.isActive ? '禁用' : '激活',
user.isActive ? styles.buttonStyles.danger : styles.buttonStyles.success,
() => onToggleActive(user.id, user.isActive),
)}
@ -524,7 +533,7 @@ export default function UsersTab({
collapsedDepartments={collapsedDepartments}
onToggleDepartmentCollapse={toggleDepartmentCollapse}
renderUserRow={renderUserRow}
totalUsers={users.length}
totalUsers={filteredUsers.length}
/>
)}
</div>

View File

@ -67,22 +67,55 @@ export default function TemplateBasicsForm({
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1.5">草稿:仅保存,不生效;启用:用户可以使用</p>
</div>
<div>
<label className="block text-sm font-semibold text-slate-900 dark:text-white mb-2">
素材类型 <span className="text-red-500">*</span>
</label>
<select
value={form.materialType}
onChange={(e) => onChange({ materialType: e.target.value })}
className={cn(inputStyles.base)}
>
{materialTypeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1.5">新版本中模板即素材类型,这里用于绑定老数据。</p>
<div className="space-y-3">
<div>
<label className="block text-sm font-semibold text-slate-900 dark:text-white mb-2">
素材类型 <span className="text-red-500">*</span>
</label>
<select
value={form.materialType}
onChange={(e) => onChange({ materialType: e.target.value })}
className={cn(inputStyles.base)}
>
{materialTypeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1.5">用于展示的业务类型,例如“视频”、“PSD”等。</p>
</div>
<div>
<label className="block text-sm font-semibold text-slate-900 dark:text-white mb-2">
素材大类 <span className="text-red-500">*</span>
</label>
<div className="flex flex-wrap gap-2">
{[
{ label: '图片/文档', value: 'IMAGE', hint: '普通素材,走图片/文件流程' },
{ label: '视频', value: 'VIDEO', hint: '走视频上传与转码流程' },
{ label: '其他', value: 'OTHER', hint: '暂不归类,可后续扩展' },
].map((option) => {
const active = (form.materialKind || 'IMAGE') === option.value
return (
<button
key={option.value}
type="button"
onClick={() => onChange({ materialKind: option.value })}
className={cn(
active ? buttonStyles.primary : buttonStyles.secondary,
buttonModifiers.pill,
'flex flex-col items-start gap-0.5 px-4 py-2 text-left min-w-[120px]',
)}
>
<span className="text-sm font-semibold">{option.label}</span>
<span className="text-[11px] text-current/70">{option.hint}</span>
</button>
)
})}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1.5">用于驱动上传/处理链路,视频模板请选择“视频”。</p>
</div>
</div>
{showPresetSelector && (

View File

@ -69,7 +69,7 @@ export default function TemplateFieldsEditor({
<h4 className="text-lg font-semibold text-slate-900 dark:text-white">字段配置</h4>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">添加用户上传时需要填写的字段</p>
<p className="text-[11px] text-slate-400 dark:text-slate-500">
先选预设再按需调整,拖拽左侧卡片即可重排顺序。
先选预设再按需调整,使用上下箭头按钮调整字段顺序。
</p>
</div>
@ -112,15 +112,11 @@ export default function TemplateFieldsEditor({
} ${
isDragging
? 'opacity-40 scale-[0.96] cursor-grabbing border-blue-500 dark:border-blue-400 shadow-lg shadow-blue-200/50 dark:shadow-blue-900/50 transition-all duration-200'
: 'cursor-grab'
: 'cursor-default'
}`}
draggable
draggable={false}
onMouseEnter={() => onEditingFieldChange(index)}
onMouseLeave={() => onEditingFieldChange(null)}
onDragStart={(event) => onFieldDragStart(event, index)}
onDragOver={(event) => onFieldDragOver(event, index, 'editor')}
onDrop={onFieldDrop}
onDragEnd={onFieldDragEnd}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
@ -337,16 +333,6 @@ export default function TemplateFieldsEditor({
/>
<span>必填</span>
</label>
<label className="flex items-center gap-1.5 text-xs text-slate-700 dark:text-slate-300 cursor-pointer">
<input
type="checkbox"
checked={field.visibleOnHomeFilter ?? false}
onChange={(e) => onUpdateField(index, { visibleOnHomeFilter: e.target.checked })}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
/>
<span>首页筛选</span>
</label>
<label className="flex items-center gap-1.5 text-xs text-slate-700 dark:text-slate-300 cursor-pointer">
<input
type="checkbox"

View File

@ -22,6 +22,7 @@ export interface UploadTemplateDto {
description: string | null
status: string
materialType: string
materialKind?: string
version: number
fieldGroups: any
displayConfig: any
@ -55,6 +56,8 @@ export interface TemplateForm {
description: string
status: string
materialType: string
// IMAGE / VIDEO / OTHER,用于区分视频类与图片类模板
materialKind?: string
fieldGroups: string
displayConfig: string
fields: FieldForm[]
@ -65,6 +68,8 @@ export interface BuiltinTemplateConfig {
name: string
description: string
materialType: string
// 预制模板的素材大类,用于前端/后端统一识别
materialKind: 'IMAGE' | 'VIDEO' | 'OTHER'
getFields: () => FieldForm[]
}

View File

@ -103,7 +103,7 @@ export type DepartmentUserGrouping = {
// 工具类型
export type ToastFn = (message: string, type?: 'success' | 'error') => void
export type AdminTab = 'materials' | 'users' | 'categories' | 'templates' | 'departments' | 'invitations' | 'monitor' | 'settings'
export type AdminTab = 'materials' | 'users' | 'categories' | 'templates' | 'departments' | 'invitations' | 'monitor'
// Session 用户类型(扩展 next-auth 的 Session.user)
export interface SessionUser {
@ -138,6 +138,8 @@ export interface DashboardData {
trends: {
userGrowth: Array<{ date: string; count: number }>
materialUploads: Array<{ date: string; count: number }>
materialUploadsDaily: Array<{ date: string; count: number }>
materialUploadsMonthly: Array<{ date: string; count: number }>
}
distribution: {
departments: Array<{ department: string; count: number }>

View File

@ -1,7 +1,8 @@
'use client'
import type { User } from '../types'
import { buttonStyles, cn, inputStyles } from '@/lib/ui-styles'
import { useThemeStyles, cn } from '@/lib/ui-styles'
import Modal from '@/components/ui/Modal'
interface TransferSuperAdminModalProps {
isOpen: boolean
@ -20,45 +21,59 @@ export default function TransferSuperAdminModal({
onChangePassword,
onConfirm,
}: TransferSuperAdminModalProps) {
const styles = useThemeStyles()
if (!isOpen || !targetUser) return null
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-md w-full border border-slate-200/70 dark:border-slate-700/80">
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">转移最高权限</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">请输入您的密码以确认此操作</p>
</div>
<div className="p-6 space-y-4">
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 text-sm text-amber-800 dark:text-amber-100">
<strong>警告:</strong>转移给 <strong>{targetUser.username}</strong>({targetUser.email})后,当前账号将失去最高权限。
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">密码</label>
<input
type="password"
value={password}
onChange={(e) => onChangePassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onConfirm()
}
}}
className={inputStyles.base}
placeholder="请输入您的密码"
/>
</div>
</div>
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3">
<button type="button" onClick={onClose} className={cn(buttonStyles.ghost)}>
<Modal
isOpen={isOpen}
onClose={onClose}
title="转移最高权限"
size="small"
footer={
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className={cn(
styles.buttonStyles.secondary,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200'
)}
>
取消
</button>
<button type="button" onClick={onConfirm} className={cn(buttonStyles.primary)}>
<button
type="button"
onClick={onConfirm}
className={cn(styles.buttonStyles.primary)}
>
确认转移
</button>
</div>
}
>
<div className="space-y-4">
<p className="text-sm text-slate-500 dark:text-slate-400">请输入您的密码以确认此操作</p>
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 text-sm text-amber-800 dark:text-amber-100">
<strong>警告:</strong>转移给 <strong>{targetUser.username}</strong>({targetUser.email})后,当前账号将失去最高权限。
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">密码</label>
<input
type="password"
value={password}
onChange={(e) => onChangePassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onConfirm()
}
}}
className={styles.inputStyles.base}
placeholder="请输入您的密码"
/>
</div>
</div>
</div>
</Modal>
)
}

View File

@ -3,6 +3,7 @@
import type { Department, User } from '../types'
import { useThemeStyles, cn } from '@/lib/ui-styles'
import { formatDateTime } from '../../_utils/formatters'
import Modal from '@/components/ui/Modal'
interface UserEditModalProps {
isOpen: boolean
@ -47,29 +48,44 @@ export default function UserEditModal({
if (!isOpen || !user) return null
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-slate-200/70 dark:border-slate-700/80 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-slate-100 [&::-webkit-scrollbar-track]:dark:bg-slate-800 [&::-webkit-scrollbar-thumb]:bg-slate-300 [&::-webkit-scrollbar-thumb]:dark:bg-slate-600 [&::-webkit-scrollbar-thumb]:rounded-full">
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">编辑用户</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 break-all">{user.email}</p>
</div>
<button
type="button"
className={cn(styles.buttonStyles.ghost, styles.buttonModifiers.icon, 'text-slate-700 dark:text-slate-200')}
aria-label="关闭编辑用户"
onClick={onClose}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<Modal
isOpen={isOpen}
onClose={onClose}
title="编辑用户"
size="large"
containerClassName="max-h-[90vh]"
bodyClassName="overflow-y-auto max-h-[calc(90vh-180px)]"
footer={
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className={cn(
styles.buttonStyles.secondary,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200'
)}
>
取消
</button>
<button
type="button"
onClick={onSave}
className={cn(
styles.buttonStyles.primary,
'bg-indigo-600 hover:bg-indigo-700 text-white'
)}
>
保存
</button>
</div>
}
>
<div className="space-y-5">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4 break-all">{user.email}</p>
</div>
<div className="p-6 space-y-5">
<section className="grid gap-4 md:grid-cols-3">
<section className="grid gap-4 md:grid-cols-3">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">注册时间</p>
<p className="text-sm font-medium text-slate-900 dark:text-white">
@ -115,21 +131,31 @@ export default function UserEditModal({
</section>
<section className="space-y-3">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">角色</label>
<button
type="button"
onClick={onToggleRole}
className={cn(
styles.buttonStyles.secondary,
'w-full justify-center max-w-xs', // 限制最大宽度,避免按钮太长
// 基础状态:白色背景,深色文字
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-200',
// 管理员状态:紫色背景,白色文字(使用 !important 确保覆盖基础文字颜色)
user.role === 'ADMIN' && '!bg-purple-600 !text-white hover:!bg-purple-700',
)}
>
{user.role === 'ADMIN' ? '管理员' : '设为管理员'}
</button>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">角色与高危操作</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<button
type="button"
onClick={onToggleRole}
className={cn(
styles.buttonStyles.secondary,
'w-full justify-center h-12',
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-200',
user.role === 'ADMIN' && '!bg-purple-600 !text-white hover:!bg-purple-700',
)}
>
{user.role === 'ADMIN' ? '管理员' : '设为管理员'}
</button>
<button
type="button"
onClick={onDelete}
className={cn(
styles.buttonStyles.danger,
'w-full justify-center h-12',
)}
>
删除用户
</button>
</div>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-3">
@ -178,51 +204,13 @@ export default function UserEditModal({
</section>
)}
<section className="space-y-2 border-t border-slate-200 dark:border-slate-700 pt-4">
<button
type="button"
onClick={onDelete}
className={cn(
styles.buttonStyles.danger,
'w-full justify-center',
// 统一UI样式已包含颜色,不需要额外添加
)}
>
删除用户
</button>
</section>
{!hasDepartments && (
<div className="text-sm text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
⚠️ 暂无部门,请先在系统设置中添加部门。
</div>
)}
</div>
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className={cn(
styles.buttonStyles.secondary,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' // 显式添加背景色、边框和文字色
)}
>
取消
</button>
<button
type="button"
onClick={onSave}
className={cn(
styles.buttonStyles.primary,
'bg-indigo-600 hover:bg-indigo-700 text-white' // 显式添加背景色和文字色
)}
>
保存
</button>
</div>
{!hasDepartments && (
<div className="text-sm text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
⚠️ 暂无部门,请先在系统设置中添加部门。
</div>
)}
</div>
</div>
</Modal>
)
}

View File

@ -1,465 +1,66 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useEffect } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { cardStyles, buttonStyles, spacingStyles, cn } from '@/lib/ui-styles'
import Header from '@/components/Header'
import MonitorTab from '../_components/MonitorTab'
import { useToast } from '../_hooks/useToast'
import { cn } from '@/lib/ui-styles'
interface DashboardData {
overview: {
totalUsers: number
activeUsers: number
totalMaterials: number
newUsersThisMonth: number
newMaterialsThisMonth: number
}
trends: {
userGrowth: Array<{ date: string; count: number }>
materialUploads: Array<{ date: string; count: number }>
}
distribution: {
departments: Array<{ department: string; count: number }>
roles: Array<{ role: string; isSuperAdmin: boolean; count: number }>
}
systemInfo: {
registrationEnabled: boolean
userActivationRequired: boolean
siteName: string
}
recentActivity: Array<{
id: string
title: string
createdAt: string
uploadedBy: {
username: string
email: string
}
}>
status: {
database: string
timestamp: string
uptime: number
}
}
export default function SystemMonitor() {
export default function MonitorPage() {
const { data: session, status } = useSession()
const router = useRouter()
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshInterval, setRefreshInterval] = useState<NodeJS.Timeout | null>(null)
const { toast, showToast, dismissToast } = useToast()
// 权限检查
useEffect(() => {
if (status === 'loading') return
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
if (status === 'authenticated' && (session.user as any)?.role !== 'ADMIN' && !isSuperAdmin) {
router.push('/')
return
if (status === 'unauthenticated') {
router.push('/login')
}
}, [session, status, router])
}, [status, router])
// 获取仪表板数据
const fetchDashboardData = async () => {
try {
setError(null)
const response = await fetch('/api/admin/system-status', {
credentials: 'include'
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
setDashboardData(data)
} catch (err) {
console.error('Failed to fetch dashboard data:', err)
setError(err instanceof Error ? err.message : '获取数据失败')
} finally {
setLoading(false)
}
}
// 初始化数据加载
useEffect(() => {
if (session) {
fetchDashboardData()
}
}, [session])
// 自动刷新(每30秒)
useEffect(() => {
if (session) {
const interval = setInterval(fetchDashboardData, 30000)
setRefreshInterval(interval)
return () => {
if (interval) clearInterval(interval)
}
}
}, [session])
// 手动刷新
const handleRefresh = () => {
setLoading(true)
fetchDashboardData()
}
// 格式化时间
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric'
})
}
// 格式化时间戳
const formatTimestamp = (timestamp: string) => {
return new Date(timestamp).toLocaleString('zh-CN')
}
// 计算增长百分比
const calculateGrowth = (current: number, previous: number) => {
if (previous === 0) return current > 0 ? 100 : 0
return Math.round(((current - previous) / previous) * 100)
}
if (status === 'loading' || loading) {
if (status === 'loading') {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-slate-600">加载系统监控数据...</p>
</div>
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950">
<div className="text-sm text-slate-500 dark:text-slate-400">正在加载监控面板...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex items-center justify-center">
<div className="text-center">
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
<strong className="font-bold">错误:</strong>
<span className="block sm:inline">{error}</span>
</div>
<button
onClick={handleRefresh}
className="mt-4 bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
重试
</button>
</div>
</div>
)
if (!session) {
return null
}
if (!dashboardData) {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex items-center justify-center">
<p className="text-slate-600 dark:text-slate-300">暂无数据</p>
</div>
)
}
const { overview, trends, distribution, systemInfo, recentActivity, status: systemStatus } = dashboardData
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
{/* 头部 */}
<div className="bg-white/90 dark:bg-slate-900/90 backdrop-blur-sm shadow-sm border-b border-slate-200/70 dark:border-slate-800/70">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div>
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">📊 系统监控仪表板</h1>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-300">
{systemInfo.siteName} - 实时系统状态监控
</p>
</div>
<div className="flex items-center space-x-4">
<button
onClick={handleRefresh}
disabled={loading}
className={cn(
buttonStyles.primary,
'py-2 px-4 rounded-xl disabled:opacity-60 disabled:cursor-not-allowed'
)}
>
{loading ? '刷新中...' : '🔄 刷新'}
</button>
<div className="text-sm text-slate-500 dark:text-slate-400">
最后更新:{formatTimestamp(systemStatus.timestamp)}
</div>
</div>
</div>
<Header session={session} />
{toast && (
<div
role="status"
aria-live="polite"
className={cn(
'fixed top-6 left-1/2 -translate-x-1/2 z-[100] px-6 py-3 rounded-lg shadow-lg flex items-center gap-3',
toast.type === 'success' ? 'bg-emerald-500 text-white' : 'bg-red-500 text-white'
)}
>
<span>{toast.message}</span>
<button
onClick={dismissToast}
className="p-1 rounded-full hover:bg-white/20 transition-colors"
aria-label="关闭提示"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
{/* 系统概览卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
<div className={cn(cardStyles.base, 'overflow-hidden')}>
<div className="p-5 space-y-2">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-blue-500/90 dark:bg-blue-500 rounded-full flex items-center justify-center shadow-sm shadow-blue-500/40">
<span className="text-white font-medium">👥</span>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-slate-500 dark:text-slate-400 truncate">总用户数</dt>
<dd className="text-lg font-semibold text-slate-900 dark:text-white">{overview.totalUsers}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className={cn(cardStyles.base, 'overflow-hidden')}>
<div className="p-5 space-y-2">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-green-500/90 dark:bg-green-500 rounded-full flex items-center justify-center shadow-sm shadow-green-500/40">
<span className="text-white font-medium">⚡</span>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-slate-500 dark:text-slate-400 truncate">活跃用户</dt>
<dd className="text-lg font-semibold text-slate-900 dark:text-white">{overview.activeUsers}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className={cn(cardStyles.base, 'overflow-hidden')}>
<div className="p-5 space-y-2">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-purple-500/90 dark:bg-purple-500 rounded-full flex items-center justify-center shadow-sm shadow-purple-500/40">
<span className="text-white font-medium">📦</span>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-slate-500 dark:text-slate-400 truncate">总素材数</dt>
<dd className="text-lg font-semibold text-slate-900 dark:text-white">{overview.totalMaterials}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className={cn(cardStyles.base, 'overflow-hidden')}>
<div className="p-5 space-y-2">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-yellow-500/90 dark:bg-yellow-400 rounded-full flex items-center justify-center shadow-sm shadow-yellow-500/40">
<span className="text-white font-medium">🆕</span>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-slate-500 dark:text-slate-400 truncate">本月新用户</dt>
<dd className="text-lg font-semibold text-slate-900 dark:text-white">{overview.newUsersThisMonth}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className={cn(cardStyles.base, 'overflow-hidden')}>
<div className="p-5 space-y-2">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-indigo-500/90 dark:bg-indigo-500 rounded-full flex items-center justify-center shadow-sm shadow-indigo-500/40">
<span className="text-white font-medium">📈</span>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-slate-500 dark:text-slate-400 truncate">本月新素材</dt>
<dd className="text-lg font-semibold text-slate-900 dark:text-white">{overview.newMaterialsThisMonth}</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
{/* 用户增长趋势 */}
<div className={cn(cardStyles.base, 'p-6')}>
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-4">📈 用户增长趋势(最近30天)</h3>
<div className="h-64 overflow-y-auto">
<div className="space-y-2">
{trends.userGrowth.map((item, index) => (
<div key={index} className="flex justify-between items-center py-2 border-b border-slate-100 dark:border-slate-800/80">
<span className="text-sm text-slate-600 dark:text-slate-300">{formatDate(item.date)}</span>
<div className="flex items-center">
<div className="w-32 bg-slate-200 dark:bg-slate-700 rounded-full h-2 mr-3">
<div
className="bg-blue-600 dark:bg-blue-500 h-2 rounded-full"
style={{ width: `${Math.min(100, (item.count / Math.max(...trends.userGrowth.map(d => d.count))) * 100)}%` }}
></div>
</div>
<span className="text-sm font-medium text-slate-900 dark:text-white">{item.count}</span>
</div>
</div>
))}
</div>
</div>
</div>
{/* 素材上传趋势 */}
<div className={cn(cardStyles.base, 'p-6')}>
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-4">📊 素材上传趋势(最近30天)</h3>
<div className="h-64 overflow-y-auto">
<div className="space-y-2">
{trends.materialUploads.map((item, index) => (
<div key={index} className="flex justify-between items-center py-2 border-b border-slate-100 dark:border-slate-800/80">
<span className="text-sm text-slate-600 dark:text-slate-300">{formatDate(item.date)}</span>
<div className="flex items-center">
<div className="w-32 bg-slate-200 dark:bg-slate-700 rounded-full h-2 mr-3">
<div
className="bg-purple-600 dark:bg-purple-500 h-2 rounded-full"
style={{ width: `${Math.min(100, (item.count / Math.max(...trends.materialUploads.map(d => d.count))) * 100)}%` }}
></div>
</div>
<span className="text-sm font-medium text-slate-900 dark:text-white">{item.count}</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
{/* 部门分布 */}
<div className={cn(cardStyles.base, 'p-6')}>
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-4">🏢 部门分布</h3>
<div className="space-y-3">
{distribution.departments.map((item, index) => (
<div key={index} className="flex justify-between items-center">
<span className="text-sm text-slate-600 dark:text-slate-300">{item.department || '未设置'}</span>
<span className="text-sm font-medium text-slate-900 dark:text-white">{item.count}</span>
</div>
))}
</div>
</div>
{/* 权限分布 */}
<div className={cn(cardStyles.base, 'p-6')}>
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-4">🛡️ 权限分布</h3>
<div className="space-y-3">
{distribution.roles.map((item, index) => (
<div key={index} className="flex justify-between items-center">
<span className="text-sm text-slate-600 dark:text-slate-300">
{item.role === 'ADMIN' && item.isSuperAdmin ? '超级管理员' :
item.role === 'ADMIN' ? '管理员' : '普通用户'}
</span>
<span className="text-sm font-medium text-slate-900 dark:text-white">{item.count}</span>
</div>
))}
</div>
</div>
{/* 系统信息 - 只读状态展示 */}
<div className={cn(cardStyles.base, 'p-6')}>
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-4">⚙️ 系统信息</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-slate-600 dark:text-slate-300">注册功能</span>
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
systemInfo.registrationEnabled
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
)}
>
{systemInfo.registrationEnabled ? '开启' : '关闭'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-slate-600 dark:text-slate-300">用户激活</span>
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
systemInfo.userActivationRequired
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300'
: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
)}
>
{systemInfo.userActivationRequired ? '需要' : '关闭'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-slate-600 dark:text-slate-300">数据库状态</span>
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
systemStatus.database === 'healthy'
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
)}
>
{systemStatus.database === 'healthy' ? '正常' : '异常'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-slate-600 dark:text-slate-300">运行时间</span>
<span className="text-sm font-medium text-slate-900 dark:text-white">
{Math.floor(systemStatus.uptime / 3600)}小时
</span>
</div>
</div>
</div>
</div>
{/* 最近活动 */}
<div className={cn(cardStyles.base, 'p-6')}>
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-4">🕒 最近活动</h3>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-700/80">
<thead className="bg-slate-50 dark:bg-slate-900/60">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
素材标题
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
上传者
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
上传时间
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-slate-900/40 divide-y divide-slate-200 dark:divide-slate-800/80">
{recentActivity.map((activity) => (
<tr key={activity.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/60 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-900 dark:text-white">
{activity.title}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500 dark:text-slate-300">
{activity.uploadedBy.username}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500">
{formatTimestamp(activity.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<MonitorTab showToast={showToast} />
</main>
</div>
)
}
}

View File

@ -1,18 +1,10 @@
'use client'
// 强制设置全局标记,确保新代码已加载(在页面加载时立即执行)
if (typeof window !== 'undefined') {
;(window as any).__ADMIN_PAGE_LOADED__ = true
;(window as any).__ADMIN_PAGE_VERSION__ = '2025-11-23-v4'
console.log('[AdminPage] 🚀 页面已加载,版本:', (window as any).__ADMIN_PAGE_VERSION__)
}
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Header from '@/components/Header'
import TemplateManager from './_components/TemplateManager'
import AppearanceSettingsPanel from './_components/AppearanceSettingsPanel'
import MonitorTab from './_components/MonitorTab'
import CategoriesTab from './_components/CategoriesTab'
import InvitationsTab from './_components/InvitationsTab'
@ -35,6 +27,13 @@ import { useDataFetch } from './_hooks/useDataFetch'
import { useUserOperations } from './_hooks/useUserOperations'
import { useDepartmentOperations } from './_hooks/useDepartmentOperations'
// 强制设置全局标记,确保新代码已加载(在页面加载时立即执行)
if (typeof window !== 'undefined') {
;(window as any).__ADMIN_PAGE_LOADED__ = true
;(window as any).__ADMIN_PAGE_VERSION__ = '2025-11-23-v4'
console.log('[AdminPage] 🚀 页面已加载,版本:', (window as any).__ADMIN_PAGE_VERSION__)
}
// 类型断言函数:安全地获取 SessionUser
function getSessionUser(session: { user?: unknown } | null): SessionUser | null {
if (!session?.user) return null
@ -45,10 +44,14 @@ function getSessionUser(session: { user?: unknown } | null): SessionUser | null
export default function AdminPage() {
const styles = useThemeStyles()
const theme = useTheme()
const tabIndicatorClass = theme === 'dark' ? 'bg-indigo-300' : 'bg-indigo-600'
const tabIndicatorLineClass = 'absolute bottom-0 left-3 right-3 h-[2px] rounded-full'
const tabFocusRingClass =
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900'
const { data: session, status } = useSession()
const sessionUser = getSessionUser(session)
const router = useRouter()
const [activeTab, setActiveTab] = useState<AdminTab>('monitor')
const [activeTab, setActiveTab] = useState<AdminTab>('users')
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
@ -176,7 +179,7 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
return <LoadingState message="加载中..." className="min-h-screen" />
}
return (
return (
<div className={cn('min-h-screen', theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50')}>
<Header session={session} />
@ -190,89 +193,31 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
} animate-fade-in`}
>
<span>{toast.message}</span>
<button
<button
onClick={dismissToast}
className="p-1 rounded-full hover:bg-white/20 transition-colors"
aria-label="关闭提示"
>
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</svg>
</button>
</div>
)}
<main className="container mx-auto px-4 py-8 max-w-7xl">
<div className="flex flex-wrap items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-4">
<button
onClick={() => router.push('/')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
router.push('/')
}
}}
className={cn(
'px-4 py-2 border rounded-lg transition-all flex items-center gap-2 shadow-sm hover:shadow-md font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2',
theme === 'dark'
? 'bg-slate-800 text-white border-slate-600 hover:bg-slate-700'
: 'bg-white text-slate-900 border-slate-300 hover:bg-slate-50'
)}
title="返回主页"
aria-label="返回主页"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
返回主页
</button>
<h1 className={cn('text-3xl font-bold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>系统管理</h1>
</div>
</div>
{/* Tabs - Apple风格动画(使用统一导航样式) */}
<nav role="tablist" aria-label="管理功能导航" className={cn('flex space-x-1 border-b mb-8 overflow-x-auto', theme === 'dark' ? 'border-slate-800' : 'border-slate-200')}>
{/* 监控仪表板 - 只有管理员及以上权限才能看到 */}
{sessionUser?.role === 'ADMIN' && (
<button
type="button"
role="tab"
aria-selected={activeTab === 'monitor'}
aria-controls="monitor-panel"
id="monitor-tab"
onClick={() => setActiveTab('monitor')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveTab('monitor')
}
}}
className={cn(
activeTab === 'monitor' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
)}
>
<span className="relative z-10">监控仪表板</span>
{activeTab === 'monitor' && (
<span className={cn(
'absolute bottom-0 left-0 right-0 h-0.5 rounded-full',
theme === 'dark'
? 'bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400'
: 'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500',
animations.slideInFromLeft
)} />
)}
</button>
)}
<button
<nav role="tablist" aria-label="管理功能导航" className={cn('flex space-x-1 mb-8 overflow-x-auto')}>
{/* 监控仪表板已移除,统一通过头像下拉菜单访问 */}
<button
type="button"
role="tab"
aria-selected={activeTab === 'users'}
aria-controls="users-panel"
id="users-tab"
onClick={() => setActiveTab('users')}
onKeyDown={(e) => {
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveTab('users')
@ -280,20 +225,14 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'users' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
tabFocusRingClass
)}
>
<span className="relative z-10">用户管理</span>
{activeTab === 'users' && (
<span className={cn(
'absolute bottom-0 left-0 right-0 h-0.5 rounded-full',
theme === 'dark'
? 'bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400'
: 'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500',
animations.slideInFromLeft
)} />
<span className={cn(tabIndicatorLineClass, tabIndicatorClass, animations.slideInFromLeft)} />
)}
</button>
</button>
<button
type="button"
role="tab"
@ -301,7 +240,7 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
aria-controls="departments-panel"
id="departments-tab"
onClick={() => setActiveTab('departments')}
onKeyDown={(e) => {
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveTab('departments')
@ -309,21 +248,15 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'departments' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
tabFocusRingClass
)}
>
<span className="relative z-10">架构管理</span>
{activeTab === 'departments' && (
<span className={cn(
'absolute bottom-0 left-0 right-0 h-0.5 rounded-full',
theme === 'dark'
? 'bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400'
: 'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500',
animations.slideInFromLeft
)} />
<span className={cn(tabIndicatorLineClass, tabIndicatorClass, animations.slideInFromLeft)} />
)}
</button>
<button
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'templates'}
@ -338,20 +271,14 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'templates' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
tabFocusRingClass
)}
>
<span className="relative z-10">模板管理</span>
{activeTab === 'templates' && (
<span className={cn(
'absolute bottom-0 left-0 right-0 h-0.5 rounded-full',
theme === 'dark'
? 'bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400'
: 'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500',
animations.slideInFromLeft
)} />
<span className={cn(tabIndicatorLineClass, tabIndicatorClass, animations.slideInFromLeft)} />
)}
</button>
</button>
<button
type="button"
role="tab"
@ -359,7 +286,7 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
aria-controls="categories-panel"
id="categories-tab"
onClick={() => setActiveTab('categories')}
onKeyDown={(e) => {
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveTab('categories')
@ -367,30 +294,24 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'categories' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
tabFocusRingClass
)}
>
<span className="relative z-10">分类管理</span>
{activeTab === 'categories' && (
<span className={cn(
'absolute bottom-0 left-0 right-0 h-0.5 rounded-full',
theme === 'dark'
? 'bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400'
: 'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500',
animations.slideInFromLeft
)} />
<span className={cn(tabIndicatorLineClass, tabIndicatorClass, animations.slideInFromLeft)} />
)}
</button>
</button>
{/* 邀请管理 - 只有管理员及以上权限才能看到 */}
{sessionUser?.role === 'ADMIN' && (
<button
<button
type="button"
role="tab"
aria-selected={activeTab === 'invitations'}
aria-controls="invitations-panel"
id="invitations-tab"
onClick={() => setActiveTab('invitations')}
onKeyDown={(e) => {
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveTab('invitations')
@ -398,16 +319,14 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'invitations' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
tabFocusRingClass
)}
>
<span className="relative z-10">邀请管理</span>
{activeTab === 'invitations' && (
<span className={cn('absolute bottom-0 left-0 right-0 h-0.5 rounded-full', theme === 'dark'
? 'bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400'
: 'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500', animations.slideInFromLeft)} />
<span className={cn(tabIndicatorLineClass, tabIndicatorClass, animations.slideInFromLeft)} />
)}
</button>
</button>
)}
<button
type="button"
@ -416,7 +335,7 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
aria-controls="materials-panel"
id="materials-tab"
onClick={() => setActiveTab('materials')}
onKeyDown={(e) => {
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveTab('materials')
@ -424,49 +343,14 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'materials' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
tabFocusRingClass
)}
>
<span className="relative z-10">素材管理</span>
{activeTab === 'materials' && (
<span className={cn(
'absolute bottom-0 left-0 right-0 h-0.5 rounded-full',
theme === 'dark'
? 'bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400'
: 'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500',
animations.slideInFromLeft
)} />
<span className={cn(tabIndicatorLineClass, tabIndicatorClass, animations.slideInFromLeft)} />
)}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'settings'}
aria-controls="settings-panel"
id="settings-tab"
onClick={() => setActiveTab('settings')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveTab('settings')
}
}}
className={cn(
activeTab === 'settings' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
)}
>
<span className="relative z-10">外观设置</span>
{activeTab === 'settings' && (
<span className={cn(
'absolute bottom-0 left-0 right-0 h-0.5 rounded-full',
theme === 'dark'
? 'bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400'
: 'bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500',
animations.slideInFromLeft
)} />
)}
</button>
</button>
</nav>
{/* Template Management */}
@ -476,12 +360,6 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
</TabErrorBoundary>
)}
{activeTab === 'settings' && (
<TabErrorBoundary tabKey="settings">
<AppearanceSettingsPanel sessionUser={sessionUser} variant="embedded" />
</TabErrorBoundary>
)}
{/* Materials Management */}
{activeTab === 'materials' && (
<TabErrorBoundary tabKey="materials">
@ -543,9 +421,10 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
<TabErrorBoundary tabKey="monitor" onRetry={fetchData}>
<div role="tabpanel" id="monitor-panel" aria-labelledby="monitor-tab">
<MonitorTab showToast={showToast} />
</div>
</div>
</TabErrorBoundary>
)}
</main>
{/* 创建邀请链接模态框 */}

View File

@ -11,9 +11,10 @@ export default function SystemSettingsPage() {
const router = useRouter()
useEffect(() => {
const isAdmin = (session?.user as any)?.role === 'ADMIN'
if (status === 'unauthenticated') {
router.replace('/login')
} else if (status === 'authenticated' && session?.user?.role !== 'ADMIN') {
} else if (status === 'authenticated' && !isAdmin) {
router.replace('/')
}
}, [status, session, router])
@ -26,7 +27,7 @@ export default function SystemSettingsPage() {
)
}
if (!session || session.user?.role !== 'ADMIN') {
if (!session || (session.user as any)?.role !== 'ADMIN') {
return null
}

View File

@ -0,0 +1,128 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { deleteFromS3, isS3Url } from '@/lib/s3'
import { unlink, access } from 'fs/promises'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
return NextResponse.json(
{ error: '需要管理员权限' },
{ status: 403 }
)
}
const results: Record<string, number> = {}
// 1. 清理过期的邀请码(已使用且过期超过30天)
const expiredInvitations = await prisma.$executeRawUnsafe(`
DELETE FROM invitations
WHERE "usedAt" IS NOT NULL
AND "expiresAt" IS NOT NULL
AND "expiresAt" < NOW() - INTERVAL '30 days'
`)
results.expiredInvitations = Number(expiredInvitations)
// 2. 清理旧的弹幕数据(超过90天)
const oldDanmakus = await prisma.$executeRawUnsafe(`
DELETE FROM danmakus
WHERE "createdAt" < NOW() - INTERVAL '90 days'
`)
results.oldDanmakus = Number(oldDanmakus)
// 3. 清理所有素材数据和记录
// 先获取所有素材记录以便删除文件
const materials = await prisma.$queryRawUnsafe<Array<{ id: string; "zipFilePath": string; "zipFileName": string }>>(`
SELECT id, "zipFilePath", "zipFileName" FROM materials
`)
let deletedFiles = 0
let failedFiles = 0
// 删除存储文件(优先S3,其次本地兼容)
for (const material of materials) {
if (material.zipFilePath && material.zipFilePath !== '') {
const filePath = material.zipFilePath
if (isS3Url(filePath)) {
try {
await deleteFromS3(filePath)
deletedFiles++
} catch (error) {
console.error('[Cleanup] Failed to delete S3 object:', filePath, error)
failedFiles++
}
continue
}
try {
await access(filePath)
await unlink(filePath)
deletedFiles++
} catch (fileError: any) {
if (fileError.code !== 'ENOENT') {
failedFiles++
}
}
}
}
// 删除所有素材记录
const deletedMaterials = await prisma.$executeRawUnsafe(`
DELETE FROM materials
`)
results.materials = Number(deletedMaterials)
results.materialFilesDeleted = deletedFiles
results.materialFilesFailed = failedFiles
// 4. 获取清理后的统计信息
const stats = await prisma.$queryRawUnsafe<Array<{ table_name: string; count: bigint }>>(`
SELECT
'invitations' as table_name,
COUNT(*)::bigint as count
FROM invitations
UNION ALL
SELECT
'danmakus' as table_name,
COUNT(*)::bigint as count
FROM danmakus
UNION ALL
SELECT
'materials' as table_name,
COUNT(*)::bigint as count
FROM materials
UNION ALL
SELECT
'users' as table_name,
COUNT(*)::bigint as count
FROM users
`)
const statsObj: Record<string, number> = {}
stats.forEach((stat) => {
statsObj[stat.table_name] = Number(stat.count)
})
return NextResponse.json({
message: '数据库清理成功',
success: true,
deleted: results,
remaining: statsObj,
})
} catch (error) {
console.error('[Cleanup] Error:', error)
return NextResponse.json(
{
error: '数据库清理失败',
details: error instanceof Error ? error.message : '未知错误',
},
{ status: 500 }
)
}
}

View File

@ -0,0 +1,185 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { ensureMaterialLogsTable } from '@/lib/material-log'
export const dynamic = 'force-dynamic'
/**
* 获取素材操作日志
* 包括上传、删除、修改等操作
*/
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: '无权限' }, { status: 403 })
}
await ensureMaterialLogsTable()
const { searchParams } = new URL(req.url)
const limit = parseInt(searchParams.get('limit') || '100')
const offset = parseInt(searchParams.get('offset') || '0')
// 获取素材操作日志
let materialLogs: any[] = []
try {
// 尝试使用 Prisma 查询
materialLogs = await (prisma as any).materialLog.findMany({
take: limit,
skip: offset,
orderBy: { timestamp: 'desc' },
include: {
user: {
select: {
username: true,
email: true,
},
},
material: {
select: {
productModel: true,
},
},
},
})
} catch (prismaError: any) {
console.error('[MaterialLogs] Prisma query error:', prismaError)
// 如果 Prisma 查询失败,尝试使用原始 SQL
try {
const rawLogs = await prisma.$queryRaw<Array<{
id: string
action: string
materialId: string
userId: string
timestamp: Date
changes: any
username: string | null
email: string | null
productModel: string | null
}>>`
SELECT
ml.id,
ml."action",
ml."materialId",
ml."userId",
ml."timestamp",
ml."changes",
u.username,
u.email,
m."productModel"
FROM "material_logs" ml
LEFT JOIN "users" u ON ml."userId" = u.id
LEFT JOIN "materials" m ON ml."materialId" = m.id
ORDER BY ml."timestamp" DESC
LIMIT ${limit}
OFFSET ${offset}
`
materialLogs = rawLogs.map((log) => ({
id: log.id,
action: log.action,
materialId: log.materialId,
userId: log.userId,
timestamp: log.timestamp,
changes: log.changes,
user: log.username ? { username: log.username, email: log.email } : null,
material: log.productModel ? { productModel: log.productModel } : null,
}))
} catch (sqlError: any) {
console.error('[MaterialLogs] SQL query error:', sqlError)
// 如果SQL也失败,返回空数组而不是抛出错误
materialLogs = []
}
}
// 字段名映射
const fieldNames: Record<string, string> = {
productModel: '产品型号',
categoryName: '一级分类',
sceneName: '二级分类',
materialType: '素材类型',
renderer: '渲染器',
authorName: '作者花名',
brandName: '品牌名称',
seriesName: '系列/产品线',
skuCode: 'SKU编码',
materialTexture: '材质/工艺',
renderTime: '渲染耗时',
imageResolution: '输出分辨率',
colorMode: '颜色模式',
}
const ACTION_META: Record<
string,
{
label: string
type: 'upload' | 'update' | 'delete' | 'toggle' | 'other'
}
> = {
UPLOAD: { label: '上传素材', type: 'upload' },
UPDATE: { label: '修改素材', type: 'update' },
DELETE: { label: '删除素材', type: 'delete' },
TOGGLE_PREMIUM: { label: '切换精品状态', type: 'toggle' },
}
// 构建日志列表
const logs = materialLogs.map((log: any) => {
const normalizedAction = typeof log.action === 'string' ? log.action.toUpperCase() : ''
const actionMeta = ACTION_META[normalizedAction] || {
label: log.action || '其它操作',
type: 'other' as const,
}
const actionText = actionMeta.label
// 构建详细修改内容
const changeDetails: string[] = []
const materialName = log.material?.productModel || '未知素材'
const username = log.user?.username || '未知用户'
if (log.changes && typeof log.changes === 'object') {
const changes = log.changes as Record<string, { oldValue: any; newValue: any }>
Object.entries(changes).forEach(([key, change]) => {
const fieldName = fieldNames[key] || key
const oldVal = change.oldValue ?? '(空)'
const newVal = change.newValue ?? '(空)'
changeDetails.push(`(${fieldName}:${oldVal} --> ${newVal})`)
})
}
if (changeDetails.length === 0) {
if (normalizedAction === 'UPLOAD') {
changeDetails.push(`(${username}上传了素材:${materialName})`)
} else if (normalizedAction === 'DELETE') {
changeDetails.push(`(${username}删除了素材:${materialName})`)
} else {
changeDetails.push(`(${username}${actionText}:${materialName})`)
}
}
return {
id: log.id,
type: actionMeta.type,
action: actionText,
actionDetail: changeDetails,
materialId: log.materialId,
materialName: materialName,
userId: log.userId,
username: username,
userEmail: log.user?.email || '未知邮箱',
timestamp: log.timestamp instanceof Date ? log.timestamp.toISOString() : (typeof log.timestamp === 'string' ? log.timestamp : new Date(log.timestamp).toISOString()),
}
})
return NextResponse.json({
logs,
total: logs.length,
})
} catch (error) {
console.error('[MaterialLogs] GET error:', error)
return NextResponse.json({ error: '获取日志失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,171 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
interface TransferRequestBody {
materialIds?: string[]
sourceUserId?: string
targetUserId?: string
/**
* 管理员可输入:用户名 或 邮箱,用于定位目标用户
*/
targetUsernameOrEmail?: string
toTeam?: boolean
}
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
const user = session?.user as any
const isAdmin = user?.role === 'ADMIN' || Boolean(user?.isSuperAdmin)
if (!session || !isAdmin) {
return NextResponse.json(
{ error: '需要管理员权限' },
{ status: 403 }
)
}
const body = (await req.json()) as TransferRequestBody
const { materialIds, sourceUserId, targetUserId, targetUsernameOrEmail, toTeam } = body
if ((!materialIds || materialIds.length === 0) && !sourceUserId) {
return NextResponse.json(
{ error: '缺少素材ID列表或来源用户ID' },
{ status: 400 }
)
}
if (!toTeam && !targetUserId && !targetUsernameOrEmail) {
return NextResponse.json(
{ error: '请指定目标用户(用户ID或用户名/邮箱),或设置 toTeam=true 移交到团队空间' },
{ status: 400 }
)
}
// 目标归属
let ownerType: 'USER' | 'TEAM'
let ownerId: string
let newAuthorName: string | null = null
if (toTeam) {
ownerType = 'TEAM'
ownerId = 'default-team'
// 移交到团队空间时,不修改作者名称,保留最初作者
} else {
// 目标用户存在性校验:优先按 ID,其次按用户名 / 邮箱
let finalTargetUser: { id: string; username: string | null; realName: string | null; email: string | null } | null =
null
if (targetUserId) {
finalTargetUser = await prisma.user.findUnique({
where: { id: targetUserId },
select: { id: true, username: true, realName: true, email: true },
})
if (!finalTargetUser) {
return NextResponse.json(
{ error: `目标用户不存在:${targetUserId}` },
{ status: 400 },
)
}
} else if (targetUsernameOrEmail) {
const keyword = targetUsernameOrEmail.trim()
if (keyword) {
const targetUser = await prisma.user.findFirst({
where: {
OR: [
{ username: keyword },
{ email: keyword },
],
},
select: { id: true, username: true, realName: true, email: true },
})
if (!targetUser) {
return NextResponse.json(
{ error: `未找到匹配的目标用户:${keyword}` },
{ status: 400 },
)
}
finalTargetUser = targetUser
}
}
if (!finalTargetUser) {
return NextResponse.json(
{ error: '请提供有效的目标用户ID、用户名或邮箱' },
{ status: 400 },
)
}
ownerType = 'USER'
ownerId = finalTargetUser.id
// 同步更新首页展示用作者名字:优先真实姓名,其次用户名,再其次邮箱
newAuthorName =
finalTargetUser.realName ||
finalTargetUser.username ||
finalTargetUser.email ||
finalTargetUser.id
}
// 构建 where 条件
const where: any = {
deletedAt: null,
}
if (materialIds && materialIds.length > 0) {
where.id = { in: materialIds }
} else if (sourceUserId) {
where.OR = [
{ createdByUserId: sourceUserId },
{ ownerType: 'USER', ownerId: sourceUserId },
{ userId: sourceUserId },
]
}
const updateData: any = {
ownerType,
ownerId,
lastTransferredAt: new Date(),
}
// 如果移交给具体用户,则视为“素材责任人”变更:同步 userId & 首页作者名
if (ownerType === 'USER') {
updateData.userId = ownerId
if (newAuthorName) {
updateData.authorName = newAuthorName
}
}
const result = await prisma.material.updateMany({
where,
data: updateData,
})
return NextResponse.json({
success: true,
message: '素材归属已更新',
affectedCount: result.count,
target: {
ownerType,
ownerId,
authorName: newAuthorName,
},
})
} catch (error) {
console.error('[Admin] Transfer material owner error:', error)
return NextResponse.json(
{
error: '素材归属变更失败',
details: error instanceof Error ? error.message : '未知错误',
},
{ status: 500 }
)
}
}

View File

@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
return NextResponse.json(
{ error: '需要管理员权限' },
{ status: 403 }
)
}
// 执行 SQL 迁移 - 需要分开执行每个命令
const commands = [
`ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "brandName" TEXT`,
`ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "seriesName" TEXT`,
`ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "skuCode" TEXT`,
`ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "materialTexture" TEXT`,
`ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "renderTime" TEXT`,
`ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "imageResolution" TEXT`,
`ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "colorMode" TEXT`,
`ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "displayImages" TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "platformName" TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "previewUrls" TEXT`,
`ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "metadata" TEXT`,
`ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "templateId" TEXT`,
`CREATE INDEX IF NOT EXISTS "materials_platformName_idx" ON "materials"("platformName")`,
]
// 执行每个 ALTER TABLE 和 CREATE INDEX 命令
for (const cmd of commands) {
try {
await prisma.$executeRawUnsafe(cmd)
} catch (error: any) {
// 如果列已存在,忽略错误
if (error?.message?.includes('already exists')) {
// 忽略
} else {
throw error
}
}
}
// 执行外键约束(使用 $queryRawUnsafe 因为包含 DO 块)
const fkCommand = `
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'materials_templateId_fkey'
) THEN
ALTER TABLE "materials" ADD CONSTRAINT "materials_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "upload_templates"("id") ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
`
try {
await prisma.$queryRawUnsafe(fkCommand)
} catch (error: any) {
// 忽略外键已存在的错误
if (!error?.message?.includes('already exists')) {
throw error
}
}
return NextResponse.json({
message: '迁移成功',
success: true
})
} catch (error) {
console.error('[Migrate] Error:', error)
return NextResponse.json(
{
error: '迁移失败',
details: error instanceof Error ? error.message : '未知错误'
},
{ status: 500 }
)
}
}

View File

@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'
export const dynamic = 'force-dynamic'
/**
* 重置素材排行榜相关数据:
* - 清空点赞记录表 material_likes
* - 清空下载日志表 material_download_logs
* - 将 materials 表中的 likeCount / downloadCount 归零
*
* 仅管理员可用(ADMIN 或 isSuperAdmin)
*/
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
const user = session?.user as any
const isAdmin = user?.role === 'ADMIN' || Boolean(user?.isSuperAdmin)
if (!session || !isAdmin) {
return NextResponse.json(
{ error: '需要管理员权限' },
{ status: 403 }
)
}
// 确保下载日志表存在(避免 TRUNCATE 报错)
await ensureMaterialDownloadLogsTable()
// 使用事务,确保一致性
const result = await prisma.$transaction(async (tx) => {
// 清空点赞记录
const clearedLikes = await tx.$executeRawUnsafe(`
TRUNCATE TABLE material_likes RESTART IDENTITY CASCADE
`)
// 清空下载日志
const clearedDownloadLogs = await tx.$executeRawUnsafe(`
TRUNCATE TABLE material_download_logs RESTART IDENTITY CASCADE
`)
// 归零计数(全部素材统一归零)
const resetCounts = await tx.$executeRawUnsafe(`
UPDATE materials
SET "likeCount" = 0,
"downloadCount" = 0
`)
return {
clearedLikes: Number(clearedLikes),
clearedDownloadLogs: Number(clearedDownloadLogs),
resetCounts: Number(resetCounts),
}
})
return NextResponse.json({
success: true,
message: '素材点赞/下载数据已重置',
details: result,
})
} catch (error) {
console.error('[Admin Reset Material Rankings] Error:', error)
return NextResponse.json(
{
error: '重置素材排行榜数据失败',
details: error instanceof Error ? error.message : '未知错误',
},
{ status: 500 }
)
}
}

View File

@ -19,16 +19,15 @@ export async function GET(req: NextRequest) {
)
}
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
if (!session || ((session.user as any)?.role !== 'ADMIN' && !isSuperAdmin)) {
if (!session) {
return NextResponse.json(
{ error: '无权限访问' },
{ status: 403 }
{ error: '未登录,无法访问系统状态' },
{ status: 401 }
)
}
// 并行获取各项统计数据(优化:减少查询次数)
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
const thirtyDaysAgoForStats = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
let userStats: { _count: { id: number } }
@ -56,13 +55,13 @@ export async function GET(req: NextRequest) {
// 用户统计(最近30天)
prisma.user.aggregate({
_count: { id: true },
where: { createdAt: { gte: thirtyDaysAgo } }
where: { createdAt: { gte: thirtyDaysAgoForStats } }
}),
// 素材统计(最近30天)
prisma.material.aggregate({
_count: { id: true },
where: { createdAt: { gte: thirtyDaysAgo } }
where: { createdAt: { gte: thirtyDaysAgoForStats } }
}),
// 系统设置(使用缓存,减少数据库查询)
@ -101,13 +100,10 @@ export async function GET(req: NextRequest) {
}
}),
// 部门统计
// 部门统计(包括所有部门,包括空字符串和null)
prisma.user.groupBy({
by: ['department'],
_count: { id: true },
where: {
department: { not: '' }
}
_count: { id: true }
}),
// 权限分布统计
@ -130,25 +126,98 @@ export async function GET(req: NextRequest) {
roleDistribution = []
}
// 计算素材上传趋势(按月统计,最近12个月)
const twelveMonthsAgo = new Date()
twelveMonthsAgo.setMonth(twelveMonthsAgo.getMonth() - 12)
// 计算趋势数据
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
let materialTrend: Array<{ date: Date, count: bigint }> = []
// 获取本月第一天
const startOfCurrentMonth = new Date()
startOfCurrentMonth.setDate(1)
startOfCurrentMonth.setHours(0, 0, 0, 0)
let materialTrendDaily: Array<{ date: Date, count: bigint }> = []
let materialTrendMonthly: Array<{ date: Date, count: bigint }> = []
let userGrowthTrend: Array<{ date: Date, count: bigint }> = []
try {
materialTrend = await prisma.$queryRaw<Array<{ date: Date, count: bigint }>>`
// 用户增长趋势(最近30天)
userGrowthTrend = await prisma.$queryRaw<Array<{ date: Date, count: bigint }>>`
SELECT
DATE_TRUNC('month', created_at)::date as date,
DATE_TRUNC('day', "createdAt")::date as date,
COUNT(*)::bigint as count
FROM materials
WHERE created_at >= ${twelveMonthsAgo}
GROUP BY DATE_TRUNC('month', created_at)
FROM "users"
WHERE "createdAt" >= ${thirtyDaysAgo}
GROUP BY DATE_TRUNC('day', "createdAt")
ORDER BY date ASC
`
// 先检查是否有素材数据
const materialCount = await prisma.material.count()
console.log('[SystemStatus] Total materials in database:', materialCount)
if (materialCount > 0) {
// 获取一些样本数据,看看createdAt的实际值
const sampleMaterials = await prisma.material.findMany({
take: 3,
select: { id: true, createdAt: true },
orderBy: { createdAt: 'desc' }
})
console.log('[SystemStatus] Sample material createdAt values:', sampleMaterials.map(m => ({
id: m.id,
createdAt: m.createdAt,
createdAtISO: m.createdAt.toISOString(),
createdAtLocal: m.createdAt.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
})))
}
// 素材每天的数据(直接使用createdAt,不转换时区,让PostgreSQL使用数据库时区)
const dailyResult = await prisma.$queryRaw<Array<{ date: Date, count: bigint }>>`
SELECT
"createdAt"::date as date,
COUNT(*)::bigint as count
FROM "materials"
GROUP BY "createdAt"::date
ORDER BY date DESC
LIMIT 100
`
materialTrendDaily = dailyResult
console.log('[SystemStatus] Material daily trend count:', dailyResult.length)
if (dailyResult.length > 0) {
console.log('[SystemStatus] Sample daily data:', dailyResult.slice(0, 5).map(item => ({
date: item.date,
dateStr: item.date.toISOString().split('T')[0],
count: Number(item.count)
})))
} else {
console.warn('[SystemStatus] No daily trend data found, but material count is:', materialCount)
}
// 素材每月的数据(直接使用createdAt,不转换时区)
const monthlyResult = await prisma.$queryRaw<Array<{ date: Date, count: bigint }>>`
SELECT
DATE_TRUNC('month', "createdAt")::date as date,
COUNT(*)::bigint as count
FROM "materials"
GROUP BY DATE_TRUNC('month', "createdAt")
ORDER BY date DESC
LIMIT 24
`
materialTrendMonthly = monthlyResult
console.log('[SystemStatus] Material monthly trend count:', monthlyResult.length)
if (monthlyResult.length > 0) {
console.log('[SystemStatus] Sample monthly data:', monthlyResult.slice(0, 5).map(item => ({
date: item.date,
dateStr: item.date.toISOString().split('T')[0].substring(0, 7),
count: Number(item.count)
})))
} else {
console.warn('[SystemStatus] No monthly trend data found, but material count is:', materialCount)
}
} catch (queryError) {
console.error('[SystemStatus] Material trend query error:', queryError)
materialTrend = []
console.error('[SystemStatus] Trend query error:', queryError)
materialTrendDaily = []
materialTrendMonthly = []
userGrowthTrend = []
}
const statusData = {
@ -161,10 +230,26 @@ export async function GET(req: NextRequest) {
},
trends: {
materialUploads: materialTrend.map(item => ({
date: item.date.toISOString().split('T')[0].substring(0, 7), // 转换为 YYYY-MM 格式
userGrowth: userGrowthTrend.map(item => ({
date: item.date.toISOString().split('T')[0], // 转换为 YYYY-MM-DD 格式
count: Number(item.count)
}))
})),
// 兼容旧字段 materialUploads,默认返回月度数据
materialUploads: materialTrendMonthly.map(item => ({
date: item.date.toISOString().split('T')[0].substring(0, 7),
count: Number(item.count)
})),
materialUploadsDaily: materialTrendDaily.map(item => ({
date: item.date.toISOString().split('T')[0], // 转换为 YYYY-MM-DD 格式
count: Number(item.count)
})),
materialUploadsMonthly: materialTrendMonthly.map(item => {
const dateStr = item.date.toISOString().split('T')[0]
return {
date: dateStr.substring(0, 7), // 转换为 YYYY-MM 格式
count: Number(item.count)
}
})
},
distribution: {

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { syncFieldPoolFromTemplates } from '@/lib/field-pool'
const selectTemplate = {
id: true,
@ -9,6 +10,7 @@ const selectTemplate = {
description: true,
status: true,
materialType: true,
materialKind: true,
version: true,
fieldGroups: true,
displayConfig: true,
@ -78,6 +80,7 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
description,
status,
materialType,
materialKind,
fieldGroups,
displayConfig,
fields,
@ -103,6 +106,11 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
if (materialType !== undefined && typeof materialType === 'string') {
updateData.materialType = materialType.trim() || '其他'
}
if (materialKind !== undefined && typeof materialKind === 'string') {
const rawKind = materialKind.trim().toUpperCase()
const allowedKinds = ['IMAGE', 'VIDEO', 'OTHER'] as const
updateData.materialKind = (allowedKinds as readonly string[]).includes(rawKind) ? rawKind : 'IMAGE'
}
updateData.version = (existing.version || 1) + 1
const formattedFields = Array.isArray(fields)
@ -198,6 +206,7 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
select: selectTemplate,
})
await syncFieldPoolFromTemplates()
return NextResponse.json(template)
} catch (error) {
console.error('Update upload template error:', error)
@ -211,6 +220,7 @@ export async function DELETE(_: NextRequest, { params }: { params: { id: string
if (!session) return NextResponse.json({ error: '无权限' }, { status: 403 })
await prisma.uploadTemplate.delete({ where: { id: params.id } })
await syncFieldPoolFromTemplates()
return NextResponse.json({ success: true })
} catch (error) {
console.error('Delete upload template error:', error)

View File

@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { Prisma } from '@prisma/client'
import { syncFieldPoolFromTemplates } from '@/lib/field-pool'
const baseSelect = {
id: true,
@ -10,6 +11,7 @@ const baseSelect = {
description: true,
status: true,
materialType: true,
materialKind: true,
version: true,
fieldGroups: true,
displayConfig: true,
@ -73,6 +75,7 @@ export async function POST(req: NextRequest) {
description,
status = 'draft',
materialType,
materialKind,
fieldGroups,
displayConfig,
fields = [],
@ -83,6 +86,10 @@ export async function POST(req: NextRequest) {
}
const finalMaterialType = typeof materialType === 'string' && materialType.trim() ? materialType.trim() : '其他'
// 仅允许 IMAGE / VIDEO / OTHER,避免脏数据
const rawKind = typeof materialKind === 'string' && materialKind.trim() ? materialKind.trim().toUpperCase() : 'IMAGE'
const allowedKinds = ['IMAGE', 'VIDEO', 'OTHER'] as const
const finalMaterialKind = (allowedKinds as readonly string[]).includes(rawKind) ? rawKind : 'IMAGE'
const formattedFields = Array.isArray(fields)
? fields.map((field: any, index: number) => ({
@ -112,6 +119,7 @@ export async function POST(req: NextRequest) {
description: description ?? null,
status,
materialType: finalMaterialType,
materialKind: finalMaterialKind,
fieldGroups: fieldGroups ?? null,
displayConfig: displayConfig ?? null,
fields: formattedFields.length
@ -124,6 +132,7 @@ export async function POST(req: NextRequest) {
select: baseSelect,
})
await syncFieldPoolFromTemplates()
return NextResponse.json(template, { status: 201 })
} catch (error: any) {
console.error('Create upload template error:', error)

View File

@ -19,11 +19,24 @@ export async function GET(request: NextRequest) {
const page = parseInt(searchParams.get('page') || '1', 10)
const pageSize = Math.min(parseInt(searchParams.get('pageSize') || '20', 10), 100) // 最大100条
const skip = (page - 1) * pageSize
const keyword = searchParams.get('search')?.trim() || ''
// 并行查询总数和数据
const where =
keyword
? {
OR: [
{ username: { contains: keyword, mode: 'insensitive' as const } },
{ email: { contains: keyword, mode: 'insensitive' as const } },
{ department: { contains: keyword, mode: 'insensitive' as const } },
],
}
: {}
const [total, users] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where }),
prisma.user.findMany({
where,
select: {
id: true,
email: true,

View File

@ -0,0 +1,112 @@
/**
* 视频转码统计 API(管理员)
*
* 提供转码任务的统计信息
*/
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { transcodeQueue } from '@/lib/video-transcode-queue'
export const dynamic = 'force-dynamic'
/**
* GET /api/admin/video-transcode-stats
* 获取转码统计信息
*/
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
)
}
// 检查是否为管理员(简化版,实际应该检查用户角色)
// TODO: 添加管理员权限检查
// 获取转码队列状态
const queueStatus = transcodeQueue.getStatus()
// 统计数据库中的转码状态
const [pendingCount, processingCount, doneCount, failedCount] = await Promise.all([
prisma.material.count({
where: {
materialKind: 'VIDEO',
videoProcessingStatus: 'pending',
},
}),
prisma.material.count({
where: {
materialKind: 'VIDEO',
videoProcessingStatus: 'processing',
},
}),
prisma.material.count({
where: {
materialKind: 'VIDEO',
videoProcessingStatus: 'done',
},
}),
prisma.material.count({
where: {
materialKind: 'VIDEO',
videoProcessingStatus: 'failed',
},
}),
])
// 获取最近失败的转码任务(最多10个)
const recentFailed = await prisma.material.findMany({
where: {
materialKind: 'VIDEO',
videoProcessingStatus: 'failed',
},
select: {
id: true,
productModel: true,
categoryName: true,
sceneName: true,
createdAt: true,
updatedAt: true,
},
orderBy: {
updatedAt: 'desc',
},
take: 10,
})
return NextResponse.json({
queue: {
queueLength: queueStatus.queueLength,
processingCount: queueStatus.processingCount,
maxConcurrent: queueStatus.maxConcurrent,
processingIds: queueStatus.processingIds,
},
statistics: {
pending: pendingCount,
processing: processingCount,
done: doneCount,
failed: failedCount,
total: pendingCount + processingCount + doneCount + failedCount,
},
recentFailed: recentFailed.map((m) => ({
id: m.id,
name: `${m.productModel}_${m.categoryName}_${m.sceneName}`,
createdAt: m.createdAt,
failedAt: m.updatedAt,
})),
})
} catch (error) {
console.error('[TranscodeStats] Error:', error)
return NextResponse.json(
{ error: '获取统计信息失败' },
{ status: 500 }
)
}
}

View File

@ -8,12 +8,18 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { getFieldPoolRecords, syncFieldPoolFromTemplates } from '@/lib/field-pool'
import { prisma } from '@/lib/prisma'
import {
getAvailableCategoryFields,
getManagedCategoryFields,
saveManagedCategoryFields,
} from '@/lib/category-fields'
type FieldPoolRecord = {
key: string
label: string
filterDisplayName?: string | null
source: string
type: string
isFilterEnabled: boolean
filterOrder?: number | null
}
export async function GET() {
const session = await getServerSession(authOptions)
@ -21,10 +27,20 @@ export async function GET() {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const [managedFields, availableFields] = await Promise.all([
getManagedCategoryFields(),
getAvailableCategoryFields(),
])
await syncFieldPoolFromTemplates()
const fields = (await getFieldPoolRecords()) as FieldPoolRecord[]
const managedFields = fields
.filter((field: FieldPoolRecord) => field.isFilterEnabled)
.map((field: FieldPoolRecord) => field.key)
const availableFields = fields.map((field: FieldPoolRecord) => ({
key: field.key,
label: field.filterDisplayName || field.label,
source: field.source === 'standard' ? 'standard' : 'custom',
type: field.type,
filterDisplayName: field.filterDisplayName,
filterOrder: field.filterOrder ?? 0,
}))
return NextResponse.json({
managedFields,
@ -41,48 +57,86 @@ export async function PATCH(req: NextRequest) {
const { fieldKey, enabled } = await req.json()
if (typeof fieldKey !== 'string' || typeof enabled !== 'boolean') {
return NextResponse.json(
{ error: '参数错误' },
{ status: 400 },
)
return NextResponse.json({ error: '参数错误' }, { status: 400 })
}
const availableFields = await getAvailableCategoryFields()
if (!availableFields.find((field) => field.key === fieldKey)) {
return NextResponse.json(
{ error: '未找到该字段' },
{ status: 404 },
)
const field = await prisma.fieldDefinition.findUnique({
where: { key: fieldKey },
})
if (!field) {
return NextResponse.json({ error: '未找到该字段' }, { status: 404 })
}
const current = new Set(await getManagedCategoryFields())
if (enabled) {
current.add(fieldKey)
} else {
current.delete(fieldKey)
}
const updatedManagedFields = await saveManagedCategoryFields(Array.from(current))
await prisma.uploadField.updateMany({
where: {
key: fieldKey,
type: 'select',
},
await prisma.fieldDefinition.update({
where: { id: field.id },
data: {
addToCategory: enabled,
isFilterEnabled: enabled,
},
})
return NextResponse.json({
managedFields: updatedManagedFields,
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Update category fields failed:', error)
return NextResponse.json(
{ error: '更新失败' },
{ status: 500 },
)
return NextResponse.json({ error: '更新失败' }, { status: 500 })
}
}
export async function PUT(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
try {
const { updates } = await req.json()
if (!Array.isArray(updates)) {
return NextResponse.json({ error: '参数错误' }, { status: 400 })
}
const normalizedUpdates = updates
.map((item: any, index: number) => {
if (!item || typeof item.fieldKey !== 'string') {
return null
}
const payload: { fieldKey: string; filterDisplayName?: string | null; filterOrder?: number } = {
fieldKey: item.fieldKey,
}
if (typeof item.filterDisplayName === 'string') {
payload.filterDisplayName = item.filterDisplayName.trim() || null
} else if (item.filterDisplayName === null) {
payload.filterDisplayName = null
}
if (typeof item.filterOrder === 'number' && Number.isFinite(item.filterOrder)) {
payload.filterOrder = item.filterOrder
} else {
payload.filterOrder = index
}
return payload
})
.filter((item): item is { fieldKey: string; filterDisplayName?: string | null; filterOrder?: number } => !!item)
if (!normalizedUpdates.length) {
return NextResponse.json({ error: '缺少有效的字段更新' }, { status: 400 })
}
await prisma.$transaction(async (tx) => {
for (const update of normalizedUpdates) {
await tx.fieldDefinition.update({
where: { key: update.fieldKey },
data: {
filterDisplayName: update.filterDisplayName ?? null,
filterOrder: update.filterOrder ?? 0,
},
})
}
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Reorder/rename category fields failed:', error)
return NextResponse.json({ error: '保存失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,65 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getFieldPoolRecords, syncFieldPoolFromTemplates, formatFieldOptionEntries } from '@/lib/field-pool'
const MATERIAL_FIELD_COLUMN_MAP: Record<string, string> = {
categoryName: 'categoryName',
sceneName: 'sceneName',
platformName: 'platformName',
materialType: 'materialType',
renderer: 'renderer',
brandName: 'brandName',
}
async function buildMaterialCounts(fieldKeys: string[]) {
const counts: Record<string, Map<string, number>> = {}
await Promise.all(
fieldKeys.map(async (fieldKey) => {
const column = MATERIAL_FIELD_COLUMN_MAP[fieldKey]
if (!column) return
const rows = await prisma.$queryRawUnsafe<Array<{ value: string | null; count: bigint }>>(
`SELECT "${column}" as value, COUNT(*)::bigint as count FROM "materials" WHERE "${column}" IS NOT NULL AND "${column}" != '' GROUP BY "${column}"`
)
const map = new Map<string, number>()
rows.forEach((row) => {
if (!row.value) return
map.set(row.value, Number(row.count ?? 0))
})
counts[fieldKey] = map
})
)
return counts
}
export async function GET() {
try {
await syncFieldPoolFromTemplates()
const fields = (await getFieldPoolRecords()) as Array<{
key: string
label: string
filterDisplayName?: string | null
isFilterEnabled?: boolean
options: Array<{ value: string; label?: string | null }>
}>
const fieldKeys = fields.filter((field) => field.isFilterEnabled).map((field) => field.key)
const countsMap = await buildMaterialCounts(fieldKeys)
const payload = fields
.filter((field) => field.isFilterEnabled)
.map((field) => ({
key: field.key,
label: field.filterDisplayName || field.label,
options: formatFieldOptionEntries(field.options).map((option) => ({
label: option.label,
value: option.value,
count: countsMap[field.key]?.get(option.value) ?? 0,
})),
}))
return NextResponse.json({ fields: payload })
} catch (error) {
console.error('[home-filter-fields] Failed to load fields:', error)
return NextResponse.json({ error: '获取筛选字段失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,65 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'
export const dynamic = 'force-dynamic'
/**
* 获取素材排行榜
* 返回下载榜、点赞榜等排行榜的素材ID列表
*/
export async function GET() {
try {
// 确保下载日志表存在(第一次使用时自动创建)
await ensureMaterialDownloadLogsTable()
// 下载榜:直接从下载日志表统计,按下载次数降序,相同次数按最近下载时间降序
const downloadRankings = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT
dl."materialId" AS id
FROM material_download_logs dl
INNER JOIN materials m ON m.id = dl."materialId" AND m."deletedAt" IS NULL
GROUP BY dl."materialId"
ORDER BY COUNT(*) DESC, MAX(dl."downloadedAt") DESC
LIMIT 10
`
// 点赞榜:从点赞关联表统计,按点赞次数降序,相同次数按最近点赞时间降序
const likeRankings = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT
ml."materialId" AS id
FROM material_likes ml
INNER JOIN materials m ON m.id = ml."materialId" AND m."deletedAt" IS NULL
GROUP BY ml."materialId"
ORDER BY COUNT(*) DESC, MAX(ml."createdAt") DESC
LIMIT 10
`
// 3. 精品(手动设置 isPremium 的素材)
const premiumRankings = await prisma.material.findMany({
where: {
isPremium: true,
},
select: {
id: true,
},
orderBy: {
updatedAt: 'desc',
},
take: 10,
})
return NextResponse.json({
downloads: downloadRankings.map((item) => item.id),
likes: likeRankings.map((item) => item.id),
premium: premiumRankings.map((m) => m.id),
})
} catch (error) {
console.error('[Material Rankings] GET error:', error)
return NextResponse.json(
{ error: '获取素材排行榜失败' },
{ status: 500 }
)
}
}

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createMaterialLog } from '@/lib/material-log'
// GET /api/materials/[id] - 获取单个素材详情
export async function GET(
@ -15,6 +16,7 @@ export async function GET(
id: true,
userId: true,
productModel: true,
materialKind: true,
brandName: true,
seriesName: true,
skuCode: true,
@ -29,16 +31,23 @@ export async function GET(
colorMode: true,
authorName: true,
previewUrl: true,
videoCoverUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoProcessingStatus: true,
videoDuration: true,
zipFileName: true,
zipFileSize: true,
downloadCount: true,
likeCount: true,
displayImages: true,
createdAt: true,
templateId: true,
user: {
select: {
id: true,
username: true,
avatar: true,
}
}
}
@ -62,6 +71,7 @@ export async function GET(
materialTexture: material.materialTexture,
categoryName: material.categoryName,
sceneName: material.sceneName,
platformName: material.platformName,
materialType: material.materialType,
renderer: material.renderer,
renderTime: material.renderTime,
@ -69,14 +79,24 @@ export async function GET(
colorMode: material.colorMode,
authorName: material.authorName,
previewUrl: material.previewUrl,
materialKind:
(material.materialKind || '').trim().toUpperCase() ||
(material.materialType === '视频' ? 'VIDEO' : 'IMAGE'),
videoCoverUrl: material.videoCoverUrl,
videoPreview540Url: material.videoPreview540Url,
videoPreviewClipUrl: material.videoPreviewClipUrl,
videoProcessingStatus: material.videoProcessingStatus,
videoDuration: material.videoDuration,
zipFileName: material.zipFileName,
zipFileSize: material.zipFileSize.toString(),
downloadCount: material.downloadCount,
likeCount: material.likeCount,
displayImages: material.displayImages,
createdAt: material.createdAt,
templateId: material.templateId,
user: {
username: material.user.username
username: material.user.username,
avatar: material.user.avatar
}
})
} catch (error: any) {
@ -149,6 +169,23 @@ export async function PUT(
)
}
// 记录修改前的值,用于日志
const oldValues: Record<string, any> = {
productModel: material.productModel,
categoryName: material.categoryName,
sceneName: material.sceneName,
materialType: material.materialType,
renderer: material.renderer,
authorName: material.authorName,
brandName: material.brandName,
seriesName: material.seriesName,
skuCode: material.skuCode,
materialTexture: material.materialTexture,
renderTime: material.renderTime,
imageResolution: material.imageResolution,
colorMode: material.colorMode,
}
// 更新素材信息
const updateData: any = {
productModel,
@ -179,6 +216,61 @@ export async function PUT(
data: updateData
})
// 记录修改详情到日志(用于监控仪表板)
const changes: string[] = []
const fieldNames: Record<string, string> = {
productModel: '产品型号',
categoryName: '一级分类',
sceneName: '二级分类',
materialType: '素材类型',
renderer: '渲染器',
authorName: '作者花名',
brandName: '品牌名称',
seriesName: '系列/产品线',
skuCode: 'SKU编码',
materialTexture: '材质/工艺',
renderTime: '渲染耗时',
imageResolution: '输出分辨率',
colorMode: '颜色模式',
}
Object.keys(updateData).forEach((key) => {
if (key === 'updatedAt') return
const oldVal = oldValues[key] ?? null
const newVal = updateData[key] ?? null
if (String(oldVal) !== String(newVal)) {
const fieldName = fieldNames[key] || key
changes.push(`${fieldName}: "${oldVal ?? '(空)'}" → "${newVal ?? '(空)'}"`)
}
})
if (changes.length > 0) {
console.log(`[MaterialUpdate] Material ${params.id} updated by ${userFromSession.id}:`, changes.join('; '))
// 记录详细修改日志到数据库
const changesObj: Record<string, { oldValue: any; newValue: any }> = {}
Object.keys(updateData).forEach((key) => {
if (key === 'updatedAt') return
const oldVal = oldValues[key] ?? null
const newVal = updateData[key] ?? null
if (String(oldVal) !== String(newVal)) {
changesObj[key] = {
oldValue: oldVal ?? null,
newValue: newVal ?? null,
}
}
})
if (Object.keys(changesObj).length > 0) {
await createMaterialLog({
materialId: params.id,
userId: userFromSession.id,
action: 'UPDATE',
changes: changesObj,
})
}
}
return NextResponse.json({
success: true,
message: '素材信息更新成功',

View File

@ -0,0 +1,126 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { deleteFromS3, isS3Url } from '@/lib/s3'
import { unlink } from 'fs/promises'
// Cleanup materials deleted more than 7 days ago (admin only)
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any).role !== 'ADMIN') {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
)
}
// 计算7天前的日期
const sevenDaysAgo = new Date()
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
// 查找7天前删除的素材
const deletedMaterials = await prisma.material.findMany({
where: {
deletedAt: {
not: null,
lte: sevenDaysAgo,
},
},
})
// 收集所有需要删除的文件并删除
let deletedFiles = 0
let failedFiles = 0
for (const material of deletedMaterials) {
const filesToDelete: string[] = []
// 源文件(zipFilePath 或 videoSourceUrl)
if (material.zipFilePath) filesToDelete.push(material.zipFilePath)
if ((material as any).videoSourceUrl && (material as any).videoSourceUrl !== material.zipFilePath) {
filesToDelete.push((material as any).videoSourceUrl)
}
// 预览图
if (material.previewUrl) filesToDelete.push(material.previewUrl)
// 多预览图(JSON数组)
if ((material as any).previewUrls) {
try {
const previewUrls = JSON.parse((material as any).previewUrls)
if (Array.isArray(previewUrls)) {
filesToDelete.push(...previewUrls.filter((url: string) => url && url.trim()))
}
} catch {
// 忽略解析错误
}
}
// 视频相关文件
if ((material as any).videoPreview540Url) filesToDelete.push((material as any).videoPreview540Url)
if ((material as any).videoPreviewClipUrl) filesToDelete.push((material as any).videoPreviewClipUrl)
if ((material as any).videoCoverUrl) filesToDelete.push((material as any).videoCoverUrl)
// 展示图片(JSON数组)
if (material.displayImages) {
try {
const displayImages = JSON.parse(material.displayImages)
if (Array.isArray(displayImages)) {
filesToDelete.push(...displayImages.filter((url: string) => url && url.trim()))
}
} catch {
// 忽略解析错误
}
}
// 去重并过滤空值
const uniqueFiles = Array.from(new Set(filesToDelete.filter((url) => url && url.trim())))
// 删除所有文件
for (const fileUrl of uniqueFiles) {
try {
if (isS3Url(fileUrl)) {
await deleteFromS3(fileUrl)
deletedFiles++
} else {
try {
await unlink(fileUrl)
deletedFiles++
} catch (fileError: any) {
if (fileError.code !== 'ENOENT') {
failedFiles++
}
}
}
} catch (error) {
console.error(`[Cleanup] Failed to delete file: ${fileUrl}`, error)
failedFiles++
}
}
}
// 永久删除数据库记录
const deletePromises = deletedMaterials.map((material) =>
prisma.material.delete({
where: { id: material.id },
})
)
await Promise.all(deletePromises)
return NextResponse.json({
message: '清理完成',
deletedCount: deletedMaterials.length
})
} catch (error) {
console.error('[Materials] CLEANUP error:', error)
return NextResponse.json(
{ error: '清理失败' },
{ status: 500 }
)
}
}

View File

@ -2,8 +2,11 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { getS3ObjectStream, isS3Url } from '@/lib/s3'
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'
import { readFile, access } from 'fs/promises'
import path from 'path'
import { Readable } from 'stream'
export const dynamic = 'force-dynamic'
@ -12,6 +15,7 @@ export async function GET(req: NextRequest) {
// Check if user is logged in
const session = await getServerSession(authOptions)
if (!session) {
console.log('[Download] No session found')
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
@ -21,19 +25,35 @@ export async function GET(req: NextRequest) {
// Check if user has download permission (skip check for admins)
const userId = (session.user as any).id
const userRole = (session.user as any).role
const userEmail = (session.user as any).email
console.log(`[Download] User ${userId} (${userEmail}, role: ${userRole}) attempting download`)
if (userRole !== 'ADMIN') {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { canDownload: true }
select: { canDownload: true, email: true, username: true }
})
if (!user || !user.canDownload) {
console.log(`[Download] User ${userId} canDownload status:`, user?.canDownload)
if (!user) {
console.error(`[Download] User ${userId} not found in database`)
return NextResponse.json(
{ error: '用户不存在,请联系管理员' },
{ status: 403 }
)
}
if (!user.canDownload) {
console.warn(`[Download] User ${userId} (${user.email}) download permission is disabled`)
return NextResponse.json(
{ error: '您的下载权限已被限制,请联系管理员' },
{ status: 403 }
)
}
} else {
console.log(`[Download] Admin user ${userId} skipping permission check`)
}
const { searchParams } = new URL(req.url)
@ -48,50 +68,79 @@ export async function GET(req: NextRequest) {
const material = await prisma.material.findUnique({
where: { id },
select: {
id: true,
userId: true,
productModel: true,
zipFilePath: true,
zipFileName: true,
materialType: true,
categoryName: true,
sceneName: true,
authorName: true,
}
})
if (!material) {
console.error(`[Download] Material ${id} not found`)
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
)
}
// 增加下载次数
await prisma.material.update({
where: { id },
data: {
downloadCount: {
increment: 1
}
}
})
console.log(`[Download] Material ${id} (${material.productModel}) found. Owner: ${material.userId}, Requester: ${userId}`)
// Read file from local storage
// 确保使用绝对路径
let filePath = material.zipFilePath
if (!path.isAbsolute(filePath)) {
filePath = path.join(process.cwd(), filePath)
const isOwnMaterial = material.userId === userId
let shouldIncrementDownload = false
if (!isOwnMaterial) {
try {
await ensureMaterialDownloadLogsTable()
const now = new Date()
shouldIncrementDownload = true
// 每次下载都记录一条日志
await prisma.$executeRaw`
INSERT INTO material_download_logs (id, "materialId", "userId", "downloadedAt")
VALUES (gen_random_uuid(), ${id}, ${userId}, ${now})
`
console.log(`[Download] Created download log for material ${id}, user ${userId}`)
} catch (logError) {
console.error(`[Download] Error handling download log:`, logError)
// 如果日志记录失败,仍然允许下载,但不增加计数
shouldIncrementDownload = false
console.warn(`[Download] Continuing download without logging due to error`)
}
} else {
// 自己下载自己的素材,不记录下载日志,也不增加下载次数
console.log(`[Download] User ${userId} downloaded own material ${id}. Not incrementing count.`)
}
console.log('Attempting to download file:', filePath)
// 检查文件是否存在
try {
await access(filePath)
} catch (error) {
console.error('File not found:', filePath)
if (shouldIncrementDownload) {
await prisma.material.update({
where: { id },
data: {
downloadCount: {
increment: 1,
},
},
})
}
if (!material.zipFilePath) {
console.error(`[Download] Material ${id} has no zipFilePath`)
return NextResponse.json(
{ error: '文件不存在,可能已被删除' },
{ error: '没有找到素材文件,请重新上传' },
{ status: 404 }
)
}
const fileBuffer = await readFile(filePath)
console.log(`[Download] Material ${id} file path: ${material.zipFilePath}`)
// 生成新的文件名:SPU_一级分类_二级分类_用户名.扩展名
// 获取原文件扩展名
const originalExt = path.extname(material.zipFileName) // 例如 .zip 或 .7z 或视频扩展名
const zipNameFallback = material.zipFileName || ''
const originalExt = path.extname(zipNameFallback) || path.extname(material.zipFilePath) || ''
const newFileName = `${material.productModel}_${material.categoryName}_${material.sceneName}_${material.authorName}${originalExt}`
console.log('Download filename:', newFileName)
@ -99,8 +148,9 @@ export async function GET(req: NextRequest) {
// 根据文件类型和素材类型确定Content-Type
let contentType = 'application/zip'
let contentDisposition = `attachment; filename="${encodeURIComponent(newFileName)}"`
const rangeHeader = req.headers.get('range')
// 如果是视频类型,使用视频的Content-Type并支持流式传输
// 如果是视频类型,使用视频的Content-Type
if (material.materialType === '视频') {
const ext = originalExt.toLowerCase()
const videoTypes: { [key: string]: string } = {
@ -113,47 +163,113 @@ export async function GET(req: NextRequest) {
'.webm': 'video/webm',
}
contentType = videoTypes[ext] || 'video/mp4'
// 视频文件支持流式传输(Range请求)
const range = req.headers.get('range')
if (range) {
// 支持视频流式传输
const fileSize = fileBuffer.length
const parts = range.replace(/bytes=/, '').split('-')
const start = parseInt(parts[0], 10)
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
const chunkSize = end - start + 1
const chunk = fileBuffer.slice(start, end + 1)
return new NextResponse(chunk as any, {
status: 206,
headers: {
'Content-Type': contentType,
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize.toString(),
},
})
if (!rangeHeader) {
contentDisposition = `inline; filename="${encodeURIComponent(newFileName)}"`
}
}
const contentDispositionHeader = `${contentDisposition}; filename*=UTF-8''${encodeURIComponent(newFileName)}`
if (isS3Url(material.zipFilePath)) {
const s3Object = await getS3ObjectStream(material.zipFilePath, rangeHeader)
const stream = toWebStream(s3Object.body)
const headers: Record<string, string> = {
'Content-Type': s3Object.contentType || contentType,
'Content-Disposition': contentDispositionHeader,
'Accept-Ranges': s3Object.acceptRanges || 'bytes',
'Cache-Control': 'no-store',
}
if (rangeHeader && s3Object.contentRange) {
headers['Content-Range'] = s3Object.contentRange
}
if (s3Object.contentLength) {
headers['Content-Length'] = s3Object.contentLength.toString()
}
const status = rangeHeader ? 206 : 200
return new NextResponse(stream as any, {
status,
headers,
})
}
// Read legacy local files (backward compatibility)
let filePath = material.zipFilePath
if (!path.isAbsolute(filePath)) {
filePath = path.join(process.cwd(), filePath)
}
console.log('Attempting to download legacy file:', filePath)
try {
await access(filePath)
} catch (error) {
console.error('File not found:', filePath)
return NextResponse.json(
{ error: '文件不存在,可能已被删除' },
{ status: 404 }
)
}
const fileBuffer = await readFile(filePath)
if (material.materialType === '视频' && rangeHeader) {
const fileSize = fileBuffer.length
const parts = rangeHeader.replace(/bytes=/, '').split('-')
const start = parseInt(parts[0], 10)
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
const chunkSize = end - start + 1
const chunk = fileBuffer.slice(start, end + 1)
// 非Range请求,返回完整文件(但允许内联播放)
contentDisposition = `inline; filename="${encodeURIComponent(newFileName)}"`
return new NextResponse(chunk as any, {
status: 206,
headers: {
'Content-Type': contentType,
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize.toString(),
},
})
}
return new NextResponse(fileBuffer as any, {
headers: {
'Content-Type': contentType,
'Content-Disposition': contentDisposition,
'Content-Disposition': contentDispositionHeader,
'Content-Length': fileBuffer.length.toString(),
'Accept-Ranges': 'bytes', // 支持Range请求(用于视频流式传输)
},
})
} catch (error) {
console.error('Download error:', error)
console.error('[Download] Error:', error)
const errorMessage = error instanceof Error ? error.message : '未知错误'
console.error('[Download] Error details:', {
message: errorMessage,
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json(
{ error: '下载失败: ' + (error instanceof Error ? error.message : '未知错误') },
{ error: '下载失败: ' + errorMessage },
{ status: 500 }
)
}
}
function toWebStream(body: any) {
if (!body) {
throw new Error('S3 object body is empty')
}
if (typeof (body as any).transformToWebStream === 'function') {
return (body as any).transformToWebStream()
}
if (body instanceof Readable) {
return Readable.toWeb(body)
}
return body as ReadableStream<any>
}

View File

@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: '未登录' }, { status: 401 })
}
const { materialId } = await req.json()
if (!materialId) {
return NextResponse.json({ error: '缺少素材ID' }, { status: 400 })
}
const existing = await prisma.materialFavorite.findFirst({
where: {
materialId,
userId: session.user.id,
},
})
if (existing) {
await prisma.materialFavorite.delete({ where: { id: existing.id } })
return NextResponse.json({ favorited: false })
}
await prisma.materialFavorite.create({
data: {
materialId,
userId: session.user.id,
},
})
return NextResponse.json({ favorited: true })
} catch (error) {
console.error('[MaterialFavorite] POST error:', error)
return NextResponse.json({ error: '更新收藏失败' }, { status: 500 })
}
}

View File

@ -26,6 +26,25 @@ export async function POST(req: NextRequest) {
const userId = (session.user as any).id
const material = await prisma.material.findUnique({
where: { id: materialId },
select: { userId: true },
})
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
)
}
if (material.userId === userId) {
return NextResponse.json(
{ error: '不能给自己的素材点赞' },
{ status: 400 }
)
}
// 检查是否已点赞
const existingLike = await prisma.materialLike.findUnique({
where: {

View File

@ -0,0 +1,160 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createMaterialLog } from '@/lib/material-log'
import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: '无操作权限' }, { status: 403 })
}
const { materialId, isPremium } = await req.json()
if (!materialId || typeof isPremium !== 'boolean') {
return NextResponse.json({ error: '参数错误' }, { status: 400 })
}
const operatorId = (session.user as any).id as string
const operator = await prisma.user.findUnique({
where: { id: operatorId },
select: {
id: true,
role: true,
isSuperAdmin: true,
department: true,
managedDepartment: true,
},
})
if (!operator) {
return NextResponse.json({ error: '未找到当前用户信息' }, { status: 403 })
}
const targetMaterial = await prisma.material.findUnique({
where: { id: materialId },
select: {
id: true,
isPremium: true,
user: {
select: {
id: true,
department: true,
},
},
},
})
if (!targetMaterial) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 })
}
if (!operator.isSuperAdmin) {
const operatorScope = (operator.managedDepartment || operator.department || '').trim()
if (!operatorScope) {
return NextResponse.json(
{ error: '尚未配置所属组织,无法设置精品' },
{ status: 403 }
)
}
const targetDepartment = (targetMaterial.user?.department || '').trim()
if (!targetDepartment) {
return NextResponse.json(
{ error: '目标素材未设置所属组织,暂不支持跨组织加精' },
{ status: 403 }
)
}
let hasAccess =
targetDepartment === operatorScope ||
targetDepartment.startsWith(`${operatorScope}-`)
if (!hasAccess) {
const departments = await prisma.department.findMany({ select: { name: true } })
const descendantMap = buildDepartmentDescendants(departments)
hasAccess = isDepartmentWithinScope(operatorScope, targetDepartment, descendantMap)
}
if (!hasAccess) {
return NextResponse.json(
{ error: '只能为所属组织及下级组织的素材设置精品' },
{ status: 403 }
)
}
}
if (targetMaterial.isPremium === isPremium) {
return NextResponse.json({ success: true, material: targetMaterial })
}
if (isPremium) {
const settings = await prisma.systemSettings.findFirst({
select: { premiumRatio: true },
})
const ratio = settings?.premiumRatio ?? 10
if (ratio <= 0) {
return NextResponse.json(
{ error: '当前已禁用精品作品,请先在系统设置中开启' },
{ status: 400 }
)
}
const [totalMaterials, currentPremiumCount] = await Promise.all([
prisma.material.count(),
prisma.material.count({ where: { isPremium: true } }),
])
if (totalMaterials === 0) {
return NextResponse.json(
{ error: '暂无素材,无法设置精品作品' },
{ status: 400 }
)
}
const allowedCount = Math.max(1, Math.floor((totalMaterials * ratio) / 100))
if (currentPremiumCount >= allowedCount) {
return NextResponse.json(
{
error: `精品作品已达上限(${allowedCount} 条),请调整比例或取消其他素材的精品状态`,
},
{ status: 400 }
)
}
}
const updated = await prisma.material.update({
where: { id: materialId },
data: { isPremium },
select: {
id: true,
isPremium: true,
},
})
// 记录切换精品状态日志
await createMaterialLog({
materialId,
userId: operatorId,
action: 'TOGGLE_PREMIUM',
changes: {
isPremium: {
oldValue: targetMaterial.isPremium,
newValue: isPremium,
},
},
})
return NextResponse.json({ success: true, material: updated })
} catch (error) {
console.error('[MaterialPremium] POST error:', error)
return NextResponse.json({ error: '更新精品状态失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
// Restore material from trash (admin only)
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any).role !== 'ADMIN') {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
)
}
// 从请求body中获取id
const body = await req.json()
const id = body.id
if (!id) {
return NextResponse.json(
{ error: '缺少素材ID' },
{ status: 400 }
)
}
// Restore material: set deletedAt to null
await prisma.material.update({
where: { id },
data: { deletedAt: null },
})
return NextResponse.json({ message: '恢复成功' })
} catch (error) {
console.error('[Materials] RESTORE error:', error)
return NextResponse.json(
{ error: '恢复失败' },
{ status: 500 }
)
}
}

View File

@ -2,6 +2,37 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createMaterialLog } from '@/lib/material-log'
import { deleteFromS3, isS3Url } from '@/lib/s3'
function parseDisplayImages(value: string | null): string[] {
if (!value) return []
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) {
return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
}
} catch (error) {
console.warn('[materials] Failed to parse displayImages JSON, fallback to single value:', error)
}
return value ? [value] : []
}
const ALLOWED_DYNAMIC_FILTER_FIELDS = new Set([
'renderer',
'brandName',
'seriesName',
'skuCode',
'materialTexture',
'renderTime',
'imageResolution',
'colorMode',
'productModel',
'platformName',
'categoryName',
'sceneName',
'authorName',
])
// Get all materials with search and filter support
export async function GET(req: NextRequest) {
@ -15,13 +46,35 @@ export async function GET(req: NextRequest) {
const platformName = searchParams.get('platform') || ''
const month = searchParams.get('month') || ''
const templateId = searchParams.get('templateId') || ''
const sortBy = searchParams.get('sortBy') || 'createdAt' // createdAt | downloadCount
const sortBy = searchParams.get('sortBy') || 'createdAt' // createdAt | downloadCount | likeCount
const page = parseInt(searchParams.get('page') || '1')
const pageSize = parseInt(searchParams.get('pageSize') || '20')
const showDeleted = searchParams.get('showDeleted') || ''
const year = searchParams.get('year') || ''
const dynamicFieldFilters: Array<{ key: string; value: string }> = []
searchParams.forEach((value, key) => {
if (key.startsWith('homeField_') && value) {
const fieldKey = key.replace('homeField_', '')
if (ALLOWED_DYNAMIC_FILTER_FIELDS.has(fieldKey)) {
dynamicFieldFilters.push({ key: fieldKey, value })
}
}
})
// Build where clause
const where: any = {}
const andConditions: any[] = []
// 根据showDeleted参数决定是否显示已删除的素材
// 注意:如果 deletedAt 字段不存在(迁移未完成),此查询可能会失败
// 需要先运行数据库迁移:docker exec flnxi-material npx prisma migrate deploy
if (showDeleted === 'true') {
// 只显示已删除的素材
andConditions.push({ deletedAt: { not: null } })
} else {
// 默认只查询未删除的素材
andConditions.push({ deletedAt: null })
}
// Add search condition (search in SPU, shop, scene category, author)
if (search) {
@ -70,6 +123,16 @@ export async function GET(req: NextRequest) {
lt: endDate
}
})
} else if (year) {
const parsedYear = parseInt(year, 10)
if (!Number.isNaN(parsedYear)) {
andConditions.push({
createdAt: {
gte: new Date(parsedYear, 0, 1),
lt: new Date(parsedYear + 1, 0, 1),
},
})
}
}
// Add template filter (模板筛选)
@ -107,6 +170,10 @@ export async function GET(req: NextRequest) {
}
}
dynamicFieldFilters.forEach(({ key, value }) => {
andConditions.push({ [key]: value } as Record<string, string>)
})
// 合并所有条件
if (andConditions.length > 0) {
where.AND = andConditions
@ -116,6 +183,8 @@ export async function GET(req: NextRequest) {
let orderBy: any = { createdAt: 'desc' }
if (sortBy === 'downloadCount') {
orderBy = { downloadCount: 'desc' }
} else if (sortBy === 'likeCount') {
orderBy = { likeCount: 'desc' }
}
// 计算总数和分页数据(并行查询,优化性能)
@ -127,6 +196,7 @@ export async function GET(req: NextRequest) {
select: {
id: true,
productModel: true,
materialKind: true,
brandName: true,
seriesName: true,
skuCode: true,
@ -141,6 +211,11 @@ export async function GET(req: NextRequest) {
colorMode: true,
authorName: true,
previewUrl: true,
videoCoverUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoProcessingStatus: true,
videoDuration: true,
zipFileName: true,
zipFileSize: true,
downloadCount: true,
@ -148,9 +223,11 @@ export async function GET(req: NextRequest) {
displayImages: true,
createdAt: true,
userId: true,
isPremium: true,
user: {
select: {
username: true,
avatar: true,
},
},
},
@ -163,27 +240,48 @@ export async function GET(req: NextRequest) {
// 如果用户已登录,查询点赞状态(优化:只在有数据时查询)
let userLikes: Set<string> = new Set()
let userFavorites: Set<string> = new Set()
if (session && materials.length > 0) {
const userId = (session.user as any).id
const materialIds = materials.map(m => m.id)
const likes = await prisma.materialLike.findMany({
where: {
userId,
materialId: { in: materialIds }
},
select: {
materialId: true
}
})
const [likes, favorites] = await Promise.all([
prisma.materialLike.findMany({
where: {
userId,
materialId: { in: materialIds }
},
select: {
materialId: true
}
}),
prisma.materialFavorite.findMany({
where: {
userId,
materialId: { in: materialIds }
},
select: {
materialId: true
}
})
])
userLikes = new Set(likes.map(l => l.materialId))
userFavorites = new Set(favorites.map(f => f.materialId))
}
// 添加点赞状态到每个素材,并转换BigInt为字符串
const materialsWithLikeStatus = materials.map(material => ({
...material,
zipFileSize: material.zipFileSize.toString(),
isLiked: userLikes.has(material.id)
}))
const materialsWithLikeStatus = materials.map((material) => {
const rawKind = (material.materialKind || '').trim().toUpperCase()
const normalizedKind = rawKind || (material.materialType === '视频' ? 'VIDEO' : 'IMAGE')
return {
...material,
materialKind: normalizedKind,
displayImages: parseDisplayImages(material.displayImages || null),
zipFileSize: material.zipFileSize.toString(),
isLiked: userLikes.has(material.id),
isFavorited: userFavorites.has(material.id),
}
})
return NextResponse.json({
materials: materialsWithLikeStatus,
@ -200,7 +298,7 @@ export async function GET(req: NextRequest) {
}
}
// Delete material (admin only)
// Delete material (admin only) - 软删除
export async function DELETE(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
@ -214,6 +312,7 @@ export async function DELETE(req: NextRequest) {
const { searchParams } = new URL(req.url)
const id = searchParams.get('id')
const permanent = searchParams.get('permanent') === 'true' // 永久删除
if (!id) {
return NextResponse.json(
@ -234,12 +333,123 @@ export async function DELETE(req: NextRequest) {
)
}
// Delete from database
await prisma.material.delete({
where: { id },
})
// 获取用户ID用于日志记录
const userId = (session.user as any).id
// TODO: Delete files from S3 and local storage
if (permanent) {
// 永久删除前记录日志
await createMaterialLog({
materialId: id,
userId,
action: 'DELETE',
changes: {
permanent: {
oldValue: false,
newValue: true,
},
},
})
// 收集所有需要删除的文件URL
const filesToDelete: string[] = []
// 源文件(zipFilePath 或 videoSourceUrl)
if (material.zipFilePath) filesToDelete.push(material.zipFilePath)
if ((material as any).videoSourceUrl && (material as any).videoSourceUrl !== material.zipFilePath) {
filesToDelete.push((material as any).videoSourceUrl)
}
// 预览图
if (material.previewUrl) filesToDelete.push(material.previewUrl)
// 多预览图(JSON数组)
if (material.previewUrls) {
try {
const previewUrls = JSON.parse(material.previewUrls)
if (Array.isArray(previewUrls)) {
filesToDelete.push(...previewUrls.filter((url: string) => url && url.trim()))
}
} catch {
// 忽略解析错误
}
}
// 视频相关文件
if ((material as any).videoPreview540Url) filesToDelete.push((material as any).videoPreview540Url)
if ((material as any).videoPreviewClipUrl) filesToDelete.push((material as any).videoPreviewClipUrl)
if ((material as any).videoCoverUrl) filesToDelete.push((material as any).videoCoverUrl)
// 展示图片(JSON数组)
if (material.displayImages) {
try {
const displayImages = JSON.parse(material.displayImages)
if (Array.isArray(displayImages)) {
filesToDelete.push(...displayImages.filter((url: string) => url && url.trim()))
}
} catch {
// 忽略解析错误
}
}
// 去重并过滤空值
const uniqueFiles = Array.from(new Set(filesToDelete.filter((url) => url && url.trim())))
// 删除所有文件(S3或本地)
let deletedFiles = 0
let failedFiles = 0
for (const fileUrl of uniqueFiles) {
try {
if (isS3Url(fileUrl)) {
await deleteFromS3(fileUrl)
deletedFiles++
console.log(`[Delete] Deleted S3 file: ${fileUrl}`)
} else {
// 本地文件(如果存在)
const { unlink } = await import('fs/promises')
try {
await unlink(fileUrl)
deletedFiles++
console.log(`[Delete] Deleted local file: ${fileUrl}`)
} catch (fileError: any) {
if (fileError.code !== 'ENOENT') {
console.error(`[Delete] Failed to delete local file: ${fileUrl}`, fileError)
failedFiles++
}
}
}
} catch (error) {
console.error(`[Delete] Failed to delete file: ${fileUrl}`, error)
failedFiles++
}
}
console.log(`[Delete] Material ${id} files cleanup: ${deletedFiles} deleted, ${failedFiles} failed`)
// 永久删除数据库记录
await prisma.material.delete({
where: { id },
})
} else {
// 软删除:设置 deletedAt
await prisma.material.update({
where: { id },
data: { deletedAt: new Date() },
})
// 记录软删除日志
await createMaterialLog({
materialId: id,
userId,
action: 'DELETE',
changes: {
permanent: {
oldValue: false,
newValue: false,
},
},
})
}
return NextResponse.json({ message: '删除成功' })
} catch (error) {
@ -251,3 +461,4 @@ export async function DELETE(req: NextRequest) {
}
}

View File

@ -3,6 +3,19 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
function parseDisplayImages(value: string | null): string[] {
if (!value) return []
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) {
return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
}
} catch (error) {
console.warn('[materials/share] Failed to parse displayImages JSON, fallback to single value:', error)
}
return value ? [value] : []
}
export const dynamic = 'force-dynamic'
// Get single material for sharing (公开访问,无需登录)
@ -41,7 +54,8 @@ export async function GET(req: NextRequest) {
downloadCount: true,
likeCount: true,
displayImages: true,
createdAt: true
createdAt: true,
isPremium: true,
}
})
@ -55,7 +69,8 @@ export async function GET(req: NextRequest) {
// 转换BigInt为字符串
const materialData = {
...material,
zipFileSize: material.zipFileSize.toString()
zipFileSize: material.zipFileSize.toString(),
displayImages: parseDisplayImages(material.displayImages),
}
return NextResponse.json(materialData)

View File

@ -16,11 +16,11 @@ export async function GET() {
count: bigint
}>>`
SELECT
EXTRACT(YEAR FROM created_at)::int as year,
EXTRACT(MONTH FROM created_at)::int as month,
EXTRACT(YEAR FROM "createdAt")::int as year,
EXTRACT(MONTH FROM "createdAt")::int as month,
COUNT(*)::bigint as count
FROM materials
GROUP BY EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at)
FROM "materials"
GROUP BY EXTRACT(YEAR FROM "createdAt"), EXTRACT(MONTH FROM "createdAt")
ORDER BY year DESC, month DESC
`
])

View File

@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getS3ObjectStream, isS3Url } from '@/lib/s3'
import { readFile, access } from 'fs/promises'
import path from 'path'
import { Readable } from 'stream'
export const dynamic = 'force-dynamic'
@ -37,29 +39,15 @@ export async function GET(req: NextRequest) {
)
}
// Read file from local storage
let filePath = material.zipFilePath
if (!path.isAbsolute(filePath)) {
filePath = path.join(process.cwd(), filePath)
}
console.log('Streaming video file:', filePath)
// 检查文件是否存在
try {
await access(filePath)
} catch (error) {
console.error('File not found:', filePath)
if (!material.zipFilePath) {
return NextResponse.json(
{ error: '文件不存在,可能已被删除' },
{ error: '未找到素材文件,请重新上传' },
{ status: 404 }
)
}
const fileBuffer = await readFile(filePath)
// 根据文件扩展名确定Content-Type
const originalExt = path.extname(material.zipFileName).toLowerCase()
const originalExt = path.extname(material.zipFileName || material.zipFilePath || '').toLowerCase()
const videoTypes: { [key: string]: string } = {
'.mp4': 'video/mp4',
'.avi': 'video/x-msvideo',
@ -71,8 +59,48 @@ export async function GET(req: NextRequest) {
}
const contentType = videoTypes[originalExt] || 'video/mp4'
// 支持Range请求(视频流式传输)
const range = req.headers.get('range')
if (isS3Url(material.zipFilePath)) {
const s3Object = await getS3ObjectStream(material.zipFilePath, range)
const bodyStream = toWebStream(s3Object.body)
const headers: Record<string, string> = {
'Content-Type': s3Object.contentType || contentType,
'Accept-Ranges': s3Object.acceptRanges || 'bytes',
'Cache-Control': 'public, max-age=3600',
}
if (range && s3Object.contentRange) {
headers['Content-Range'] = s3Object.contentRange
}
if (!range && s3Object.contentLength) {
headers['Content-Length'] = s3Object.contentLength.toString()
}
const status = range ? 206 : 200
return new NextResponse(bodyStream as any, {
status,
headers,
})
}
// Legacy local file support
let filePath = material.zipFilePath
if (!path.isAbsolute(filePath)) {
filePath = path.join(process.cwd(), filePath)
}
console.log('Streaming legacy video file:', filePath)
try {
await access(filePath)
} catch (error) {
console.error('File not found:', filePath)
return NextResponse.json(
{ error: '文件不存在,可能已被删除' },
{ status: 404 }
)
}
const fileBuffer = await readFile(filePath)
if (range) {
const fileSize = fileBuffer.length
const parts = range.replace(/bytes=/, '').split('-')
@ -88,12 +116,11 @@ export async function GET(req: NextRequest) {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize.toString(),
'Cache-Control': 'public, max-age=3600', // 缓存1小时
'Cache-Control': 'public, max-age=3600',
},
})
}
// 非Range请求,返回完整文件
return new NextResponse(fileBuffer as any, {
headers: {
'Content-Type': contentType,
@ -111,3 +138,19 @@ export async function GET(req: NextRequest) {
}
}
function toWebStream(body: any) {
if (!body) {
throw new Error('S3 object body is empty')
}
if (typeof (body as any).transformToWebStream === 'function') {
return (body as any).transformToWebStream()
}
if (body instanceof Readable) {
return Readable.toWeb(body)
}
return body as ReadableStream<any>
}

View File

@ -0,0 +1,261 @@
/**
* 视频转码 API
*
* 处理视频素材的转码任务:
* - 生成 540P 预览视频
* - 生成 10 秒超小预览片段
* - 从视频中截取封面图(如果用户没有上传)
*/
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { processVideoTranscode } from '@/lib/video-processor'
import { transcodeQueue } from '@/lib/video-transcode-queue'
import { tmpdir } from 'os'
import { join } from 'path'
export const dynamic = 'force-dynamic'
export const maxDuration = 3600 // 1 hour timeout for transcode
/**
* GET /api/materials/transcode?materialId=xxx
* 查询视频转码状态
*/
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const materialId = searchParams.get('materialId')
if (!materialId) {
return NextResponse.json(
{ error: '缺少素材ID' },
{ status: 400 }
)
}
const material = await prisma.material.findUnique({
where: { id: materialId },
select: {
id: true,
videoProcessingStatus: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoDuration: true,
},
})
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
)
}
return NextResponse.json({
materialId: material.id,
status: material.videoProcessingStatus || 'pending',
hasPreview540: !!material.videoPreview540Url,
hasClip: !!material.videoPreviewClipUrl,
hasCover: !!material.videoCoverUrl,
duration: material.videoDuration,
})
} catch (error) {
console.error('[Transcode] GET error:', error)
return NextResponse.json(
{ error: '查询转码状态失败' },
{ status: 500 }
)
}
}
/**
* POST /api/materials/transcode
* 触发视频转码任务
*/
export async function POST(req: NextRequest) {
try {
const body = await req.json().catch(() => ({}))
const { materialId } = body
if (!materialId) {
return NextResponse.json(
{ error: '缺少素材ID' },
{ status: 400 }
)
}
// 获取素材信息
const material = await prisma.material.findUnique({
where: { id: materialId },
select: {
id: true,
// @ts-ignore - materialKind 字段已在 schema 中定义
materialKind: true,
videoSourceUrl: true,
videoCoverUrl: true,
videoProcessingStatus: true,
materialType: true,
},
})
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
)
}
// 检查是否为视频素材
const materialKind = (material as any).materialKind || ''
const isVideo = materialKind.toUpperCase() === 'VIDEO' || material.materialType === '视频'
if (!isVideo) {
return NextResponse.json(
{ error: '此素材不是视频类型' },
{ status: 400 }
)
}
// 检查是否已有源视频
if (!material.videoSourceUrl) {
return NextResponse.json(
{ error: '未找到源视频文件' },
{ status: 400 }
)
}
// 检查转码状态
const forceRetry = body.forceRetry === true // 允许强制重新转码
if (material.videoProcessingStatus === 'processing' && !forceRetry) {
return NextResponse.json(
{ error: '转码任务正在进行中', materialId },
{ status: 409 }
)
}
if (material.videoProcessingStatus === 'done' && !forceRetry) {
return NextResponse.json(
{ message: '转码已完成', materialId },
{ status: 200 }
)
}
// 如果强制重试,重置状态
if (forceRetry && material.videoProcessingStatus === 'done') {
console.log(`[Transcode] Force retry requested for material ${materialId}`)
}
// 更新状态为 processing
await prisma.material.update({
where: { id: materialId },
data: {
videoProcessingStatus: 'processing',
},
})
// 异步执行转码任务(不阻塞响应)
// needsCoverImage: 如果用户已经上传了封面图,则不需要从视频中截取
const needsCoverImage = !material.videoCoverUrl
// 使用队列管理转码任务,避免并发过多导致系统过载
transcodeQueue.add(materialId, material.videoSourceUrl, needsCoverImage, processVideoTranscodeTask).catch(
(error) => {
console.error(`[Transcode] Task failed for material ${materialId}:`, error)
// 更新状态为 failed
prisma.material
.update({
where: { id: materialId },
data: {
videoProcessingStatus: 'failed',
},
})
.catch((dbError) => {
console.error(`[Transcode] Failed to update status to failed:`, dbError)
})
}
)
return NextResponse.json({
message: '转码任务已启动',
materialId,
status: 'processing',
})
} catch (error) {
console.error('[Transcode] API error:', error)
return NextResponse.json(
{ error: '转码任务启动失败' },
{ status: 500 }
)
}
}
/**
* 执行转码任务(异步,带重试机制)
*/
async function processVideoTranscodeTask(
materialId: string,
videoSourceUrl: string,
needsCoverImage: boolean,
retryCount: number = 0
): Promise<void> {
const MAX_RETRIES = parseInt(process.env.VIDEO_TRANSCODE_MAX_RETRIES || '2', 10)
try {
console.log(`[Transcode] Starting transcode task for material ${materialId}${retryCount > 0 ? ` (retry ${retryCount}/${MAX_RETRIES})` : ''}...`)
const outputDir = tmpdir()
const result = await processVideoTranscode({
inputPath: videoSourceUrl,
outputDir,
materialId,
needsCoverImage,
})
// 更新数据库
const updateData: any = {
videoProcessingStatus: 'done',
videoPreview540Url: result.preview540Url,
videoPreviewClipUrl: result.clipUrl,
videoDuration: result.duration,
}
// 如果用户没有上传封面图,使用自动截取的封面图
if (needsCoverImage && result.coverUrl) {
updateData.videoCoverUrl = result.coverUrl
// 同时更新 previewUrl(如果当前是占位符)
const material = await prisma.material.findUnique({
where: { id: materialId },
select: { previewUrl: true },
})
if (material?.previewUrl?.startsWith('data:image')) {
updateData.previewUrl = result.coverUrl
}
}
await prisma.material.update({
where: { id: materialId },
data: updateData,
})
console.log(`[Transcode] Transcode task completed for material ${materialId}`)
} catch (error) {
console.error(`[Transcode] Transcode task failed for material ${materialId}:`, error)
// 重试机制
if (retryCount < MAX_RETRIES) {
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000) // 指数退避,最多30秒
console.log(`[Transcode] Retrying transcode task for material ${materialId} after ${delay}ms...`)
await new Promise((resolve) => setTimeout(resolve, delay))
// 递归重试
return processVideoTranscodeTask(materialId, videoSourceUrl, needsCoverImage, retryCount + 1)
} else {
// 达到最大重试次数,标记为失败
console.error(`[Transcode] Max retries (${MAX_RETRIES}) reached for material ${materialId}, marking as failed`)
throw error
}
}
}

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createMaterialLog } from '@/lib/material-log'
import { uploadToS3, deleteFromS3 } from '@/lib/s3'
import { processImage, getProcessedFileName } from '@/lib/image-processor'
@ -88,6 +89,8 @@ export async function PUT(req: NextRequest) {
)
}
const userId = (session.user as any).id
// Update material record
const material = await prisma.material.update({
where: { id },
@ -116,6 +119,42 @@ export async function PUT(req: NextRequest) {
},
})
// 记录变更日志
const changedFields: Record<string, { oldValue: any; newValue: any }> = {}
const collectChange = (key: string, newValue: any) => {
const oldValue = (existingMaterial as any)[key]
const normOld = oldValue ?? null
const normNew = newValue ?? null
if (normOld === null && normNew === null) return
if (String(normOld) !== String(normNew)) {
changedFields[key] = { oldValue: normOld, newValue: normNew }
}
}
collectChange('previewUrl', previewUrl)
collectChange('productModel', productModel)
collectChange('brandName', brandName)
collectChange('seriesName', seriesName)
collectChange('skuCode', skuCode)
collectChange('materialTexture', materialTexture)
collectChange('categoryName', categoryName)
collectChange('sceneName', sceneName)
collectChange('materialType', materialType)
collectChange('renderer', renderer)
collectChange('renderTime', renderTime)
collectChange('imageResolution', imageResolution)
collectChange('colorMode', colorMode)
collectChange('authorName', authorName)
if (Object.keys(changedFields).length > 0) {
await createMaterialLog({
materialId: id,
userId,
action: 'UPDATE',
changes: changedFields,
})
}
// 转换BigInt为字符串
const materialData = {
...material,

View File

@ -2,12 +2,41 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createMaterialLog } from '@/lib/material-log'
import { uploadToS3 } from '@/lib/s3'
import { processImage, getProcessedFileName, sliceImage } from '@/lib/image-processor'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { randomBytes } from 'crypto'
const MATERIAL_ID_PREFIX = 'FLNXI'
type MaterialKind = 'IMAGE' | 'VIDEO' | 'OTHER'
async function generateMaterialId() {
const now = new Date()
const dateStr = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
].join('')
for (let attempt = 0; attempt < 5; attempt += 1) {
const randomSuffix = Math.floor(Math.random() * 100000)
.toString()
.padStart(5, '0')
const candidateId = `${MATERIAL_ID_PREFIX}${dateStr}${randomSuffix}`
const exists = await prisma.material.findUnique({
where: { id: candidateId },
select: { id: true },
})
if (!exists) {
return candidateId
}
}
const fallbackSuffix = Date.now().toString().slice(-5)
return `${MATERIAL_ID_PREFIX}${dateStr}${fallbackSuffix}`
}
// 生成唯一文件名(避免并发冲突)
function generateUniqueFileName(prefix: string, ext: string): string {
const timestamp = Date.now()
@ -22,6 +51,11 @@ export const dynamic = 'force-dynamic'
// Increase timeout for large file uploads
export const maxDuration = 10800 // 3 hours timeout (10800 seconds)
const VIDEO_PENDING_PLACEHOLDER = (() => {
const svg = `<svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" fill="#0f172a"/><text x="50%" y="46%" font-size="42" fill="#ffffff" text-anchor="middle" font-family="PingFang SC, 'Microsoft YaHei', 'Segoe UI', sans-serif">等待转码</text><text x="50%" y="62%" font-size="20" fill="#94a3b8" text-anchor="middle" font-family="PingFang SC, 'Microsoft YaHei', 'Segoe UI', sans-serif">预览生成中…</text></svg>`
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
})()
export async function POST(req: NextRequest) {
try {
console.log('[Upload] Starting upload process...')
@ -44,15 +78,28 @@ export async function POST(req: NextRequest) {
return trimmed ? trimmed : null
}
const previewImage = formData.get('previewImage') as File
const previewImage = formData.get('previewImage') as File // 向后兼容:第一张预览图
const previewImages = formData.getAll('previewImages') as File[] // 多预览图(最多5张)
const displayImage = formData.get('displayImage') as File // PSD素材的展示图片
const modelImages = formData.getAll('modelImages') as File[] // 模特类型的多张展示图片
const zipFile = formData.get('zipFile') as File
const productModel = formData.get('productModel') as string
const categoryName = formData.get('categoryName') as string
const sceneName = formData.get('sceneName') as string
const platformName = formData.get('platformName') as string || '' // 平台分类(可选)
const materialType = (formData.get('materialType') as string) || '3DMAX'
const rawMaterialKind = (formData.get('materialKind') as string) || ''
const allowedMaterialKinds: MaterialKind[] = ['IMAGE', 'VIDEO', 'OTHER']
const normalizedKind = rawMaterialKind
? rawMaterialKind.toUpperCase()
: materialType === '视频'
? 'VIDEO'
: 'IMAGE'
const materialKind: MaterialKind = allowedMaterialKinds.includes(normalizedKind as MaterialKind)
? (normalizedKind as MaterialKind)
: materialType === '视频'
? 'VIDEO'
: 'IMAGE'
const isVideoMaterial = materialKind === 'VIDEO'
const renderer = (formData.get('renderer') as string) || 'CR'
const brandName = normalizeInput(formData.get('brandName'))
const seriesName = normalizeInput(formData.get('seriesName'))
@ -79,7 +126,6 @@ export async function POST(req: NextRequest) {
previewImageSize: previewImage?.size,
hasDisplayImage: !!displayImage,
displayImageSize: displayImage?.size,
modelImagesCount: modelImages.length,
hasZipFile: !!zipFile,
zipFileSize: zipFile?.size,
productModel,
@ -88,7 +134,7 @@ export async function POST(req: NextRequest) {
materialType
})
console.log('[Upload] Material type:', materialType)
console.log('[Upload] Material type/kind:', materialType, materialKind)
// Validation - 基础字段验证(材质类型不需要分类和场景)
if (materialType !== '材质' && (!productModel || !categoryName || !sceneName)) {
@ -118,7 +164,7 @@ export async function POST(req: NextRequest) {
}
// 对于视频类型,必须有视频文件
if (materialType === '视频' && !zipFile) {
if (isVideoMaterial && !zipFile) {
console.log('[Upload] Validation failed - video requires video file')
return NextResponse.json(
{ error: '请上传视频文件' },
@ -135,27 +181,13 @@ export async function POST(req: NextRequest) {
)
}
// 对于模特类型,必须有至少一张展示图片
if (materialType === '模特' && modelImages.length === 0) {
console.log('[Upload] Validation failed - Model requires model images')
return NextResponse.json(
{ error: '模特素材必须上传至少一张展示图片' },
{ status: 400 }
)
}
// 处理多预览图(优先使用previewImages,如果没有则使用previewImage向后兼容)
const imagesToProcess = previewImages.length > 0 ? previewImages : (previewImage ? [previewImage] : [])
// 对于视频类型,必须有预览图(封面)
if (materialType === '视频' && !previewImage) {
console.log('[Upload] Validation failed - video requires preview image')
return NextResponse.json(
{ error: '视频素材需要上传预览图(封面)' },
{ status: 400 }
)
}
// 对于非PSD、非模特、非视频类型,必须有预览图
if (materialType !== 'PSD' && materialType !== '模特' && materialType !== '视频' && materialType !== '材质' && !previewImage) {
console.log('[Upload] Validation failed - non-PSD/Model/Video requires preview image')
// 对于视频类型,封面图是可选的(如果没有上传,转码任务会自动从视频中截取)
// 对于非PSD、非模特、非视频、非材质类型,必须有预览图(包括3DMAX等类型)
if (materialType !== 'PSD' && materialType !== '视频' && materialType !== '材质' && imagesToProcess.length === 0) {
console.log('[Upload] Validation failed - non-PSD/Video/Material types require preview image')
return NextResponse.json(
{ error: '请上传预览图' },
{ status: 400 }
@ -182,102 +214,90 @@ export async function POST(req: NextRequest) {
console.log('[Upload] User found:', authorName)
let previewUrl: string = ''
let previewUrls: string[] = [] // 多预览图URLs
let hasUserUploadedCover = false // 标记用户是否实际上传了封面图(用于视频素材)
// 处理预览图(非PSD、非模特、非材质类型,以及视频类型需要封面)
if (materialType !== 'PSD' && materialType !== '模特' && materialType !== '材质' && previewImage) {
// 验证预览图大小(限制50MB)
const maxPreviewSize = 50 * 1024 * 1024 // 50MB
if (previewImage.size > maxPreviewSize) {
console.log(`[Upload] Preview image too large: ${(previewImage.size / 1024 / 1024).toFixed(2)}MB`)
return NextResponse.json(
{ error: `预览图过大,请使用小于50MB的图片(当前:${(previewImage.size / 1024 / 1024).toFixed(2)}MB)` },
{ status: 400 }
)
}
// 处理多预览图(非PSD、非模特、非材质类型,以及视频类型需要封面)
const shouldProcessPreviewImages =
imagesToProcess.length > 0 && (isVideoMaterial || (materialType !== 'PSD' && materialType !== '材质'))
// 验证预览图格式
if (shouldProcessPreviewImages) {
const allowedImageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']
if (!allowedImageTypes.includes(previewImage.type)) {
console.log(`[Upload] Invalid image type: ${previewImage.type}`)
return NextResponse.json(
{ error: `不支持的图片格式:${previewImage.type},请使用 JPG、PNG、WebP 或 GIF 格式` },
{ status: 400 }
)
const maxPreviewSize = 50 * 1024 * 1024 // 50MB
// 验证所有预览图
for (const img of imagesToProcess) {
if (img.size > maxPreviewSize) {
console.log(`[Upload] Preview image too large: ${(img.size / 1024 / 1024).toFixed(2)}MB`)
return NextResponse.json(
{ error: `预览图过大,请使用小于50MB的图片(当前:${(img.size / 1024 / 1024).toFixed(2)}MB)` },
{ status: 400 }
)
}
if (!allowedImageTypes.includes(img.type)) {
console.log(`[Upload] Invalid image type: ${img.type}`)
return NextResponse.json(
{ error: `不支持的图片格式:${img.type},请使用 JPG、PNG、WebP 或 GIF 格式` },
{ status: 400 }
)
}
}
// 处理预览图:压缩到300KB以内,转换为webP,缩放适配300x400显示
console.log(`[Upload] Processing preview image... (${previewImage.type}, ${(previewImage.size / 1024).toFixed(2)}KB)`)
// 处理所有预览图
console.log(`[Upload] Processing ${imagesToProcess.length} preview images...`)
try {
const originalBuffer = Buffer.from(await previewImage.arrayBuffer())
const processedBuffer = await processImage(originalBuffer, {
maxSizeKB: 300,
maxWidth: 600,
maxHeight: 800,
format: 'webp',
})
console.log('[Upload] Preview image processed successfully')
for (let i = 0; i < imagesToProcess.length; i++) {
const img = imagesToProcess[i]
console.log(`[Upload] Processing preview image ${i + 1}/${imagesToProcess.length}... (${img.type}, ${(img.size / 1024).toFixed(2)}KB)`)
try {
const originalBuffer = Buffer.from(await img.arrayBuffer())
console.log(`[Upload] Preview image ${i + 1} buffer size: ${originalBuffer.length} bytes`)
const processedBuffer = await processImage(originalBuffer, {
maxSizeKB: 300,
maxWidth: 600,
maxHeight: 800,
format: 'webp',
})
console.log(`[Upload] Preview image ${i + 1} processed, size: ${processedBuffer.length} bytes`)
// Upload processed preview image to S3
console.log('[Upload] Uploading preview image to S3...')
const processedFileName = getProcessedFileName(previewImage.name)
const fileExt = path.extname(processedFileName)
const previewFileName = generateUniqueFileName('preview', fileExt)
previewUrl = await uploadToS3(processedBuffer, previewFileName, 'image/webp')
console.log('[Upload] Preview image uploaded to S3:', previewUrl)
const processedFileName = getProcessedFileName(img.name)
const fileExt = path.extname(processedFileName)
const previewFileName = generateUniqueFileName(`preview-${i}`, fileExt)
console.log(`[Upload] Uploading preview image ${i + 1} to S3: ${previewFileName}`)
const uploadedUrl = await uploadToS3(processedBuffer, previewFileName, 'image/webp')
previewUrls.push(uploadedUrl)
console.log(`[Upload] Preview image ${i + 1} uploaded to S3:`, uploadedUrl)
} catch (singleImageError) {
console.error(`[Upload] Error processing preview image ${i + 1}:`, singleImageError)
if (singleImageError instanceof Error) {
console.error(`[Upload] Error details: ${singleImageError.message}`)
console.error(`[Upload] Error stack: ${singleImageError.stack}`)
}
throw new Error(`预览图 ${i + 1} 处理失败:${singleImageError instanceof Error ? singleImageError.message : '未知错误'}`)
}
}
// 第一张作为主预览图(向后兼容)
previewUrl = previewUrls[0] || ''
hasUserUploadedCover = previewUrl.length > 0 // 用户实际上传了封面图
console.log('[Upload] All preview images processed successfully, previewUrl:', previewUrl ? `${previewUrl.substring(0, 50)}...` : 'EMPTY')
} catch (imageError) {
console.error('[Upload] Preview image processing error:', imageError)
if (imageError instanceof Error) {
console.error('[Upload] Preview image error name:', imageError.name)
console.error('[Upload] Preview image error message:', imageError.message)
console.error('[Upload] Preview image error stack:', imageError.stack)
}
return NextResponse.json(
{ error: `预览图处理失败:${imageError instanceof Error ? imageError.message : '未知错误'}` },
{ status: 500 }
)
}
}
// 处理视频类型的预览图(封面)
if (materialType === '视频' && previewImage) {
// 验证预览图大小(限制50MB)
const maxPreviewSize = 50 * 1024 * 1024 // 50MB
if (previewImage.size > maxPreviewSize) {
console.log(`[Upload] Preview image too large: ${(previewImage.size / 1024 / 1024).toFixed(2)}MB`)
return NextResponse.json(
{ error: `预览图过大,请使用小于50MB的图片(当前:${(previewImage.size / 1024 / 1024).toFixed(2)}MB)` },
{ status: 400 }
)
}
// 验证预览图格式
const allowedImageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']
if (!allowedImageTypes.includes(previewImage.type)) {
console.log(`[Upload] Invalid image type: ${previewImage.type}`)
return NextResponse.json(
{ error: `不支持的图片格式:${previewImage.type},请使用 JPG、PNG、WebP 或 GIF 格式` },
{ status: 400 }
)
}
// 处理预览图:压缩到300KB以内,转换为webP,缩放适配300x400显示
console.log(`[Upload] Processing video preview image (cover)... (${previewImage.type}, ${(previewImage.size / 1024).toFixed(2)}KB)`)
try {
const originalBuffer = Buffer.from(await previewImage.arrayBuffer())
const processedBuffer = await processImage(originalBuffer, {
maxSizeKB: 300,
maxWidth: 600,
maxHeight: 800,
format: 'webp',
})
console.log('[Upload] Video preview image processed successfully')
// Upload processed preview image to S3
console.log('[Upload] Uploading video preview image to S3...')
const processedFileName = getProcessedFileName(previewImage.name)
const fileExt = path.extname(processedFileName)
const previewFileName = generateUniqueFileName('preview', fileExt)
previewUrl = await uploadToS3(processedBuffer, previewFileName, 'image/webp')
console.log('[Upload] Video preview image uploaded to S3:', previewUrl)
} catch (imageError) {
console.error('[Upload] Video preview image processing error:', imageError)
return NextResponse.json(
{ error: `视频封面处理失败:${imageError instanceof Error ? imageError.message : '未知错误'}` },
{
error: `预览图处理失败:${imageError instanceof Error ? imageError.message : '未知错误'}`,
details: imageError instanceof Error ? imageError.stack : undefined,
errorType: imageError instanceof Error ? imageError.name : 'UnknownError'
},
{ status: 500 }
)
}
@ -318,46 +338,7 @@ export async function POST(req: NextRequest) {
}
}
// Handle Model display images (模特类型的多图处理)
if (materialType === '模特' && modelImages.length > 0) {
console.log(`[Upload] Processing ${modelImages.length} model images...`)
try {
// 处理每张模特图片
for (let i = 0; i < modelImages.length; i++) {
const modelImage = modelImages[i]
console.log(`[Upload] Processing model image ${i + 1}/${modelImages.length}...`)
// 读取图片
const imageBuffer = Buffer.from(await modelImage.arrayBuffer())
// 切割图片(按1500px高度)
const slicedBuffers = await sliceImage(imageBuffer, 1500)
console.log(`[Upload] Model image ${i + 1} sliced into ${slicedBuffers.length} parts`)
// 上传每个切片到S3
for (let j = 0; j < slicedBuffers.length; j++) {
const sliceFileName = generateUniqueFileName(`model-${i + 1}-${j + 1}`, '.webp')
const sliceUrl = await uploadToS3(slicedBuffers[j], sliceFileName, 'image/webp')
displayImages.push(sliceUrl)
console.log(`[Upload] Model image ${i + 1} slice ${j + 1}/${slicedBuffers.length} uploaded to S3:`, sliceUrl)
}
}
// 使用第一张图片作为预览图
if (displayImages.length > 0) {
previewUrl = displayImages[0]
console.log('[Upload] Using first model image as preview:', previewUrl)
}
} catch (modelImageError) {
console.error('[Upload] Model image processing error:', modelImageError)
return NextResponse.json(
{ error: `模特图片处理失败:${modelImageError instanceof Error ? modelImageError.message : '未知错误'}` },
{ status: 500 }
)
}
}
// Save zip/PSD/视频 file locally (if exists - 模特类型和材质类型可以没有素材文件)
// Save zip/PSD/视频 file to S3 (if exists - 模特类型和材质类型可以没有素材文件)
let zipFileName = ''
let zipFilePath = ''
let zipFileSize = BigInt(0)
@ -365,95 +346,500 @@ export async function POST(req: NextRequest) {
if (!zipFile) {
console.log('[Upload] No file provided (Model/Material type with images only), skipping file save...')
} else {
console.log('[Upload] Saving file locally...')
const uploadDir = process.env.UPLOAD_DIR || './uploads'
await mkdir(uploadDir, { recursive: true })
const zipFileExt = path.extname(zipFile.name)
zipFileName = generateUniqueFileName('material', zipFileExt)
zipFilePath = path.join(uploadDir, zipFileName)
// 读取并保存素材文件(包括视频文件)
const zipBuffer = Buffer.from(await zipFile.arrayBuffer())
await writeFile(zipFilePath, zipBuffer)
zipFileSize = BigInt(zipBuffer.length) // 记录文件大小(字节)
console.log('[Upload] File saved:', zipFilePath, `(${(Number(zipFileSize) / 1024 / 1024).toFixed(2)} MB)`)
console.log('[Upload] Uploading original file to S3...')
try {
const zipFileExt = path.extname(zipFile.name)
zipFileName = generateUniqueFileName('material', zipFileExt)
// 读取并上传素材文件(包括视频文件)
const zipBuffer = Buffer.from(await zipFile.arrayBuffer())
zipFileSize = BigInt(zipBuffer.length) // 记录文件大小(字节)
const contentType = zipFile.type || 'application/octet-stream'
zipFilePath = await uploadToS3(zipBuffer, zipFileName, contentType)
console.log('[Upload] File uploaded to bucket:', zipFilePath, `(${(Number(zipFileSize) / 1024 / 1024).toFixed(2)} MB)`)
} catch (fileError) {
console.error('[Upload] File save error:', fileError)
if (fileError instanceof Error) {
console.error('[Upload] File save error message:', fileError.message)
console.error('[Upload] File save error stack:', fileError.stack)
}
return NextResponse.json(
{ error: `文件保存失败:${fileError instanceof Error ? fileError.message : '未知错误'}` },
{ status: 500 }
)
}
}
// Create material record
console.log('[Upload] Creating database record...')
// 构建 metadata,包含模板字段值
console.log('[Upload] Preview URL status:', {
hasPreviewUrl: !!previewUrl,
previewUrlLength: previewUrl?.length || 0,
previewUrlsCount: previewUrls.length,
materialType,
imagesToProcessCount: imagesToProcess.length,
isVideoMaterial,
})
// 验证必填字段 - previewUrl是数据库必填字段
// 对于视频素材,如果用户没有上传封面图,使用占位符(转码任务会自动生成封面图)
if (!previewUrl || previewUrl.trim() === '') {
if (isVideoMaterial) {
// 视频素材没有上传封面图时,使用占位符URL(转码任务会自动生成)
previewUrl = VIDEO_PENDING_PLACEHOLDER
console.log('[Upload] Video material without cover image, using placeholder')
} else {
console.error('[Upload] Validation failed - previewUrl is required but empty', {
materialType,
hasPreviewImages: imagesToProcess.length > 0,
previewUrlsCount: previewUrls.length,
hasDisplayImages: displayImages.length > 0,
})
return NextResponse.json(
{ error: '预览图处理失败,请重新上传预览图。如果已上传预览图,可能是图片处理出错,请检查图片格式是否正确' },
{ status: 400 }
)
}
}
if (!productModel || productModel.trim() === '') {
console.error('[Upload] Validation failed - productModel is required')
return NextResponse.json(
{ error: '产品型号不能为空' },
{ status: 400 }
)
}
if (materialType !== '材质' && (!categoryName || categoryName.trim() === '')) {
console.error('[Upload] Validation failed - categoryName is required for non-material type')
return NextResponse.json(
{ error: '分类不能为空' },
{ status: 400 }
)
}
if (materialType !== '材质' && (!sceneName || sceneName.trim() === '')) {
console.error('[Upload] Validation failed - sceneName is required for non-material type')
return NextResponse.json(
{ error: '场景不能为空' },
{ status: 400 }
)
}
// 构建 metadata,包含模板字段值和多预览图URLs
let metadata: string | null = null
const metadataObj: any = {}
if (templateId && Object.keys(templateFields).length > 0) {
metadata = JSON.stringify({
templateId,
templateFields,
})
metadataObj.templateId = templateId
metadataObj.templateFields = templateFields
}
if (previewUrls.length > 0) {
metadataObj.previewUrls = previewUrls
}
if (Object.keys(metadataObj).length > 0) {
metadata = JSON.stringify(metadataObj)
}
const material = await prisma.material.create({
data: {
previewUrl,
productModel,
brandName,
seriesName,
skuCode,
materialTexture,
categoryName: materialType === '材质' ? '通用' : categoryName, // 材质类型使用默认分类
sceneName: materialType === '材质' ? '通用' : sceneName, // 材质类型使用默认场景
platformName: platformName || '', // 平台分类(可选)
materialType,
renderer,
renderTime,
imageResolution,
colorMode,
authorName,
zipFileName,
zipFilePath,
zipFileSize,
displayImages: JSON.stringify(displayImages), // PSD/模特展示图片URLs数组
metadata, // 模板ID和字段值
templateId: templateId || null, // 直接存储模板ID,方便查询
userId: (session.user as any).id,
},
include: {
user: {
select: {
username: true,
console.log('[Upload] Material data to create:', {
previewUrl: previewUrl ? `${previewUrl.substring(0, 50)}...` : 'EMPTY',
previewUrlLength: previewUrl?.length || 0,
productModel,
categoryName: materialType === '材质' ? '通用' : categoryName,
sceneName: materialType === '材质' ? '通用' : sceneName,
materialType,
materialKind,
renderer,
authorName,
hasMetadata: !!metadata,
hasTemplateId: !!templateId,
templateId,
userId: (session.user as any).id,
zipFileName,
zipFileSize: zipFileSize.toString(),
previewUrlsCount: previewUrls.length,
})
// 验证并清理数据,确保符合数据库约束
const finalCategoryName = materialType === '材质' ? '通用' : (categoryName || '')
const finalSceneName = materialType === '材质' ? '通用' : (sceneName || '')
const finalPlatformName = platformName || ''
const finalZipFileName = zipFileName || ''
const finalZipFilePath = zipFilePath || ''
// 验证字符串长度(PostgreSQL TEXT 类型理论上无限制,但为了安全起见检查)
const maxStringLength = 10000 // 设置一个合理的最大长度
if (previewUrl.length > maxStringLength) {
console.error('[Upload] previewUrl too long:', previewUrl.length)
return NextResponse.json(
{ error: '预览图URL过长,请检查' },
{ status: 400 }
)
}
if (productModel.length > maxStringLength) {
console.error('[Upload] productModel too long:', productModel.length)
return NextResponse.json(
{ error: '产品型号过长,请缩短后重试' },
{ status: 400 }
)
}
// 验证 userId 是否存在(userId 已在前面定义)
if (!userId || typeof userId !== 'string') {
console.error('[Upload] Invalid userId:', userId)
return NextResponse.json(
{ error: '用户ID无效,请重新登录' },
{ status: 401 }
)
}
// 如果提供了 templateId,验证它是否存在
if (templateId) {
try {
const templateExists = await prisma.uploadTemplate.findUnique({
where: { id: templateId },
select: { id: true },
})
if (!templateExists) {
console.error('[Upload] Template not found:', templateId)
return NextResponse.json(
{ error: `模板不存在:${templateId}` },
{ status: 400 }
)
}
} catch (templateCheckError) {
console.error('[Upload] Error checking template:', templateCheckError)
// 如果检查模板时出错,继续执行(可能是数据库连接问题)
}
}
try {
const materialId = await generateMaterialId()
const material = await prisma.material.create({
data: {
id: materialId,
previewUrl: previewUrl.substring(0, maxStringLength), // 确保不超过最大长度
// previewUrls: previewUrls.length > 0 ? JSON.stringify(previewUrls) : null, // 暂时存储在metadata中,等数据库迁移后启用
productModel: productModel.substring(0, maxStringLength),
brandName: brandName ? brandName.substring(0, maxStringLength) : null,
seriesName: seriesName ? seriesName.substring(0, maxStringLength) : null,
skuCode: skuCode ? skuCode.substring(0, maxStringLength) : null,
materialTexture: materialTexture ? materialTexture.substring(0, maxStringLength) : null,
categoryName: finalCategoryName.substring(0, maxStringLength),
sceneName: finalSceneName.substring(0, maxStringLength),
platformName: finalPlatformName.substring(0, maxStringLength),
materialType,
// @ts-ignore - materialKind 字段已在 schema 中定义,Prisma Client 类型可能需要 IDE 重启后更新
materialKind,
renderer,
renderTime: renderTime ? renderTime.substring(0, maxStringLength) : null,
imageResolution: imageResolution ? imageResolution.substring(0, maxStringLength) : null,
colorMode: colorMode ? colorMode.substring(0, maxStringLength) : null,
authorName: authorName.substring(0, maxStringLength),
// 素材归属:默认归属团队空间,createdByUserId 记录上传者
ownerType: 'TEAM',
ownerId: 'default-team',
createdByUserId: userId,
zipFileName: finalZipFileName.substring(0, maxStringLength),
zipFilePath: finalZipFilePath.substring(0, maxStringLength),
zipFileSize,
displayImages: JSON.stringify(displayImages).substring(0, maxStringLength * 10), // displayImages 可能较长
metadata: metadata ? metadata.substring(0, maxStringLength * 10) : null, // metadata 可能较长
templateId: templateId || null,
videoSourceUrl: isVideoMaterial && finalZipFilePath ? finalZipFilePath.substring(0, maxStringLength) : null,
videoPreview540Url: isVideoMaterial ? null : null,
videoPreviewClipUrl: isVideoMaterial ? null : null,
// 如果用户上传了封面图就使用,否则为null(转码任务会自动从视频中截取)
// 注意:只有当用户实际上传了封面图时才设置,占位符不算
videoCoverUrl: isVideoMaterial && hasUserUploadedCover ? previewUrl.substring(0, maxStringLength) : null,
videoProcessingStatus: isVideoMaterial ? 'pending' : null,
videoDuration: isVideoMaterial ? null : null,
userId,
},
include: {
user: {
select: {
username: true,
},
},
},
},
})
console.log('[Upload] Database record created:', material.id)
})
console.log('[Upload] Database record created:', material.id)
// 转换BigInt为字符串以支持JSON序列化
const materialData = {
...material,
zipFileSize: material.zipFileSize.toString()
// 记录上传日志
await createMaterialLog({
materialId: material.id,
userId,
action: 'UPLOAD',
})
// 如果是视频素材,异步触发转码任务
if (isVideoMaterial && finalZipFilePath) {
console.log('[Upload] Video material detected, triggering transcode task...')
// 直接调用转码队列,而不是通过HTTP API(避免网络连接问题)
// 使用 setImmediate 确保在响应返回后再执行,避免阻塞
setImmediate(async () => {
try {
// 动态导入转码相关模块(避免循环依赖和初始化问题)
const { transcodeQueue } = await import('@/lib/video-transcode-queue')
// 定义转码任务函数(与 transcode/route.ts 中的逻辑一致)
const processVideoTranscodeTask = async (
materialId: string,
videoSourceUrl: string,
needsCoverImage: boolean,
retryCount: number = 0
): Promise<void> => {
const { processVideoTranscode } = await import('@/lib/video-processor')
const { tmpdir } = await import('os')
const MAX_RETRIES = parseInt(process.env.VIDEO_TRANSCODE_MAX_RETRIES || '2', 10)
try {
console.log(`[Transcode] Starting transcode task for material ${materialId}${retryCount > 0 ? ` (retry ${retryCount}/${MAX_RETRIES})` : ''}...`)
const outputDir = tmpdir()
const result = await processVideoTranscode({
inputPath: videoSourceUrl,
outputDir,
materialId,
needsCoverImage,
})
// 更新数据库
const updateData: any = {
videoProcessingStatus: 'done',
videoPreview540Url: result.preview540Url,
videoPreviewClipUrl: result.clipUrl,
videoDuration: result.duration,
}
// 如果用户没有上传封面图,使用自动截取的封面图
if (needsCoverImage && result.coverUrl) {
updateData.videoCoverUrl = result.coverUrl
// 同时更新 previewUrl(如果当前是占位符)
const material = await prisma.material.findUnique({
where: { id: materialId },
select: { previewUrl: true },
})
if (material?.previewUrl?.startsWith('data:image')) {
updateData.previewUrl = result.coverUrl
}
}
await prisma.material.update({
where: { id: materialId },
data: updateData,
})
console.log(`[Transcode] Transcode task completed for material ${materialId}`)
} catch (error) {
console.error(`[Transcode] Transcode task failed for material ${materialId}:`, error)
// 重试机制
if (retryCount < MAX_RETRIES) {
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000)
console.log(`[Transcode] Retrying transcode task for material ${materialId} after ${delay}ms...`)
await new Promise((resolve) => setTimeout(resolve, delay))
return processVideoTranscodeTask(materialId, videoSourceUrl, needsCoverImage, retryCount + 1)
} else {
console.error(`[Transcode] Max retries (${MAX_RETRIES}) reached for material ${materialId}, marking as failed`)
throw error
}
}
}
// 检查是否需要生成封面图
const needsCoverImage = !material.videoCoverUrl
// 添加到转码队列
transcodeQueue.add(
material.id,
material.videoSourceUrl!,
needsCoverImage,
processVideoTranscodeTask
).catch((error) => {
console.error(`[Upload] Transcode task failed for material ${material.id}:`, error)
// 更新状态为 failed
prisma.material.update({
where: { id: material.id },
data: { videoProcessingStatus: 'failed' },
}).catch((dbError) => {
console.error(`[Upload] Failed to update status to failed:`, dbError)
})
})
console.log('[Upload] Transcode task added to queue for material:', material.id)
} catch (error) {
console.error('[Upload] Failed to trigger transcode task:', error)
// 转码任务触发失败不影响上传成功,但更新状态为failed
try {
await prisma.material.update({
where: { id: material.id },
data: { videoProcessingStatus: 'failed' },
})
} catch (dbError) {
console.error('[Upload] Failed to update material status to failed:', dbError)
}
}
})
}
// 转换BigInt为字符串以支持JSON序列化
const materialData = {
...material,
zipFileSize: material.zipFileSize.toString()
}
console.log('[Upload] Upload completed successfully!')
return NextResponse.json(
{ message: '上传成功', material: materialData },
{ status: 201 }
)
} catch (dbError) {
console.error('[Upload] Database error:', dbError)
if (dbError instanceof Error) {
console.error('[Upload] Database error name:', dbError.name)
console.error('[Upload] Database error message:', dbError.message)
console.error('[Upload] Database error stack:', dbError.stack)
// 记录创建数据时的所有字段值(用于调试)
console.error('[Upload] Failed data:', {
previewUrl: previewUrl ? `${previewUrl.substring(0, 50)}...` : 'EMPTY',
previewUrlLength: previewUrl?.length || 0,
productModel,
categoryName: materialType === '材质' ? '通用' : categoryName,
sceneName: materialType === '材质' ? '通用' : sceneName,
materialType,
renderer,
authorName,
userId: (session.user as any).id,
templateId,
zipFileName,
zipFileSize: zipFileSize.toString(),
hasMetadata: !!metadata,
})
// 提供更具体的数据库错误信息
const errorMsg = dbError.message.toLowerCase()
if (errorMsg.includes('unique constraint') || errorMsg.includes('duplicate')) {
return NextResponse.json(
{
error: '数据已存在,请检查是否重复上传',
details: dbError.message,
errorType: dbError.name
},
{ status: 400 }
)
}
if (errorMsg.includes('foreign key constraint') || errorMsg.includes('foreign key')) {
// 检查是哪个外键失败
let detailMsg = '关联数据不存在'
if (errorMsg.includes('userid') || errorMsg.includes('user')) {
detailMsg = `用户ID不存在:${(session.user as any).id}`
} else if (errorMsg.includes('templateid') || errorMsg.includes('template')) {
detailMsg = `模板ID不存在:${templateId}`
}
return NextResponse.json(
{
error: detailMsg,
details: dbError.message,
errorType: dbError.name
},
{ status: 400 }
)
}
if (errorMsg.includes('required') || errorMsg.includes('not null')) {
return NextResponse.json(
{
error: `必填字段缺失:${dbError.message}`,
details: dbError.message,
errorType: dbError.name
},
{ status: 400 }
)
}
if (errorMsg.includes('value too long') || errorMsg.includes('string data right truncated')) {
return NextResponse.json(
{
error: '字段值过长,请检查产品型号、分类、场景等字段长度',
details: dbError.message,
errorType: dbError.name
},
{ status: 400 }
)
}
if (errorMsg.includes('invalid input') || errorMsg.includes('invalid value')) {
return NextResponse.json(
{
error: '数据格式不正确,请检查所有字段值',
details: dbError.message,
errorType: dbError.name
},
{ status: 400 }
)
}
// 对于其他数据库错误,返回详细信息
return NextResponse.json(
{
error: '数据库操作失败',
details: dbError.message,
errorType: dbError.name,
errorStack: process.env.NODE_ENV === 'development' ? dbError.stack : undefined
},
{ status: 500 }
)
}
throw dbError // 重新抛出,让外层catch处理
}
console.log('[Upload] Upload completed successfully!')
return NextResponse.json(
{ message: '上传成功', material: materialData },
{ status: 201 }
)
} catch (error) {
console.error('Upload error:', error)
console.error('[Upload] Upload error:', error)
// Log detailed error information
if (error instanceof Error) {
console.error('Error name:', error.name)
console.error('Error message:', error.message)
console.error('Error stack:', error.stack)
console.error('[Upload] Error name:', error.name)
console.error('[Upload] Error message:', error.message)
console.error('[Upload] Error stack:', error.stack)
// 根据错误类型提供更具体的错误信息
if (error.message.includes('S3') || error.message.includes('upload')) {
return NextResponse.json(
{
error: '文件上传到存储失败,请稍后重试',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
},
{ status: 500 }
)
}
if (error.message.includes('processImage') || error.message.includes('image')) {
return NextResponse.json(
{
error: '图片处理失败,请检查图片格式是否正确',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
},
{ status: 500 }
)
}
if (error.message.includes('database') || error.message.includes('prisma')) {
return NextResponse.json(
{
error: '数据库操作失败,请稍后重试',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
},
{ status: 500 }
)
}
}
// Return more specific error message for debugging
const errorMessage = error instanceof Error ? error.message : '上传失败,请稍后再试'
// 在生产环境也返回错误详情,帮助排查问题
return NextResponse.json(
{
error: '上传失败,请稍后再试',
details: process.env.NODE_ENV === 'development' ? errorMessage : undefined
details: errorMessage, // 始终返回错误详情,帮助排查问题
errorType: error instanceof Error ? error.name : 'UnknownError'
},
{ status: 500 }
)

View File

@ -26,6 +26,7 @@ export async function GET() {
id: true,
headerBgColor: true,
siteName: true,
premiumRatio: true,
updatedAt: true,
createdAt: true,
headerLogoUrl: true,
@ -40,22 +41,27 @@ export async function GET() {
enableUserActivation: true,
enableAiImageEdit: true,
enableTunnelService: true,
// homeDeleteButtonMode 是可选列,这里不直接在 select 中声明,避免旧版本类型不匹配
// enabledBuiltinTemplates 可能不存在,先不查询
},
}) as any
// 如果字段存在,尝试查询它
try {
const fullSettings = await prisma.$queryRaw<Array<{ enabledBuiltinTemplates?: string | null }>>`
SELECT "enabledBuiltinTemplates" FROM "system_settings" LIMIT 1
const fullSettings = await prisma.$queryRaw<Array<{ enabledBuiltinTemplates?: string | null; homeDeleteButtonMode?: string | null; premiumRatio?: number | null }>>`
SELECT "enabledBuiltinTemplates", "homeDeleteButtonMode", "premiumRatio" FROM "system_settings" LIMIT 1
`
if (fullSettings[0] && settings) {
settings.enabledBuiltinTemplates = fullSettings[0].enabledBuiltinTemplates
settings.homeDeleteButtonMode = fullSettings[0].homeDeleteButtonMode || 'ADMIN_ONLY'
settings.premiumRatio = fullSettings[0].premiumRatio ?? 10 // 默认值10%
}
} catch {
// 字段不存在,设置为 null(表示全部启用)
// 字段不存在,设置为默认值
if (settings) {
settings.enabledBuiltinTemplates = null
settings.homeDeleteButtonMode = 'ADMIN_ONLY'
settings.premiumRatio = settings.premiumRatio ?? 10 // 确保有默认值
}
}
@ -66,7 +72,8 @@ export async function GET() {
headerBgColor: '#1f2937',
headerLogoUrl: defaultLogos.header,
loginLogoUrl: defaultLogos.login,
siteName: '由壹点3D素材',
siteName: 'FLNXI素材库',
premiumRatio: 10,
enableUserActivation: false,
enableRegistration: true,
enableAiImageEdit: featureFlags.aiEditEnabled,
@ -88,16 +95,17 @@ export async function GET() {
}
}
// PUT - 更新系统设置(仅管理员)
// PUT - 更新系统设置(管理员权限)
export async function PUT(request: NextRequest) {
try {
await ensureSystemSettingsOptionalColumns()
const session = await getServerSession(authOptions)
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
if (!session || session.user.role !== 'ADMIN') {
const userRole = (session?.user as any)?.role
const isSuperAdmin = Boolean((session?.user as any)?.isSuperAdmin)
if (!session || userRole !== 'ADMIN') {
return NextResponse.json(
{ error: '无权限' },
{ error: '只有管理员可以访问系统设置' },
{ status: 403 }
)
}
@ -119,8 +127,22 @@ export async function PUT(request: NextRequest) {
enableAiImageEdit,
enableTunnelService,
enabledBuiltinTemplates,
homeDeleteButtonMode,
premiumRatio,
} = body
let sanitizedPremiumRatio: number | undefined
if (premiumRatio !== undefined) {
const numericRatio = Number(premiumRatio)
if (!Number.isFinite(numericRatio)) {
return NextResponse.json(
{ error: '精品作品比例必须为数字' },
{ status: 400 }
)
}
sanitizedPremiumRatio = Math.min(100, Math.max(0, Number(numericRatio.toFixed(2))))
}
// 只有最高权限账号可以修改 enableUserActivation
if (
(enableUserActivation !== undefined ||
@ -138,13 +160,14 @@ export async function PUT(request: NextRequest) {
let settings = await prisma.systemSettings.findFirst()
if (!settings) {
// 创建
// 创建(可选列如 homeDeleteButtonMode 使用原生 SQL 单独更新,以兼容旧版本 Prisma 类型)
settings = await prisma.systemSettings.create({
data: {
headerBgColor: headerBgColor || '#1f2937',
headerLogoUrl: headerLogoUrl ?? defaultLogos.header,
loginLogoUrl: loginLogoUrl ?? defaultLogos.login,
siteName: siteName || '由壹点3D素材',
siteName: siteName || 'FLNXI素材库',
premiumRatio: sanitizedPremiumRatio ?? 10,
enableUserActivation: enableUserActivation !== undefined ? enableUserActivation : false,
enableRegistration: enableRegistration !== undefined ? enableRegistration : true,
enableAiImageEdit:
@ -153,6 +176,17 @@ export async function PUT(request: NextRequest) {
enableTunnelService !== undefined ? enableTunnelService : featureFlags.tunnelEnabled,
}
})
// 通过原生 SQL 写入可选列,避免类型不匹配
try {
await prisma.$executeRawUnsafe(
`UPDATE "system_settings" SET "homeDeleteButtonMode" = $1 WHERE "id" = $2`,
homeDeleteButtonMode ?? 'ADMIN_ONLY',
settings.id
)
} catch (e) {
console.error('[SystemSettings] Failed to set homeDeleteButtonMode on create:', e)
}
} else {
// 更新
const updateData: any = {}
@ -171,11 +205,25 @@ export async function PUT(request: NextRequest) {
if (enableAiImageEdit !== undefined) updateData.enableAiImageEdit = enableAiImageEdit
if (enableTunnelService !== undefined) updateData.enableTunnelService = enableTunnelService
if (enabledBuiltinTemplates !== undefined) updateData.enabledBuiltinTemplates = enabledBuiltinTemplates
if (sanitizedPremiumRatio !== undefined) updateData.premiumRatio = sanitizedPremiumRatio
settings = await prisma.systemSettings.update({
where: { id: settings.id },
data: updateData,
})
// 单独更新可选列 homeDeleteButtonMode
if (homeDeleteButtonMode !== undefined) {
try {
await prisma.$executeRawUnsafe(
`UPDATE "system_settings" SET "homeDeleteButtonMode" = $1 WHERE "id" = $2`,
homeDeleteButtonMode,
settings.id
)
} catch (e) {
console.error('[SystemSettings] Failed to update homeDeleteButtonMode:', e)
}
}
}
// 清除缓存,强制下次重新加载
@ -190,3 +238,4 @@ export async function PUT(request: NextRequest) {
)
}
}

View File

@ -5,69 +5,107 @@ import bcrypt from 'bcryptjs'
// 系统初始化:创建默认部门、店铺、管理员账户
export async function POST(req: NextRequest) {
try {
// 检查是否已经初始化过
const existingAdmin = await prisma.user.findFirst({
where: { email: 'admin' }
})
// 从环境变量读取初始管理员配置,如果没有则使用默认值(向后兼容)
const initAdminEmail = process.env.INIT_ADMIN_EMAIL || 'admin@flnxi.com'
const initAdminPassword = process.env.INIT_ADMIN_PASSWORD || '88888888'
const initAdminUsername = initAdminEmail.split('@')[0] // 从邮箱提取用户名
if (existingAdmin) {
return NextResponse.json({
success: true,
message: '系统已初始化',
initialized: true
})
}
// 使用事务确保所有数据都创建成功
// 使用事务确保所有数据都创建成功或已存在
const result = await prisma.$transaction(async (tx) => {
// 1. 创建默认管理员账户
const hashedPassword = await bcrypt.hash('88888888', 10)
const admin = await tx.user.create({
data: {
email: 'admin',
username: 'admin',
password: hashedPassword,
role: 'ADMIN',
isActive: true,
canDownload: true,
// 标记为使用默认密码,首次登录需要修改
department: '系统管理'
// 1. 确保默认管理员账户存在
// 优先查找环境变量配置的邮箱,如果没有则查找旧的 'admin'
let admin = await tx.user.findFirst({
where: {
OR: [
{ email: initAdminEmail },
{ email: 'admin' } // 向后兼容旧配置
]
}
})
// 2. 创建默认部门
const defaultDepartment = await tx.department.create({
data: { name: '默认部门' }
})
let adminCreated = false
if (!admin) {
// 检查是否已有任何用户(避免在已有用户的情况下创建默认管理员)
const userCount = await tx.user.count()
if (userCount === 0) {
// 只有在没有任何用户时才创建初始管理员
const hashedPassword = await bcrypt.hash(initAdminPassword, 10)
admin = await tx.user.create({
data: {
email: initAdminEmail,
username: initAdminUsername,
password: hashedPassword,
role: 'ADMIN',
isSuperAdmin: true, // 初始管理员设为超级管理员
isActive: true,
canDownload: true,
department: '系统管理'
}
})
adminCreated = true
} else {
// 如果已有用户,返回第一个管理员用户(如果有)
admin = await tx.user.findFirst({
where: { role: 'ADMIN' }
}) || await tx.user.findFirst()
}
}
// 3. 创建默认店铺(分类)
const defaultCategory = await tx.category.create({
data: { name: '默认店铺' }
})
const ensureDepartment = async () => {
const existing = await tx.department.findFirst({ where: { name: '默认部门' } })
if (existing) return { entity: existing, created: false }
const created = await tx.department.create({ data: { name: '默认部门' } })
return { entity: created, created: true }
}
// 4. 创建默认场景
const defaultScene = await tx.scene.create({
data: { name: '默认场景' }
})
const ensureCategory = async () => {
const existing = await tx.category.findFirst({ where: { name: '默认店铺' } })
if (existing) return { entity: existing, created: false }
const created = await tx.category.create({ data: { name: '默认店铺' } })
return { entity: created, created: true }
}
const ensureScene = async () => {
const existing = await tx.scene.findFirst({ where: { name: '默认场景' } })
if (existing) return { entity: existing, created: false }
const created = await tx.scene.create({ data: { name: '默认场景' } })
return { entity: created, created: true }
}
const departmentResult = await ensureDepartment()
const categoryResult = await ensureCategory()
const sceneResult = await ensureScene()
return {
admin,
defaultDepartment,
defaultCategory,
defaultScene
defaultDepartment: departmentResult.entity,
defaultCategory: categoryResult.entity,
defaultScene: sceneResult.entity,
createdFlags: {
admin: adminCreated,
department: departmentResult.created,
category: categoryResult.created,
scene: sceneResult.created
}
}
})
const alreadyInitialized =
!result.createdFlags.admin &&
!result.createdFlags.department &&
!result.createdFlags.category &&
!result.createdFlags.scene
return NextResponse.json({
success: true,
message: '系统初始化成功',
message: alreadyInitialized ? '系统已初始化' : '系统初始化成功',
initialized: true,
data: {
admin: {
admin: result.admin ? {
email: result.admin.email,
username: result.admin.username,
password: '88888888' // 仅返回提示,不返回实际密码
},
password: result.createdFlags.admin ? '(已使用配置的密码)' : '(未创建新账号)'
} : null,
defaultDepartment: result.defaultDepartment.name,
defaultCategory: result.defaultCategory.name,
defaultScene: result.defaultScene.name
@ -89,8 +127,16 @@ export async function POST(req: NextRequest) {
// 检查系统是否已初始化
export async function GET() {
try {
// 优先查找环境变量配置的管理员,如果没有则查找旧的 'admin'
const initAdminEmail = process.env.INIT_ADMIN_EMAIL || 'admin@flnxi.com'
const admin = await prisma.user.findFirst({
where: { email: 'admin' }
where: {
OR: [
{ email: initAdminEmail },
{ email: 'admin' }, // 向后兼容
{ role: 'ADMIN' } // 或者查找任何管理员账号
]
}
})
const hasDepartment = await prisma.department.findFirst()

View File

@ -1,167 +1,42 @@
/**
* 模板分类选项管理 API
*
* 管理特定 fieldKey 的分类选项
* 更新时会同步更新所有模板中该 fieldKey 的字段选项
*/
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { normalizeFieldOptionValue, syncFieldPoolFromTemplates } from '@/lib/field-pool'
// 检查是否是数组字符串的辅助函数(与 route.ts 保持一致)
function isArrayString(str: string): boolean {
const trimmed = str.trim()
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) {
return false
}
try {
const parsed = JSON.parse(trimmed)
return Array.isArray(parsed)
} catch {
return false
}
}
// 标准字段模板配置(与 template-categories/route.ts 保持一致)
const STANDARD_FIELD_TEMPLATES: Record<string, { key: string; label: string; type: string; addToCategory: boolean; optionsStatic: string | null }> = {
categoryName: {
key: 'categoryName',
label: '使用店铺',
type: 'select', // 老版本是下拉,必填
addToCategory: true,
optionsStatic: JSON.stringify(['默认店铺']), // select类型需要optionsStatic
},
sceneName: {
key: 'sceneName',
label: '场景类别',
type: 'select',
addToCategory: true,
optionsStatic: JSON.stringify(['默认场景']),
},
platformName: {
key: 'platformName',
label: '平台分类',
type: 'select',
addToCategory: true,
optionsStatic: JSON.stringify(['默认平台']),
},
renderer: {
key: 'renderer',
label: '渲染器',
type: 'select',
addToCategory: true,
optionsStatic: JSON.stringify(['默认渲染器']),
},
brandName: {
key: 'brandName',
label: '品牌名称',
type: 'select',
addToCategory: true,
optionsStatic: JSON.stringify(['默认品牌']),
},
seriesName: {
key: 'seriesName',
label: '系列 / 产品线',
type: 'select',
addToCategory: true,
optionsStatic: JSON.stringify(['默认产品线']),
},
materialTexture: {
key: 'materialTexture',
label: '材质 / 工艺',
type: 'select',
addToCategory: true,
optionsStatic: JSON.stringify(['默认材质']),
},
}
// 获取某个 fieldKey 的所有选项(合并所有模板中的该字段)
export async function GET(
_: NextRequest,
{ params }: { params: { fieldKey: string } }
{ params }: { params: { fieldKey: string } },
) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const { fieldKey } = params
// 查找所有启用的模板中包含该 fieldKey 且勾选了 addToCategory 的字段
const templates = await prisma.uploadTemplate.findMany({
where: {
status: 'active',
},
await syncFieldPoolFromTemplates()
const field = await prisma.fieldDefinition.findUnique({
where: { key: params.fieldKey },
include: {
fields: {
where: {
key: fieldKey,
addToCategory: true,
type: 'select',
},
options: {
orderBy: { orderIndex: 'asc' },
},
},
})
// 合并所有模板中该字段的选项
const allOptions = new Set<string>()
let fieldLabel = ''
templates.forEach(template => {
template.fields.forEach(field => {
if (!fieldLabel && field.label) {
fieldLabel = field.label
}
if (field.optionsStatic) {
try {
let options: string[] = []
if (typeof field.optionsStatic === 'string') {
try {
options = JSON.parse(field.optionsStatic)
} catch {
options = field.optionsStatic.split('\n').map(line => line.trim()).filter(Boolean)
}
} else if (Array.isArray(field.optionsStatic)) {
options = field.optionsStatic
}
if (Array.isArray(options)) {
options.forEach(opt => {
const optStr = String(opt).trim()
// 过滤掉数组字符串和空值
if (optStr && !isArrayString(optStr)) {
allOptions.add(optStr)
}
})
}
} catch (error) {
console.error(`Failed to parse optionsStatic for field ${field.key}:`, error)
}
}
})
})
if (!field) {
return NextResponse.json({ error: '未找到该字段' }, { status: 404 })
}
return NextResponse.json({
fieldKey,
fieldLabel: fieldLabel || fieldKey,
options: Array.from(allOptions).sort(),
fieldKey: field.key,
fieldLabel: field.filterDisplayName || field.label,
options: field.options.map((option) => option.value),
})
} catch (error) {
console.error('Error fetching template category options:', error)
return NextResponse.json(
{ error: '获取分类选项失败' },
{ status: 500 }
)
return NextResponse.json({ error: '获取分类选项失败' }, { status: 500 })
}
}
// 更新某个 fieldKey 的选项(同步更新所有模板中该字段)
export async function PUT(
req: NextRequest,
{ params }: { params: { fieldKey: string } }
{ params }: { params: { fieldKey: string } },
) {
try {
const session = await getServerSession(authOptions)
@ -169,178 +44,87 @@ export async function PUT(
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const { fieldKey } = params
const { options } = await req.json()
if (!Array.isArray(options)) {
return NextResponse.json(
{ error: '选项必须是数组格式' },
{ status: 400 }
)
return NextResponse.json({ error: '选项必须是数组格式' }, { status: 400 })
}
// 过滤掉数组字符串和空值
const filteredOptions = options
.map(opt => String(opt).trim())
.filter(opt => opt && !isArrayString(opt))
const normalized = Array.from(
new Set(
options
.map((value) => normalizeFieldOptionValue(String(value ?? '')))
.filter(Boolean),
),
)
// 检查是否是标准字段模板中的字段
const standardField = STANDARD_FIELD_TEMPLATES[fieldKey]
const isStandardField = standardField && standardField.addToCategory && standardField.type === 'select'
// 查找所有启用的模板中包含该 fieldKey 且勾选了 addToCategory 的字段
const templates = await prisma.uploadTemplate.findMany({
where: {
status: 'active',
},
include: {
fields: {
where: {
key: fieldKey,
addToCategory: true,
type: 'select',
},
},
},
})
// 如果既不是标准字段,也没有在数据库中找到对应的字段,则返回错误
if (!isStandardField && (templates.length === 0 || templates.every(t => t.fields.length === 0))) {
return NextResponse.json(
{ error: '未找到该字段' },
{ status: 404 }
)
if (!normalized.length) {
return NextResponse.json({ error: '选项不能为空' }, { status: 400 })
}
// 如果是标准字段,但数据库中没有包含该字段的模板,需要创建一个默认模板或确保至少有一个模板包含该字段
let hasFieldsToUpdate = templates.some(t => t.fields.length > 0)
if (isStandardField && !hasFieldsToUpdate) {
// 查找是否有任何启用的模板(不限制字段)
const anyActiveTemplates = await prisma.uploadTemplate.findMany({
where: {
status: 'active',
},
take: 1,
})
if (anyActiveTemplates.length > 0) {
// 在第一个启用的模板中添加该字段(如果不存在)
const template = anyActiveTemplates[0]
const existingField = await prisma.uploadField.findFirst({
where: {
templateId: template.id,
key: fieldKey,
},
})
if (!existingField) {
// 创建该字段
await prisma.uploadField.create({
data: {
templateId: template.id,
key: fieldKey,
label: standardField.label,
type: 'select',
required: false,
orderIndex: 999, // 放在最后
addToCategory: true,
optionsStatic: JSON.stringify(filteredOptions),
},
})
hasFieldsToUpdate = true
} else {
// 如果字段存在但没有勾选 addToCategory,更新它
if (!existingField.addToCategory || existingField.type !== 'select') {
await prisma.uploadField.update({
where: { id: existingField.id },
data: {
addToCategory: true,
type: 'select',
optionsStatic: JSON.stringify(filteredOptions),
},
})
hasFieldsToUpdate = true
} else {
// 字段已存在且配置正确,直接更新选项
await prisma.uploadField.update({
where: { id: existingField.id },
data: {
optionsStatic: JSON.stringify(filteredOptions),
},
})
hasFieldsToUpdate = true
}
}
} else {
// 如果没有启用的模板,创建一个默认模板
await prisma.uploadTemplate.create({
data: {
name: '默认模板',
description: '系统自动创建的默认模板',
status: 'active',
fields: {
create: {
key: fieldKey,
label: standardField.label,
type: 'select',
required: false,
orderIndex: 0,
addToCategory: true,
optionsStatic: JSON.stringify(filteredOptions),
},
},
},
})
hasFieldsToUpdate = true
}
}
// 重新查询所有包含该字段的模板(包括刚创建的)
const allTemplates = await prisma.uploadTemplate.findMany({
where: {
status: 'active',
},
include: {
fields: {
where: {
key: fieldKey,
addToCategory: true,
type: 'select',
},
},
},
})
// 更新所有模板中该 fieldKey 的字段选项
const updatedFields: string[] = []
const optionsJson = JSON.stringify(filteredOptions)
await prisma.$transaction(async (tx) => {
for (const template of allTemplates) {
for (const field of template.fields) {
await tx.uploadField.update({
where: { id: field.id },
const field = await tx.fieldDefinition.findUnique({
where: { key: params.fieldKey },
include: { options: true },
})
if (!field) {
throw new Error('FIELD_NOT_FOUND')
}
const existingMap = new Map(field.options.map((option) => [option.value, option]))
let orderIndex = 0
for (const value of normalized) {
const data = {
label: value,
orderIndex,
}
const existing = existingMap.get(value)
if (existing) {
await tx.fieldOption.update({
where: { id: existing.id },
data,
})
} else {
await tx.fieldOption.create({
data: {
optionsStatic: optionsJson,
fieldId: field.id,
value,
...data,
},
})
updatedFields.push(field.id)
}
orderIndex += 1
}
await tx.fieldOption.deleteMany({
where: {
fieldId: field.id,
value: { notIn: normalized },
},
})
await tx.uploadField.updateMany({
where: {
key: field.key,
type: 'select',
},
data: {
addToCategory: true,
optionsStatic: JSON.stringify(normalized),
},
})
})
return NextResponse.json({
success: true,
updatedFields: updatedFields.length,
options: filteredOptions,
isStandardField, // 返回是否是标准字段,供前端参考
options: normalized,
})
} catch (error) {
} catch (error: any) {
if (error?.message === 'FIELD_NOT_FOUND') {
return NextResponse.json({ error: '未找到该字段' }, { status: 404 })
}
console.error('Error updating template category options:', error)
return NextResponse.json(
{ error: '更新分类选项失败' },
{ status: 500 }
)
return NextResponse.json({ error: '更新分类选项失败' }, { status: 500 })
}
}

View File

@ -1,246 +1,31 @@
/**
* 模板分类 API
*
* 从所有模板中提取勾选了"添加到分类"的下拉字段
* 相同 fieldKey 的字段只显示一个卡片,多个模板共享
* 同时包含标准字段模板中设置了 addToCategory 的字段
*/
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import {
BUILTIN_TEMPLATE_KEYS,
STANDARD_FIELDS,
createBuiltinTemplate,
} from '@/lib/upload-template-presets'
import { getManagedCategoryFields } from '@/lib/category-fields'
import { formatFieldOptionValues, getFieldPoolRecords, syncFieldPoolFromTemplates } from '@/lib/field-pool'
// 检查是否是数组字符串的辅助函数
function isArrayString(str: string): boolean {
const trimmed = str.trim()
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) {
return false
}
try {
const parsed = JSON.parse(trimmed)
return Array.isArray(parsed)
} catch {
return false
}
}
// 解析选项的辅助函数
function parseOptions(optionsStatic: string | null): string[] {
if (!optionsStatic) return []
// 先尝试直接解析为 JSON
try {
const parsed = JSON.parse(optionsStatic)
if (Array.isArray(parsed)) {
// 解析数组中的每个选项,如果选项本身是字符串化的数组,再次解析
const result: string[] = []
parsed.forEach(opt => {
const optStr = String(opt).trim()
if (!optStr) return
// 检查是否是字符串化的数组(如 "[\"默认店铺\"]")
if (isArrayString(optStr)) {
try {
const nestedParsed = JSON.parse(optStr)
if (Array.isArray(nestedParsed)) {
// 如果是数组,展开其中的元素,但排除数组字符串本身
nestedParsed.forEach(item => {
const itemStr = String(item).trim()
// 再次检查,避免嵌套的数组字符串
if (itemStr && !isArrayString(itemStr)) {
result.push(itemStr)
}
})
}
// 如果是数组字符串,不添加它本身
} catch {
// 解析失败,不添加
}
} else {
// 不是数组字符串,正常添加
result.push(optStr)
}
})
return result.filter(Boolean)
}
// 如果解析后不是数组,但原始字符串看起来像数组字符串,尝试再次解析
if (typeof parsed === 'string' && isArrayString(parsed)) {
try {
const doubleParsed = JSON.parse(parsed)
if (Array.isArray(doubleParsed)) {
return doubleParsed
.map(opt => String(opt).trim())
.filter(opt => opt && !isArrayString(opt))
}
} catch {
// 忽略
}
}
} catch {
// 如果不是 JSON,尝试按行分割
return optionsStatic
.split('\n')
.map(line => line.trim())
.filter(line => line && !isArrayString(line))
}
return []
type FieldPoolRecord = {
key: string
label: string
filterDisplayName?: string | null
options: Array<{ value: string }>
isFilterEnabled?: boolean
filterOrder?: number | null
}
export async function GET() {
try {
const managedFieldSet = new Set(await getManagedCategoryFields())
const includeField = (fieldKey: string) => managedFieldSet.has(fieldKey)
await syncFieldPoolFromTemplates()
const fields = (await getFieldPoolRecords()) as FieldPoolRecord[]
const enabledFields = fields.filter((field) => field.isFilterEnabled)
const payload = enabledFields.map((field: FieldPoolRecord) => ({
fieldKey: field.key,
fieldLabel: field.filterDisplayName || field.label,
fieldDefaultLabel: field.label,
filterOrder: field.filterOrder ?? 0,
options: formatFieldOptionValues(field.options),
}))
// 使用 Map 按 fieldKey 去重,相同 fieldKey 的字段合并到一个分类
const categoryMap = new Map<string, {
fieldKey: string
fieldLabel: string
options: Set<string>
}>()
const fieldLabelFallback = new Map<string, string>()
// 首先添加标准字段模板中设置了 addToCategory 的字段
Object.values(STANDARD_FIELDS).forEach(field => {
if (field.addToCategory === true && includeField(field.key)) {
const options = parseOptions(field.optionsStatic ?? null)
if (!categoryMap.has(field.key)) {
categoryMap.set(field.key, {
fieldKey: field.key,
fieldLabel: field.label,
options: new Set<string>(),
})
}
const entry = categoryMap.get(field.key)
options.forEach(opt => {
// 在添加到 Set 之前就过滤掉数组字符串
if (opt && !isArrayString(opt)) {
entry?.options.add(opt)
}
})
}
})
// 合并所有预制模板(新版本仅内存定义)
BUILTIN_TEMPLATE_KEYS.forEach(key => {
const template = createBuiltinTemplate(key)
template.fields.forEach(field => {
if (field.addToCategory && includeField(field.key)) {
const options = parseOptions(field.optionsStatic ?? null)
if (!categoryMap.has(field.key)) {
categoryMap.set(field.key, {
fieldKey: field.key,
fieldLabel: field.label,
options: new Set<string>(),
})
}
const entry = categoryMap.get(field.key)
options.forEach(opt => {
// 在添加到 Set 之前就过滤掉数组字符串
if (opt && !isArrayString(opt)) {
entry?.options.add(opt)
}
})
}
})
})
// 获取所有启用的模板及其字段
const templates = await prisma.uploadTemplate.findMany({
where: {
status: 'active',
},
include: {
fields: {
where: {
addToCategory: true,
},
orderBy: { orderIndex: 'asc' },
},
},
})
// 遍历所有模板的字段,合并到分类Map中
templates.forEach(template => {
template.fields.forEach(field => {
if (includeField(field.key)) {
fieldLabelFallback.set(field.key, field.label)
const options = parseOptions(field.optionsStatic ?? null)
const existing = categoryMap.get(field.key)
if (existing) {
options.forEach(opt => {
if (typeof opt === 'string' && opt.trim() && !isArrayString(opt.trim())) {
existing.options.add(opt.trim())
}
})
} else {
// 过滤掉数组字符串后再创建 Set
const filteredOptions = options.filter(opt => opt && !isArrayString(opt))
categoryMap.set(field.key, {
fieldKey: field.key,
fieldLabel: field.label,
options: new Set<string>(filteredOptions),
})
}
}
})
})
const resolveLabel = (fieldKey: string) =>
STANDARD_FIELDS[fieldKey]?.label ||
fieldLabelFallback.get(fieldKey) ||
fieldKey
managedFieldSet.forEach(fieldKey => {
if (!categoryMap.has(fieldKey)) {
categoryMap.set(fieldKey, {
fieldKey,
fieldLabel: resolveLabel(fieldKey),
options: new Set<string>(),
})
}
})
// 转换为数组格式并排序,确保去重
const categories = Array.from(categoryMap.values()).map(cat => {
// 使用 Set 再次去重,确保没有重复选项
// 同时过滤掉看起来像数组字符串的选项(如 "[\"默认店铺\"]")
const uniqueOptions = Array.from(new Set(cat.options))
.filter(opt => {
const trimmed = opt.trim()
// 排除看起来像数组字符串的选项
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
const parsed = JSON.parse(trimmed)
// 如果是数组,说明这是数组字符串,应该被过滤掉
if (Array.isArray(parsed)) {
return false
}
} catch {
// 如果解析失败,保留原选项
}
}
return true
})
return {
fieldKey: cat.fieldKey,
fieldLabel: cat.fieldLabel,
options: uniqueOptions.sort(),
}
})
return NextResponse.json(categories)
return NextResponse.json(payload)
} catch (error) {
console.error('Error fetching template categories:', error)
return NextResponse.json(
{ error: '获取模板分类失败' },
{ status: 500 }
)
return NextResponse.json({ error: '获取模板分类失败' }, { status: 500 })
}
}

View File

@ -24,6 +24,7 @@ const publicSelect = {
description: true,
status: true,
materialType: true,
materialKind: true,
fields: {
where: {
// 只返回非只读字段,只读字段不需要用户填写
@ -95,7 +96,36 @@ export async function GET() {
...customTemplates.map(t => ({ ...t, isBuiltin: false })),
]
return NextResponse.json(allTemplates)
// 统计每个模板的素材数量,用于前端动态排序
const templateCounts = await Promise.all(
allTemplates.map((template) => {
if (template.isBuiltin) {
const key = template.id.replace('builtin-', '')
const materialType = BUILTIN_TEMPLATE_TYPES[key] || template.materialType
return prisma.material.count({
where: {
materialType,
OR: [
{ templateId: null },
{ templateId: template.id },
],
},
})
}
return prisma.material.count({
where: {
templateId: template.id,
},
})
})
)
const templatesWithCounts = allTemplates.map((template, index) => ({
...template,
materialCount: templateCounts[index],
}))
return NextResponse.json(templatesWithCounts)
} catch (error) {
console.error('Get active upload templates error:', error)
return NextResponse.json({ error: '获取上传模板失败' }, { status: 500 })

View File

@ -0,0 +1,86 @@
/**
* 用户头像上传API
*
* POST: 上传用户头像
*
* @created 2025-11-25
* @api
*/
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { uploadToS3 } from '@/lib/s3'
import { processImage, getProcessedFileName } from '@/lib/image-processor'
import path from 'path'
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
)
}
const formData = await req.formData()
const avatarFile = formData.get('avatar') as File
if (!avatarFile) {
return NextResponse.json(
{ error: '请选择头像文件' },
{ status: 400 }
)
}
// 验证文件类型
const allowedImageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
if (!allowedImageTypes.includes(avatarFile.type)) {
return NextResponse.json(
{ error: '不支持的图片格式,请使用 JPG、PNG 或 WebP 格式' },
{ status: 400 }
)
}
// 验证文件大小(限制5MB)
const maxSize = 5 * 1024 * 1024 // 5MB
if (avatarFile.size > maxSize) {
return NextResponse.json(
{ error: `头像过大,请使用小于5MB的图片(当前:${(avatarFile.size / 1024 / 1024).toFixed(2)}MB)` },
{ status: 400 }
)
}
// 处理头像:压缩并转换为webP
const originalBuffer = Buffer.from(await avatarFile.arrayBuffer())
const processedBuffer = await processImage(originalBuffer, {
maxSizeKB: 200,
maxWidth: 400,
maxHeight: 400,
format: 'webp',
})
// 上传到S3
const processedFileName = getProcessedFileName(avatarFile.name)
const fileExt = path.extname(processedFileName)
const avatarFileName = `avatar-${session.user.id}-${Date.now()}${fileExt}`
const avatarUrl = await uploadToS3(processedBuffer, avatarFileName, 'image/webp')
// 更新数据库
await prisma.user.update({
where: { id: session.user.id },
data: { avatar: avatarUrl },
})
return NextResponse.json({ avatarUrl })
} catch (error) {
console.error('[UserAvatar] POST error:', error)
return NextResponse.json(
{ error: '上传头像失败' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
function parseDisplayImages(value: string | null): string[] {
if (!value) return []
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) {
return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
}
} catch (error) {
console.warn('[UserFavorites] Failed to parse displayImages JSON:', error)
}
return value ? [value] : []
}
export const dynamic = 'force-dynamic'
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: '未登录' }, { status: 401 })
}
const favorites = await prisma.materialFavorite.findMany({
where: { userId: session.user.id },
include: {
material: {
select: {
id: true,
productModel: true,
previewUrl: true,
displayImages: true,
materialType: true,
categoryName: true,
sceneName: true,
renderer: true,
downloadCount: true,
likeCount: true,
createdAt: true,
userId: true,
isPremium: true,
zipFileSize: true,
},
},
},
orderBy: { createdAt: 'desc' },
})
const materials = favorites
.map((favorite) => favorite.material)
.filter((material): material is NonNullable<typeof material> => Boolean(material))
.map((material) => ({
...material,
displayImages: parseDisplayImages(material.displayImages as unknown as string | null),
zipFileSize: material.zipFileSize.toString(),
isFavorited: true,
isLiked: false,
}))
return NextResponse.json(materials)
} catch (error) {
console.error('[UserFavorites] GET error:', error)
return NextResponse.json({ error: '获取收藏失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
function parseDisplayImages(value: string | null): string[] {
if (!value) return []
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) {
return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
}
} catch (error) {
console.warn('[UserLikes] Failed to parse displayImages JSON:', error)
}
return value ? [value] : []
}
export const dynamic = 'force-dynamic'
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: '未登录' }, { status: 401 })
}
const likes = await prisma.materialLike.findMany({
where: { userId: session.user.id },
include: {
material: {
select: {
id: true,
productModel: true,
previewUrl: true,
displayImages: true,
materialType: true,
categoryName: true,
sceneName: true,
renderer: true,
downloadCount: true,
likeCount: true,
createdAt: true,
userId: true,
isPremium: true,
zipFileSize: true,
},
},
},
orderBy: { createdAt: 'desc' },
})
const materials = likes
.map((like) => like.material)
.filter((material): material is NonNullable<typeof material> => Boolean(material))
.map((material) => ({
...material,
displayImages: parseDisplayImages(material.displayImages as unknown as string | null),
zipFileSize: material.zipFileSize.toString(),
isFavorited: false,
isLiked: true,
}))
return NextResponse.json(materials)
} catch (error) {
console.error('[UserLikes] GET error:', error)
return NextResponse.json({ error: '获取点赞失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,102 @@
/**
* 用户素材列表API
*
* GET: 获取当前登录用户上传的所有素材
*
* @created 2025-11-25
* @api
*/
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
function parseDisplayImages(value: string | null): string[] {
if (!value) return []
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) {
return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
}
} catch (error) {
console.warn('[UserMaterials] Failed to parse displayImages JSON:', error)
}
return value ? [value] : []
}
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
)
}
const currentUserId = session.user.id
const materials = await (prisma as any).material.findMany({
where: {
// 个人中心「我的素材」:
// 1)我上传的:createdByUserId = 当前用户(新字段);
// 2)当前由我负责的:ownerType = 'USER' 且 ownerId = 当前用户;
// 3)兜底兼容:userId = 当前用户(历史/遗留字段,且新上传目前也会写入)。
OR: [
{ createdByUserId: currentUserId },
{ ownerType: 'USER', ownerId: currentUserId },
{ userId: currentUserId },
],
deletedAt: null,
} as any,
select: {
id: true,
userId: true,
productModel: true,
previewUrl: true,
displayImages: true,
materialType: true,
materialKind: true,
categoryName: true,
sceneName: true,
renderer: true,
downloadCount: true,
likeCount: true,
createdAt: true,
isPremium: true,
videoCoverUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoProcessingStatus: true,
videoDuration: true,
zipFileName: true,
zipFileSize: true,
},
orderBy: { createdAt: 'desc' },
})
const formatted = materials.map((material: any) => {
// 统一处理 BigInt 等前端无法直接消费的类型
const safeZipFileSize =
typeof material.zipFileSize === 'bigint'
? material.zipFileSize.toString()
: material.zipFileSize ?? null
return {
...material,
zipFileSize: safeZipFileSize,
displayImages: parseDisplayImages(material.displayImages as unknown as string | null),
}
})
return NextResponse.json(formatted)
} catch (error) {
console.error('[UserMaterials] GET error:', error)
return NextResponse.json(
{ error: '获取素材列表失败' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,55 @@
/**
* 用户个人信息API
*
* GET: 获取当前登录用户的个人信息
*
* @created 2025-11-25
* @api
*/
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
)
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
username: true,
email: true,
avatar: true,
department: true,
realName: true,
phone: true,
createdAt: true,
},
})
if (!user) {
return NextResponse.json(
{ error: '用户不存在' },
{ status: 404 }
)
}
return NextResponse.json(user)
} catch (error) {
console.error('[UserProfile] GET error:', error)
return NextResponse.json(
{ error: '获取用户信息失败' },
{ status: 500 }
)
}
}

View File

@ -23,11 +23,11 @@ function CompleteProfileContent() {
// 系统设置
const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')
const [siteName, setSiteName] = useState('由壹点3D素材')
const [siteName, setSiteName] = useState('FLNXI素材库')
useEffect(() => {
const defaultLogo = process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''
const defaultSiteName = '由壹点3D素材'
const defaultSiteName = 'FLNXI素材库'
// 加载系统设置
const loadSettings = async () => {
@ -169,7 +169,7 @@ function CompleteProfileContent() {
</svg>
</div>
<h2 className="text-xl font-semibold mb-2 text-red-600">出错了</h2>
<p className="text-gray-600 mb-4">{error}</p>
<p className="text-slate-600 dark:text-slate-400 mb-4">{error}</p>
<button
onClick={() => router.push('/login')}
className="btn-primary"
@ -197,8 +197,8 @@ function CompleteProfileContent() {
className="rounded-apple shadow-apple"
/>
</div>
<h1 className="text-3xl font-bold text-gray-900">完善个人信息</h1>
<p className="text-gray-600 mt-2">首次登录,请完善您的用户信息</p>
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">完善个人信息</h1>
<p className="text-slate-600 dark:text-slate-400 mt-2">首次登录,请完善您的用户信息</p>
</div>
{/* Form */}
@ -217,8 +217,8 @@ function CompleteProfileContent() {
/>
)}
<div>
<p className="text-sm text-gray-600">钉钉账号</p>
<p className="font-medium text-gray-900">{userInfo.realName}</p>
<p className="text-sm text-slate-600 dark:text-slate-400">钉钉账号</p>
<p className="font-medium text-slate-900 dark:text-slate-100">{userInfo.realName}</p>
</div>
</div>
</div>
@ -239,17 +239,17 @@ function CompleteProfileContent() {
{/* 显示钉钉用户名(只读) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
用户名
</label>
<div className="input-apple bg-gray-50 text-gray-700 cursor-not-allowed">
<div className="input-apple bg-slate-50 dark:bg-slate-700 text-slate-700 dark:text-slate-300 cursor-not-allowed">
{userInfo?.username || userInfo?.realName || '加载中...'}
</div>
<p className="mt-1 text-xs text-gray-500">使用您的钉钉姓名作为系统用户名</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">使用您的钉钉姓名作为系统用户名</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
所属部门 *
</label>
<select
@ -279,7 +279,7 @@ function CompleteProfileContent() {
<div className="mt-6 text-center">
<button
onClick={() => router.push('/login')}
className="text-sm text-gray-600 hover:text-gray-900"
className="text-sm text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100"
>
返回登录
</button>
@ -296,7 +296,7 @@ export default function CompleteProfilePage() {
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"></div>
<p className="mt-4 text-gray-600">加载中...</p>
<p className="mt-4 text-slate-600 dark:text-slate-400">加载中...</p>
</div>
</div>
}>
@ -305,3 +305,4 @@ export default function CompleteProfilePage() {
)
}

View File

@ -155,14 +155,14 @@ function DingtalkCallbackContent() {
)}
<h2 className={`text-xl font-semibold mb-2 ${
status === 'error' ? 'text-red-600' : 'text-gray-800'
status === 'error' ? 'text-red-600' : 'text-slate-800 dark:text-slate-200'
}`}>
{status === 'loading' && '钉钉登录'}
{status === 'success' && '登录成功'}
{status === 'error' && '登录失败'}
</h2>
<p className="text-gray-600">{message}</p>
<p className="text-slate-600 dark:text-slate-400">{message}</p>
</div>
</div>
</div>
@ -178,8 +178,8 @@ export default function DingtalkCallbackPage() {
<div className="mb-4">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent"></div>
</div>
<h2 className="text-xl font-semibold mb-2 text-gray-800">钉钉登录</h2>
<p className="text-gray-600">正在加载...</p>
<h2 className="text-xl font-semibold mb-2 text-slate-800 dark:text-slate-200">钉钉登录</h2>
<p className="text-slate-600 dark:text-slate-400">正在加载...</p>
</div>
</div>
</div>

View File

@ -1,425 +1,27 @@
/**
* EditMaterialPage 组件
*
* 素材编辑页面
* 允许用户编辑自己上传的素材信息
* 使用三层架构样式系统,支持深浅色主题
* 素材编辑页面 - 已废弃,重定向到首页编辑弹窗
*
* @created 2025-01-XX
* @component
* @deprecated 此页面已废弃,编辑功能现在使用首页的EditMaterialModal弹窗
*/
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useEffect } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Header from '@/components/Header'
import { useThemeStyles, cn } from '@/lib/ui-styles'
interface Material {
id: string
userId: string
productModel: string
brandName?: string | null
seriesName?: string | null
skuCode?: string | null
materialTexture?: string | null
categoryName: string
sceneName: string
materialType: string
renderer: string
renderTime?: string | null
imageResolution?: string | null
colorMode?: string | null
authorName: string
}
export default function EditMaterialPage({ params }: { params: { id: string } }) {
const styles = useThemeStyles()
const { data: session, status } = useSession()
const router = useRouter()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [material, setMaterial] = useState<Material | null>(null)
const [productModel, setProductModel] = useState('')
const [brandName, setBrandName] = useState('')
const [seriesName, setSeriesName] = useState('')
const [skuCode, setSkuCode] = useState('')
const [materialTexture, setMaterialTexture] = useState('')
const [categoryName, setCategoryName] = useState('')
const [sceneName, setSceneName] = useState('')
const [materialType, setMaterialType] = useState('')
const [renderer, setRenderer] = useState('')
const [renderTime, setRenderTime] = useState('')
const [imageResolution, setImageResolution] = useState('')
const [colorMode, setColorMode] = useState('')
const [authorName, setAuthorName] = useState('')
const [categories, setCategories] = useState<Array<{ id: string; name: string }>>([])
const [scenes, setScenes] = useState<Array<{ id: string; name: string }>>([])
const materialTypes = [
'3DMAX', 'PSD', 'AI', 'CAD', 'C4D', '材质', '模特', '标准饰品', '其他'
]
const fetchMaterial = useCallback(async () => {
try {
const response = await fetch(`/api/materials/${params.id}`)
if (!response.ok) {
throw new Error('获取素材信息失败')
}
const data = await response.json()
// 权限检查
if (data.userId !== (session?.user as any)?.id && (session?.user as any)?.role !== 'ADMIN') {
setError('您没有权限编辑此素材')
setTimeout(() => router.push('/'), 2000)
return
}
setMaterial(data)
setProductModel(data.productModel || '')
setBrandName(data.brandName || '')
setSeriesName(data.seriesName || '')
setSkuCode(data.skuCode || '')
setMaterialTexture(data.materialTexture || '')
setCategoryName(data.categoryName || '')
setSceneName(data.sceneName || '')
setMaterialType(data.materialType || '')
setRenderer(data.renderer || '')
setRenderTime(data.renderTime || '')
setImageResolution(data.imageResolution || '')
setColorMode(data.colorMode || '')
setAuthorName(data.authorName || '')
setLoading(false)
} catch (error: any) {
console.error('获取素材失败:', error)
setError(error.message || '获取素材信息失败')
setLoading(false)
}
}, [params.id, router, session])
const fetchCategories = useCallback(async () => {
try {
const response = await fetch('/api/categories')
if (response.ok) {
const data = await response.json()
setCategories(data)
}
} catch (error) {
console.error('获取分类失败:', error)
}
}, [])
const fetchScenes = useCallback(async () => {
try {
const response = await fetch('/api/scenes')
if (response.ok) {
const data = await response.json()
setScenes(data)
}
} catch (error) {
console.error('获取场景失败:', error)
}
}, [])
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
return
// 重定向到首页,并传递编辑参数
if (status !== 'loading') {
router.replace(`/?edit=${params.id}`)
}
}, [status, params.id, router])
if (status === 'authenticated') {
fetchMaterial()
fetchCategories()
fetchScenes()
}
}, [status, router, fetchMaterial, fetchCategories, fetchScenes])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSuccess('')
if (!productModel || !categoryName || !sceneName || !materialType || !renderer || !authorName) {
setError('请填写所有必填字段')
return
}
setSubmitting(true)
try {
const response = await fetch(`/api/materials/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
productModel,
brandName,
seriesName,
skuCode,
materialTexture,
categoryName,
sceneName,
materialType,
renderer,
renderTime,
imageResolution,
colorMode,
authorName
})
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || '更新失败')
}
setSuccess('素材信息更新成功!')
setTimeout(() => {
router.push('/')
}, 1500)
} catch (error: any) {
console.error('更新失败:', error)
setError(error.message || '更新失败,请稍后重试')
} finally {
setSubmitting(false)
}
}
if (status === 'loading' || loading) {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<Header session={session} />
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="text-center text-slate-600 dark:text-slate-400">加载中...</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<Header session={session} />
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-lg p-8">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-6">编辑素材信息</h1>
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-600 rounded-lg">
{error}
</div>
)}
{success && (
<div className="mb-4 p-4 bg-green-50 border border-green-200 text-green-600 rounded-lg">
{success}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
产品型号 *
</label>
<input
type="text"
value={productModel}
onChange={(e) => setProductModel(e.target.value)}
className="input-apple"
required
/>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500 mb-2">商品 / 品牌信息</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">品牌名称</label>
<input
type="text"
value={brandName}
onChange={(e) => setBrandName(e.target.value)}
className="input-apple"
placeholder="例如:Apple、小米"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">系列 / 产品线</label>
<input
type="text"
value={seriesName}
onChange={(e) => setSeriesName(e.target.value)}
className="input-apple"
placeholder="例如:iPhone 15 Pro"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">SKU 编码</label>
<input
type="text"
value={skuCode}
onChange={(e) => setSkuCode(e.target.value)}
className="input-apple"
placeholder="例如:SKU-0001"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">材质 / 工艺</label>
<input
type="text"
value={materialTexture}
onChange={(e) => setMaterialTexture(e.target.value)}
className="input-apple"
placeholder="例如:钛金属、皮革、磨砂玻璃等"
/>
</div>
</div>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500 mb-2">渲染 / 制作信息</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">渲染耗时</label>
<input
type="text"
value={renderTime}
onChange={(e) => setRenderTime(e.target.value)}
className="input-apple"
placeholder="例如:2 小时 / 120 分钟"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">输出分辨率</label>
<input
type="text"
value={imageResolution}
onChange={(e) => setImageResolution(e.target.value)}
className="input-apple"
placeholder="例如:5000 x 5000"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">颜色模式</label>
<select
value={colorMode}
onChange={(e) => setColorMode(e.target.value)}
className="input-apple"
>
<option value="">请选择</option>
<option value="RGB">RGB</option>
<option value="CMYK">CMYK</option>
<option value="LAB">Lab</option>
<option value="其他">其他</option>
</select>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
分类 *
</label>
<select
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
className="input-apple"
required
>
<option value="">请选择分类</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.name}>{cat.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
场景 *
</label>
<select
value={sceneName}
onChange={(e) => setSceneName(e.target.value)}
className="input-apple"
required
>
<option value="">请选择场景</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>{scene.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
素材类型 *
</label>
<select
value={materialType}
onChange={(e) => setMaterialType(e.target.value)}
className="input-apple"
required
>
<option value="">请选择素材类型</option>
{materialTypes.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
渲染器 *
</label>
<input
type="text"
value={renderer}
onChange={(e) => setRenderer(e.target.value)}
className="input-apple"
placeholder="例如: V-Ray, Corona, Arnold"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
作者 *
</label>
<input
type="text"
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
className="input-apple"
required
/>
</div>
<div className="flex gap-4 pt-4">
<button
type="button"
onClick={() => router.back()}
className="flex-1 px-6 py-3 border border-slate-300 dark:border-slate-600 rounded-apple font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
disabled={submitting}
>
取消
</button>
<button
type="submit"
className="flex-1 btn-primary"
disabled={submitting}
>
{submitting ? '保存中...' : '保存修改'}
</button>
</div>
</form>
</div>
</div>
</div>
)
return null
}

View File

@ -40,11 +40,45 @@ body {
}
.btn-primary {
@apply btn-apple bg-gray-900 dark:bg-gray-700 text-white hover:bg-gray-800 dark:hover:bg-gray-600 active:scale-95;
@apply btn-apple bg-slate-900 dark:bg-slate-700 text-white hover:bg-slate-800 dark:hover:bg-slate-600 active:scale-95;
}
.btn-secondary {
@apply btn-apple bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 active:scale-95;
@apply btn-apple bg-slate-200 dark:bg-slate-700 text-slate-900 dark:text-slate-200 hover:bg-slate-300 dark:hover:bg-slate-600 active:scale-95;
}
/* 滑块样式 */
.slider::-webkit-slider-thumb {
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.slider:focus {
outline: none;
}
.slider:focus::-webkit-slider-thumb {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}
.slider:focus::-moz-range-thumb {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}
.btn-danger {
@ -53,12 +87,12 @@ body {
/* Apple-style input */
.input-apple {
@apply w-full px-4 py-3 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none transition-all;
@apply w-full px-4 py-3 rounded-apple border border-slate-300 dark:border-slate-600 dark:bg-slate-800 dark:text-white focus:border-slate-900 dark:focus:border-slate-400 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600 outline-none transition-all;
}
/* Apple-style card */
.card-apple {
@apply bg-white dark:bg-gray-800 rounded-apple shadow-apple hover:shadow-apple-lg transition-all duration-300 border border-transparent dark:border-gray-700;
@apply bg-white dark:bg-slate-800 rounded-apple shadow-apple hover:shadow-apple-lg transition-all duration-300 border border-transparent dark:border-slate-700;
}
/* Scrollbar styling (light & dark) */
@ -167,3 +201,6 @@ body {
0 1px 0 rgba(0, 0, 0, 0.4);
}
/* Material 预览弹窗现在重新使用统一 UI Modal 样式,
不再需要单独的溢出与尺寸覆盖规则。 */

View File

@ -25,7 +25,7 @@ export async function generateMetadata(): Promise<Metadata> {
const settings = await getSystemSettings()
return {
title: settings?.siteName || '由壹点3D素材',
title: settings?.siteName || 'FLNXI素材库',
description: '3D素材分享平台',
}
}
@ -68,3 +68,4 @@ export default function RootLayout({
)
}

View File

@ -16,7 +16,7 @@ export default function LoginPage() {
// 系统设置
const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')
const [siteName, setSiteName] = useState('由壹点3D素材')
const [siteName, setSiteName] = useState('FLNXI素材库')
// Logo 加载状态
const [logoError, setLogoError] = useState(false)
@ -54,7 +54,7 @@ export default function LoginPage() {
if (response.ok) {
const settings = await response.json()
setLoginLogoUrl(settings.loginLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')
setSiteName(settings.siteName || '由壹点3D素材')
setSiteName(settings.siteName || 'FLNXI素材库')
}
} catch (error) {
console.error('加载系统设置失败:', error)
@ -285,8 +285,8 @@ export default function LoginPage() {
</div>
)}
</div>
<h1 className="text-3xl font-bold text-gray-900">{siteName}</h1>
<p className="text-gray-600 mt-2">欢迎回来,请登录您的账号</p>
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">{siteName}</h1>
<p className="text-slate-600 dark:text-slate-400 mt-2">欢迎回来,请登录您的账号</p>
</div>
{/* Login Form */}
@ -299,7 +299,7 @@ export default function LoginPage() {
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
邮箱
</label>
<input
@ -313,7 +313,7 @@ export default function LoginPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
密码
</label>
<input
@ -339,10 +339,10 @@ export default function LoginPage() {
{/* 分隔线 */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
<div className="w-full border-t border-slate-300 dark:border-slate-600"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">或</span>
<span className="px-2 bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">或</span>
</div>
</div>
@ -351,21 +351,21 @@ export default function LoginPage() {
id="dingtalk-login-btn"
type="button"
onClick={handleDingtalkLogin}
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-xl shadow-sm bg-white hover:bg-gray-50 transition-colors"
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-xl shadow-sm bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
>
<svg style={{ width: '28px', height: '28px' }} viewBox="0 0 1024 1024">
<path d="M512 2C230.2 2 2 230.2 2 512s228.2 510 510 510 510-228.2 510-510S793.3 2 512 2z m235.9 442c-1 4.6-3.6 10.8-7.2 19.1l-0.5 0.5c-21.6 45.8-77.3 135.5-77.3 135.5l-0.5-0.5-16.5 28.3h78.8L574.3 826.8l34-136h-61.8l21.6-90.2c-17.5 4.1-38.1 9.8-62.3 18 0 0-33 19.1-94.8-37.1 0 0-41.7-37.1-17.5-45.8 10.3-4.1 50-8.8 81.4-12.9 42.2-5.7 68.5-8.8 68.5-8.8s-130.3 2.1-161.2-3.1c-30.9-4.6-70.1-56.7-78.3-102 0 0-12.9-24.7 27.8-12.9 40.2 11.8 209.2 45.8 209.2 45.8S321.4 375 307 358.5c-14.4-16.5-42.8-89.6-39.2-134.5 0 0 1.5-11.3 12.9-8.2 0 0 161.8 74.2 272.5 114.4C664.5 371.4 760.8 392 747.9 444z" fill="#3296FA"></path>
</svg>
<span className="text-lg font-medium text-gray-700">钉钉登录</span>
<span className="text-lg font-medium text-slate-700 dark:text-slate-300">钉钉登录</span>
</button>
</div>
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
<p className="text-sm text-slate-600 dark:text-slate-400">
还没有账号?{' '}
<Link
href="/register"
className="text-gray-900 hover:text-gray-700 font-medium underline"
className="text-slate-900 dark:text-slate-100 hover:text-slate-700 dark:hover:text-slate-300 font-medium underline"
>
立即注册
</Link>
@ -383,11 +383,11 @@ export default function LoginPage() {
className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-semibold text-gray-900">钉钉扫码登录</h3>
<div className="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">钉钉扫码登录</h3>
<button
onClick={() => setShowDingtalkModal(false)}
className="text-gray-400 hover:text-gray-600 transition-colors"
className="text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@ -409,3 +409,4 @@ export default function LoginPage() {
)
}

File diff suppressed because it is too large Load Diff

669
app/profile/page.tsx Normal file
View File

@ -0,0 +1,669 @@
/**
* 个人中心页面
*
* 用户个人信息管理页面
* 包含用户信息展示、头像设置、上传的素材、下载记录等
* 使用三层架构样式系统,支持深浅色主题
*
* @created 2025-11-25
* @page
*/
'use client'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useState, useEffect } from 'react'
import Image from 'next/image'
import { useThemeStyles, cn } from '@/lib/ui-styles'
import Header from '@/components/Header'
import Avatar from '@/components/ui/Avatar'
import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
import MaterialPreviewModal from '@/components/MaterialPreviewModal'
import MaterialActionButtons from '@/components/MaterialActionButtons'
import { useDialog } from '@/components/ui/DialogProvider'
interface UserProfile {
id: string
username: string
email: string
avatar: string | null
department: string
realName: string | null
phone: string | null
createdAt: string
}
interface Material {
id: string
userId: string
productModel: string
previewUrl: string
displayImages?: string[]
materialType?: string
materialKind?: 'IMAGE' | 'VIDEO' | 'OTHER'
categoryName?: string
sceneName?: string
renderer?: string
authorName?: string
downloadCount: number
likeCount: number
createdAt: string
isPremium?: boolean
isFavorited?: boolean
isLiked?: boolean
zipFileName?: string
zipFileSize?: string | number
videoCoverUrl?: string | null
videoPreview540Url?: string | null
videoPreviewClipUrl?: string | null
videoProcessingStatus?: string | null
videoDuration?: number | null
user?: {
username?: string
avatar?: string | null
}
rankingBadges?: Array<{ label: string; gradient: string }>
}
export default function ProfilePage() {
const { data: session, status } = useSession()
const router = useRouter()
const styles = useThemeStyles()
const { alert } = useDialog()
const [profile, setProfile] = useState<UserProfile | null>(null)
const [materials, setMaterials] = useState<Material[]>([])
const [loading, setLoading] = useState(true)
const [showAvatarModal, setShowAvatarModal] = useState(false)
const [avatarFile, setAvatarFile] = useState<File | null>(null)
const [avatarPreview, setAvatarPreview] = useState<string>('')
const [uploading, setUploading] = useState(false)
const [previewMaterial, setPreviewMaterial] = useState<Material | null>(null)
const [favoriteMaterials, setFavoriteMaterials] = useState<Material[]>([])
const [downloadingIds, setDownloadingIds] = useState<Set<string>>(new Set())
const [downloadProgresses, setDownloadProgresses] = useState<Record<string, number>>({})
const handleDownload = (id: string) => {
// 如果已经在下载,直接返回
if (downloadingIds.has(id)) {
return
}
console.log(`[Download] Starting download for material ${id}`)
setDownloadingIds((prev) => new Set(prev).add(id))
setDownloadProgresses((prev) => ({ ...prev, [id]: 0 }))
const xhr = new XMLHttpRequest()
// 监听下载进度
xhr.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100)
setDownloadProgresses((prev) => ({ ...prev, [id]: percentComplete }))
}
})
xhr.addEventListener('load', () => {
console.log(`[Download] Response received, status: ${xhr.status}`)
if (xhr.status === 200) {
try {
const contentType = xhr.getResponseHeader('Content-Type') || 'application/octet-stream'
const blob = new Blob([xhr.response], { type: contentType })
// 从响应头获取文件名
const contentDisposition = xhr.getResponseHeader('Content-Disposition')
let filename = 'material'
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''))
}
} else if (contentType) {
const extensionMap: Record<string, string> = {
'application/zip': '.zip',
'video/mp4': '.mp4',
'video/webm': '.webm',
'video/x-msvideo': '.avi',
'video/quicktime': '.mov',
}
const matchedExt = Object.entries(extensionMap).find(([type]) => contentType.includes(type))
if (matchedExt) {
filename += matchedExt[1]
}
}
// 使用download属性直接下载,避免浏览器提示
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.style.display = 'none'
document.body.appendChild(a)
a.click()
// 延迟移除,确保下载开始
setTimeout(() => {
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}, 100)
// 刷新列表以更新下载次数
fetchMaterials()
fetchLikedMaterials()
} catch (error) {
console.error('下载处理失败:', error)
alert({
title: '下载失败',
message: '下载失败,请稍后重试',
variant: 'error',
})
}
} else {
console.error(`[Download] Download failed with status ${xhr.status}`)
try {
const errorText = xhr.responseText
console.error(`[Download] Error response:`, errorText)
const error = JSON.parse(errorText)
alert({
title: '下载失败',
message: error.error || `下载失败 (状态码: ${xhr.status})`,
variant: 'error',
})
} catch (parseError) {
console.error('[Download] Failed to parse error response:', parseError)
alert({
title: '下载失败',
message: `下载失败 (状态码: ${xhr.status})`,
variant: 'error',
})
}
}
// 清理下载状态
setDownloadingIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
setDownloadProgresses((prev) => {
const next = { ...prev }
delete next[id]
return next
})
})
xhr.addEventListener('error', (e) => {
console.error('[Download] Network error:', e)
alert({
title: '下载失败',
message: '网络错误,请检查网络连接后重试',
variant: 'error',
})
setDownloadingIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
setDownloadProgresses((prev) => {
const next = { ...prev }
delete next[id]
return next
})
})
xhr.open('GET', `/api/materials/download?id=${id}`)
xhr.responseType = 'arraybuffer'
xhr.send()
}
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
return
}
if (status === 'authenticated' && session) {
fetchProfile()
fetchMaterials()
fetchLikedMaterials()
}
}, [status, session, router])
const fetchProfile = async () => {
try {
const res = await fetch('/api/user/profile')
if (res.ok) {
const data = await res.json()
setProfile(data)
}
} catch (error) {
console.error('Failed to fetch profile:', error)
} finally {
setLoading(false)
}
}
const fetchMaterials = async () => {
try {
const res = await fetch('/api/user/materials')
if (res.ok) {
const data = await res.json()
setMaterials(data)
}
} catch (error) {
console.error('Failed to fetch materials:', error)
}
}
const fetchLikedMaterials = async () => {
try {
const res = await fetch('/api/user/likes')
if (res.ok) {
const data = await res.json()
setFavoriteMaterials(data)
}
} catch (error) {
console.error('Failed to fetch liked materials:', error)
}
}
const handleAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
if (!file.type.startsWith('image/')) {
alert({ title: '提示', message: '请选择图片文件', variant: 'warning' })
return
}
setAvatarFile(file)
setAvatarPreview(URL.createObjectURL(file))
setShowAvatarModal(true)
}
}
const handleAvatarUpload = async () => {
if (!avatarFile) return
setUploading(true)
try {
const formData = new FormData()
formData.append('avatar', avatarFile)
const res = await fetch('/api/user/avatar', {
method: 'POST',
body: formData,
})
if (res.ok) {
const data = await res.json()
setProfile(prev => prev ? { ...prev, avatar: data.avatarUrl } : null)
setShowAvatarModal(false)
setAvatarFile(null)
setAvatarPreview('')
// 刷新页面以更新 session 中的头像(JWT callback 会从数据库获取最新头像)
// 由于 JWT callback 每5分钟自动刷新一次,也可以等待自动刷新
window.location.reload()
} else {
const error = await res.json()
alert({ title: '上传失败', message: error.error || '上传失败', variant: 'error' })
}
} catch (error) {
console.error('Failed to upload avatar:', error)
alert({ title: '上传失败', message: '上传失败,请稍后重试', variant: 'error' })
} finally {
setUploading(false)
}
}
if (status === 'loading' || loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-slate-500 dark:text-slate-400">加载中...</p>
</div>
)
}
if (!session || !profile) {
return null
}
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<Header session={session} />
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* 用户信息卡片 */}
<div className={cn(styles.cardStyles.base, 'mb-6')}>
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
{/* 头像 */}
<div className="relative">
<Avatar
src={profile.avatar || undefined}
name={profile.username}
size="xl"
/>
<label className="absolute bottom-0 right-0 w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center cursor-pointer hover:bg-blue-700 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<input
type="file"
accept="image/*"
onChange={handleAvatarSelect}
className="hidden"
/>
</label>
</div>
{/* 用户信息 */}
<div className="flex-1 text-center sm:text-left">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
{profile.realName || profile.username}
</h1>
<p className="text-slate-600 dark:text-slate-400 mb-1">@{profile.username}</p>
<p className="text-slate-500 dark:text-slate-500 text-sm mb-4">{profile.email}</p>
<div className="flex flex-wrap gap-4 text-sm">
<div>
<span className="text-slate-500 dark:text-slate-400">部门:</span>
<span className="text-slate-700 dark:text-slate-300">{profile.department || '未设置'}</span>
</div>
{profile.phone && (
<div>
<span className="text-slate-500 dark:text-slate-400">手机:</span>
<span className="text-slate-700 dark:text-slate-300">{profile.phone}</span>
</div>
)}
<div>
<span className="text-slate-500 dark:text-slate-400">注册时间:</span>
<span className="text-slate-700 dark:text-slate-300">
{new Date(profile.createdAt).toLocaleDateString('zh-CN')}
</span>
</div>
</div>
</div>
</div>
</div>
{/* 我的素材 */}
<div className={cn(styles.cardStyles.base)}>
<h2 className="text-xl font-bold text-slate-900 dark:text-white mb-4">我的素材</h2>
{materials.length === 0 ? (
<p className="text-slate-500 dark:text-slate-400 text-center py-8">还没有上传过素材</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{materials.map((material) => (
<div
key={material.id}
className="relative aspect-[3/4] rounded-2xl overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
onClick={async () => {
// 获取完整的素材信息
try {
const response = await fetch(`/api/materials/${material.id}`)
if (response.ok) {
const fullMaterial = await response.json()
// 解析 displayImages(可能是字符串或数组)
let displayImagesArray: string[] = []
const parseDisplayImages = (value: any): string[] => {
if (!value) return []
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) {
return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
}
} catch {
return value.split(',').filter((s: string) => s.trim())
}
}
return []
}
displayImagesArray = parseDisplayImages(fullMaterial.displayImages) || parseDisplayImages(material.displayImages)
// 确保所有必需字段都存在
const safeMaterial: Material = {
id: fullMaterial.id || material.id,
userId: fullMaterial.userId || material.userId || '',
productModel: fullMaterial.productModel || material.productModel,
previewUrl: fullMaterial.previewUrl || material.previewUrl,
displayImages: displayImagesArray.length > 0 ? displayImagesArray : material.displayImages,
materialType: fullMaterial.materialType || material.materialType,
materialKind: fullMaterial.materialKind || material.materialKind,
categoryName: fullMaterial.categoryName || material.categoryName || '',
sceneName: fullMaterial.sceneName || material.sceneName || '',
renderer: fullMaterial.renderer || material.renderer,
authorName: fullMaterial.authorName || material.authorName,
downloadCount: fullMaterial.downloadCount ?? material.downloadCount ?? 0,
likeCount: fullMaterial.likeCount ?? material.likeCount ?? 0,
createdAt: fullMaterial.createdAt || material.createdAt,
isPremium: fullMaterial.isPremium ?? material.isPremium ?? false,
isFavorited: fullMaterial.isFavorited ?? material.isFavorited ?? false,
isLiked: fullMaterial.isLiked ?? material.isLiked ?? false,
zipFileName: fullMaterial.zipFileName || material.zipFileName,
zipFileSize: fullMaterial.zipFileSize || material.zipFileSize,
videoCoverUrl: fullMaterial.videoCoverUrl ?? material.videoCoverUrl,
videoPreview540Url: fullMaterial.videoPreview540Url ?? material.videoPreview540Url,
videoPreviewClipUrl: fullMaterial.videoPreviewClipUrl ?? material.videoPreviewClipUrl,
videoProcessingStatus: fullMaterial.videoProcessingStatus ?? material.videoProcessingStatus,
videoDuration: fullMaterial.videoDuration ?? material.videoDuration,
user: {
username: fullMaterial.user?.username || material.user?.username,
avatar: fullMaterial.user?.avatar || material.user?.avatar || null,
},
rankingBadges: fullMaterial.rankingBadges || material.rankingBadges,
}
setPreviewMaterial(safeMaterial)
} else {
setPreviewMaterial(material)
}
} catch (error) {
console.error('Failed to fetch material details:', error)
setPreviewMaterial(material)
}
}}
onDoubleClick={() => router.push(`/share/${material.id}`)}
title="单击预览,双击查看详情"
>
<Image
src={material.previewUrl}
alt={material.productModel}
fill
unoptimized
className="object-cover"
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2">
<p className="text-white text-xs font-medium truncate">{material.productModel}</p>
<div className="flex items-center gap-2 text-[10px] text-white/80 mt-1">
<span>下载 {material.downloadCount}</span>
<span>点赞 {material.likeCount}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* 我的点赞 */}
<div className={cn(styles.cardStyles.base, 'mt-6')}>
<h2 className="text-xl font-bold text-slate-900 dark:text-white mb-4">我的点赞</h2>
{favoriteMaterials.length === 0 ? (
<p className="text-slate-500 dark:text-slate-400 text-center py-8">还没有点赞素材</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{favoriteMaterials.map((material) => (
<div
key={material.id}
className="relative aspect-[3/4] rounded-2xl overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
onClick={async () => {
// 获取完整的素材信息
try {
const response = await fetch(`/api/materials/${material.id}`)
if (response.ok) {
const fullMaterial = await response.json()
// 解析 displayImages(可能是字符串或数组)
let displayImagesArray: string[] = []
const parseDisplayImages = (value: any): string[] => {
if (!value) return []
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) {
return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
}
} catch {
return value.split(',').filter((s: string) => s.trim())
}
}
return []
}
displayImagesArray = parseDisplayImages(fullMaterial.displayImages) || parseDisplayImages(material.displayImages)
// 确保所有必需字段都存在
const safeMaterial: Material = {
id: fullMaterial.id || material.id,
userId: fullMaterial.userId || material.userId || '',
productModel: fullMaterial.productModel || material.productModel,
previewUrl: fullMaterial.previewUrl || material.previewUrl,
displayImages: displayImagesArray.length > 0 ? displayImagesArray : material.displayImages,
materialType: fullMaterial.materialType || material.materialType,
materialKind: fullMaterial.materialKind || material.materialKind,
categoryName: fullMaterial.categoryName || material.categoryName || '',
sceneName: fullMaterial.sceneName || material.sceneName || '',
renderer: fullMaterial.renderer || material.renderer,
authorName: fullMaterial.authorName || material.authorName,
downloadCount: fullMaterial.downloadCount ?? material.downloadCount ?? 0,
likeCount: fullMaterial.likeCount ?? material.likeCount ?? 0,
createdAt: fullMaterial.createdAt || material.createdAt,
isPremium: fullMaterial.isPremium ?? material.isPremium ?? false,
isFavorited: fullMaterial.isFavorited ?? material.isFavorited ?? false,
isLiked: fullMaterial.isLiked ?? material.isLiked ?? false,
zipFileName: fullMaterial.zipFileName || material.zipFileName,
zipFileSize: fullMaterial.zipFileSize || material.zipFileSize,
videoCoverUrl: fullMaterial.videoCoverUrl ?? material.videoCoverUrl,
videoPreview540Url: fullMaterial.videoPreview540Url ?? material.videoPreview540Url,
videoPreviewClipUrl: fullMaterial.videoPreviewClipUrl ?? material.videoPreviewClipUrl,
videoProcessingStatus: fullMaterial.videoProcessingStatus ?? material.videoProcessingStatus,
videoDuration: fullMaterial.videoDuration ?? material.videoDuration,
user: {
username: fullMaterial.user?.username || material.user?.username,
avatar: fullMaterial.user?.avatar || material.user?.avatar || null,
},
rankingBadges: fullMaterial.rankingBadges || material.rankingBadges,
}
setPreviewMaterial(safeMaterial)
} else {
setPreviewMaterial(material)
}
} catch (error) {
console.error('Failed to fetch material details:', error)
setPreviewMaterial(material)
}
}}
onDoubleClick={() => router.push(`/share/${material.id}`)}
title="单击预览,双击查看详情"
>
<Image
src={material.previewUrl}
alt={material.productModel}
fill
unoptimized
className="object-cover"
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2">
<p className="text-white text-xs font-medium truncate">{material.productModel}</p>
<div className="flex items-center gap-2 text-[10px] text-white/80 mt-1">
<span>下载 {material.downloadCount}</span>
<span>点赞 {material.likeCount}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* 头像上传确认弹窗 */}
<Modal
isOpen={showAvatarModal}
onClose={() => {
setShowAvatarModal(false)
if (avatarPreview) {
URL.revokeObjectURL(avatarPreview)
}
setAvatarFile(null)
setAvatarPreview('')
}}
size="small"
title="上传头像"
>
<div className="space-y-4">
{avatarPreview && (
<div className="flex justify-center">
<div className="relative w-32 h-32 rounded-full overflow-hidden border-4 border-slate-200 dark:border-slate-700">
<Image
src={avatarPreview}
alt="Avatar preview"
fill
unoptimized
className="object-cover"
/>
</div>
</div>
)}
<div className="flex gap-3 justify-end">
<Button
variant="secondary"
onClick={() => {
setShowAvatarModal(false)
if (avatarPreview) {
URL.revokeObjectURL(avatarPreview)
}
setAvatarFile(null)
setAvatarPreview('')
}}
>
取消
</Button>
<Button
variant="primary"
onClick={handleAvatarUpload}
disabled={uploading}
loading={uploading}
>
确认上传
</Button>
</div>
</div>
</Modal>
<MaterialPreviewModal
isOpen={!!previewMaterial}
onClose={() => setPreviewMaterial(null)}
material={previewMaterial}
onDownload={previewMaterial ? () => handleDownload(previewMaterial.id) : undefined}
actionSection={
previewMaterial ? (
<MaterialActionButtons
layout="modal"
material={previewMaterial}
themeColor={session?.user ? (session.user as any).themeColor || '#6366f1' : '#6366f1'}
onDownload={() => handleDownload(previewMaterial.id)}
isDownloading={downloadingIds.has(previewMaterial.id)}
downloadProgress={downloadProgresses[previewMaterial.id] || 0}
inlineLikeButton={false}
/>
) : undefined
}
/>
</div>
)
}

View File

@ -80,15 +80,15 @@ export default function RegisterPage() {
<div className="flex justify-center mb-4">
<Image
src={process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''}
alt="由壹点3D素材"
alt="FLNXI素材库"
width={80}
height={80}
unoptimized
className="rounded-apple shadow-apple"
/>
</div>
<h1 className="text-3xl font-bold text-gray-900">由壹点3D素材</h1>
<p className="text-gray-600 mt-2">创建您的账号,开始分享3D素材</p>
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">FLNXI素材库</h1>
<p className="text-slate-600 dark:text-slate-400 mt-2">创建您的账号,开始分享3D素材</p>
</div>
{/* Register Form */}
@ -101,7 +101,7 @@ export default function RegisterPage() {
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
邮箱
</label>
<input
@ -115,7 +115,7 @@ export default function RegisterPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
用户名
</label>
<input
@ -129,7 +129,7 @@ export default function RegisterPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
部门
</label>
<select
@ -145,14 +145,14 @@ export default function RegisterPage() {
))}
</select>
{departments.length === 0 && (
<p className="text-sm text-gray-500 mt-1">
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
暂无可选部门,请联系管理员添加
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
密码
</label>
<input
@ -166,7 +166,7 @@ export default function RegisterPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
确认密码
</label>
<input
@ -189,11 +189,11 @@ export default function RegisterPage() {
</form>
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
<p className="text-sm text-slate-600 dark:text-slate-400">
已有账号?{' '}
<Link
href="/login"
className="text-gray-900 hover:text-gray-700 font-medium underline"
className="text-slate-900 dark:text-slate-100 hover:text-slate-700 dark:hover:text-slate-300 font-medium underline"
>
立即登录
</Link>
@ -205,3 +205,4 @@ export default function RegisterPage() {
)
}

View File

@ -19,7 +19,7 @@ import Image from 'next/image'
import { format } from 'date-fns'
import Header from '@/components/Header'
import { useDialog } from '@/components/ui/DialogProvider'
import { useThemeStyles, cn } from '@/lib/ui-styles'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
interface Material {
id: string
@ -47,6 +47,7 @@ interface Material {
export default function SharePage() {
const styles = useThemeStyles()
const theme = useTheme()
const { data: session, status } = useSession()
const router = useRouter()
const { alert } = useDialog()
@ -300,7 +301,7 @@ export default function SharePage() {
<div className="w-80 flex-shrink-0">
<div className={cn('sticky top-24 rounded-2xl shadow-lg p-6 space-y-6', styles.cardStyles.base)}>
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-3">
<h1 className={cn('text-2xl font-bold mb-3', theme === 'dark' ? 'text-white' : 'text-slate-900')}>
{material.productModel}
</h1>
<div className="flex flex-wrap gap-2">
@ -318,30 +319,30 @@ export default function SharePage() {
<div className="space-y-4">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">作者</p>
<p className="text-sm font-medium text-slate-900 dark:text-white">{material.authorName}</p>
<p className={cn('text-xs mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>作者</p>
<p className={cn('text-sm font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.authorName}</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">文件大小</p>
<p className={cn('text-xs mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>文件大小</p>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400">{formatFileSize(material.zipFileSize)}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">下载</p>
<p className="text-sm font-medium text-slate-900 dark:text-white">{material.downloadCount}</p>
<p className={cn('text-xs mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>下载</p>
<p className={cn('text-sm font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.downloadCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">点赞</p>
<p className="text-sm font-medium text-slate-900 dark:text-white">{material.likeCount}</p>
<p className={cn('text-xs mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>点赞</p>
<p className={cn('text-sm font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.likeCount}</p>
</div>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">文件名</p>
<p className="text-xs text-slate-700 dark:text-slate-300 break-all">{material.zipFileName}</p>
<p className={cn('text-xs mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>文件名</p>
<p className={cn('text-xs break-all', theme === 'dark' ? 'text-slate-300' : 'text-slate-700')}>{material.zipFileName}</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">上传时间</p>
<p className="text-xs text-slate-700 dark:text-slate-300">
<p className={cn('text-xs mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>上传时间</p>
<p className={cn('text-xs', theme === 'dark' ? 'text-slate-300' : 'text-slate-700')}>
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
</p>
</div>
@ -352,7 +353,11 @@ export default function SharePage() {
<button
onClick={handleDownload}
disabled={downloading}
className="w-full btn-primary py-3 text-sm font-medium relative overflow-hidden disabled:opacity-75"
className={cn(
'w-full py-3 text-sm font-medium relative overflow-hidden disabled:opacity-75 rounded-xl',
'bg-blue-600 hover:bg-blue-700 text-white transition-colors',
styles.buttonStyles.primary
)}
>
{downloading ? (
<>
@ -392,10 +397,13 @@ export default function SharePage() {
)}
<button
onClick={() => router.push('/')}
className="w-full py-2.5 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-sm font-medium hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
onClick={() => router.back()}
className={cn(
'w-full py-2.5 rounded-xl text-sm font-medium transition-colors',
styles.buttonStyles.secondary
)}
>
返回首页
返回上一页
</button>
</div>
</div>
@ -403,7 +411,7 @@ export default function SharePage() {
) : material.materialType === '视频' ? (
/* 视频类型:视频播放布局 */
<div className="max-w-6xl mx-auto">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-lg overflow-hidden">
<div className={cn('rounded-2xl shadow-lg overflow-hidden', styles.cardStyles.base)}>
{/* 视频播放器 */}
<div className="relative w-full bg-black">
<video
@ -420,7 +428,7 @@ export default function SharePage() {
{/* Material Info */}
<div className="p-8">
<div className="mb-6">
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">
<h1 className={cn('text-3xl font-bold mb-2', theme === 'dark' ? 'text-white' : 'text-slate-900')}>
{material.productModel}
</h1>
<div className="flex items-center gap-3 mb-4">
@ -438,31 +446,31 @@ export default function SharePage() {
<div className="grid grid-cols-2 gap-6 mb-6">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-1">作者</p>
<p className="text-lg font-medium text-slate-900 dark:text-white">{material.authorName}</p>
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>作者</p>
<p className={cn('text-lg font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.authorName}</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-1">文件大小</p>
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>文件大小</p>
<p className="text-lg font-medium text-blue-600 dark:text-blue-400">{formatFileSize(material.zipFileSize)}</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-1">下载次数</p>
<p className="text-lg font-medium text-slate-900 dark:text-white">{material.downloadCount}</p>
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>下载次数</p>
<p className={cn('text-lg font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.downloadCount}</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-1">点赞数</p>
<p className="text-lg font-medium text-slate-900 dark:text-white">{material.likeCount}</p>
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>点赞数</p>
<p className={cn('text-lg font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.likeCount}</p>
</div>
</div>
<div className="mb-6">
<p className="text-sm text-slate-500 dark:text-slate-400 mb-1">文件名</p>
<p className="text-sm text-slate-700 dark:text-slate-300 break-all">{material.zipFileName}</p>
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>文件名</p>
<p className={cn('text-sm break-all', theme === 'dark' ? 'text-slate-300' : 'text-slate-700')}>{material.zipFileName}</p>
</div>
<div className="mb-6">
<p className="text-sm text-slate-500 dark:text-slate-400 mb-1">上传时间</p>
<p className="text-sm text-slate-700 dark:text-slate-300">
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>上传时间</p>
<p className={cn('text-sm', theme === 'dark' ? 'text-slate-300' : 'text-slate-700')}>
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
</p>
</div>
@ -471,7 +479,11 @@ export default function SharePage() {
<button
onClick={handleDownload}
disabled={downloading}
className="w-full btn-primary py-4 text-lg font-medium relative overflow-hidden disabled:opacity-75"
className={cn(
'w-full py-4 text-lg font-medium relative overflow-hidden disabled:opacity-75 rounded-xl',
'bg-blue-600 hover:bg-blue-700 text-white transition-colors',
styles.buttonStyles.primary
)}
>
{downloading ? (
<>
@ -498,7 +510,10 @@ export default function SharePage() {
<button
onClick={() => router.push('/')}
className="w-full mt-4 py-3 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl font-medium hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
className={cn(
'w-full mt-4 py-3 rounded-xl font-medium transition-colors',
styles.buttonStyles.secondary
)}
>
返回首页
</button>
@ -506,11 +521,11 @@ export default function SharePage() {
</div>
</div>
) : (
/* 非PSD/非视频类型:原有布局 */
/* 非PSD/非视频类型:统一布局 */
<div className="max-w-4xl mx-auto">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-lg overflow-hidden">
<div className={cn('rounded-2xl shadow-lg overflow-hidden', styles.cardStyles.base)}>
{/* 普通预览图 */}
<div className="relative w-full h-96 bg-slate-200 dark:bg-slate-700">
<div className={cn('relative w-full h-96', theme === 'dark' ? 'bg-slate-800' : 'bg-slate-200')}>
<Image
src={material.previewUrl}
alt={material.productModel}
@ -524,7 +539,7 @@ export default function SharePage() {
{/* Material Info */}
<div className="p-8">
<div className="mb-6">
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">
<h1 className={cn('text-3xl font-bold mb-2', theme === 'dark' ? 'text-white' : 'text-slate-900')}>
{material.productModel}
</h1>
<div className="flex items-center gap-3 mb-4">
@ -539,31 +554,31 @@ export default function SharePage() {
<div className="grid grid-cols-2 gap-6 mb-6">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-1">作者</p>
<p className="text-lg font-medium text-slate-900 dark:text-white">{material.authorName}</p>
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>作者</p>
<p className={cn('text-lg font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.authorName}</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-1">文件大小</p>
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>文件大小</p>
<p className="text-lg font-medium text-blue-600 dark:text-blue-400">{formatFileSize(material.zipFileSize)}</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-1">下载次数</p>
<p className="text-lg font-medium text-slate-900 dark:text-white">{material.downloadCount}</p>
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>下载次数</p>
<p className={cn('text-lg font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.downloadCount}</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-1">点赞数</p>
<p className="text-lg font-medium text-slate-900 dark:text-white">{material.likeCount}</p>
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>点赞数</p>
<p className={cn('text-lg font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.likeCount}</p>
</div>
</div>
<div className="mb-6">
<p className="text-sm text-slate-500 dark:text-slate-400 mb-1">文件名</p>
<p className="text-sm text-slate-700 dark:text-slate-300 break-all">{material.zipFileName}</p>
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>文件名</p>
<p className={cn('text-sm break-all', theme === 'dark' ? 'text-slate-300' : 'text-slate-700')}>{material.zipFileName}</p>
</div>
<div className="mb-6">
<p className="text-sm text-slate-500 dark:text-slate-400 mb-1">上传时间</p>
<p className="text-sm text-slate-700 dark:text-slate-300">
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>上传时间</p>
<p className={cn('text-sm', theme === 'dark' ? 'text-slate-300' : 'text-slate-700')}>
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
</p>
</div>
@ -574,7 +589,11 @@ export default function SharePage() {
<button
onClick={handleDownload}
disabled={downloading}
className="w-full btn-primary py-4 text-lg font-medium relative overflow-hidden disabled:opacity-75"
className={cn(
'w-full py-4 text-lg font-medium relative overflow-hidden disabled:opacity-75 rounded-xl',
'bg-blue-600 hover:bg-blue-700 text-white transition-colors',
styles.buttonStyles.primary
)}
>
{downloading ? (
<>
@ -601,7 +620,10 @@ export default function SharePage() {
<button
onClick={() => router.push('/')}
className="w-full mt-4 py-3 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl font-medium hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
className={cn(
'w-full mt-4 py-3 rounded-xl font-medium transition-colors',
styles.buttonStyles.secondary
)}
>
返回首页
</button>

View File

@ -410,7 +410,7 @@ export default function AIChatWidget() {
<>
<style dangerouslySetInnerHTML={{ __html: shimmerStyle }} />
<div
className="fixed w-[420px] h-[680px] bg-white dark:bg-gray-900 rounded-2xl overflow-hidden flex flex-col"
className="fixed w-[420px] h-[680px] bg-white dark:bg-slate-900 rounded-2xl overflow-hidden flex flex-col"
style={{
left: position.x ? `${position.x}px` : 'auto',
top: position.y ? `${position.y}px` : 'auto',
@ -451,7 +451,7 @@ export default function AIChatWidget() {
{/* 消息列表 */}
<div
ref={dropZoneRef}
className="flex-1 overflow-y-auto p-4 space-y-4 bg-gradient-to-b from-gray-50 to-white dark:from-gray-900 dark:to-gray-950 relative"
className="flex-1 overflow-y-auto p-4 space-y-4 bg-gradient-to-b from-slate-50 to-white dark:from-slate-900 dark:to-slate-950 relative"
style={{
scrollbarWidth: 'thin',
scrollbarColor: '#cbd5e1 transparent',
@ -682,7 +682,7 @@ export default function AIChatWidget() {
}
}}
placeholder="输入消息..."
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm resize-none placeholder:text-gray-400 dark:placeholder:text-gray-500"
className="flex-1 px-3 py-2 border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm resize-none placeholder:text-slate-400 dark:placeholder:text-slate-500"
rows={2}
disabled={sending}
/>
@ -707,7 +707,7 @@ export default function AIChatWidget() {
)}
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 text-center">
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2 text-center">
支持文字+图片聊天 · 可上传/拖拽/粘贴图片 · 最多5张
</p>
</div>

View File

@ -260,7 +260,7 @@ export default function DanmakuWidget() {
onClick={() => setIsOpen(!isOpen)}
className={`w-14 h-14 rounded-full shadow-lg hover:shadow-xl transition-all transform hover:scale-110 ${
isOpen
? 'bg-gray-600 hover:bg-gray-700'
? 'bg-slate-600 dark:bg-slate-700 hover:bg-slate-700 dark:hover:bg-slate-600'
: 'bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600'
} text-white flex items-center justify-center`}
>

View File

@ -1,7 +1,9 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useCallback } from 'react'
import Image from 'next/image'
import Modal from '@/components/ui/Modal'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
interface EditMaterialModalProps {
material: {
@ -13,124 +15,129 @@ interface EditMaterialModalProps {
materialType: string
renderer: string
authorName: string
brandName?: string | null
seriesName?: string | null
skuCode?: string | null
materialTexture?: string | null
renderTime?: string | null
imageResolution?: string | null
colorMode?: string | null
templateId?: string | null
platformName?: string | null
}
onClose: () => void
onSuccess: () => void
isOpen?: boolean
}
export default function EditMaterialModal({ material, onClose, onSuccess }: EditMaterialModalProps) {
type UploadTemplateOption = {
id: string
name: string
description: string | null
materialType?: string
fields: any[]
isBuiltin?: boolean
}
export default function EditMaterialModal({ material, onClose, onSuccess, isOpen = true }: EditMaterialModalProps) {
const styles = useThemeStyles()
const theme = useTheme()
const [previewImage, setPreviewImage] = useState<File | null>(null)
const [previewUrl, setPreviewUrl] = useState<string>(material.previewUrl)
const [productModel, setProductModel] = useState(material.productModel)
const [categoryName, setCategoryName] = useState(material.categoryName)
const [sceneName, setSceneName] = useState(material.sceneName)
const [materialType, setMaterialType] = useState(material.materialType || '3DMAX')
const [renderer, setRenderer] = useState(material.renderer || 'CR')
const [authorName, setAuthorName] = useState(material.authorName)
const [categories, setCategories] = useState<Array<{ id: string; name: string }>>([])
const [scenes, setScenes] = useState<Array<{ id: string; name: string }>>([])
const [showNewCategoryInput, setShowNewCategoryInput] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [showNewSceneInput, setShowNewSceneInput] = useState(false)
const [newSceneName, setNewSceneName] = useState('')
const [templateFieldValues, setTemplateFieldValues] = useState<Record<string, any>>({})
const [selectedTemplate, setSelectedTemplate] = useState<UploadTemplateOption | null>(null)
const [templateCategoryOptions, setTemplateCategoryOptions] = useState<Record<string, string[]>>({})
const [optionsLoading, setOptionsLoading] = useState(true)
const [updating, setUpdating] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const previewInputRef = useRef<HTMLInputElement>(null)
// 加载分类和场景列表
// 初始化字段值
useEffect(() => {
fetchCategories()
fetchScenes()
}, [])
const initialValues: Record<string, any> = {
productModel: material.productModel,
brandName: material.brandName || '',
seriesName: material.seriesName || '',
skuCode: material.skuCode || '',
materialTexture: material.materialTexture || '',
categoryName: material.categoryName || '',
sceneName: material.sceneName || '',
platformName: material.platformName || '',
materialType: material.materialType || '3DMAX',
renderer: material.renderer || 'CR',
renderTime: material.renderTime || '',
imageResolution: material.imageResolution || '',
colorMode: material.colorMode || '',
authorName: material.authorName || '',
}
setTemplateFieldValues(initialValues)
}, [material])
const fetchCategories = async () => {
try {
const response = await fetch('/api/categories')
if (response.ok) {
const data = await response.json()
setCategories(data)
// 加载模板和分类选项
useEffect(() => {
const loadTemplateAndOptions = async () => {
setOptionsLoading(true)
try {
// 加载模板分类选项
const categoriesResponse = await fetch('/api/template-categories', { cache: 'no-store' })
if (categoriesResponse.ok) {
const categoriesData = await categoriesResponse.json()
const map = Array.isArray(categoriesData)
? categoriesData.reduce<Record<string, string[]>>((acc, field: any) => {
if (field?.fieldKey && Array.isArray(field.options)) {
acc[field.fieldKey] = field.options
}
return acc
}, {})
: {}
setTemplateCategoryOptions(map)
}
// 如果有templateId,加载模板
if (material.templateId) {
const templateResponse = await fetch(`/api/admin/upload-templates/${material.templateId}`)
if (templateResponse.ok) {
const templateData = await templateResponse.json()
setSelectedTemplate({
id: templateData.id,
name: templateData.name,
description: templateData.description,
materialType: templateData.materialType,
fields: templateData.fields || [],
isBuiltin: templateData.isBuiltin,
})
}
} else {
// 如果没有templateId,尝试根据materialType查找匹配的模板
const templatesResponse = await fetch('/api/upload-templates')
if (templatesResponse.ok) {
const templatesData = await templatesResponse.json()
const templates = Array.isArray(templatesData) ? templatesData : []
const matchingTemplate = templates.find(
(t: any) => t.materialType === material.materialType && t.status === 'active'
)
if (matchingTemplate) {
setSelectedTemplate({
id: matchingTemplate.id,
name: matchingTemplate.name,
description: matchingTemplate.description,
materialType: matchingTemplate.materialType,
fields: matchingTemplate.fields || [],
isBuiltin: matchingTemplate.isBuiltin,
})
}
}
}
} catch (error) {
console.error('[EditMaterialModal] Failed to load template:', error)
} finally {
setOptionsLoading(false)
}
} catch (error) {
console.error('Error fetching categories:', error)
}
}
const fetchScenes = async () => {
try {
const response = await fetch('/api/scenes')
if (response.ok) {
const data = await response.json()
setScenes(data)
}
} catch (error) {
console.error('Error fetching scenes:', error)
}
}
const handleAddNewCategory = async () => {
if (!newCategoryName.trim()) {
setError('请输入一级分类名称')
return
}
try {
const response = await fetch('/api/categories', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newCategoryName.trim() }),
})
if (response.ok) {
const newCategory = await response.json()
await fetchCategories()
setCategoryName(newCategory.name)
setNewCategoryName('')
setShowNewCategoryInput(false)
setError('')
} else {
const data = await response.json()
setError(data.error || '添加一级分类失败')
}
} catch (error) {
console.error('Error adding category:', error)
setError('添加一级分类失败')
}
}
const handleAddNewScene = async () => {
if (!newSceneName.trim()) {
setError('请输入场景名称')
return
}
try {
const response = await fetch('/api/scenes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newSceneName.trim() }),
})
if (response.ok) {
const newScene = await response.json()
await fetchScenes()
setSceneName(newScene.name)
setNewSceneName('')
setShowNewSceneInput(false)
setError('')
} else {
const data = await response.json()
setError(data.error || '添加场景失败')
}
} catch (error) {
console.error('Error adding scene:', error)
setError('添加场景失败')
}
}
loadTemplateAndOptions()
}, [material.templateId, material.materialType])
const handlePreviewChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
@ -148,10 +155,16 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSuccess('')
if (!productModel || !categoryName || !sceneName || !renderer || !authorName) {
setError('请填写所有字段')
return
// 验证必填字段
const requiredFields = selectedTemplate?.fields.filter((f: any) => f.required) || []
for (const field of requiredFields) {
const value = templateFieldValues[field.key]
if (!value || (typeof value === 'string' && value.trim() === '')) {
setError(`请填写必填字段:${field.label}`)
return
}
}
setUpdating(true)
@ -159,12 +172,13 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
try {
const formData = new FormData()
formData.append('id', material.id)
formData.append('productModel', productModel)
formData.append('categoryName', categoryName)
formData.append('sceneName', sceneName)
formData.append('materialType', materialType)
formData.append('renderer', renderer)
formData.append('authorName', authorName)
// 添加所有模板字段值
Object.entries(templateFieldValues).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, String(value))
}
})
if (previewImage) {
formData.append('previewImage', previewImage)
@ -176,7 +190,11 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
})
if (response.ok) {
onSuccess()
setSuccess('素材信息已更新')
setTimeout(() => {
onSuccess()
onClose()
}, 1000)
} else {
const data = await response.json()
setError(data.error || '更新失败')
@ -189,284 +207,334 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-apple max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-apple-lg">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">编辑素材</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
>
×
</button>
// 渲染模板字段(和UploadModal相同的逻辑)
const renderTemplateField = (field: any) => {
const fieldValue = templateFieldValues[field.key] || ''
const isHalfWidth = field.layoutWidth === 'half'
return (
<div
key={field.id}
className={isHalfWidth ? 'w-full sm:w-1/2 inline-block sm:pr-2' : 'w-full'}
>
<div className="space-y-2.5">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-semibold text-slate-800 dark:text-slate-200 tracking-tight">
{field.label}
</label>
{field.required && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-[0.18em] text-white bg-gradient-to-r from-purple-500 to-indigo-600 shadow-[0_8px_16px_rgba(79,70,229,0.35)]">
必填
</span>
)}
</div>
{field.type === 'text' || field.type === 'uppercase' ? (
<input
type="text"
value={fieldValue}
onChange={(e) => {
const value = field.type === 'uppercase'
? e.target.value.toUpperCase()
: e.target.value
setTemplateFieldValues({
...templateFieldValues,
[field.key]: value,
})
}}
required={field.required}
placeholder={
field.key === 'imageResolution'
? '例如:5000 * 5000'
: `请输入${field.label || field.key}`
}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80',
field.type === 'uppercase' && 'uppercase'
)}
/>
) : field.type === 'textarea' ? (
<textarea
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
required={field.required}
placeholder={`请输入${field.label || field.key}`}
rows={2}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60 resize-none',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80'
)}
/>
) : field.type === 'number' ? (
<input
type="number"
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
required={field.required}
placeholder={`请输入${field.label || field.key}`}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80'
)}
/>
) : field.type === 'select' ? (
<select
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
required={field.required}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80'
)}
>
<option value="">请选择</option>
{(() => {
// 如果字段勾选了 addToCategory,优先使用从模板分类API获取的选项
if (field.addToCategory && templateCategoryOptions[field.key]) {
return templateCategoryOptions[field.key].map((opt: string) => (
<option key={opt} value={opt}>
{opt}
</option>
))
}
// 否则使用模板中的 optionsStatic
if (field.optionsStatic) {
try {
const options = typeof field.optionsStatic === 'string'
? JSON.parse(field.optionsStatic)
: field.optionsStatic
return Array.isArray(options)
? options.map((opt: string) => (
<option key={opt} value={opt}>
{opt}
</option>
))
: null
} catch {
return null
}
}
return null
})()}
</select>
) : field.type === 'checkbox' ? (
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={fieldValue || false}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.checked,
})
}}
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600"
/>
<span className="text-sm text-slate-700 dark:text-slate-300">
{field.label}
</span>
</label>
) : field.type === 'radio' ? (
<div className="space-y-2">
{field.optionsStatic ? (
(() => {
try {
const options = typeof field.optionsStatic === 'string'
? JSON.parse(field.optionsStatic)
: field.optionsStatic
return Array.isArray(options)
? options.map((opt: string) => (
<label key={opt} className="flex items-center space-x-2">
<input
type="radio"
name={field.key}
value={opt}
checked={fieldValue === opt}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
required={field.required}
className="w-4 h-4"
/>
<span className="text-sm text-slate-700 dark:text-slate-300">
{opt}
</span>
</label>
))
: null
} catch {
return null
}
})()
) : null}
</div>
) : field.type === 'date' ? (
<input
type="date"
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
required={field.required}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80'
)}
/>
) : null}
</div>
</div>
)
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
closeOnOverlayClick={false}
size="large"
title="编辑素材"
bodyClassName="max-h-[70vh] overflow-y-auto"
>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className={cn(
'px-4 py-3 rounded-xl border',
'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800',
'text-red-600 dark:text-red-400'
)}>
{error}
</div>
)}
{success && (
<div className={cn(
'px-4 py-3 rounded-xl border',
'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800',
'text-emerald-700 dark:text-emerald-400'
)}>
{success}
</div>
)}
{/* Preview Image - 和UploadModal相同的布局 */}
<div>
<label className={cn('block text-sm font-medium mb-2', theme === 'dark' ? 'text-slate-200' : 'text-slate-700')}>
预览图
</label>
<div
onClick={() => previewInputRef.current?.click()}
className={cn(
styles.inputStyles.base,
'border-2 border-dashed cursor-pointer flex flex-col sm:flex-row gap-4 items-center p-4',
'hover:border-indigo-400 dark:hover:border-indigo-500 hover:bg-slate-50/50 dark:hover:bg-slate-800/40'
)}
>
<div className="relative w-28 h-28 sm:w-32 sm:h-32 rounded-2xl overflow-hidden border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 flex-shrink-0">
<Image src={previewUrl} alt="预览图" fill unoptimized className="object-cover" />
</div>
<div className="flex-1 text-left space-y-1">
<p className={cn('text-sm font-semibold', theme === 'dark' ? 'text-slate-200' : 'text-slate-800')}>
点击或拖拽替换预览图
</p>
<p className={cn('text-xs', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
推荐上传 600px 以上的 JPG/PNG/WebP,系统会自动等比压缩
</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
previewInputRef.current?.click()
}}
className="inline-flex items-center text-xs font-medium text-blue-600 hover:text-blue-500 dark:text-blue-300 dark:hover:text-blue-200"
>
更换预览图
<svg className="w-3.5 h-3.5 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v12m0 0l3-3m-3 3l-3-3M5 20h14" />
</svg>
</button>
</div>
</div>
<input
ref={previewInputRef}
type="file"
accept="image/*"
onChange={handlePreviewChange}
className="hidden"
/>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple">
{error}
{/* 动态渲染模板字段 */}
{selectedTemplate && selectedTemplate.fields && selectedTemplate.fields.length > 0 ? (
<div className="space-y-4">
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-4">
模板字段
</h3>
<div className="space-y-4">
{selectedTemplate.fields.map((field: any) => renderTemplateField(field))}
</div>
</div>
)}
{/* Preview Image */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
预览图
</label>
<div
onClick={() => previewInputRef.current?.click()}
className="border-2 border-dashed border-gray-300 rounded-apple p-4 text-center cursor-pointer hover:border-primary-500 transition-colors"
>
<div className="relative w-full h-48">
<Image
src={previewUrl}
alt="预览"
fill
unoptimized
className="object-contain"
/>
</div>
<p className="text-xs text-gray-400 mt-2">
点击更换预览图(可选)
</p>
</div>
<input
ref={previewInputRef}
type="file"
accept="image/*"
onChange={handlePreviewChange}
className="hidden"
/>
</div>
{/* Product Model */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
商品型号 *
</label>
<input
type="text"
value={productModel}
onChange={(e) => {
// 只允许英文、数字和常见符号(- _ .),过滤中文,自动转大写
const value = e.target.value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase()
setProductModel(value)
}}
className="input-apple"
placeholder="例如:XY-2024-001"
required
/>
) : optionsLoading ? (
<div className="text-center py-8 text-slate-500 dark:text-slate-400">
加载模板中...
</div>
{/* Category Name (一级分类) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
一级分类(使用店铺) *
</label>
{!showNewCategoryInput ? (
<div className="space-y-2">
<select
value={categoryName}
onChange={(e) => {
if (e.target.value === '__add_new__') {
setShowNewCategoryInput(true)
setCategoryName('')
} else {
setCategoryName(e.target.value)
}
}}
className="input-apple"
required
>
<option value="">请选择一级分类</option>
{categories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
<option value="__add_new__">➕ 添加新分类</option>
</select>
</div>
) : (
<div className="space-y-2">
<div className="flex gap-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="input-apple flex-1"
placeholder="输入新的一级分类名称"
autoFocus
/>
<button
type="button"
onClick={handleAddNewCategory}
className="px-4 py-2 bg-blue-600 text-white rounded-apple hover:bg-blue-700 transition-colors"
>
确认
</button>
<button
type="button"
onClick={() => {
setShowNewCategoryInput(false)
setNewCategoryName('')
setError('')
}}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-apple hover:bg-gray-200 transition-colors"
>
取消
</button>
</div>
</div>
)}
) : (
<div className="text-center py-8 text-amber-500 dark:text-amber-400">
未找到对应的上传模板,请使用基础字段编辑
</div>
)}
{/* Scene Name (二级分类) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
二级分类(场景名称) *
</label>
{!showNewSceneInput ? (
<div className="space-y-2">
<select
value={sceneName}
onChange={(e) => {
if (e.target.value === '__add_new__') {
setShowNewSceneInput(true)
setSceneName('')
} else {
setSceneName(e.target.value)
}
}}
className="input-apple"
required
>
<option value="">请选择场景</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>
{scene.name}
</option>
))}
<option value="__add_new__">+ 添加新场景</option>
</select>
</div>
) : (
<div className="space-y-2">
<div className="flex space-x-2">
<input
type="text"
value={newSceneName}
onChange={(e) => setNewSceneName(e.target.value)}
className="input-apple"
placeholder="输入新场景名称"
autoFocus
/>
<button
type="button"
onClick={handleAddNewScene}
className="btn-primary whitespace-nowrap"
>
确定
</button>
<button
type="button"
onClick={() => {
setShowNewSceneInput(false)
setNewSceneName('')
setError('')
}}
className="btn-secondary whitespace-nowrap"
>
取消
</button>
</div>
<p className="text-xs text-gray-500">
添加后将自动加入到场景列表中
</p>
</div>
)}
</div>
{/* Material Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
素材类型 *
</label>
<select
value={materialType}
onChange={(e) => setMaterialType(e.target.value)}
className="input-apple"
required
>
<option value="3DMAX">3DMAX</option>
<option value="PSD">PSD</option>
<option value="AI">AI</option>
<option value="CAD">CAD</option>
<option value="C4D">C4D</option>
<option value="其他">其他</option>
</select>
</div>
{/* Renderer (仅3DMAX显示) */}
{materialType === '3DMAX' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
渲染器 *
</label>
<select
value={renderer}
onChange={(e) => setRenderer(e.target.value)}
className="input-apple"
required
>
<option value="CR">CR</option>
<option value="VR">VR</option>
<option value="其它">其它</option>
</select>
</div>
)}
{/* Author Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
作者花名 *
</label>
<input
type="text"
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
className="input-apple"
placeholder="作者花名"
required
/>
</div>
{/* Actions */}
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-secondary"
disabled={updating}
>
取消
</button>
<button
type="submit"
className="btn-primary"
disabled={updating}
>
{updating ? '更新中...' : '保存修改'}
</button>
</div>
</form>
</div>
</div>
{/* Actions */}
<div className="flex justify-end space-x-3 pt-6 pb-4 border-t border-slate-200 dark:border-slate-700 mt-6">
<button
type="button"
onClick={onClose}
className={cn(styles.buttonStyles.secondary, 'min-w-[120px]')}
disabled={updating}
>
取消
</button>
<button
type="submit"
className={cn(styles.buttonStyles.primary, 'min-w-[140px]')}
disabled={updating}
>
{updating ? '更新中...' : '保存修改'}
</button>
</div>
</form>
</Modal>
)
}

View File

@ -20,32 +20,85 @@ import { initializeNetwork } from '@/lib/network'
import { useThemeStyles, cn } from '@/lib/ui-styles'
import Avatar from '@/components/ui/Avatar'
interface HeaderCompactModeProps {
isScrolled: boolean
searchKeyword: string
onSearchChange: (value: string) => void
onOpenFilter: () => void
onOpenUpload: () => void
categoryItems: Array<{ key: string; label: string }>
selectedCategoryKey: string
onSelectCategory: (key: string) => void
themeColor: string
}
interface HeaderProps {
session: Session | null
compactMode?: HeaderCompactModeProps
}
const THEME_STORAGE_KEY = 'admin-theme-preference'
export default function Header({ session }: HeaderProps) {
export default function Header({ session, compactMode }: HeaderProps) {
const styles = useThemeStyles()
const isAdmin = session ? ((session.user as any)?.role === 'ADMIN' || (session.user as any)?.isSuperAdmin) : false
const isSuperAdmin = session ? Boolean((session.user as any)?.isSuperAdmin) : false
const [showDropdown, setShowDropdown] = useState(false)
const [isInternalNetwork, setIsInternalNetwork] = useState(false)
const [networkChecked, setNetworkChecked] = useState(false)
const [showNetworkBanner, setShowNetworkBanner] = useState(false)
const [isAccessingFromExternal, setIsAccessingFromExternal] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const [showCategoryMenu, setShowCategoryMenu] = useState(false)
// 系统设置
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
const [headerLogoUrl, setHeaderLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '')
const [siteName, setSiteName] = useState('由壹点3D素材')
const [siteName, setSiteName] = useState('FLNXI素材库')
// Logo 加载状态
const [logoError, setLogoError] = useState(false)
const [logoRetryCount, setLogoRetryCount] = useState(0)
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const [themeReady, setThemeReady] = useState(false)
const [isHeaderDarkBg, setIsHeaderDarkBg] = useState(false)
const fallbackHeaderBg = theme === 'dark' ? 'rgba(15,23,42,0.9)' : 'rgba(255,255,255,0.9)'
const parseColorToRGB = useCallback((color: string): { r: number; g: number; b: number } | null => {
if (!color) return null
const trimmed = color.trim()
const hexMatch = trimmed.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)
if (hexMatch) {
let hex = hexMatch[1]
if (hex.length === 3) {
hex = hex.split('').map((ch) => ch + ch).join('')
}
const num = parseInt(hex, 16)
return {
r: (num >> 16) & 255,
g: (num >> 8) & 255,
b: num & 255,
}
}
const rgbMatch = trimmed.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i)
if (rgbMatch) {
return {
r: parseInt(rgbMatch[1], 10),
g: parseInt(rgbMatch[2], 10),
b: parseInt(rgbMatch[3], 10),
}
}
return null
}, [])
const isColorDark = useCallback((color: string) => {
const rgb = parseColorToRGB(color)
if (!rgb) {
return theme === 'dark'
}
const luminance = (0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b) / 255
return luminance < 0.55
}, [parseColorToRGB, theme])
const applyTheme = useCallback((nextTheme: 'light' | 'dark') => {
if (typeof document === 'undefined') return
@ -111,7 +164,7 @@ export default function Header({ session }: HeaderProps) {
const settings = await response.json()
setHeaderBgColor(settings.headerBgColor || '#1f2937')
setHeaderLogoUrl(settings.headerLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '')
setSiteName(settings.siteName || '由壹点3D素材')
setSiteName(settings.siteName || 'FLNXI素材库')
}
} catch (error) {
console.error('加载系统设置失败:', error)
@ -127,6 +180,11 @@ export default function Header({ session }: HeaderProps) {
}
}, [siteName])
useEffect(() => {
const detectionColor = headerBgColor || (theme === 'dark' ? '#0f172a' : '#ffffff')
setIsHeaderDarkBg(isColorDark(detectionColor))
}, [headerBgColor, theme, isColorDark])
// 检测网络状态
useEffect(() => {
const detectNetwork = async () => {
@ -182,6 +240,14 @@ export default function Header({ session }: HeaderProps) {
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const headerPrimaryTextClass = isHeaderDarkBg ? 'text-white' : 'text-slate-900'
const compactActive = Boolean(compactMode?.isScrolled)
const compactCategoryItems = compactMode?.categoryItems || []
const compactSelectedCategory =
compactCategoryItems.find(
(item: HeaderCompactModeProps['categoryItems'][number]) => item.key === compactMode?.selectedCategoryKey
) || compactCategoryItems[0] || null
return (
<>
{/* 网络切换提示横幅 */}
@ -225,93 +291,257 @@ export default function Header({ session }: HeaderProps) {
className={cn(
'sticky z-50 shadow-sm',
showNetworkBanner ? 'top-[72px]' : 'top-0',
'bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl border-b border-white/20 dark:border-slate-700/30',
'backdrop-blur-xl border-b border-white/20 dark:border-slate-700/30',
'transition-all duration-300'
)}
style={{
backgroundColor: headerBgColor
? `${headerBgColor}CC` // 叠加透明度,实现毛玻璃效果
: fallbackHeaderBg
}}
>
<div className="container mx-auto px-4 py-4 max-w-7xl">
<div className="flex items-center justify-between">
{/* Logo */}
<Link href="/" className="flex items-center space-x-3">
{headerLogoUrl && !logoError ? (
<Image
key={logoRetryCount}
src={headerLogoUrl + (logoRetryCount > 0 ? `?retry=${logoRetryCount}` : '')}
alt={siteName}
width={40}
height={40}
unoptimized
className="rounded-lg"
onError={(e) => {
try {
const img = e.target as HTMLImageElement
if (!img) return
// 检查是否是 localhost 地址但当前访问域名不是 localhost
const currentHost = typeof window !== 'undefined' ? window.location.hostname : ''
const checkLogoUrl = (url: string, host: string) => {
try {
const logoHost = new URL(url).hostname
return (logoHost === 'localhost' || logoHost === '127.0.0.1') && host !== 'localhost' && host !== '127.0.0.1'
} catch {
return true // 如果 URL 无效,视为错误
<div className="container mx-auto px-4 py-3 max-w-7xl">
<div className="flex items-center justify-between gap-6 h-12">
{/* Logo + 首页收缩模式下的分类与搜索卡片 */}
<div className="flex items-center gap-4 flex-1 min-w-0 h-full">
<Link href="/" className="flex items-center space-x-3 flex-shrink-0 h-full">
{headerLogoUrl && !logoError ? (
<Image
key={logoRetryCount}
src={headerLogoUrl + (logoRetryCount > 0 ? `?retry=${logoRetryCount}` : '')}
alt={siteName}
width={40}
height={40}
unoptimized
className="rounded-lg"
onError={(e) => {
try {
const img = e.target as HTMLImageElement
if (!img) return
// 检查是否是 localhost 地址但当前访问域名不是 localhost
const currentHost = typeof window !== 'undefined' ? window.location.hostname : ''
const checkLogoUrl = (url: string, host: string) => {
try {
const logoHost = new URL(url).hostname
return (logoHost === 'localhost' || logoHost === '127.0.0.1') && host !== 'localhost' && host !== '127.0.0.1'
} catch {
return true // 如果 URL 无效,视为错误
}
}
}
if (checkLogoUrl(headerLogoUrl, currentHost)) {
console.warn(`[Header] Logo 地址 ${headerLogoUrl} 指向本地服务,但当前访问域名为 ${currentHost},自动降级为占位图`)
setLogoError(true)
if (img) img.style.display = 'none'
return
}
setLogoRetryCount((prevCount) => {
const newCount = prevCount + 1
if (newCount <= 3) {
// 重试加载(最多3次)
console.warn(`Logo 加载失败,正在重试 (${newCount}/3):`, headerLogoUrl)
setTimeout(() => {
if (img && img.parentNode) {
img.src = headerLogoUrl + `?retry=${newCount}`
}
}, 1000 * newCount) // 递增延迟:1s, 2s, 3s
return newCount
} else {
// 重试失败,记录错误并显示占位符
console.error('Logo 加载失败,已重试3次:', headerLogoUrl)
if (checkLogoUrl(headerLogoUrl, currentHost)) {
console.warn(
`[Header] Logo 地址 ${headerLogoUrl} 指向本地服务,但当前访问域名为 ${currentHost},自动降级为占位图`
)
setLogoError(true)
if (img) img.style.display = 'none'
return prevCount
return
}
})
} catch (error) {
// 捕获所有错误,防止崩溃
console.error('[Header] Logo 错误处理异常:', error)
setLogoError(true)
}
}}
/>
) : (
<div className={cn(
'w-10 h-10 rounded-lg flex items-center justify-center',
theme === 'dark' ? 'bg-white/10' : 'bg-slate-900/10'
)}>
<span className={cn(
'text-lg font-bold',
theme === 'dark' ? 'text-white' : 'text-slate-900'
)}>{siteName.charAt(0)}</span>
setLogoRetryCount((prevCount) => {
const newCount = prevCount + 1
if (newCount <= 3) {
// 重试加载(最多3次)
console.warn(`Logo 加载失败,正在重试 (${newCount}/3):`, headerLogoUrl)
setTimeout(() => {
if (img && img.parentNode) {
img.src = headerLogoUrl + `?retry=${newCount}`
}
}, 1000 * newCount) // 递增延迟:1s, 2s, 3s
return newCount
} else {
// 重试失败,记录错误并显示占位符
console.error('Logo 加载失败,已重试3次:', headerLogoUrl)
setLogoError(true)
if (img) img.style.display = 'none'
return prevCount
}
})
} catch (error) {
// 捕获所有错误,防止崩溃
console.error('[Header] Logo 错误处理异常:', error)
setLogoError(true)
}
}}
/>
) : (
<div
className={cn(
'w-10 h-10 rounded-lg flex items-center justify-center',
isHeaderDarkBg ? 'bg-white/15' : 'bg-slate-900/10'
)}
>
<span
className={cn(
'text-lg font-bold',
headerPrimaryTextClass
)}
>
{siteName.charAt(0)}
</span>
</div>
)}
<span
className={cn(
'text-2xl font-semibold transition-colors',
headerPrimaryTextClass
)}
>
{siteName}
</span>
</Link>
{/* 首页收缩模式:分类 + 搜索卡片(含筛选与上传) */}
{compactActive && compactMode && (
<div className="flex items-center gap-3 flex-1 min-w-0 max-w-xl h-full animate-fadeInSlideDown">
{/* 分类 三 */}
<div
className="relative flex-shrink-0 select-none h-full flex items-center"
onMouseEnter={() => setShowCategoryMenu(true)}
onMouseLeave={() => setShowCategoryMenu(false)}
>
<button
type="button"
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm font-medium transition-all duration-200 h-8',
isHeaderDarkBg
? 'text-white/90'
: 'text-slate-800'
)}
>
<span>分类</span>
<span className="inline-flex items-center justify-center w-4 h-4">
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeWidth={1.6} d="M4 6h12M4 10h12M4 14h12" />
</svg>
</span>
</button>
{showCategoryMenu && compactCategoryItems.length > 0 && (
<div
className={cn(
'absolute top-full left-0 mt-0 w-40 md:w-44 pt-1.5 pb-1.5 z-50',
'rounded-xl bg-white/90 dark:bg-slate-900/90 backdrop-blur-xl',
'border border-white/40 dark:border-slate-700/70',
'shadow-[0_18px_45px_rgba(15,23,42,0.35)]',
'origin-top-left',
'animate-slide-in-from-top-2'
)}
onMouseEnter={() => setShowCategoryMenu(true)}
onMouseLeave={() => setShowCategoryMenu(false)}
style={{
transformOrigin: 'top left'
}}
>
{compactCategoryItems.map((item: HeaderCompactModeProps['categoryItems'][number]) => (
<button
key={item.key}
type="button"
onClick={() => {
compactMode.onSelectCategory(item.key)
setShowCategoryMenu(false)
}}
className={cn(
'w-full px-4 py-2 text-left text-sm transition-all duration-150',
'text-slate-800 dark:text-slate-100',
'hover:bg-slate-100/80 dark:hover:bg-slate-800/80',
'active:scale-[0.98]'
)}
>
{item.label}
</button>
))}
</div>
)}
</div>
{/* 搜索卡片:类别文字 + 输入框 + 筛选图标 + 上传按钮(集成在尾部) */}
<div
className={cn(
'flex items-center gap-0 flex-1 min-w-0 h-9',
'rounded-lg overflow-hidden',
'bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl',
'border border-white/40 dark:border-slate-700/70',
'shadow-[0_14px_35px_rgba(15,23,42,0.30)]'
)}
>
{/* 左侧内容区域 */}
<div className="flex items-center gap-1.5 flex-1 min-w-0 px-2.5 h-full">
{/* 当前分类标签 */}
{compactSelectedCategory && (
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-slate-100/80 dark:bg-slate-800/80 text-xs font-medium text-slate-600 dark:text-slate-200 whitespace-nowrap h-5">
<span>{compactSelectedCategory.label}</span>
</div>
)}
{/* 搜索输入 */}
<div className="relative flex-1 min-w-0 h-full flex items-center">
<input
type="text"
value={compactMode.searchKeyword}
onChange={(e) => compactMode.onSearchChange(e.target.value)}
placeholder="搜索SPU、店铺、场景类别、作者..."
className={cn(
'w-full bg-transparent border-none outline-none text-sm text-slate-900 dark:text-slate-50 placeholder:text-slate-400 dark:placeholder:text-slate-500',
'pl-5 pr-10 h-full'
)}
/>
<svg
className="absolute left-1 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{/* 右侧筛选图标按钮(内嵌在输入框尾部区域,只显示图标) */}
<button
type="button"
onClick={compactMode.onOpenFilter}
className="absolute right-1.5 top-1/2 -translate-y-1/2 inline-flex items-center justify-center w-6 h-6 rounded border border-green-500/60 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-green-50 dark:hover:bg-green-900/20 hover:border-green-500 transition-colors"
title="筛选"
aria-label="筛选"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 4h18l-7 8v6l-4 2v-8L3 4z"
/>
</svg>
</button>
</div>
</div>
{/* 上传素材按钮(直接集成在卡片尾部,作为卡片的一部分) */}
<button
type="button"
onClick={compactMode.onOpenUpload}
className={cn(
'flex items-center justify-center px-3 h-9 rounded-r-lg text-xs font-medium whitespace-nowrap',
'border-l border-slate-300 dark:border-slate-600',
'bg-indigo-50 dark:bg-indigo-900/20',
'text-indigo-600 dark:text-indigo-400',
'hover:bg-indigo-100 dark:hover:bg-indigo-900/30',
'active:scale-[0.98] transition-all'
)}
>
上传素材
</button>
</div>
</div>
)}
<span className={cn(
'text-2xl font-semibold',
theme === 'dark' ? 'text-white' : 'text-slate-900'
)}>
{siteName}
</span>
</Link>
</div>
{/* Navigation */}
<nav className="flex items-center space-x-4">
{/* Navigation */}
<nav className="flex items-center space-x-4">
{/* Network Status Indicator */}
{networkChecked && (
<div className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${
@ -339,7 +569,7 @@ export default function Header({ session }: HeaderProps) {
aria-pressed={theme === 'dark'}
className={cn(
"relative flex items-center px-2 py-1 rounded-full border shadow-[0_5px_14px_rgba(15,23,42,0.25)] backdrop-blur focus:outline-none focus-visible:outline-none focus-visible:ring-0 transition-all w-16 h-8 overflow-hidden",
theme === 'dark'
isHeaderDarkBg
? 'border-white/25 bg-white/15'
: 'border-slate-300 bg-white/80'
)}
@ -396,15 +626,17 @@ export default function Header({ session }: HeaderProps) {
{/* User Dropdown */}
{session && (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowDropdown(!showDropdown)}
className={cn(
'flex items-center p-1.5 rounded-lg transition-all',
'hover:bg-white/10'
)}
>
<button
onClick={() => setShowDropdown(!showDropdown)}
aria-label="打开用户菜单"
className={cn(
'flex items-center justify-center rounded-full transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40',
isHeaderDarkBg ? 'hover:bg-white/15 p-1' : 'hover:bg-slate-900/5 p-1'
)}
>
{/* 使用 Avatar 组件 */}
<Avatar
src={(session.user as any)?.avatar || undefined}
name={session.user?.name || ''}
size="sm"
onClick={() => setShowDropdown(!showDropdown)}
@ -426,39 +658,112 @@ export default function Header({ session }: HeaderProps) {
</p>
</div>
{/* 系统设置(管理员) */}
{isAdmin && (
<Link
href="/admin"
onClick={() => setShowDropdown(false)}
className={cn(
'w-full text-left px-4 py-2 text-sm transition-all flex items-center space-x-2',
'text-slate-700 dark:text-slate-200',
'hover:bg-slate-100 dark:hover:bg-slate-700'
)}
{/* 个人中心 */}
<Link
href="/profile"
onClick={() => setShowDropdown(false)}
className={cn(
'w-full text-left px-4 py-2 text-sm transition-all flex items-center space-x-2',
'text-slate-700 dark:text-slate-200',
'hover:bg-slate-100 dark:hover:bg-slate-700'
)}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<span>个人中心</span>
</Link>
{isAdmin && (
<>
<Link
href="/admin"
onClick={() => setShowDropdown(false)}
className={cn(
'w-full text-left px-4 py-2 text-sm transition-all flex items-center space-x-2',
'text-slate-700 dark:text-slate-200',
'hover:bg-slate-100 dark:hover:bg-slate-700'
)}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span>系统设置</span>
</Link>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
/>
</svg>
<span>企业管理</span>
</Link>
{isAdmin && (
<Link
href="/admin/settings"
onClick={() => setShowDropdown(false)}
className={cn(
'w-full text-left px-4 py-2 text-sm transition-all flex items-center space-x-2',
'text-slate-700 dark:text-slate-200',
'hover:bg-slate-100 dark:hover:bg-slate-700'
)}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
/>
</svg>
<span>系统设置</span>
</Link>
)}
</>
)}
<Link
href="/admin/monitor"
onClick={() => setShowDropdown(false)}
className={cn(
'w-full text-left px-4 py-2 text-sm transition-all flex items-center space-x-2',
'text-slate-700 dark:text-slate-200',
'hover:bg-slate-100 dark:hover:bg-slate-700'
)}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h4l3 8 4-16 3 8h4"
/>
</svg>
<span>监控仪表板</span>
</Link>
{/* 退出登录 */}
<button
@ -501,3 +806,4 @@ export default function Header({ session }: HeaderProps) {
)
}

View File

@ -0,0 +1,247 @@
'use client'
import React, { useMemo, type CSSProperties } from 'react'
import { useThemeStyles, cn } from '@/lib/ui-styles'
interface BasicMaterial {
id: string
isLiked?: boolean
isPremium?: boolean
isFavorited?: boolean
}
interface MaterialActionButtonsProps {
material: BasicMaterial
layout?: 'card' | 'modal'
className?: string
themeColor: string
onEdit?: () => void
onDelete?: () => void
onShare?: () => void
onLike?: () => void
onFavorite?: () => void
onDownload?: () => void
onTogglePremium?: (nextState: boolean) => void
canEdit?: boolean
canDelete?: boolean
canTogglePremium?: boolean
showShare?: boolean
isDownloading?: boolean
downloadProgress?: number
isFavorited?: boolean
inlineLikeButton?: boolean
}
const ICON_BUTTON_BASE_SIZE = 40 // 约等于编辑/删除按钮的外框尺寸
const DOWNLOAD_SIZE_MULTIPLIER = 1.5
const DOWNLOAD_MIN_WIDTH = 120
const HeartIcon = ({ filled }: { filled: boolean }) => (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill={filled ? 'currentColor' : 'none'} stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={filled ? 0 : 1.8}
d="M12 20.848l-.88-.806C6.173 15.54 3 12.64 3 8.94 3 6.28 5.244 4 7.905 4c1.586 0 3.126.77 4.095 1.988C12.969 4.77 14.509 4 16.095 4 18.756 4 21 6.28 21 8.94c0 3.7-3.173 6.6-8.12 11.102l-.88.806z"
/>
</svg>
)
const DownloadIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
)
const EditIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
)
const DeleteIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
)
const ShareIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
)
const PremiumIcon = () => (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l2.9 6.26 6.9.54-5.2 4.73 1.6 6.47L12 16.9l-6.2 3.1 1.6-6.47-5.2-4.73 6.9-.54L12 2z" />
</svg>
)
const FavoriteIcon = ({ filled }: { filled: boolean }) => (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth={filled ? 0 : 1.6}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 4h12a1 1 0 011 1v15.382a.5.5 0 01-.777.416L12 17.5l-6.223 3.298A.5.5 0 015 20.382V5a1 1 0 011-1z" />
</svg>
)
export default function MaterialActionButtons({
material,
layout = 'card',
className,
themeColor: _themeColor,
onEdit,
onDelete,
onShare,
onLike,
onFavorite,
onDownload,
onTogglePremium,
canEdit,
canDelete,
canTogglePremium,
showShare = true,
isDownloading,
downloadProgress = 0,
isFavorited,
inlineLikeButton = true,
}: MaterialActionButtonsProps) {
const styles = useThemeStyles()
const isModal = layout === 'modal'
const showDeleteButton = Boolean(canDelete && onDelete)
const containerClasses = cn(
'flex items-center gap-1.5 w-full',
isModal && 'flex-wrap',
className
)
const likeActive = Boolean(material.isLiked)
const premiumActive = Boolean(material.isPremium)
const downloadButtonStyle = useMemo<CSSProperties>(() => {
const widthBase = ICON_BUTTON_BASE_SIZE * DOWNLOAD_SIZE_MULTIPLIER * 2
const targetWidth = Math.max(widthBase, DOWNLOAD_MIN_WIDTH)
const shouldLockWidth = showDeleteButton && !isModal
return {
minWidth: `${targetWidth}px`,
width: shouldLockWidth ? `${targetWidth}px` : undefined,
flexGrow: shouldLockWidth ? 0 : 1,
}
}, [isModal, showDeleteButton])
const downloadButtonBaseClasses =
'px-3 text-sm font-semibold flex items-center justify-center gap-1.5 relative overflow-hidden transition-colors disabled:opacity-60 disabled:cursor-not-allowed rounded-xl border bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-100 border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800'
return (
<div className={containerClasses}>
{canEdit && onEdit && (
<button
type="button"
onClick={onEdit}
className="h-10 w-10 flex items-center justify-center rounded-xl border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
title="编辑"
>
<EditIcon />
</button>
)}
{canDelete && onDelete && (
<button
type="button"
onClick={onDelete}
className="h-10 w-10 flex items-center justify-center rounded-xl border border-red-200 dark:border-red-700 text-red-600 dark:text-red-200 hover:bg-red-50 dark:hover:bg-red-800 transition-colors"
title="删除素材"
>
<DeleteIcon />
</button>
)}
{showShare && onShare && (
<button
type="button"
onClick={onShare}
className="h-10 w-10 flex items-center justify-center rounded-xl border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
title="分享"
>
<ShareIcon />
</button>
)}
{canTogglePremium && onTogglePremium && (
<button
type="button"
onClick={() => onTogglePremium(!premiumActive)}
className={cn(
'px-2.5 py-2 rounded-xl border transition-colors flex items-center gap-1 text-sm font-semibold w-[90px] justify-center flex-shrink-0',
premiumActive
? 'border-amber-500 bg-amber-500 text-white'
: 'border-amber-200 dark:border-amber-600 text-amber-600 dark:text-amber-200 hover:bg-amber-50 dark:hover:bg-amber-900/30'
)}
title={premiumActive ? '取消精品' : '加精'}
>
<PremiumIcon />
{premiumActive ? '精品' : '加精'}
</button>
)}
{onFavorite && layout === 'modal' && (
<button
type="button"
onClick={onFavorite}
className={cn(
'p-2.5 rounded-xl border transition-colors',
isFavorited
? 'border-pink-500 bg-pink-500 text-white'
: 'border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800'
)}
title={isFavorited ? '取消收藏' : '收藏'}
>
<FavoriteIcon filled={Boolean(isFavorited)} />
</button>
)}
{onLike && inlineLikeButton && (
<button
type="button"
onClick={onLike}
className={cn(
'p-2.5 rounded-xl border transition-colors',
likeActive
? 'border-red-500 bg-red-500 text-white'
: 'border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800'
)}
title={likeActive ? '已点赞' : '点赞'}
>
<HeartIcon filled={likeActive} />
</button>
)}
{onDownload && (
<button
type="button"
onClick={onDownload}
disabled={isDownloading}
className={cn(
downloadButtonBaseClasses,
showDeleteButton && !isModal ? 'flex-none h-10' : 'flex-1 basis-0',
isModal ? 'w-full min-w-[200px] h-11' : 'h-10'
)}
style={downloadButtonStyle}
title="下载"
>
{isDownloading ? (
<>
<div
className="absolute left-0 top-0 bottom-0 bg-blue-600 transition-all duration-300"
style={{ width: `${downloadProgress}%` }}
/>
<span className="relative z-10 flex items-center gap-1.5">
<svg className="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{downloadProgress}%
</span>
</>
) : (
<>
<DownloadIcon />
<span>下载</span>
</>
)}
</button>
)}
</div>
)
}

View File

@ -1,58 +1,554 @@
'use client'
import { useRef } from 'react'
import type { ReactNode } from 'react'
import { useState, useEffect } from 'react'
import Image from 'next/image'
import { format } from 'date-fns'
import Modal from './ui/Modal'
import Tag from './ui/Tag'
import Avatar from './ui/Avatar'
import Button from './ui/Button'
import { cn, useThemeStyles, useTheme } from '@/lib/ui-styles'
const VIDEO_STATUS_META = {
pending: {
label: '等待转码',
description: '素材上传完成,后台即将启动压缩任务。',
},
processing: {
label: '转码中',
description: '正在生成 540P 预览与首页 10 秒预览片段。',
},
done: {
label: '可预览',
description: '已生成压缩版本,可直接播放预览。',
},
failed: {
label: '转码失败',
description: '压缩任务失败,请联系管理员重新处理。',
},
} as const
interface PreviewMaterialInfo {
id?: string
productModel?: string
materialKind?: 'IMAGE' | 'VIDEO' | 'OTHER'
previewUrl?: string
videoCoverUrl?: string | null
videoPreview540Url?: string | null
videoPreviewClipUrl?: string | null
videoProcessingStatus?: string | null
videoDuration?: number | null
displayImages?: string[]
materialType?: string
categoryName?: string
sceneName?: string
renderer?: string
authorName?: string
downloadCount?: number
likeCount?: number
createdAt?: string
zipFileName?: string
zipFileSize?: string | number
rankingBadges?: Array<{ label: string; gradient: string }>
isPremium?: boolean
isFavorited?: boolean
user?: {
username?: string
avatar?: string | null
}
}
interface MaterialPreviewModalProps {
isOpen: boolean
onClose: () => void
imageUrl: string
material?: PreviewMaterialInfo | null
imageUrl?: string
title?: string
description?: string
onDownload?: () => void
actionSection?: ReactNode
}
const formatFileSize = (bytes?: string | number) => {
if (bytes === undefined || bytes === null) return ''
const size = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes
if (!size || Number.isNaN(size)) return ''
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}`
}
const resolveVideoStatus = (status?: string | null): 'pending' | 'processing' | 'done' | 'failed' => {
const normalized = (status || '').trim().toLowerCase()
if (normalized === 'processing' || normalized === 'done' || normalized === 'failed') {
return normalized
}
return 'pending'
}
const formatVideoDuration = (seconds?: number | null) => {
if (!seconds || Number.isNaN(seconds) || seconds <= 0) return ''
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
}
export default function MaterialPreviewModal({
isOpen,
onClose,
material,
imageUrl,
title,
description,
onDownload,
actionSection,
}: MaterialPreviewModalProps) {
const imageRef = useRef<HTMLDivElement>(null)
const styles = useThemeStyles()
const theme = useTheme()
const [showFullImage, setShowFullImage] = useState(false)
const [fullImageUrl, setFullImageUrl] = useState<string | null>(null)
const [videoStatus, setVideoStatus] = useState<string | null | undefined>(material?.videoProcessingStatus)
const [isPolling, setIsPolling] = useState(false)
// 当 material 变化时,同步更新 videoStatus
useEffect(() => {
if (material?.videoProcessingStatus !== undefined) {
setVideoStatus(material.videoProcessingStatus)
}
}, [material?.videoProcessingStatus])
// 主题颜色
const textPrimary = theme === 'dark' ? 'text-white' : 'text-slate-900'
const textSecondary = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
const borderColor = theme === 'dark' ? 'border-slate-700/70' : 'border-slate-200/70'
const bgColor = theme === 'dark' ? 'bg-slate-800' : 'bg-slate-100'
const resolvedMaterialKind = (material?.materialKind || '').toUpperCase()
const isVideoMaterial = resolvedMaterialKind === 'VIDEO' || material?.materialType === '视频'
const currentVideoStatus = isVideoMaterial ? resolveVideoStatus(videoStatus) : 'done'
const inferVideoMimeType = () => {
if (!material?.zipFileName) return 'video/mp4'
const ext = material.zipFileName.split('.').pop()?.toLowerCase()
const videoMap: Record<string, string> = {
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
wmv: 'video/x-ms-wmv',
flv: 'video/x-flv',
mkv: 'video/x-matroska',
}
return (ext && videoMap[ext]) || 'video/mp4'
}
const videoMimeType = inferVideoMimeType()
const previewVideoSource =
isVideoMaterial && currentVideoStatus === 'done'
? material?.videoPreview540Url || material?.videoPreviewClipUrl || null
: null
const fallbackVideoSource =
isVideoMaterial && currentVideoStatus === 'done' && material?.id ? `/api/materials/stream?id=${material.id}` : null
const videoSource = previewVideoSource || fallbackVideoSource
const videoSourceMimeType = previewVideoSource ? undefined : videoMimeType
const fallbackCover =
material?.videoCoverUrl ||
(material?.displayImages && material.displayImages.length > 0 ? material.displayImages[0] : null) ||
material?.previewUrl ||
imageUrl
const videoStatusMeta = isVideoMaterial ? VIDEO_STATUS_META[currentVideoStatus] : null
const videoDurationLabel = isVideoMaterial ? formatVideoDuration(material?.videoDuration) : ''
const galleryImages = material
? material.displayImages && material.displayImages.length > 0
? material.displayImages
: fallbackCover
? [fallbackCover]
: []
: imageUrl
? [imageUrl]
: []
const videoPoster = isVideoMaterial ? fallbackCover || galleryImages[0] : undefined
// 视频转码状态轮询
useEffect(() => {
if (!isOpen || !isVideoMaterial || !material?.id) {
setIsPolling(false)
return
}
const currentStatus = resolveVideoStatus(videoStatus)
// 只在 pending 或 processing 状态时轮询
if (currentStatus !== 'pending' && currentStatus !== 'processing') {
setIsPolling(false)
return
}
setIsPolling(true)
let pollInterval: NodeJS.Timeout | null = null
let pollTimeout: NodeJS.Timeout | null = null
let isCleanedUp = false
const pollStatus = async () => {
if (isCleanedUp) return
try {
const response = await fetch(`/api/materials/transcode?materialId=${material.id}`)
if (!response.ok) {
return
}
const data = await response.json()
const newStatus = data.status || 'pending'
if (isCleanedUp) return
setVideoStatus(newStatus)
// 如果状态变为 done 或 failed,停止轮询
if (newStatus === 'done' || newStatus === 'failed') {
setIsPolling(false)
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
if (pollTimeout) {
clearTimeout(pollTimeout)
pollTimeout = null
}
// 不再自动刷新页面,让用户继续查看弹窗
// 如果需要刷新数据,可以通过父组件的事件回调来实现
}
} catch (error) {
console.error('[MaterialPreview] Failed to poll transcode status:', error)
}
}
// 立即执行一次
pollStatus()
// 每 5 秒轮询一次
pollInterval = setInterval(pollStatus, 5000)
// 最多轮询 10 分钟(120 次)
pollTimeout = setTimeout(() => {
setIsPolling(false)
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}, 10 * 60 * 1000)
return () => {
isCleanedUp = true
if (pollInterval) {
clearInterval(pollInterval)
}
if (pollTimeout) {
clearTimeout(pollTimeout)
}
setIsPolling(false)
}
}, [isOpen, isVideoMaterial, material?.id])
const modalTitle = material?.productModel || title || '素材预览'
const isLongForm =
material?.materialType === 'PSD' ||
material?.materialType === '模特' ||
(material?.displayImages && material.displayImages.length > 1)
const rankingBadges = material?.rankingBadges ?? []
const downloadBadges = rankingBadges.filter((badge) => badge.label.includes('下载'))
const likeBadges = rankingBadges.filter((badge) => badge.label.includes('点赞'))
const renderBadgeChip = (badge: { label: string; gradient: string }) => (
<span
key={badge.label}
className={cn(
'px-3 py-1 text-[11px] font-semibold text-white rounded-full shadow-sm bg-gradient-to-r',
badge.gradient
)}
>
{badge.label}
</span>
)
const fileSizeLabel = formatFileSize(material?.zipFileSize)
const primaryDisplayTitle = material?.id || material?.productModel || title || '素材预览'
const secondaryTitle =
material?.productModel && material?.id && material.productModel !== material.id
? material.productModel
: !material?.productModel && title && title !== material?.id
? title
: null
const handleImageClick = (imgUrl: string) => {
setFullImageUrl(imgUrl)
setShowFullImage(true)
}
// 当预览模态框关闭时,重置全图查看状态
useEffect(() => {
if (!isOpen) {
setShowFullImage(false)
setFullImageUrl(null)
}
}, [isOpen])
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="fullscreen"
closeOnOverlayClick={true}
closeOnEscape={true}
showCloseButton={true}
size="xlarge"
title={modalTitle}
closeOnOverlayClick
closeOnEscape
showCloseButton
containerStyle={{
width: 'fit-content',
maxWidth: 'min(1000px, 96vw)',
}}
>
<div className="relative w-full h-full flex items-center justify-center p-4 -m-6">
<div className="text-center w-full max-w-7xl">
<div
ref={imageRef}
className="relative w-full max-w-5xl h-[80vh] mx-auto mb-4"
>
<Image
src={imageUrl}
alt={title || '素材预览'}
fill
unoptimized
sizes="(max-width: 768px) 100vw, 60vw"
className="object-contain"
/>
<div className="flex flex-col lg:flex-row gap-3 lg:h-[90vh]">
<div
className={cn(
'flex-shrink min-h-[450px] max-h-[90vh]',
'overflow-y-auto pr-2 lg:pr-3',
'lg:max-w-[600px]'
)}
>
<div className="w-auto max-w-[600px]">
{isVideoMaterial ? (
videoSource ? (
<div
className={cn(
'w-full rounded-[32px] overflow-hidden shadow-inner border leading-[0]',
borderColor,
bgColor
)}
>
<video
key={material?.id}
controls
controlsList="nodownload"
poster={videoPoster}
className="block w-auto max-w-[600px] h-auto object-contain align-top"
style={{
maxWidth: '100%',
width: 'auto',
height: 'auto',
objectFit: 'contain',
display: 'block',
borderRadius: '32px',
}}
>
<source src={videoSource} {...(videoSourceMimeType ? { type: videoSourceMimeType } : {})} />
您的浏览器不支持视频播放。
</video>
</div>
) : (
<div className="w-full h-[360px] flex flex-col items-center justify-center text-center px-8 bg-gradient-to-b from-slate-900 to-slate-800 text-white rounded-[32px]">
<svg className="w-10 h-10 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.868v4.264a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M4.5 6.75v10.5a1.5 1.5 0 001.5 1.5h12a1.5 1.5 0 001.5-1.5V6.75A1.5 1.5 0 0018 5.25H6a1.5 1.5 0 00-1.5 1.5z" />
</svg>
<p className="text-lg font-semibold">{videoStatusMeta?.label || '预览生成中'}</p>
<p className="text-sm text-white/80 mt-2">
{videoStatusMeta?.description || '视频压缩任务进行中,稍后可直接在此播放压缩预览。'}
</p>
<p className="text-xs text-white/60 mt-3">原始文件已保存,可随时下载使用。</p>
</div>
)
) : galleryImages.length > 0 ? (
isLongForm ? (
<div className={cn('w-full rounded-[32px] overflow-hidden shadow-inner leading-[0]', theme === 'dark' ? 'bg-slate-900/70' : 'bg-white/95')}>
{galleryImages.map((img, index) => (
<div
key={`${img}-${index}`}
className="relative w-full bg-transparent cursor-pointer"
onClick={() => handleImageClick(img)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={img}
alt={`${modalTitle}-preview-${index + 1}`}
className="block w-auto max-w-[600px] h-auto object-contain align-top"
loading={index === 0 ? 'eager' : 'lazy'}
/>
</div>
))}
</div>
) : (
<div
className={cn(
'w-full rounded-[32px] overflow-hidden shadow-inner border cursor-pointer',
borderColor,
bgColor
)}
onClick={() => handleImageClick(galleryImages[0])}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={galleryImages[0]}
alt={`${modalTitle}-preview`}
className="block w-auto max-w-[600px] h-auto object-contain align-top"
/>
</div>
)
) : (
<div className={cn(
'w-full h-64 rounded-[32px] flex items-center justify-center border border-dashed',
bgColor,
borderColor,
textSecondary
)}>
暂无预览图
</div>
)}
</div>
{title && (
<p className="text-white text-lg font-medium">{title}</p>
)}
{description && (
<p className="text-white/80 text-sm mt-2">{description}</p>
)}
</div>
{(material || title || description) && (
<div className={cn(
'w-full lg:w-[300px] flex-shrink-0 space-y-4 border-t lg:border-t-0 lg:border-l lg:pl-4 pt-4 lg:pt-0',
borderColor
)}>
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div>
<h3 className={cn('text-2xl font-bold break-all', textPrimary)}>
{primaryDisplayTitle}
</h3>
</div>
</div>
{material && (
<div className="flex flex-wrap items-center gap-2">
{material.categoryName && <Tag size="sm">{material.categoryName}</Tag>}
{material.sceneName && <Tag color="green" size="sm">{material.sceneName}</Tag>}
{material.materialType && <Tag color="orange" size="sm">{material.materialType}</Tag>}
{material.materialType === '3DMAX' && material.renderer && (
<Tag color="purple" size="sm">
{material.renderer}
</Tag>
)}
</div>
)}
</div>
{material?.authorName && (
<div className="flex items-center gap-3">
<Avatar name={material.authorName} src={material.user?.avatar || undefined} size="sm" />
<p className={cn('text-sm font-semibold', textPrimary)}>
{material.authorName}
</p>
</div>
)}
<div className={cn('grid grid-cols-2 gap-3 text-sm', textPrimary)}>
{material?.downloadCount !== undefined && (
<div>
<p className={cn('text-xs mb-1', textSecondary)}>下载</p>
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium">{material.downloadCount}</p>
{downloadBadges.map(renderBadgeChip)}
</div>
</div>
)}
{material?.likeCount !== undefined && (
<div>
<p className={cn('text-xs mb-1', textSecondary)}>点赞</p>
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium">{material.likeCount}</p>
{likeBadges.map(renderBadgeChip)}
</div>
</div>
)}
{fileSizeLabel && (
<div className="col-span-2">
<p className={cn('text-xs mb-1', textSecondary)}>文件大小</p>
<p className="font-medium text-blue-600 dark:text-blue-400">{fileSizeLabel}</p>
</div>
)}
{material?.zipFileName && (
<div className="col-span-2">
<p className={cn('text-xs mb-1', textSecondary)}>文件名</p>
<p className={cn('text-xs font-medium break-all', textPrimary)}>
{material.zipFileName}
</p>
</div>
)}
{material?.createdAt && (
<div className="col-span-2">
<p className={cn('text-xs mb-1', textSecondary)}>上传时间</p>
<p className="text-sm font-medium">
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
</p>
</div>
)}
</div>
{(description || (!material && title)) && (
<p className={cn('text-sm leading-relaxed', textSecondary)}>
{description || '该素材使用统一预览弹窗展示'}
</p>
)}
{isVideoMaterial && currentVideoStatus !== 'done' && (
<div className="p-3 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 text-xs text-amber-700 dark:text-amber-100">
{isPolling ? '正在检查转码状态...' : '压缩预览生成中,片刻后刷新即可在线播放,当前已可直接下载原始视频。'}
</div>
)}
{actionSection ? (
<div className="pt-2 space-y-2">{actionSection}</div>
) : (
onDownload && (
<div className="space-y-2 pt-2">
<Button variant="primary" size="md" className="w-full justify-center h-11" onClick={onDownload}>
立即下载
</Button>
</div>
)
)}
<Button
variant="secondary"
size="md"
className="w-full justify-center h-11"
onClick={onClose}
>
返回上一页
</Button>
</div>
)}
</div>
{/* 全图查看模态框 */}
{showFullImage && fullImageUrl && (
<div
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/80 backdrop-blur-md"
onClick={() => {
setShowFullImage(false)
setFullImageUrl(null)
}}
>
<div className="relative max-w-[95vw] max-h-[95vh] p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={fullImageUrl || ''}
alt="Full preview"
className="max-w-full max-h-[95vh] object-contain rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
<button
onClick={() => {
setShowFullImage(false)
setFullImageUrl(null)
}}
className="absolute top-6 right-6 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
aria-label="关闭"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)}
</Modal>
)
}

View File

@ -12,7 +12,7 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useMemo } from 'react'
import { useThemeStyles, cn } from '@/lib/ui-styles'
interface RankingUser {
@ -26,10 +26,46 @@ interface RankingData {
likes: RankingUser[]
}
type PanelTab = 'contributors' | 'downloads' | 'likes'
const PANEL_DIMENSIONS = { width: 150, height: 400 }
const BUTTON_SIZE = 48
const clampToViewport = (
position: { x: number; y: number },
size: { width: number; height: number }
) => {
if (typeof window === 'undefined') return position
const maxX = Math.max(0, window.innerWidth - size.width)
const maxY = Math.max(0, window.innerHeight - size.height)
return {
x: Math.min(Math.max(0, position.x), maxX),
y: Math.min(Math.max(0, position.y), maxY),
}
}
const TAB_META: Record<
PanelTab,
{ label: string; accent: string }
> = {
contributors: {
label: '贡献榜',
accent: 'text-amber-600',
},
downloads: {
label: '下载榜',
accent: 'text-sky-600',
},
likes: {
label: '点赞榜',
accent: 'text-rose-600',
},
}
export default function RankingPanel() {
const styles = useThemeStyles()
const [isVisible, setIsVisible] = useState(true)
const [currentTab, setCurrentTab] = useState<'contributors' | 'downloads' | 'likes'>('contributors')
const [currentTab, setCurrentTab] = useState<PanelTab>('contributors')
const [rankings, setRankings] = useState<RankingData>({
contributors: [],
downloads: [],
@ -37,13 +73,19 @@ export default function RankingPanel() {
})
const [loading, setLoading] = useState(true)
const [position, setPosition] = useState({ x: 32, y: 0 }) // 默认位置 left-8 = 32px
const [panelCentered, setPanelCentered] = useState(true)
const [isDragging, setIsDragging] = useState(false)
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
const panelRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const [buttonPosition, setButtonPosition] = useState({ x: 32, y: 0 }) // 缩略按钮位置
const [buttonCentered, setButtonCentered] = useState(true)
const [isButtonDragging, setIsButtonDragging] = useState(false)
const [buttonDragOffset, setButtonDragOffset] = useState({ x: 0, y: 0 })
// 自动轮播 - 滚动间隔调慢到10秒
useEffect(() => {
const tabs: ('contributors' | 'downloads' | 'likes')[] = ['contributors', 'downloads', 'likes']
const tabs: PanelTab[] = ['contributors', 'downloads', 'likes']
let currentIndex = 0
const interval = setInterval(() => {
@ -61,7 +103,11 @@ export default function RankingPanel() {
const response = await fetch('/api/rankings')
if (response.ok) {
const data = await response.json()
setRankings(data)
setRankings({
contributors: (data.contributors || []).filter((item: RankingUser) => item.count > 0),
downloads: (data.downloads || []).filter((item: RankingUser) => item.count > 0),
likes: (data.likes || []).filter((item: RankingUser) => item.count > 0),
})
}
} catch (error) {
console.error('获取排行榜失败:', error)
@ -76,15 +122,43 @@ export default function RankingPanel() {
return () => clearInterval(interval)
}, [])
const clampPanelPosition = (value: { x: number; y: number }) => {
const width = panelRef.current?.offsetWidth || PANEL_DIMENSIONS.width
const height = panelRef.current?.offsetHeight || PANEL_DIMENSIONS.height
return clampToViewport(value, { width, height })
}
const clampButtonPosition = (value: { x: number; y: number }) => {
const width = buttonRef.current?.offsetWidth || BUTTON_SIZE
const height = buttonRef.current?.offsetHeight || BUTTON_SIZE
return clampToViewport(value, { width, height })
}
const handleClose = () => {
setIsVisible(false)
// 保存关闭状态到localStorage
localStorage.setItem('rankingPanelClosed', 'true')
// 保存当前面板位置作为缩略按钮的初始位置
const clamped = clampPanelPosition(position)
setButtonPosition(clamped)
setButtonCentered(false)
localStorage.setItem('rankingPanelButtonPosition', JSON.stringify(clamped))
}
const handleOpen = () => {
setIsVisible(true)
localStorage.removeItem('rankingPanelClosed')
setPosition((prev) => {
const fallback = buttonCentered
? { x: buttonPosition.x, y: 0 }
: buttonPosition.x === 0 && buttonPosition.y === 0
? prev
: buttonPosition
const nextPosition = clampPanelPosition(fallback)
setPanelCentered(buttonCentered && nextPosition.y === 0)
localStorage.setItem('rankingPanelPosition', JSON.stringify(nextPosition))
return nextPosition
})
}
// 检查localStorage中的关闭状态
@ -112,15 +186,12 @@ export default function RankingPanel() {
if (isDragging) {
const newX = e.clientX - dragOffset.x
const newY = e.clientY - dragOffset.y
// 限制在视口内
const maxX = window.innerWidth - (panelRef.current?.offsetWidth || 150)
const maxY = window.innerHeight - (panelRef.current?.offsetHeight || 400)
const newPosition = {
x: Math.max(0, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY))
const size = {
width: panelRef.current?.offsetWidth || PANEL_DIMENSIONS.width,
height: panelRef.current?.offsetHeight || PANEL_DIMENSIONS.height,
}
const newPosition = clampToViewport({ x: newX, y: newY }, size)
setPanelCentered(false)
setPosition(newPosition)
// 保存位置到localStorage
localStorage.setItem('rankingPanelPosition', JSON.stringify(newPosition))
@ -148,27 +219,127 @@ export default function RankingPanel() {
if (savedPosition) {
try {
const pos = JSON.parse(savedPosition)
setPosition(pos)
setPosition(clampPanelPosition(pos))
setPanelCentered(false)
} catch (e) {
// 忽略解析错误
}
}
// 恢复缩略按钮位置
const savedButtonPosition = localStorage.getItem('rankingPanelButtonPosition')
if (savedButtonPosition) {
try {
const pos = JSON.parse(savedButtonPosition)
setButtonPosition(clampButtonPosition(pos))
setButtonCentered(false)
} catch (e) {
// 忽略解析错误
}
}
}, [])
useEffect(() => {
const handleResize = () => {
setPosition((prev) => clampPanelPosition(prev))
setButtonPosition((prev) => clampButtonPosition(prev))
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
// 缩略按钮拖动处理
const handleButtonMouseDown = (e: React.MouseEvent) => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect()
setButtonDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top
})
setIsButtonDragging(true)
}
}
useEffect(() => {
const handleButtonMouseMove = (e: MouseEvent) => {
if (isButtonDragging && buttonRef.current) {
const newX = e.clientX - buttonDragOffset.x
const newY = e.clientY - buttonDragOffset.y
const size = {
width: buttonRef.current.offsetWidth || BUTTON_SIZE,
height: buttonRef.current.offsetHeight || BUTTON_SIZE,
}
const newPosition = clampToViewport({ x: newX, y: newY }, size)
setButtonCentered(false)
setButtonPosition(newPosition)
// 保存位置到localStorage
localStorage.setItem('rankingPanelButtonPosition', JSON.stringify(newPosition))
}
}
const handleButtonMouseUp = () => {
setIsButtonDragging(false)
}
if (isButtonDragging) {
document.addEventListener('mousemove', handleButtonMouseMove)
document.addEventListener('mouseup', handleButtonMouseUp)
}
return () => {
document.removeEventListener('mousemove', handleButtonMouseMove)
document.removeEventListener('mouseup', handleButtonMouseUp)
}
}, [isButtonDragging, buttonDragOffset])
const currentTabMeta = useMemo(() => TAB_META[currentTab], [currentTab])
// 必须在组件顶层调用 hooks
const clickStartRef = useRef<{ x: number; y: number; time: number } | null>(null)
if (!isVisible) {
// 显示一个小的重新打开按钮
// 显示一个小的重新打开按钮(可拖动)
return (
<button
onClick={handleOpen}
ref={buttonRef}
onMouseDown={(e) => {
// 记录点击开始位置和时间
clickStartRef.current = {
x: e.clientX,
y: e.clientY,
time: Date.now()
}
handleButtonMouseDown(e)
}}
onClick={(e) => {
// 检查是否是拖动操作(移动超过5px或时间超过200ms)
if (clickStartRef.current) {
const deltaX = Math.abs(e.clientX - clickStartRef.current.x)
const deltaY = Math.abs(e.clientY - clickStartRef.current.y)
const deltaTime = Date.now() - clickStartRef.current.time
// 如果移动距离小于5px且时间小于200ms,认为是点击
if (deltaX < 5 && deltaY < 5 && deltaTime < 200 && !isButtonDragging) {
handleOpen()
}
clickStartRef.current = null
}
}}
className={cn(
'fixed left-8 top-1/2 -translate-y-1/2 z-40 w-12 h-12 rounded-lg shadow-lg border flex items-center justify-center transition-colors',
'bg-slate-900 dark:bg-slate-950',
'border-slate-700 dark:border-slate-800',
'hover:bg-slate-800'
'fixed z-40 w-12 h-12 rounded-xl shadow-lg border flex items-center justify-center transition-colors cursor-move backdrop-blur',
'bg-white/90 dark:bg-slate-900/90',
'border-slate-200 dark:border-slate-700',
'hover:bg-white dark:hover:bg-slate-900',
isButtonDragging && 'cursor-grabbing'
)}
title="显示排行榜"
style={{
left: `${buttonPosition.x}px`,
top: buttonCentered ? '50%' : `${buttonPosition.y}px`,
transform: buttonCentered ? 'translateY(-50%)' : 'none',
}}
title="显示排行榜(可拖动)"
>
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-6 h-6 text-slate-500 dark:text-slate-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</button>
@ -185,7 +356,7 @@ export default function RankingPanel() {
)
case 1:
return (
<svg className="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<svg className="w-5 h-5 text-slate-400 dark:text-slate-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
)
@ -200,17 +371,6 @@ export default function RankingPanel() {
}
}
const getTabTitle = () => {
switch (currentTab) {
case 'contributors':
return '🏆 贡献排行榜'
case 'downloads':
return '📥 下载排行榜'
case 'likes':
return '❤️ 点赞排行榜'
}
}
const getCurrentData = () => {
switch (currentTab) {
case 'contributors':
@ -226,81 +386,80 @@ export default function RankingPanel() {
<div
ref={panelRef}
className={cn(
'fixed z-40 w-[150px] h-[400px] rounded-lg shadow-2xl border overflow-hidden backdrop-blur-sm',
'bg-slate-900 dark:bg-slate-950',
'border-slate-700 dark:border-slate-800'
'fixed z-40 w-[150px] h-[400px] rounded-2xl shadow-2xl border overflow-hidden backdrop-blur-xl',
'bg-white/90 dark:bg-slate-950',
'border-slate-200/80 dark:border-slate-800'
)}
style={{
left: `${position.x}px`,
top: position.y === 0 ? '50%' : `${position.y}px`,
transform: position.y === 0 ? 'translateY(-50%)' : 'none',
top: panelCentered ? '50%' : `${position.y}px`,
transform: panelCentered ? 'translateY(-50%)' : 'none',
cursor: isDragging ? 'grabbing' : 'default'
}}
>
{/* 拖动区域和关闭按钮 */}
<div
className="bg-gradient-to-r from-gray-800 to-gray-900 px-3 py-3 text-center border-b border-gray-700 relative cursor-move select-none"
className="bg-white/80 dark:bg-slate-900/80 px-3 py-3 text-center border-b border-slate-200/80 dark:border-slate-800 relative cursor-move select-none"
onMouseDown={handleMouseDown}
>
{/* 关闭按钮 */}
<button
onClick={handleClose}
className="absolute top-1.5 right-1.5 w-3 h-3 flex items-center justify-center bg-gray-800 hover:bg-gray-700 rounded-full transition-colors z-10"
className="absolute top-1.5 right-1.5 w-6 h-6 flex items-center justify-center bg-white/80 dark:bg-slate-900/80 border border-white/50 dark:border-slate-700 rounded-full transition-colors z-10 hover:bg-white dark:hover:bg-slate-800"
aria-label="关闭"
onMouseDown={(e) => e.stopPropagation()} // 阻止拖动
onMouseDown={(e) => e.stopPropagation()}
>
<svg className="w-2 h-2 text-gray-400 hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-4 h-4 text-slate-500 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* 标题 */}
<h3 className="text-sm font-bold text-white leading-tight">
{getTabTitle()}
<h3 className="text-sm font-bold text-slate-900 dark:text-white leading-tight">
{currentTabMeta.label}
</h3>
</div>
{/* 指示器 */}
<div className="flex justify-center gap-1 py-2 bg-gray-800 border-b border-gray-700">
<button
onClick={() => setCurrentTab('contributors')}
className={`w-1.5 h-1.5 rounded-full transition-all ${
currentTab === 'contributors' ? 'bg-white w-4' : 'bg-gray-600'
}`}
/>
<button
onClick={() => setCurrentTab('downloads')}
className={`w-1.5 h-1.5 rounded-full transition-all ${
currentTab === 'downloads' ? 'bg-white w-4' : 'bg-gray-600'
}`}
/>
<button
onClick={() => setCurrentTab('likes')}
className={`w-1.5 h-1.5 rounded-full transition-all ${
currentTab === 'likes' ? 'bg-white w-4' : 'bg-gray-600'
}`}
/>
<div className="flex items-center gap-1 px-3 py-2 bg-white/70 dark:bg-slate-900/80 border-b border-slate-200/60 dark:border-slate-800/70">
{(Object.keys(TAB_META) as PanelTab[]).map((tabKey) => {
const isActive = tabKey === currentTab
return (
<button
key={tabKey}
onClick={() => setCurrentTab(tabKey)}
className={cn(
'flex-1 h-1.5 rounded-full transition-all',
isActive
? 'bg-slate-400 dark:bg-slate-200'
: 'bg-slate-200/70 dark:bg-slate-700/70 hover:bg-slate-300/80 dark:hover:bg-slate-600/80'
)}
>
<span className="sr-only">{TAB_META[tabKey].label}</span>
</button>
)
})}
</div>
{/* 排行榜内容 */}
<div className="p-3 overflow-y-auto" style={{ height: 'calc(100% - 80px)' }}>
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-xs text-gray-400">加载中...</div>
<div className="text-xs text-slate-500 dark:text-slate-400">加载中...</div>
</div>
) : getCurrentData().length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-xs text-gray-500 text-center">暂无数据</div>
<div className="text-xs text-slate-500 dark:text-slate-400 text-center">暂无数据</div>
</div>
) : (
<div className="space-y-2">
{getCurrentData().map((user, index) => (
<div
key={index}
className={`flex items-center gap-2 p-2 rounded-lg transition-all ${
className={`flex items-center gap-2 p-2 rounded-lg transition-all border ${
index < 3
? 'bg-gradient-to-r from-yellow-900/30 to-orange-900/30 border border-yellow-600/30'
: 'bg-gray-800/50 border border-gray-700'
? 'bg-yellow-50 text-slate-900 dark:bg-slate-900/70 dark:text-white border-yellow-100 dark:border-yellow-500/20'
: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-200 border-slate-200 dark:border-slate-800'
}`}
>
{/* 排名/奖牌 */}
@ -308,7 +467,7 @@ export default function RankingPanel() {
{index < 3 ? (
getMedalIcon(index)
) : (
<span className="text-xs font-medium text-gray-400">
<span className="text-xs font-medium text-slate-400 dark:text-slate-500">
{index + 1}
</span>
)}
@ -316,22 +475,14 @@ export default function RankingPanel() {
{/* 用户名 */}
<div className="flex-1 min-w-0">
<div
className={`text-xs font-medium truncate ${
index < 3
? 'text-yellow-400'
: 'text-gray-200'
}`}
>
<div className="text-xs font-medium truncate">
{user.username}
</div>
</div>
{/* 数量 */}
<div className="flex-shrink-0">
<span className={`text-xs font-bold ${
index < 3 ? 'text-yellow-400' : 'text-gray-400'
}`}>
<span className="text-xs font-bold">
{user.count}
</span>
</div>

View File

@ -23,23 +23,33 @@ interface UploadModalProps {
onSuccess: () => void
}
type MaterialKind = 'IMAGE' | 'VIDEO' | 'OTHER'
type UploadTemplateOption = {
id: string
name: string
description: string | null
materialType?: string
materialKind?: MaterialKind
fields: any[]
isBuiltin?: boolean
}
const normalizeMaterialKind = (value?: string | null): MaterialKind => {
if (!value) return 'IMAGE'
const upper = value.toUpperCase()
if (upper === 'VIDEO' || upper === 'OTHER' || upper === 'IMAGE') {
return upper as MaterialKind
}
return 'IMAGE'
}
export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
const styles = useThemeStyles()
const [previewImage, setPreviewImage] = useState<File | null>(null)
const [previewUrl, setPreviewUrl] = useState<string>('')
const [previewImages, setPreviewImages] = useState<File[]>([]) // 多预览图(最多5张)
const [previewUrls, setPreviewUrls] = useState<string[]>([]) // 多预览图预览URLs
const [displayImage, setDisplayImage] = useState<File | null>(null) // PSD展示图片
const [displayImageUrl, setDisplayImageUrl] = useState<string>('') // PSD展示图片预览URL
const [modelImages, setModelImages] = useState<File[]>([]) // 模特类型的多张展示图片
const [modelImageUrls, setModelImageUrls] = useState<string[]>([]) // 模特图片预览URLs
const [zipFile, setZipFile] = useState<File | null>(null)
const [productModel, setProductModel] = useState('')
const [brandName, setBrandName] = useState('')
@ -84,11 +94,13 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
const [uploadProgress, setUploadProgress] = useState(0)
const [uploadSpeed, setUploadSpeed] = useState(0)
const [uploadComplete, setUploadComplete] = useState(false) // 上传完成状态
const [pendingMaterialType, setPendingMaterialType] = useState<string | null>(null)
const [error, setError] = useState('')
const [isDragging, setIsDragging] = useState(false)
const [isDisplayImageDragging, setIsDisplayImageDragging] = useState(false) // PSD展示图片拖拽状态
const [isPreviewDragging, setIsPreviewDragging] = useState(false) // 预览图片拖拽状态
const [retryCount, setRetryCount] = useState(0)
const [draggingPreviewIndex, setDraggingPreviewIndex] = useState<number | null>(null)
const MAX_RETRY = 2
// 断点续传相关状态
@ -101,8 +113,72 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
const displayImageDropZoneRef = useRef<HTMLDivElement>(null) // PSD展示图片拖拽区域
const displayImageInputRef = useRef<HTMLInputElement>(null) // PSD展示图片输入
const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区域
const previewDragTargetRef = useRef<number | null>(null)
const reorderPreviewItems = useCallback(
(fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return
setPreviewImages((prev) => {
const items = [...prev]
if (fromIndex >= items.length || toIndex >= items.length) return prev
const [moved] = items.splice(fromIndex, 1)
items.splice(toIndex, 0, moved)
return items
})
setPreviewUrls((prev) => {
const urls = [...prev]
if (fromIndex >= urls.length || toIndex >= urls.length) return prev
const [moved] = urls.splice(fromIndex, 1)
urls.splice(toIndex, 0, moved)
return urls
})
},
[]
)
const handlePreviewCardDragStart = (index: number) => (event: React.DragEvent) => {
event.stopPropagation()
setDraggingPreviewIndex(index)
previewDragTargetRef.current = index
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(index))
}
const handlePreviewCardDragOver = (index: number) => (event: React.DragEvent) => {
event.preventDefault()
event.stopPropagation()
if (draggingPreviewIndex === null || draggingPreviewIndex === index) {
return
}
reorderPreviewItems(draggingPreviewIndex, index)
setDraggingPreviewIndex(index)
previewDragTargetRef.current = index
}
const handlePreviewCardDragEnd = () => {
setDraggingPreviewIndex(null)
previewDragTargetRef.current = null
}
const handlePreviewCardDrop = (event: React.DragEvent) => {
event.preventDefault()
event.stopPropagation()
setDraggingPreviewIndex(null)
previewDragTargetRef.current = null
}
const xhrRef = useRef<XMLHttpRequest | null>(null)
const VIDEO_ACCEPT_TYPES =
'video/mp4,video/quicktime,video/x-msvideo,video/x-matroska,video/webm,video/m4v,.mp4,.mov,.avi,.mkv,.webm,.m4v'
const VIDEO_FILE_EXTENSIONS = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v']
const isVideoFile = (file: File) => {
if (!file) return false
if (file.type && file.type.startsWith('video/')) {
return true
}
const ext = file.name.split('.').pop()?.toLowerCase() || ''
return VIDEO_FILE_EXTENSIONS.includes(ext)
}
// 保存上传状态到 localStorage(使用 useCallback 确保函数稳定)
const saveUploadState = useCallback(() => {
if (uploading || uploadComplete || (uploadProgress > 0 && uploadProgress < 100)) {
@ -120,9 +196,8 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
renderTime,
imageResolution,
colorMode,
previewImageName: previewImage?.name || '',
previewImagesNames: previewImages.map(img => img.name),
displayImageName: displayImage?.name || '',
modelImagesNames: modelImages.map(img => img.name),
zipFileName: zipFile?.name || '',
uploadProgress,
timestamp: Date.now()
@ -133,7 +208,26 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
console.error('Failed to save upload state to localStorage:', e)
}
}
}, [uploading, uploadProgress, productModel, brandName, seriesName, skuCode, materialTexture, categoryName, sceneName, platformName, materialType, renderer, renderTime, imageResolution, colorMode, previewImage, displayImage, modelImages, zipFile])
}, [
uploading,
uploadProgress,
productModel,
brandName,
seriesName,
skuCode,
materialTexture,
categoryName,
sceneName,
platformName,
materialType,
renderer,
renderTime,
imageResolution,
colorMode,
previewImages,
displayImage,
zipFile,
])
// 清除保存的上传状态
const clearUploadState = useCallback(() => {
@ -161,12 +255,12 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
}
const resetForm = useCallback(() => {
setPreviewImage(null)
setPreviewUrl('')
// 释放所有预览图URL
previewUrls.forEach(url => URL.revokeObjectURL(url))
setPreviewImages([])
setPreviewUrls([])
setDisplayImage(null)
setDisplayImageUrl('')
setModelImages([])
setModelImageUrls([])
setZipFile(null)
setProductModel('')
setBrandName('')
@ -376,16 +470,43 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
}
const handlePreviewChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
if (!file.type.startsWith('image/')) {
setError('请选择图片文件')
return
}
setPreviewImage(file)
setPreviewUrl(URL.createObjectURL(file))
setError('')
const files = Array.from(e.target.files || [])
if (files.length === 0) return
// 验证文件类型
const imageFiles = files.filter(file => file.type.startsWith('image/'))
if (imageFiles.length !== files.length) {
setError('请选择图片文件')
return
}
// 检查总数是否超过5张
const currentCount = previewImages.length
const newCount = currentCount + imageFiles.length
if (newCount > 5) {
setError(`最多只能上传5张预览图(当前:${currentCount}张,尝试添加:${imageFiles.length}张)`)
return
}
// 添加新图片
const newImages = [...previewImages, ...imageFiles]
setPreviewImages(newImages)
setPreviewUrls([...previewUrls, ...imageFiles.map(file => URL.createObjectURL(file))])
setError('')
// 清空input,允许重复选择同一文件
if (previewInputRef.current) {
previewInputRef.current.value = ''
}
}
const removePreviewImage = (index: number) => {
const newImages = previewImages.filter((_, i) => i !== index)
const newUrls = previewUrls.filter((_, i) => i !== index)
setPreviewImages(newImages)
// 释放URL对象
URL.revokeObjectURL(previewUrls[index])
setPreviewUrls(newUrls)
}
const handleDisplayImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -401,42 +522,14 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
}
}
// 处理模特类型的多图上传
const handleModelImagesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) return
// 验证所有文件都是图片
const invalidFiles = files.filter(file => !file.type.startsWith('image/'))
if (invalidFiles.length > 0) {
setError('请只选择图片文件')
return
}
// 添加新图片到现有列表
const newImages = [...modelImages, ...files]
const newUrls = [...modelImageUrls, ...files.map(file => URL.createObjectURL(file))]
setModelImages(newImages)
setModelImageUrls(newUrls)
setError('')
}
// 删除模特图片
const removeModelImage = (index: number) => {
const newImages = modelImages.filter((_, i) => i !== index)
const newUrls = modelImageUrls.filter((_, i) => i !== index)
// 释放被删除图片的URL
URL.revokeObjectURL(modelImageUrls[index])
setModelImages(newImages)
setModelImageUrls(newUrls)
}
const handleZipChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
if (currentMaterialKind === 'VIDEO' && !isVideoFile(file)) {
setError('请上传视频文件(支持 MP4、MOV、AVI、MKV、WebM 等格式)')
e.target.value = ''
return
}
setZipFile(file)
setError('')
}
@ -478,6 +571,10 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
const file = files[0]
if (!file) return
if (currentMaterialKind === 'VIDEO' && !isVideoFile(file)) {
setError('请拖拽正确的视频文件,支持 MP4、MOV、AVI、MKV、WebM 等格式')
return
}
setZipFile(file)
setError('')
}
@ -516,14 +613,24 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
if (files.length === 0) return
const imageFiles = files.filter(f => f.type.startsWith('image/'))
if (imageFiles.length > 0) {
const imgFile = imageFiles[0]
setPreviewImage(imgFile)
setPreviewUrl(URL.createObjectURL(imgFile))
setError('')
} else {
if (imageFiles.length === 0) {
setError('请拖拽图片文件')
return
}
// 检查总数是否超过5张
const currentCount = previewImages.length
const newCount = currentCount + imageFiles.length
if (newCount > 5) {
setError(`最多只能上传5张预览图(当前:${currentCount}张,尝试添加:${imageFiles.length}张)`)
return
}
// 添加新图片
const newImages = [...previewImages, ...imageFiles]
setPreviewImages(newImages)
setPreviewUrls([...previewUrls, ...imageFiles.map(file => URL.createObjectURL(file))])
setError('')
}
// PSD展示图片拖拽处理
@ -663,6 +770,8 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
}
const currentMaterialType = selectedTemplate?.materialType || materialType
const submittingMaterialKind = currentMaterialKind
const submittingIsVideo = submittingMaterialKind === 'VIDEO'
// 验证模板必填字段
if (selectedTemplate && selectedTemplate.fields) {
@ -695,7 +804,7 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
}
// 素材文件验证(模特类型和材质类型素材文件可选)
if (currentMaterialType !== '模特' && currentMaterialType !== '材质' && !zipFile) {
if (!submittingIsVideo && currentMaterialType !== '模特' && currentMaterialType !== '材质' && !zipFile) {
setError('请上传素材文件')
return
}
@ -706,28 +815,21 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
return
}
if (currentMaterialType === '模特' && modelImages.length === 0) {
setError('模特素材必须上传至少一张展示图片')
return
}
if (currentMaterialType === '视频' && !previewImage) {
setError('视频素材需要上传预览图(封面)')
return
}
if (currentMaterialType !== '模特' && !previewImage) {
// 视频素材的封面图是可选的(如果没有上传,系统会自动从视频中截取)
// 对于非视频、非PSD类型,必须有预览图
if (!submittingIsVideo && currentMaterialType !== 'PSD' && previewImages.length === 0) {
setError('请上传预览图')
return
}
// 视频类型验证
if (currentMaterialType === '视频' && !zipFile) {
if (submittingIsVideo && !zipFile) {
setError('请上传视频文件')
return
}
// 重置重试计数和状态
setPendingMaterialType(currentMaterialType)
setRetryCount(0)
setUploading(true)
setUploadProgress(0)
@ -736,19 +838,17 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
try {
const formData = new FormData()
// Only append preview image if it exists
if (previewImage) {
formData.append('previewImage', previewImage)
// 添加预览图(多张,最多5张)
if (previewImages.length > 0) {
previewImages.forEach((img, index) => {
formData.append('previewImages', img)
})
// 第一张作为主预览图(向后兼容)
formData.append('previewImage', previewImages[0])
}
if (displayImage) {
formData.append('displayImage', displayImage)
}
// 添加模特类型的多张图片
if (currentMaterialType === '模特' && modelImages.length > 0) {
modelImages.forEach((image, index) => {
formData.append('modelImages', image)
})
}
// 素材文件对于模特类型是可选的
if (zipFile) {
formData.append('zipFile', zipFile)
@ -768,6 +868,7 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
formData.append('renderTime', templateFieldValues.renderTime || '')
formData.append('imageResolution', templateFieldValues.imageResolution || '')
formData.append('colorMode', templateFieldValues.colorMode || '')
formData.append('materialKind', submittingMaterialKind)
const xhr = new XMLHttpRequest()
xhrRef.current = xhr // 保存引用以便取消
@ -801,11 +902,13 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
setUploadProgress(100)
setUploadComplete(true)
setUploading(false)
setPendingMaterialType(null)
// 清除上传状态和重置表单
clearUploadState()
resetForm()
setRetryCount(0)
setPendingMaterialType(null)
// 短暂延迟确保UI更新完成,然后关闭弹窗
setTimeout(() => {
@ -817,6 +920,14 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
const data = JSON.parse(xhr.responseText)
let errorMsg = data.error || '上传失败'
// 如果有详细信息,添加到错误消息中
if (data.details) {
errorMsg += `\n详细信息: ${data.details}`
}
if (data.errorType) {
errorMsg += `\n错误类型: ${data.errorType}`
}
// 提供更详细的错误信息
if (xhr.status === 413) {
errorMsg = '文件过大,请联系管理员增加上传限制'
@ -827,8 +938,9 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
}
setError(errorMsg)
} catch {
setError(`上传失败 (状态码: ${xhr.status})`)
} catch (parseError) {
console.error('[UploadModal] Failed to parse error response:', parseError)
setError(`上传失败 (状态码: ${xhr.status})\n响应内容: ${xhr.responseText.substring(0, 200)}`)
}
setUploading(false)
setRetryCount(0)
@ -861,6 +973,7 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
setError(errorMsg)
setUploading(false)
setUploadComplete(false)
setPendingMaterialType(null)
})
xhr.addEventListener('abort', () => {
@ -868,6 +981,7 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
setUploading(false)
setUploadComplete(false)
setRetryCount(0)
setPendingMaterialType(null)
xhrRef.current = null
})
@ -885,6 +999,7 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
setError('上传失败,请稍后再试')
setUploading(false)
setUploadComplete(false)
setPendingMaterialType(null)
}
}
@ -897,7 +1012,23 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
}
const resolvedMaterialType = selectedTemplate?.materialType || materialType
const previewRequiredInUi = resolvedMaterialType !== '模特'
const currentMaterialKind = normalizeMaterialKind(
selectedTemplate?.materialKind || (resolvedMaterialType === '视频' ? 'VIDEO' : 'IMAGE')
)
const isVideoTemplate = currentMaterialKind === 'VIDEO'
// 视频模板的预览图是可选的(如果没有上传,转码任务会自动从视频中截取)
// 只有非视频、非PSD类型才需要必填预览图
const previewRequiredInUi = !isVideoTemplate && resolvedMaterialType !== 'PSD'
const isPostProcessing = uploading && uploadProgress === 100 && !uploadComplete
const postProcessingMessage =
pendingMaterialType === 'PSD'
? 'PSD 展示图较大,正在切割并生成长图预览,请稍等...'
: '文件已上传,系统正在保存素材并刷新列表,请勿关闭窗口...'
const submitButtonLabel = uploading
? isPostProcessing
? '处理中...'
: `上传中 ${uploadProgress}%`
: '上传'
return (
<>
@ -1000,7 +1131,7 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
className="px-4 py-2 text-base font-medium rounded-apple bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={uploading}
>
{uploading ? `上传中 ${uploadProgress}%` : '上传'}
{submitButtonLabel}
</button>
</div>
</div>
@ -1056,9 +1187,10 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
{/* 预览图片和素材文件卡片 - 移到上方,样式与预览面板一致 */}
<div className="space-y-5">
{resolvedMaterialType !== 'PSD' && (
<div className="relative">
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
{resolvedMaterialType === '视频' ? '视频封面(预览图)' : '预览图片'}{' '}
{isVideoTemplate ? '视频封面(预览图)' : '预览图片'}{' '}
{previewRequiredInUi ? (
<span className="text-red-500">*</span>
) : (
@ -1074,38 +1206,121 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
onDrop={handlePreviewDrop}
onClick={() => previewInputRef.current?.click()}
>
<div className="flex items-center justify-center space-x-3">
{previewUrl ? (
<div className="relative w-16 h-16 rounded-xl overflow-hidden border-2 border-slate-200 dark:border-slate-600 flex-shrink-0">
<Image
src={previewUrl}
alt="Preview"
fill
unoptimized
className="object-cover"
/>
</div>
) : (
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-slate-100 to-slate-50 dark:from-slate-700 dark:to-slate-800 flex items-center justify-center flex-shrink-0 shadow-inner">
<svg className="w-8 h-8 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
<input
ref={previewInputRef}
type="file"
onChange={handlePreviewChange}
accept="image/*"
multiple
className="hidden"
/>
{previewImages.length === 0 ? (
<div className="flex flex-wrap items-center gap-4 sm:flex-nowrap">
<div className="w-16 h-16 sm:w-20 sm:h-20 flex-shrink-0 rounded-xl border-2 border-dashed border-slate-300/80 dark:border-slate-600/80 flex items-center justify-center text-slate-500 dark:text-slate-400 bg-white/40 dark:bg-slate-800/40">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
)}
<div className="text-left flex-1 min-w-0">
<input
ref={previewInputRef}
type="file"
onChange={handlePreviewChange}
accept="image/*"
className="hidden"
/>
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">点击或拖拽上传预览图</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">支持 JPG、PNG、WebP 等格式</p>
<div className="text-left flex-1 min-w-[200px] space-y-1">
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">
点击或拖拽上传{isVideoTemplate ? '视频封面(可选,最多5张)' : '预览图(最多5张)'}
</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500">
{isVideoTemplate
? '支持 JPG、PNG、WebP 等格式。如不上传,系统将自动从视频中截取封面图'
: '支持 JPG、PNG、WebP 等格式,可直接拖拽图片到此区域'}
</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
previewInputRef.current?.click()
}}
className="inline-flex items-center text-[11px] font-medium text-blue-600 hover:text-blue-500 dark:text-blue-300 dark:hover:text-blue-200"
>
上传预览图
<svg className="w-3.5 h-3.5 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v12m0 0l3-3m-3 3l-3-3M5 20h14" />
</svg>
</button>
</div>
</div>
</div>
) : (
<>
<div className="space-y-3">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
{previewImages.map((img, index) => (
<div
key={index}
draggable
onDragStart={handlePreviewCardDragStart(index)}
onDragOver={handlePreviewCardDragOver(index)}
onDrop={handlePreviewCardDrop}
onDragEnd={handlePreviewCardDragEnd}
className={cn(
'relative group cursor-grab active:cursor-grabbing',
draggingPreviewIndex === index &&
'ring-2 ring-indigo-500 rounded-xl'
)}
>
<div className="relative w-full aspect-square rounded-xl overflow-hidden border-2 border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800">
<Image
src={previewUrls[index]}
alt={`Preview ${index + 1}`}
fill
unoptimized
className="object-cover"
/>
{index === 0 && (
<div className="absolute top-1 left-1 bg-blue-600 text-white text-[10px] px-1.5 py-0.5 rounded">
封面
</div>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
removePreviewImage(index)
}}
className="absolute top-1 right-1 w-5 h-5 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-xs"
>
×
</button>
</div>
</div>
))}
{previewImages.length < 5 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
previewInputRef.current?.click()
}}
className="w-full aspect-square rounded-xl border-2 border-dashed border-slate-300/80 dark:border-slate-600/80 flex flex-col items-center justify-center gap-2 text-slate-500 dark:text-slate-400 bg-white/40 dark:bg-slate-800/40 hover:border-blue-400 hover:text-blue-500 dark:hover:border-blue-500 dark:hover:text-blue-400 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="text-[10px] font-medium">
{isVideoTemplate ? '添加封面' : '继续添加'}
</span>
</button>
)}
</div>
</div>
<div className="text-left mt-3">
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">
已上传 {previewImages.length}/5 张,第一张自动设为{isVideoTemplate ? '视频封面' : '卡片封面'}
</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">
支持 JPG、PNG、WebP 等格式,可直接拖拽图片到此区域
</p>
</div>
</>
)}
</div>
</div>
)}
{/* PSD展示图片(仅PSD类型显示) */}
{resolvedMaterialType === 'PSD' && (
@ -1157,7 +1372,11 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
{/* 素材文件 */}
<div className="relative">
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
素材文件 {(() => {
{isVideoTemplate ? '视频文件' : '素材文件'}{' '}
{(() => {
if (isVideoTemplate) {
return <span className="text-red-500">*</span>
}
const currentMaterialType = selectedTemplate?.materialType || materialType
return currentMaterialType === '模特' ? '(可选)' : <span className="text-red-500">*</span>
})()}
@ -1169,7 +1388,7 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => !zipFile && zipInputRef.current?.click()}
onClick={() => zipInputRef.current?.click()}
>
{zipFile ? (
<div className="flex items-center justify-center space-x-3">
@ -1198,15 +1417,62 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
ref={zipInputRef}
type="file"
onChange={handleZipChange}
accept={isVideoTemplate ? VIDEO_ACCEPT_TYPES : '.zip,.rar,.7z,.7zip,.tar,.gz,.bz2,.pak'}
className="hidden"
/>
<p className="text-xs text-slate-600 dark:text-slate-400">点击或拖拽上传素材文件</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">支持 ZIP、RAR 等压缩格式</p>
<p className="text-xs text-slate-600 dark:text-slate-400">
点击或拖拽上传{isVideoTemplate ? '视频文件' : '素材压缩包'}
</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">
{isVideoTemplate ? '支持 MP4、MOV、AVI、MKV、WebM 等常见视频格式' : '支持 ZIP、RAR、7Z 等压缩格式'}
</p>
</div>
</div>
)}
</div>
</div>
{/* 上传进度 - 移到素材文件上传区域下方 */}
{(uploading || uploadComplete) && (
<div className="space-y-2 mt-4">
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-400">
<span>{uploadComplete ? '上传完成' : '上传进度'}</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full transition-all duration-300 ${
uploadComplete
? 'bg-green-500'
: 'bg-blue-600'
}`}
style={{ width: `${uploadProgress}%` }}
></div>
</div>
{!uploadComplete && !isPostProcessing && (
<div className="text-sm text-slate-500 dark:text-slate-400 text-right">
速度: {formatSpeed(uploadSpeed)}
</div>
)}
{isPostProcessing && (
<div className="text-xs text-amber-600 dark:text-amber-300 text-center font-medium">
{postProcessingMessage}
</div>
)}
{uploadComplete && (
<div className="text-sm text-green-600 dark:text-green-400 text-center font-medium">
✓ 上传成功,正在关闭...
</div>
)}
</div>
)}
{/* Error Message - 移到素材文件上传区域下方 */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 px-4 py-3 rounded-apple mt-4">
{error}
</div>
)}
</div>
{/* 动态渲染模板字段 */}
@ -1251,7 +1517,11 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
})
}}
required={field.required}
placeholder={`请输入${field.label || field.key}`}
placeholder={
field.key === 'imageResolution'
? '例如:5000 * 5000'
: `请输入${field.label || field.key}`
}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60',
field.required
@ -1429,120 +1699,6 @@ const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区
{/* 模特展示图片(仅模特类型显示) */}
{(() => {
const currentMaterialType = selectedTemplate?.materialType || materialType
return currentMaterialType === '模特'
})() && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
模特展示图片 * (可上传多张)
</label>
<div className="border-2 border-dashed rounded-apple p-4 border-slate-300 dark:border-slate-600">
{/* 已上传的图片预览 */}
{modelImageUrls.length > 0 && (
<div className="grid grid-cols-4 gap-4 mb-4">
{modelImageUrls.map((url, index) => (
<div key={index} className="relative group">
<div className="relative w-full h-32 rounded-apple overflow-hidden border-2 border-slate-200 dark:border-slate-600">
<Image
src={url}
alt={`模特图 ${index + 1}`}
fill
unoptimized
className="object-cover"
/>
{index === 0 && (
<div className="absolute top-1 left-1 bg-blue-500 text-white text-xs px-2 py-1 rounded">
预览图
</div>
)}
</div>
<button
type="button"
onClick={() => removeModelImage(index)}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</div>
))}
</div>
)}
{/* 上传按钮 */}
<div className="flex items-center space-x-4">
<div className="w-32 h-32 rounded-apple bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<svg className="w-12 h-12 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div className="flex-1">
<input
type="file"
onChange={handleModelImagesChange}
accept="image/*"
multiple
className="hidden"
id="modelImagesInput"
/>
<button
type="button"
onClick={() => document.getElementById('modelImagesInput')?.click()}
className="btn-secondary"
>
{modelImages.length > 0 ? '继续添加图片' : '选择图片'}
</button>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">
可一次选择多张图片上传
</p>
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
💡 第一张图会自动作为预览图,高度超过1500px会自动切割
</p>
</div>
</div>
</div>
</div>
)}
{/* 上传进度 */}
{(uploading || uploadComplete) && (
<div className="space-y-2">
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-400">
<span>{uploadComplete ? '上传完成' : '上传进度'}</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full transition-all duration-300 ${
uploadComplete
? 'bg-green-500'
: 'bg-blue-600'
}`}
style={{ width: `${uploadProgress}%` }}
></div>
</div>
{!uploadComplete && (
<div className="text-sm text-slate-500 dark:text-slate-400 text-right">
速度: {formatSpeed(uploadSpeed)}
</div>
)}
{uploadComplete && (
<div className="text-sm text-green-600 dark:text-green-400 text-center font-medium">
✓ 上传成功,正在关闭...
</div>
)}
</div>
)}
{/* Error Message */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 px-4 py-3 rounded-apple">
{error}
</div>
)}
</form>
</div>
</div>

View File

@ -14,7 +14,7 @@
import { useEffect, useRef, ReactNode, CSSProperties, useState } from 'react'
import { createPortal } from 'react-dom'
import { useThemeStyles } from '@/lib/ui-styles'
import { useThemeStyles, cn } from '@/lib/ui-styles'
export type ModalSize = 'xs' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | 'fullscreen'
@ -46,6 +46,11 @@ interface ModalProps {
closeOnEscape?: boolean
footer?: ReactNode
className?: string
containerClassName?: string
containerStyle?: CSSProperties
contentClassName?: string
bodyClassName?: string
bodyStyle?: CSSProperties
}
export default function Modal({
@ -59,10 +64,16 @@ export default function Modal({
closeOnEscape = true,
footer,
className = '',
containerClassName = '',
containerStyle,
contentClassName = '',
bodyClassName = '',
bodyStyle,
}: ModalProps) {
const [isMounted, setIsMounted] = useState(false)
const styles = useThemeStyles()
const modalRef = useRef<HTMLDivElement>(null)
const shouldCloseRef = useRef(false)
// ESC键关闭
useEffect(() => {
@ -96,10 +107,15 @@ export default function Modal({
if (!isOpen) return null
const handleOverlayClick = (e: React.MouseEvent) => {
if (closeOnOverlayClick && e.target === e.currentTarget) {
const handleOverlayMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
shouldCloseRef.current = closeOnOverlayClick && e.target === e.currentTarget
}
const handleOverlayMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
if (shouldCloseRef.current && e.target === e.currentTarget) {
onClose()
}
shouldCloseRef.current = false
}
const sizeClass = styles.modalStyles.sizes[size]
@ -109,6 +125,7 @@ export default function Modal({
...sizeInlineStyle,
borderRadius: MODAL_BORDER_RADIUS,
...(isCustomContent && { maxHeight: '92vh' }),
...(containerStyle || {}),
}
const contentInlineStyle: CSSProperties = {
@ -118,18 +135,19 @@ export default function Modal({
const modalMarkup = (
<div
className={`${styles.modalStyles.overlay} ${className}`}
onClick={handleOverlayClick}
onMouseDown={handleOverlayMouseDown}
onMouseUp={handleOverlayMouseUp}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
>
<div
ref={modalRef}
className={`${styles.modalStyles.container} ${sizeClass}`}
className={cn(styles.modalStyles.container, sizeClass, containerClassName)}
style={containerInlineStyle}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.modalStyles.content} style={contentInlineStyle}>
<div className={cn(styles.modalStyles.content, contentClassName)} style={contentInlineStyle}>
{(title || showCloseButton) && (
<div className={styles.modalStyles.header}>
{title && (
@ -164,7 +182,9 @@ export default function Modal({
{title || showCloseButton || footer ? (
<>
<div className={styles.modalStyles.body}>{children}</div>
<div className={cn(styles.modalStyles.body, bodyClassName)} style={bodyStyle}>
{children}
</div>
{footer && <div className={styles.modalStyles.footer}>{footer}</div>}
</>
) : (

View File

@ -25,7 +25,7 @@ import { useThemeStyles, cn } from '@/lib/ui-styles'
interface TagProps {
children: ReactNode
color?: 'blue' | 'green' | 'orange' | 'purple' | 'indigo' | 'pink' | 'cyan'
size?: 'sm' | 'md' | 'lg'
size?: 'xs' | 'sm' | 'md' | 'lg'
className?: string
onClick?: () => void
selected?: boolean
@ -67,10 +67,11 @@ export default function Tag({
}
const sizeClasses = {
xs: 'px-2.5 py-0.5 text-[11px] font-medium',
sm: 'px-3 py-1 text-xs font-medium',
md: 'px-4 py-1.5 text-sm font-medium',
lg: 'px-5 py-2 text-base font-medium',
}
} as const
// 苹果美学:圆角更大,过渡更流畅,选中状态有阴影
const baseClasses = cn(

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
# ================================================================
# 由壹点3D素材 - 部署脚本 v1.2
# FLNXI素材库 - 部署脚本 v1.2
# 功能:本地构建 -> 推送到Registry -> 远程部署
# 特性:自动加载基础镜像、智能重试、镜像清理、趣味提示
# ================================================================
@ -9,17 +9,23 @@ param(
[string]$Username = "root",
[SecureString]$Password = $null,
[string]$KeyPath = "",
[string]$ImageName = "youyidian-3d-material",
[string]$ImageName = "flnxi-material",
[string]$Tag = "local-deploy",
[string]$RemotePath = "/root/deploy",
[string]$EnvFile = "",
[string]$Registry = "harbor", # 默认使用 Harbor
[string]$HarborUrl = "harbor.flnxi.com",
[string]$HarborProjectName = "flnxi", # Harbor 项目名称,例如: flnxi, sucai, demo, library
[string]$HarborUsername = "admin",
[string]$HarborPassword = "Harbor12345",
[switch]$SkipCheck,
[switch]$SkipBuild,
[switch]$SkipUpload,
[switch]$Debug,
[switch]$Help,
[switch]$CleanImages,
[int]$KeepImageCount = 3
[int]$KeepImageCount = 5,
[switch]$CleanDockerDesktop = $true # 默认启用清理 Docker Desktop 未使用资源
)
# 脚本配置
@ -40,21 +46,21 @@ $script:StageTracker = @{
}
$script:FunTips = @(
"☕ 记得喝口咖啡,让部署的快乐多续一会~",
"🎯 今日小目标:让 CI/CD 成为最懂你的搭档。",
"🐱 猫咪提示:部署途中别忘了伸个懒腰。",
"🛰️ 数据正在轨道中穿梭,耐心是宇航员的必备素质。",
"🍀 小彩蛋:部署顺利指数 +1%,好运常伴你。",
"🎵 背景 BGM 同步播放中,部署节奏稳稳的。",
"🧊 如果日志有点长,记得深呼吸,冷静才是王牌技能。",
"📦 镜像像快递一样打包上路,收件人:Production。",
"🚀 每一个 build 都是一次漂亮的起飞。",
"✨ 代码正在变身,从开发模式切换到生产模式~",
"🎨 由壹点3D素材正在打包,准备惊艳登场!",
"⚡ 部署速度:光速模式已启动",
"🎪 部署马戏团正在表演,请保持期待~",
"🌙 夜深了?没关系,部署脚本陪你到天亮",
"🎁 部署完成后,记得给自己一个小奖励!"
"记得喝口咖啡,让部署的快乐多续一会",
"今日小目标:让 CI/CD 成为最懂你的搭档",
"猫咪提示:部署途中别忘了伸个懒腰",
"数据正在轨道中穿梭,耐心是宇航员的必备素质",
"小彩蛋:部署顺利指数 +1%,好运常伴你",
"背景 BGM 同步播放中,部署节奏稳稳的",
"如果日志有点长,记得深呼吸,冷静才是王牌技能",
"镜像像快递一样打包上路,收件人:Production",
"每一个 build 都是一次漂亮的起飞",
"代码正在变身,从开发模式切换到生产模式",
"FLNXI素材库正在打包,准备惊艳登场",
"部署速度:光速模式已启动",
"部署马戏团正在表演,请保持期待",
"夜深了?没关系,部署脚本陪你到天亮",
"部署完成后,记得给自己一个小奖励"
)
function Initialize-StageTracker {
@ -75,7 +81,7 @@ function Enter-Stage {
param(
[string]$Title,
[string]$Subtitle = "",
[string]$Emoji = "🚀"
[string]$Emoji = "[*]"
)
$script:StageTracker.Current++
$current = $script:StageTracker.Current
@ -85,8 +91,11 @@ function Enter-Stage {
$filledLength = [Math]::Floor($progress * $barLength)
$bar = ("█" * $filledLength).PadRight($barLength, "░")
Write-ColorOutput ""
Write-ColorOutput ("{0} {1} ({2}/{3})" -f $Emoji, $Title, $current, $total) "Magenta"
Write-ColorOutput ("[{0}] {1:P0}" -f $bar, $progress) "Magenta"
$stageMessage = $Emoji + " " + $Title + " (" + [string]$current + "/" + [string]$total + ")"
Write-ColorOutput $stageMessage "Magenta"
$progressPercent = [Math]::Round($progress * 100)
$progressMessage = "[" + $bar + "] " + [string]$progressPercent + "%"
Write-ColorOutput $progressMessage "Magenta"
if ($Subtitle) {
Write-Info $Subtitle
}
@ -103,9 +112,11 @@ function Get-FunTip {
function Celebrate-Stage {
param([string]$Title)
$tip = Get-FunTip
Write-ColorOutput ("🎉 {0}完成!" -f $Title) "Green"
$completeMessage = $Title + "完成!"
Write-ColorOutput $completeMessage "Green"
if ($tip) {
Write-ColorOutput (" {0}" -f $tip) "Gray"
$tipMessage = " " + $tip
Write-ColorOutput $tipMessage "Gray"
}
}
@ -140,7 +151,8 @@ function Test-RemoteHealth {
Write-Success ("健康检查通过,应用已就绪 ({0}s)" -f $elapsed)
$tip = Get-FunTip
if ($tip) {
Write-ColorOutput ("✨ 健康彩蛋: {0}" -f $tip) "Gray"
$healthTipMessage = "健康彩蛋: " + $tip
Write-ColorOutput $healthTipMessage "Gray"
}
return $true
} else {
@ -166,21 +178,28 @@ if ($Help) {
Write-ColorOutput " .\deploy-new-fixed.ps1 [参数]" "Yellow"
Write-ColorOutput "" "White"
Write-ColorOutput "参数:" "White"
Write-ColorOutput " -Server <地址> 服务器地址 (默认: 45.249.208.118)" "Gray"
Write-ColorOutput " -Username <用户> SSH用户名 (默认: root)" "Gray"
Write-ColorOutput " -KeyPath <路径> SSH私钥路径 (推荐)" "Gray"
Write-ColorOutput " -EnvFile <路径> 指定构建期使用的环境变量文件" "Gray"
Write-ColorOutput " -Server [地址] 服务器地址 (默认: 45.249.208.118)" "Gray"
Write-ColorOutput " -Username [用户] SSH用户名 (默认: root)" "Gray"
Write-ColorOutput " -KeyPath [路径] SSH私钥路径 (推荐)" "Gray"
Write-ColorOutput " -EnvFile [路径] 指定构建期使用的环境变量文件" "Gray"
Write-ColorOutput " -Registry [类型] Registry类型: default 或 harbor (默认: default)" "Gray"
Write-ColorOutput " -HarborUrl [地址] Harbor地址 (默认: harbor.flnxi.com)" "Gray"
Write-ColorOutput " -HarborProjectName [名称] Harbor项目名称 (默认: flnxi)" "Gray"
Write-ColorOutput " -HarborUsername [用户] Harbor用户名 (默认: admin)" "Gray"
Write-ColorOutput " -HarborPassword [密码] Harbor密码 (默认: Harbor12345)" "Gray"
Write-ColorOutput " -SkipCheck 跳过代码检查" "Gray"
Write-ColorOutput " -SkipBuild 跳过Docker构建" "Gray"
Write-ColorOutput " -SkipUpload 跳过镜像推送和远程部署" "Gray"
Write-ColorOutput " -CleanImages 清理旧版本镜像" "Gray"
Write-ColorOutput " -KeepImageCount <N> 保留的镜像数量 (默认: 3)" "Gray"
Write-ColorOutput " -KeepImageCount [N] 保留的镜像数量 (默认: 5)" "Gray"
Write-ColorOutput " -CleanDockerDesktop 清理 Docker Desktop 未使用资源 (默认: 启用)" "Gray"
Write-ColorOutput " -Debug 启用调试模式" "Gray"
Write-ColorOutput " -Help 显示此帮助信息" "Gray"
Write-ColorOutput "" "White"
Write-ColorOutput "示例:" "White"
Write-ColorOutput " .\deploy-new-fixed.ps1" "Yellow"
Write-ColorOutput " .\deploy-new-fixed.ps1 -KeyPath 'C:\Users\YourUsername\.ssh\youyidian_prod_ed25519'" "Yellow"
Write-ColorOutput " .\deploy-new-fixed.ps1 -Registry harbor" "Yellow"
Write-ColorOutput " .\deploy-new-fixed.ps1 -SkipCheck -SkipUpload" "Yellow"
Write-ColorOutput " .\deploy-new-fixed.ps1 -CleanImages -KeepImageCount 5" "Yellow"
exit 0
@ -197,22 +216,22 @@ function Write-ColorOutput {
function Write-Success {
param([string]$Message)
Write-ColorOutput "✓ $Message" "Green"
Write-ColorOutput "[OK] $Message" "Green"
}
function Write-Warning {
param([string]$Message)
Write-ColorOutput "⚠ $Message" "Yellow"
Write-ColorOutput "[WARN] $Message" "Yellow"
}
function Write-ErrorColored {
param([string]$Message)
Write-ColorOutput "✗ $Message" "Red"
Write-ColorOutput "[ERROR] $Message" "Red"
}
function Write-Info {
param([string]$Message)
Write-ColorOutput "ℹ $Message" "Cyan"
Write-ColorOutput "[INFO] $Message" "Cyan"
}
# 环境变量文件处理
@ -474,7 +493,7 @@ function Clean-LocalDockerImages {
[int]$KeepCount = 3
)
Write-Info "🧹 清理本地 Docker 镜像..."
Write-Info "清理本地 Docker 镜像..."
try {
# 清理悬空镜像
@ -527,7 +546,7 @@ function Clean-RemoteDockerImages {
[int]$KeepCount = 3
)
Write-Info "🧹 清理服务器上的 Docker 镜像..."
Write-Info "清理服务器上的 Docker 镜像..."
$cleanCmd = @"
# 清理悬空镜像
@ -570,7 +589,7 @@ function Clean-RegistryImages {
[int]$KeepCount = 5
)
Write-Info "🧹 清理 Docker Registry 中的旧镜像..."
Write-Info "清理 Docker Registry 中的旧镜像..."
try {
# 获取镜像的所有标签
@ -620,35 +639,95 @@ function Clean-RegistryImages {
}
}
# 内部函数:加载基础镜像
# 内部函数:检查并准备基础镜像(优先级:本地已有 > Registry > 本地文件 > 网络下载)
function Load-BaseImages {
param([string]$ProjectRoot)
param(
[string]$ProjectRoot,
[string]$RegistryUrl = "",
[string]$RegistryType = "harbor",
[string]$RegistryProject = ""
)
$imagesDir = Join-Path $ProjectRoot "docker-images"
if (-not (Test-Path $imagesDir)) {
return
}
# 基础镜像列表
$baseImages = @(
"node:18-alpine"
)
Write-Info "📦 检查并加载基础镜像..."
$loadedCount = 0
$pulledCount = 0
$skippedCount = 0
Get-ChildItem $imagesDir -Directory -ErrorAction SilentlyContinue | ForEach-Object {
$imageDir = $_.FullName
Get-ChildItem $imageDir -Filter "*.tar" -ErrorAction SilentlyContinue | ForEach-Object {
$tarFile = $_.FullName
$result = docker load -i $tarFile 2>&1
Write-Info "检查基础镜像..."
foreach ($baseImage in $baseImages) {
# 1. 优先检查本地是否已有该镜像(最快,直接使用)
$localCheck = docker images --format "{{.Repository}}:{{.Tag}}" | Select-String -Pattern "^${baseImage}$"
if ($localCheck) {
Write-Info " [✓] 本地已存在,直接使用: $baseImage"
$skippedCount++
continue
}
# 2. 如果本地没有,尝试从Registry拉取(避免从Docker Hub下载)
if ($RegistryUrl) {
$imageName = $baseImage -replace ":", "-"
if ($RegistryType -eq "harbor") {
$registryBaseImage = "${RegistryUrl}/${RegistryProject}/base-${imageName}:latest"
} else {
$registryBaseImage = "${RegistryUrl}/youyidian/base-${imageName}:latest"
}
Write-Info " 尝试从Registry拉取: $registryBaseImage"
$pullResult = docker pull $registryBaseImage 2>&1
if ($LASTEXITCODE -eq 0) {
$loadedImage = $result | Select-String -Pattern "Loaded image: (.+)" | ForEach-Object { $_.Matches[0].Groups[1].Value }
if ($loadedImage) {
Write-Info " ✓ 已加载: $loadedImage"
$loadedCount++
}
# 标记为原始名称
docker tag $registryBaseImage $baseImage 2>&1 | Out-Null
Write-Info " [OK] 已从Registry拉取并标记: $baseImage"
$pulledCount++
continue
} else {
Write-Info " [跳过] Registry中不存在"
}
}
# 3. 如果Registry也没有,尝试从本地文件加载
$imagesDir = Join-Path $ProjectRoot "docker-images"
if (Test-Path $imagesDir) {
$foundInFiles = $false
Get-ChildItem $imagesDir -Directory -ErrorAction SilentlyContinue | ForEach-Object {
if ($foundInFiles) { return }
$imageDir = $_.FullName
Get-ChildItem $imageDir -Filter "*.tar" -ErrorAction SilentlyContinue | ForEach-Object {
if ($foundInFiles) { return }
$tarFile = $_.FullName
Write-Info " 尝试从本地文件加载: $($_.Name)"
$result = docker load -i $tarFile 2>&1
if ($LASTEXITCODE -eq 0) {
$loadedImage = $result | Select-String -Pattern "Loaded image: (.+)" | ForEach-Object { $_.Matches[0].Groups[1].Value }
if ($loadedImage -and $loadedImage -match $baseImage) {
Write-Info " [OK] 已从本地文件加载: $loadedImage"
$loadedCount++
$foundInFiles = $true
}
}
}
}
if ($foundInFiles) {
continue
}
}
# 4. 如果以上都没有,构建时会从网络下载(Docker会自动处理)
Write-Info " [提示] 未找到基础镜像,构建时将尝试从网络下载: $baseImage"
}
if ($skippedCount -gt 0) {
Write-Success "本地已有 $skippedCount 个基础镜像,直接使用"
}
if ($pulledCount -gt 0) {
Write-Success "从Registry拉取 $pulledCount 个基础镜像"
}
if ($loadedCount -gt 0) {
Write-Success "已加载 $loadedCount 个基础镜像"
Write-Success "从本地文件加载 $loadedCount 个基础镜像"
}
}
@ -661,7 +740,8 @@ function Main {
# 加载基础镜像(在构建前)
if (-not $SkipBuild) {
$projectRoot = if ($PSScriptRoot) { Split-Path $PSScriptRoot -Parent } else { Get-Location }
Load-BaseImages -ProjectRoot $projectRoot
$baseRegistryUrl = if ($Registry -eq "harbor") { $HarborUrl } else { "${Server}:5000" }
Load-BaseImages -ProjectRoot $projectRoot -RegistryUrl $baseRegistryUrl -RegistryType $Registry -RegistryProject $HarborProjectName
Write-ColorOutput ""
}
@ -670,6 +750,11 @@ function Main {
Write-Info " 服务器: $Server"
Write-Info " 用户名: $Username"
Write-Info " 镜像: $ImageName`:$Tag"
Write-Info " Registry: $Registry"
if ($Registry -eq "harbor") {
Write-Info " Harbor地址: $HarborUrl"
Write-Info " Harbor项目: $HarborProjectName"
}
Write-Info " 跳过检查: $SkipCheck"
Write-Info " 跳过构建: $SkipBuild"
Write-Info " 跳过上传: $SkipUpload"
@ -677,7 +762,7 @@ function Main {
# 调试模式
if ($Debug) {
Write-Info "🐛 调试模式已启用:将输出额外的诊断信息"
Write-Info "[DEBUG] 调试模式已启用:将输出额外的诊断信息"
}
$willCheck = -not $SkipCheck
@ -737,7 +822,7 @@ function Main {
# 1. 检查阶段
if (-not $SkipCheck) {
Enter-Stage "检查阶段" "🔍 先给代码做个快速体检"
Enter-Stage "检查阶段" "先给代码做个快速体检"
Write-ColorOutput "===> 检查阶段" "Cyan"
Write-Info "运行 npm run check..."
@ -758,7 +843,7 @@ function Main {
$fullImage = "${ImageName}:${Tag}"
if (-not $SkipBuild) {
Enter-Stage "构建阶段" "🧱 正在把代码揉成可部署的镜像"
Enter-Stage "构建阶段" "正在把代码揉成可部署的镜像"
Write-ColorOutput "===> 构建阶段" "Cyan"
$envState = Prepare-BuildEnvironment -ProjectRoot $projectRoot -EnvFileCandidate $EnvFile -Server $Server
try {
@ -807,15 +892,43 @@ function Main {
# 3. 推送镜像到 Docker Registry
if (-not $SkipUpload) {
Enter-Stage "镜像推送阶段" "📦 把镜像打包寄到私有仓库"
Enter-Stage "镜像推送阶段" "把镜像打包寄到私有仓库"
Write-ColorOutput "===> 推送镜像到 Docker Registry" "Cyan"
$registryUrl = "${Server}:5000"
# 推送到 youyidian/youyidian-3d-material 命名空间
$registryImage = "${registryUrl}/youyidian/${ImageName}:${Tag}"
Write-Info "Registry 地址: $registryUrl"
Write-Info "目标镜像: $registryImage"
# 根据 Registry 参数选择 Registry URL 和命名空间
if ($Registry -eq "harbor") {
$registryUrl = $HarborUrl
# Harbor 使用指定的项目名称,仓库名称为镜像名称
$registryImage = "${registryUrl}/${HarborProjectName}/${ImageName}:${Tag}"
Write-Info "使用 Harbor Registry: $registryUrl"
Write-Info "目标镜像: $registryImage"
# 登录 Harbor
Write-Info "登录 Harbor..."
try {
# 使用 PowerShell 的方式传递密码
$securePassword = ConvertTo-SecureString $HarborPassword -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($HarborUsername, $securePassword)
$passwordPlain = $credential.GetNetworkCredential().Password
$loginProcess = Start-Process -FilePath "docker" -ArgumentList @("login", $HarborUrl, "-u", $HarborUsername, "-p", $passwordPlain) -NoNewWindow -PassThru -Wait
if ($loginProcess.ExitCode -eq 0) {
Write-Success "Harbor 登录成功"
} else {
Write-Warning "Harbor 登录失败,尝试继续推送..."
}
} catch {
Write-Warning "Harbor 登录过程异常: $_,尝试继续推送..."
}
} else {
$registryUrl = "${Server}:5000"
# 推送到 youyidian/flnxi-material 命名空间
$registryImage = "${registryUrl}/youyidian/${ImageName}:${Tag}"
Write-Info "使用默认 Registry: $registryUrl"
Write-Info "目标镜像: $registryImage"
}
# 标记镜像
Write-Info "标记镜像..."
@ -857,9 +970,11 @@ function Main {
Write-Warning "镜像推送失败 (尝试 $pushAttempt/$pushMaxRetries),将重试..."
} else {
Write-ErrorColored "镜像推送失败,已重试 $pushMaxRetries 次"
Write-Info "提示: 请确保 Docker 已配置允许不安全的 Registry ($registryUrl)"
Write-Info " 在 Windows 上,编辑 Docker Desktop 设置 -> Docker Engine,添加:"
Write-Info " { `"insecure-registries`": [`"$registryUrl`"] }"
if ($Registry -ne "harbor") {
Write-Info "提示: 请确保 Docker 已配置允许不安全的 Registry ($registryUrl)"
Write-Info " 在 Windows 上,编辑 Docker Desktop 设置 -> Docker Engine,添加:"
Write-Info " { `"insecure-registries`": [`"$registryUrl`"] }"
}
exit 1
}
}
@ -875,7 +990,7 @@ function Main {
Celebrate-Stage "镜像推送阶段"
# 测试SSH连接(用于后续部署命令)
Enter-Stage "远程部署阶段" "🛰️ 正在连线服务器,准备热乎乎上线"
Enter-Stage "远程部署阶段" "正在连线服务器,准备热乎乎上线"
Write-Info "测试SSH连接..."
if ([string]::IsNullOrWhiteSpace($KeyPath)) {
$sshKeys = Find-SSHKeys
@ -889,10 +1004,18 @@ function Main {
Write-Warning "SSH连接测试失败,将跳过自动部署"
Write-Info "请手动在服务器上执行部署命令"
} else {
# 服务器端只需要拉取已构建好的镜像并部署,不需要基础镜像
# 因为镜像已经在本地构建完成,所有层(包括基础镜像层)都已包含在镜像中
# 在服务器上拉取镜像并部署(带重试机制)
Write-Info "在服务器上拉取镜像并部署..."
# 先停止并删除旧容器,然后拉取新镜像,重新启动(使用 --no-deps --pull never 避免拉取依赖服务和重复拉取)
$deployCmd = "cd $RemotePath && docker-compose stop app && docker-compose rm -f app && docker pull $registryImage && docker tag $registryImage $fullImage && docker-compose up -d --no-deps --pull never app"
# 如果使用 Harbor,需要在服务器上先登录
if ($Registry -eq "harbor") {
$harborLoginCmd = "docker login $HarborUrl -u $HarborUsername -p $HarborPassword"
$deployCmd = "cd $RemotePath && $harborLoginCmd && docker-compose stop app && docker-compose rm -f app && docker pull $registryImage && docker tag $registryImage $fullImage && docker-compose up -d --no-deps --pull never app"
} else {
# 先停止并删除旧容器,然后拉取新镜像,重新启动(使用 --no-deps --pull never 避免拉取依赖服务和重复拉取)
$deployCmd = "cd $RemotePath && docker-compose stop app && docker-compose rm -f app && docker pull $registryImage && docker tag $registryImage $fullImage && docker-compose up -d --no-deps --pull never app"
}
$sshArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=30", "-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3")
if ($KeyPath -and (Test-Path $KeyPath)) {
$sshArgs += @("-i", $KeyPath)
@ -967,16 +1090,19 @@ function Main {
Clean-RemoteDockerImages -Server $Server -Username $Username -KeyPath $KeyPath -ImageName $ImageName -KeepCount $KeepImageCount
}
# 清理 Registry 镜像
$registryUrl = "http://${Server}:5000"
Clean-RegistryImages -RegistryUrl $registryUrl -ImageName "youyidian/$ImageName" -KeepCount $KeepImageCount
# 清理 flnxi-sucai 镜像(完全删除)
Clean-RegistryImages -RegistryUrl $registryUrl -ImageName "youyidian/flnxi-sucai" -KeepCount 0
# 清理 Registry 镜像(仅适用于旧 Registry)
if ($Registry -eq "harbor") {
Write-Info "Harbor 镜像请在 Harbor 控制台按需清理,这里暂不自动操作。"
} else {
$registryUrl = "http://${Server}:5000"
Clean-RegistryImages -RegistryUrl $registryUrl -ImageName "youyidian/$ImageName" -KeepCount $KeepImageCount
Clean-RegistryImages -RegistryUrl $registryUrl -ImageName "youyidian/flnxi-sucai" -KeepCount 0
}
}
Write-ColorOutput "" "Magenta"
Write-ColorOutput "========================================" "Magenta"
Write-ColorOutput " 🎉 部署完成!" "Magenta"
Write-ColorOutput " 部署完成!" "Magenta"
Write-ColorOutput "========================================" "Magenta"
Write-Success "所有阶段执行成功"
Write-Info "镜像: $fullImage"
@ -985,34 +1111,45 @@ function Main {
# 显示访问信息
Write-ColorOutput "" "Cyan"
Write-ColorOutput "🌐 访问地址:" "Cyan"
Write-ColorOutput "访问地址:" "Cyan"
Write-ColorOutput " http://${Server}:4320" "Yellow"
Write-ColorOutput " http://${Server}:4320/admin" "Yellow"
if ($CleanImages) {
Write-Info "已启用镜像清理,保留了最新 $KeepImageCount 个版本"
} else {
Write-Info "💡 提示: 使用 -CleanImages 参数可以自动清理旧版本镜像"
Write-Info "提示: 使用 -CleanImages 参数可以自动清理旧版本镜像"
}
if (-not $SkipUpload) {
Write-Info "镜像已推送到 Registry: ${Server}:5000/youyidian/${ImageName}:${Tag}"
Write-Info "如果自动部署失败,请在服务器上手动执行:"
Write-Info " cd $RemotePath"
Write-Info " docker-compose stop app && docker-compose rm -f app"
Write-Info " docker pull ${Server}:5000/youyidian/${ImageName}:${Tag}"
Write-Info " docker tag ${Server}:5000/youyidian/${ImageName}:${Tag} $fullImage"
Write-Info " docker-compose up -d --no-deps --pull never app"
Write-Info " # 如需执行数据库迁移,请手动运行: docker exec youyidian-3d-material npx prisma migrate deploy"
if ($Registry -eq "harbor") {
Write-Info "镜像已推送到 Harbor Registry: $registryImage"
Write-Info "如果自动部署失败,请在服务器上手动执行:"
Write-Info " cd $RemotePath"
Write-Info " docker login $HarborUrl -u $HarborUsername -p $HarborPassword"
Write-Info " docker-compose stop app && docker-compose rm -f app"
Write-Info " docker pull $registryImage"
Write-Info " docker tag $registryImage $fullImage"
Write-Info " docker-compose up -d --no-deps --pull never app"
} else {
Write-Info "镜像已推送到 Registry: $registryImage"
Write-Info "如果自动部署失败,请在服务器上手动执行:"
Write-Info " cd $RemotePath"
Write-Info " docker-compose stop app && docker-compose rm -f app"
Write-Info " docker pull $registryImage"
Write-Info " docker tag $registryImage $fullImage"
Write-Info " docker-compose up -d --no-deps --pull never app"
}
Write-Info " # 如需执行数据库迁移,请手动运行: docker exec flnxi-material npx prisma migrate deploy"
}
# 成功后的趣味提示
$successTips = @(
"🎊 恭喜!由壹点3D素材已成功部署!",
"🌟 现在可以去喝杯咖啡,享受部署成功的喜悦~",
"🎯 部署完成度:100% | 心情指数:💯",
"✨ 代码已安全抵达生产环境,准备接受用户检阅!",
"🎉 部署成功!由壹点3D素材已整装待发!"
"恭喜!FLNXI素材库已成功部署!",
"现在可以去喝杯咖啡,享受部署成功的喜悦",
"部署完成度:100% | 心情指数:100",
"代码已安全抵达生产环境,准备接受用户检阅",
"部署成功!FLNXI素材库已整装待发"
)
$successTip = Get-Random -InputObject $successTips
Write-ColorOutput "" "Green"
@ -1020,4 +1157,5 @@ function Main {
}
# 执行主函数
Main
Main

View File

@ -9,7 +9,7 @@ param(
[string]$Username = "root",
[string]$Password = "",
[string]$KeyPath = "",
[string]$ImageName = "youyidian-3d-material",
[string]$ImageName = "flnxi-material",
[string]$Tag = "local-deploy",
[string]$LocalPath = "",
[string]$RemotePath = "/opt/deploy",
@ -413,4 +413,4 @@ if ($Help -or $args.Count -eq 0) {
}
# 执行主函数
Main
Main

62
deploy-simple.ps1 Normal file
View File

@ -0,0 +1,62 @@
# Simple deployment script for flnxi_sucai
param(
[string]$Server = "45.249.208.118",
[string]$Username = "root",
[string]$ImageName = "flnxi-material",
[string]$Tag = "local-deploy",
[string]$RemotePath = "/root/deploy",
[string]$KeyPath = ""
)
Write-Host "Starting deployment..."
Write-Host "Server: $Server"
Write-Host "Image: $ImageName`:$Tag"
# Build Docker image
Write-Host "Building Docker image..."
docker build -t $ImageName`:$Tag -f Dockerfile .
if ($LASTEXITCODE -ne 0) {
Write-Host "Docker build failed!" -ForegroundColor Red
exit 1
}
# Tag and push to registry
$registryUrl = "${Server}:5000"
$registryImage = "${registryUrl}/youyidian/${ImageName}:${Tag}"
Write-Host "Tagging image..."
docker tag $ImageName`:$Tag $registryImage
if ($LASTEXITCODE -ne 0) {
Write-Host "Image tagging failed!" -ForegroundColor Red
exit 1
}
Write-Host "Pushing image to registry..."
docker push $registryImage
if ($LASTEXITCODE -ne 0) {
Write-Host "Image push failed!" -ForegroundColor Red
exit 1
}
# Deploy to server
Write-Host "Deploying to server..."
$deployCmd = "cd $RemotePath && docker-compose stop app && docker-compose rm -f app && docker pull $registryImage && docker tag $registryImage $ImageName`:$Tag && docker-compose up -d --no-deps app"
if ([string]::IsNullOrWhiteSpace($KeyPath)) {
# Use default SSH connection
ssh ${Username}@${Server} $deployCmd
} else {
# Use SSH key authentication
Write-Host "Using SSH key: $KeyPath"
ssh -i $KeyPath ${Username}@${Server} $deployCmd
}
if ($LASTEXITCODE -ne 0) {
Write-Host "Deployment failed!" -ForegroundColor Red
exit 1
}
Write-Host "Deployment completed successfully!" -ForegroundColor Green
Write-Host "Access URL: http://$Server:4320" -ForegroundColor Green

View File

@ -1,7 +1,7 @@
version: '3.8'
# ============================================
# 由壹点3D素材 - Pro版配置
# FLNXI素材库 - Pro版配置
# 支持内网穿透,资源存储在MinIO存储桶
# 从私有镜像仓库拉取镜像,无需源码
# ============================================
@ -9,13 +9,14 @@ version: '3.8'
services:
# PostgreSQL 数据库
postgres:
image: postgres:15-alpine
container_name: youyidian-postgres
# 从 Harbor 的 runtime 子目录拉取(正式环境使用公开镜像)
image: ${POSTGRES_IMAGE:-harbor.flnxi.com/flnxi/runtime/postgres:15-alpine}
container_name: flnxi-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-youyidian123456}
POSTGRES_DB: ${POSTGRES_DB:-youyidian3d}
POSTGRES_DB: ${POSTGRES_DB:-flnxi}
POSTGRES_INITDB_ARGS: "--encoding=UTF-8"
volumes:
- postgres_data:/var/lib/postgresql/data
@ -30,8 +31,9 @@ services:
# MinIO 对象存储
# 使用特定版本号确保稳定性(符合项目宗旨:稳定性优先)
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
container_name: youyidian-minio
# 从 Harbor 的 flnxi 项目下的 runtime 子目录拉取
image: ${MINIO_IMAGE:-harbor.flnxi.com/flnxi/runtime/minio:RELEASE.2025-04-22T22-12-26Z}
container_name: flnxi-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
@ -53,8 +55,9 @@ services:
# MinIO 初始化服务(创建bucket)
# 使用 latest 标签确保可用性(mc 客户端版本兼容性好)
minio-init:
image: minio/mc:latest
container_name: youyidian-minio-init
# 从 Harbor 的 flnxi 项目下的 runtime 子目录拉取
image: ${MINIO_MC_IMAGE:-harbor.flnxi.com/flnxi/runtime/minio-mc:latest}
container_name: flnxi-minio-init
depends_on:
minio:
condition: service_healthy
@ -62,8 +65,8 @@ services:
/bin/sh -c "
sleep 5;
mc alias set myminio http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin123456};
mc mb myminio/${S3_BUCKET:-youyidian3d} --ignore-existing;
mc anonymous set download myminio/${S3_BUCKET:-youyidian3d};
mc mb myminio/${S3_BUCKET:-flnxi-materials} --ignore-existing;
mc anonymous set download myminio/${S3_BUCKET:-flnxi-materials};
echo 'MinIO bucket created and configured successfully';
exit 0;
"
@ -72,22 +75,25 @@ services:
# 主应用(Pro版 - 从私有镜像仓库拉取)
app:
image: ${DOCKER_REGISTRY:-your-registry.com}/youyidian-3d-material:pro-${IMAGE_TAG:-latest}
container_name: youyidian-3d-material
# 根据环境从不同的 Harbor 项目拉取:
# - 测试环境: harbor.flnxi.com/flnxi-test/flnxi-material:pro-test (私有,需登录)
# - 正式环境: harbor.flnxi.com/flnxi/flnxi-material:pro-latest (公开,无需登录)
image: ${APP_IMAGE:-${DOCKER_REGISTRY:-harbor.flnxi.com/flnxi}/flnxi-material:pro-${IMAGE_TAG:-latest}}
container_name: flnxi-material
restart: unless-stopped
ports:
- "${APP_PORT:-4320}:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-youyidian123456}@postgres:5432/${POSTGRES_DB:-youyidian3d}
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-youyidian123456}@postgres:5432/${POSTGRES_DB:-flnxi}
- DATABASE_DRIVER=node
- STORAGE_PROVIDER=s3
- S3_ENDPOINT=http://minio:9000
- S3_BUCKET=${S3_BUCKET:-youyidian3d}
- S3_BUCKET=${S3_BUCKET:-flnxi-materials}
- S3_ACCESS_KEY_ID=${MINIO_ROOT_USER:-minioadmin}
- S3_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123456}
- S3_REGION=us-east-1
- S3_PUBLIC_DOMAIN=http://${SERVER_IP:-45.249.208.118}:${MINIO_API_PORT:-9000}/${S3_BUCKET:-youyidian3d}
- S3_PUBLIC_DOMAIN=http://${SERVER_IP:-127.0.0.1}:${MINIO_API_PORT:-9000}/${S3_BUCKET:-flnxi-materials}
- S3_ENABLE_PATH_STYLE=1
- S3_SET_ACL=1
- S3_PREVIEW_URL_EXPIRE_IN=7200
@ -122,3 +128,5 @@ networks:
app-network:
driver: bridge

View File

@ -20,7 +20,7 @@ services:
# 启用清单删除
- REGISTRY_STORAGE_MAINTENANCE_DELETEENABLED=true
# CORS 配置,允许 Web UI 访问
- REGISTRY_HTTP_HEADERS_Access-Control-Allow-Origin=["http://45.249.208.118:5001"]
- REGISTRY_HTTP_HEADERS_Access-Control-Allow-Origin=["https://harbor.flnxi.com"]
- REGISTRY_HTTP_HEADERS_Access-Control-Allow-Methods=["HEAD","GET","OPTIONS","DELETE","PUT"]
- REGISTRY_HTTP_HEADERS_Access-Control-Allow-Headers=["*"]
- REGISTRY_HTTP_HEADERS_Access-Control-Expose-Headers=["Docker-Content-Digest"]
@ -54,7 +54,7 @@ services:
- "5001:80"
environment:
# Registry API 地址
- REGISTRY_TITLE=由壹点3D素材 - Registry
- REGISTRY_TITLE=FLNXI素材库 - Registry
- REGISTRY_URL=http://45.249.208.118:5000
- SINGLE_REGISTRY=true
- SHOW_CONTENT_DIGEST=true
@ -74,3 +74,4 @@ networks:
external: true
name: deploy_app-network

View File

@ -1,13 +1,11 @@
version: '3.8'
# ============================================
# 由壹点3D素材 - 标准版配置
# 内网使用,所有资源存储在服务器本地
# 从私有镜像仓库拉取镜像,无需源码
# FLNXI素材库 - 标准版配置(统一存储桶方案)
# 与 Pro 版一致:所有素材统一写入 MinIO/S3
# ============================================
services:
# PostgreSQL 数据库
postgres:
image: postgres:15-alpine
container_name: youyidian-postgres
@ -15,7 +13,7 @@ services:
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-youyidian123456}
POSTGRES_DB: ${POSTGRES_DB:-youyidian3d}
POSTGRES_DB: ${POSTGRES_DB:-flnxi}
POSTGRES_INITDB_ARGS: "--encoding=UTF-8"
volumes:
- postgres_data:/var/lib/postgresql/data
@ -27,21 +25,67 @@ services:
timeout: 5s
retries: 5
# 主应用(标准版 - 从私有镜像仓库拉取)
# 所有资源存储在服务器本地,不使用MinIO
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
container_name: youyidian-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin123456}
volumes:
- minio_data:/data
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
networks:
- app-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
minio-init:
image: minio/mc:latest
container_name: youyidian-minio-init
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
sleep 5;
mc alias set myminio http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin123456};
mc mb myminio/${S3_BUCKET:-youyidian3d} --ignore-existing;
mc anonymous set download myminio/${S3_BUCKET:-youyidian3d};
echo 'MinIO bucket ready';
exit 0;
"
networks:
- app-network
app:
image: ${DOCKER_REGISTRY:-your-registry.com}/youyidian-3d-material:standard-${IMAGE_TAG:-latest}
container_name: youyidian-3d-material
image: ${DOCKER_REGISTRY:-your-registry.com}/flnxi-material:standard-${IMAGE_TAG:-latest}
container_name: flnxi-material
restart: unless-stopped
ports:
- "${APP_PORT:-4320}:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-youyidian123456}@postgres:5432/${POSTGRES_DB:-youyidian3d}
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-youyidian123456}@postgres:5432/${POSTGRES_DB:-flnxi}
- DATABASE_DRIVER=node
- STORAGE_PROVIDER=local
# 标准版不使用S3,所有文件存储在本地
- UPLOAD_DIR=/app/uploads
- STORAGE_PROVIDER=s3
- S3_ENDPOINT=http://minio:9000
- S3_BUCKET=${S3_BUCKET:-youyidian3d}
- S3_ACCESS_KEY_ID=${MINIO_ROOT_USER:-minioadmin}
- S3_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123456}
- S3_REGION=us-east-1
- S3_PUBLIC_DOMAIN=http://${SERVER_IP:-localhost}:${MINIO_API_PORT:-9000}/${S3_BUCKET:-youyidian3d}
- S3_FILE_PATH=${S3_FILE_PATH:-materials/}
- S3_ENABLE_PATH_STYLE=1
- S3_SET_ACL=1
- S3_PREVIEW_URL_EXPIRE_IN=7200
- NEXT_PUBLIC_S3_FILE_PATH=${S3_FILE_PATH:-materials/}
- NEXTAUTH_URL=http://${SERVER_IP:-localhost}:${APP_PORT:-4320}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-change-this-to-a-random-string-in-production}
volumes:
@ -49,6 +93,10 @@ services:
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
minio-init:
condition: service_completed_successfully
networks:
- app-network
healthcheck:
@ -60,8 +108,12 @@ services:
volumes:
postgres_data:
driver: local
minio_data:
driver: local
networks:
app-network:
driver: bridge

View File

@ -1,5 +1,6 @@
services:
app:
image: ${DOCKER_IMAGE:-flnxi-material:local-deploy}
build:
context: .
dockerfile: Dockerfile
@ -7,7 +8,7 @@ services:
DATABASE_URL: ${DATABASE_URL}
NEXTAUTH_URL: ${NEXTAUTH_URL}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
container_name: youyidian-3d-material
container_name: flnxi-material
restart: unless-stopped
ports:
- "4320:3000"
@ -63,3 +64,4 @@ networks:
name: youyidian-network
driver: bridge

View File

@ -1,6 +1,8 @@
# Docker 基础镜像仓库
本目录用于存储项目所需的基础 Docker 镜像,避免每次部署时从网络下载。
本目录用于存储项目所需的基础 Docker 镜像,作为备用方案。
**注意**:如果你本地已经有基础镜像(如 `node:18-alpine`),部署脚本会直接使用,无需从 Registry 或本地文件加载。
## 目录结构

View File

@ -0,0 +1,90 @@
## 初次部署与“出厂设置”指南
本指南覆盖 NAS/内网环境从零安装的完整流程,亦可用于将现有测试环境**一键恢复到出厂状态**。建议严格按顺序执行,并在自述中记录执行时间,便于后续追溯。
### 1. 环境准备
- 服务器/NAS:4C8G 以上、100GB 可用空间,建议启用 SSD/NVMe。
- 系统:Ubuntu 22.04 LTS 或群晖 DSM(需支持 Docker / Podman)。
- 依赖:Docker 24+、Docker Compose V2、Node.js 20(仅本地调试需要)、pnpm 或 npm。
- 网络:可访问对象存储(MinIO/S3),放通 80/443/3000/5432/9000 等常用端口。
- 证书:准备好公网域名和 TLS 证书(可选,Nginx/Traefik 层处理)。
### 2. 拉取代码与依赖
```bash
git clone https://your.repo/flnxi_sucai.git
cd flnxi_sucai
cp .env.example .env
npm install # 或 pnpm install
```
- 将 `config-bianliang.env` 与 `.env` 一并填写,保证 `DATABASE_URL`、S3、域名、NEXTAUTH_SECRET 等关键变量存在。
- NAS 环境若使用外置 Postgres/MinIO,可在 `.env` 中直接指向局域网地址。
### 3. 数据库初始化 / 恢复出厂
> **⚠️ 会删除所有数据。** 确认已备份再执行。
```bash
npx prisma migrate reset --force --skip-seed
```
- 该命令会串行应用 `prisma/migrations` 下的所有迁移,包括最新的素材归属字段。
- 如果在容器内执行,确保 `DATABASE_URL` 已指向真实数据库。
### 4. 生成 Prisma Client
```bash
npx prisma generate
```
- Docker 镜像构建过程会自动执行;裸机调试时建议显式运行,避免 TS/构建期间的类型缺失。
### 5. 启动服务
```bash
# 开发 / 单机调试
npm run dev
# 生产部署(推荐)
docker compose -f docker-compose.prod.yml up -d --build
```
- Compose 会启动 Next.js、Nginx、Postgres、MinIO 等组件。若已有外部 Postgres/MinIO,可注释对应服务并调整 `.env`。
### 6. 系统初始化接口
部署启动后,调用一次 `POST /api/system/init` 即可创建默认资源:
```bash
curl -X POST https://your-domain.com/api/system/init
```
返回值示例:
```json
{
"success": true,
"message": "系统初始化成功",
"data": {
"admin": { "email": "admin", "username": "admin", "password": "88888888" },
"defaultDepartment": "默认部门",
"defaultCategory": "默认店铺",
"defaultScene": "默认场景"
}
}
```
- 首次登录后务必修改管理员密码,可在“个人中心 → 修改密码”完成。
### 7. 首次登录与检查
1. 访问 `https://your-domain.com/login`,使用 `admin / 88888888` 登录。
2. 进入后台 `/admin`,确认仪表盘可正常加载。
3. 打开“系统设置”,填写对象存储、LOGO、前端域名等信息。
4. 上传一份素材,验证转码、归属(`ownerType/ownerId`)、预览、下载链路是否正常。
### 8. 恢复为干净环境的快速指令
若需要再次“清空一切”并保留最新 schema,可直接执行:
```bash
npx prisma migrate reset --force --skip-seed
curl -X POST https://your-domain.com/api/system/init
```
可将上述两条命令收录到自定义脚本,例如 `scripts/reset-to-factory.ps1/.sh`,方便演示或客户验收前重置。
### 9. 常见问题
- **`prisma migrate reset` 卡在输入确认**:加上 `--force --skip-generate`/`--skip-seed` 使其无交互运行。
- **`postgres connection refused`**:确认容器/服务已启动,或在 `.env` 中关闭 TLS(`?sslmode=disable`)。
- **MinIO/S3 访问失败**:在 System Settings 中补齐 `s3Endpoint/s3Bucket/s3PublicUrl`,并确保对象存储已创建对应桶。
- **视频转码异常**:检查服务器是否已安装 `ffmpeg`,或在 Dockerfile 中启用 `apt install ffmpeg`。日志可在容器内 `/root/.pm2/logs` 或 `docker logs youyidian-3d-material` 查看。
### 10. 下一步
- 按照 `docs/USER_ONBOARDING_GUIDE.md` 给团队成员做首登引导。
- 开启钉钉登录、AI 改图、内网穿透等可选模块前,建议先在测试环境演练一遍整套流程。

View File

@ -0,0 +1,57 @@
## 新手用户上手指引
这份指引面向**首次接触 FLNXI 素材库**的普通成员,帮助他们在 10 分钟内完成账号激活、素材上传、权限确认与移交。管理员可在培训/迎新时直接引用。
### 1. 获取账号并首次登录
1. 打开管理员发送的登录地址(示例:`https://materials.example.com/login`)。
2. 输入分配的账号密码或使用钉钉扫码(若已开启)。
3. 首次登录后,系统会提示**修改默认密码**,请设置一个长度 ≥ 8 位、包含数字和字母的新密码。
4. 进入“个人资料”页补齐真实姓名、部门、联系方式,确保后续素材归属准确展示。
### 2. 了解素材归属 / 所有权
- 每份素材都包含两个身份信息:
- `createdByUserId`:最初上传者(用来追溯责任人)。
- `ownerType + ownerId`:当前归属(个人/团队)。离职移交后只会更新归属,不会丢失上传者信息。
- 在“我的素材”列表中可看到自己上传并归属到个人的作品;若素材被移交给团队,该列表会同步更新。
### 3. 上传与预览流程(以视频为例)
1. 点击右上角“上传素材”,选择匹配的模板。
2. 填写基础信息:品牌、系列、SKU、材质、场景等。
3. 上传源文件(压缩包/视频)。上传结束后系统会:
- 调用 FFmpeg 生成 540P 预览和 10 秒短片;
- 自动截图生成封面,保持与预览视频一致的比例;
- 删除本地临时源文件,避免占用 NAS 空间。
4. 在首页卡片或详情弹窗中即可查看转码进度:
- `pending`:等待转码(展示“预览生成中…”占位图);
- `processing`:FFmpeg 运行中;
- `done`:可播放/下载;
- `failed`:可点击“重新转码”或联系管理员。
### 4. 分享与互动
- **收藏/点赞**:在卡片上直接操作,数据会实时同步到个人中心的“我的收藏”。
- **下载**:根据权限决定是否显示按钮;下载记录会写入 `material_download_logs`,便于审计。
- **分享链接**:点击卡片右上角“分享”生成短链,可面向外部预览但不允许下载原文件。
### 5. 所有权移交 / 离职交接
- 若素材需要交给其他成员或团队空间,请联系管理员在后台执行“素材移交”:
1. 管理员选择目标素材 → 点击“移交所有权”;
2. 选择目标类型:`USER`(个人)或 `TEAM`(部门/项目组);
3. 保存后系统会记录 `lastTransferredAt`,并在详情页展示最新归属。
- 被移交的素材依旧保留原作者信息,可在日志中查看完整操作历史。
### 6. 常见问题
| 场景 | 解决方案 |
| --- | --- |
| 登录后提示“账号未激活” | 联系管理员在用户管理中启用 `isActive` |
| 上传后长时间显示“等待转码” | 处理队列可能被占满,刷新或通知管理员检查 FFmpeg/队列服务 |
| 详情弹窗显示黑边或白屏 | 按 F5 强刷;若仍异常,请记下素材 ID 反馈给研发 |
| 我的素材为空 | 确认是否刚执行过所有权移交或系统刚恢复出厂,可重新上传或联系管理员核对归属 |
### 7. 建议的“新人成长任务”
1. 上传一份个人作品并补齐所有元数据。
2. 将素材分享到公共链接,邀请同事点赞/收藏。
3. 体验一次素材移交(可与同组同事互换),理解离职交接流程。
4. 在个人中心下载自己的素材压缩包,验证解压内容正确。
完成以上步骤后,即可熟练掌握素材库的核心操作。若需要更深入的后台/模板管理权限,请参考管理员文档或联系系统负责人开通角色。

View File

@ -4,7 +4,7 @@
**部署时间:** 2025-11-11 10:26
**服务器:** 45.249.208.118:4320
**容器名称:** youyidian-3d-material
**容器名称:** flnxi-material
**容器状态:** ✅ 正在运行
**应用状态:** ✅ Next.js 已就绪
@ -78,7 +78,7 @@ http://45.249.208.118:4320
### 容器信息
```
容器名称: youyidian-3d-material
容器名称: flnxi-material
镜像: youyidian-3d-app:latest
端口映射: 0.0.0.0:4320->3000/tcp
状态: Up (运行中)
@ -107,3 +107,4 @@ Next.js 14.2.33
…(内容同原文件,保持不变)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -292,7 +292,7 @@ NAS_PASSWORD="jsjj8888"
NAS_PROJECT_PATH="/mnt/tank/apps/youyidian-3d"
echo "========================================="
echo "部署 由壹点3D素材 到 TrueNAS 服务器"
echo "部署 FLNXI素材库 到 TrueNAS 服务器"
echo "========================================="
# 检查是否已经打包
@ -478,7 +478,7 @@ NAS_PASSWORD="jsjj8888"
NAS_PROJECT_PATH="/mnt/tank/apps/youyidian-3d"
echo "========================================="
echo "部署 由壹点3D素材 到 TrueNAS 服务器"
echo "部署 FLNXI素材库 到 TrueNAS 服务器"
echo "========================================="
# 检查是否已经打包
@ -1506,7 +1506,7 @@ enum Role {
**服务器信息:**
- 📍 主机:192.168.58.20
- 🐳 容器名:youyidian-3d-material
- 🐳 容器名:flnxi-material
- 📦 项目路径:/mnt/tank/apps/youyidian-3d
- 💾 上传文件存储:/mnt/tank/apps/youyidian-3d/uploads
@ -1831,7 +1831,7 @@ token = "LiuYunKu_NAS_2024_SecureToken"
**3D素材项目容器:**
```bash
docker ps | grep youyidian-3d-material
docker ps | grep flnxi-material
# 端口:0.0.0.0:4320->3000/tcp
```
@ -3759,13 +3759,13 @@ export default function Header({ session }: HeaderProps) {
<Link href="/" className="flex items-center space-x-3">
<Image
src="http://s3.1080p.hk/shuzhou/2025/05/18/f6f20d120a4e45aeaae3411fe0000b6a.png"
alt="由壹点3D素材"
alt="FLNXI素材库"
width={40}
height={40}
className="rounded-lg"
/>
<span className="text-2xl font-semibold text-white">
由壹点3D素材
FLNXI素材库
</span>
</Link>
@ -3904,13 +3904,13 @@ export default function Header({ session }: HeaderProps) {
<Link href="/" className="flex items-center space-x-3">
<Image
src="http://s3.1080p.hk/shuzhou/2025/05/18/f6f20d120a4e45aeaae3411fe0000b6a.png"
alt="由壹点3D素材"
alt="FLNXI素材库"
width={40}
height={40}
className="rounded-lg"
/>
<span className="text-2xl font-semibold text-white">
由壹点3D素材
FLNXI素材库
</span>
</Link>
@ -4051,13 +4051,13 @@ export default function Header({ session }: HeaderProps) {
<Link href="/" className="flex items-center space-x-3">
<Image
src="http://s3.1080p.hk/shuzhou/2025/05/18/f6f20d120a4e45aeaae3411fe0000b6a.png"
alt="由壹点3D素材"
alt="FLNXI素材库"
width={40}
height={40}
className="rounded-lg"
/>
<span className="text-2xl font-semibold text-white">
由壹点3D素材
FLNXI素材库
</span>
</Link>
@ -4546,7 +4546,7 @@ docker compose logs --tail=50 app
---
**感谢使用由壹点3D素材管理系统!** 🎉
**感谢使用FLNXI素材库管理系统!** 🎉
```
@ -4567,7 +4567,7 @@ NAS_PASSWORD="jsjj8888"
NAS_PROJECT_DIR="/mnt/tank/apps/youyidian-3d"
echo "========================================="
echo " 由壹点3D素材系统 - 功能更新部署"
echo " FLNXI素材库系统 - 功能更新部署"
echo " 版本:v2.0.0"
echo " 更新日期:2025-10-16"
echo "========================================="
@ -6170,7 +6170,7 @@ docker compose logs --tail=50 app
---
**感谢使用由壹点3D素材管理系统!** 🎉
**感谢使用FLNXI素材库管理系统!** 🎉
```
@ -6198,7 +6198,7 @@ NAS_PASSWORD="jsjj8888"
NAS_PROJECT_DIR="/mnt/tank/apps/youyidian-3d"
echo "========================================="
echo " 由壹点3D素材系统 - 功能更新部署"
echo " FLNXI素材库系统 - 功能更新部署"
echo " 版本:v2.0.0"
echo " 更新日期:2025-10-16"
echo "========================================="
@ -10276,7 +10276,7 @@ NAS_PASSWORD="jsjj8888"
NAS_PROJECT_DIR="/mnt/tank/apps/youyidian-3d"
echo "========================================="
echo " 由壹点3D素材系统 - 功能更新部署"
echo " FLNXI素材库系统 - 功能更新部署"
echo " 版本:v2.0.0"
echo " 更新日期:2025-10-16"
echo "========================================="
@ -10417,7 +10417,7 @@ NAS_PASSWORD="jsjj8888"
NAS_PROJECT_DIR="/mnt/tank/apps/youyidian-3d"
echo "========================================="
echo " 由壹点3D素材系统 - 功能更新部署"
echo " FLNXI素材库系统 - 功能更新部署"
echo " 版本:v2.0.0"
echo " 更新日期:2025-10-16"
echo "========================================="
@ -21448,13 +21448,13 @@ export default function Header({ session }: HeaderProps) {
<Link href="/" className="flex items-center space-x-3">
<Image
src="http://s3.1080p.hk/shuzhou/2025/05/18/f6f20d120a4e45aeaae3411fe0000b6a.png"
alt="由壹点3D素材"
alt="FLNXI素材库"
width={40}
height={40}
className="rounded-lg"
/>
<span className="text-2xl font-semibold text-white">
由壹点3D素材
FLNXI素材库
</span>
</Link>
@ -21612,13 +21612,13 @@ export default function Header({ session }: HeaderProps) {
<Link href="/" className="flex items-center space-x-3">
<Image
src="http://s3.1080p.hk/shuzhou/2025/05/18/f6f20d120a4e45aeaae3411fe0000b6a.png"
alt="由壹点3D素材"
alt="FLNXI素材库"
width={40}
height={40}
className="rounded-lg"
/>
<span className="text-2xl font-semibold text-white">
由壹点3D素材
FLNXI素材库
</span>
</Link>
@ -21776,13 +21776,13 @@ export default function Header({ session }: HeaderProps) {
<Link href="/" className="flex items-center space-x-3">
<Image
src="http://s3.1080p.hk/shuzhou/2025/05/18/f6f20d120a4e45aeaae3411fe0000b6a.png"
alt="由壹点3D素材"
alt="FLNXI素材库"
width={40}
height={40}
className="rounded-lg"
/>
<span className="text-2xl font-semibold text-white">
由壹点3D素材
FLNXI素材库
</span>
</Link>
@ -23898,3 +23898,5 @@ export default function Home() {
检查进度日志

View File

@ -121,7 +121,7 @@ model SystemSettings {
headerBgColor String @default("#1f2937") // 顶部导航栏背景色
headerLogoUrl String @default("http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png") // 导航栏LOGO URL(深色背景用)
loginLogoUrl String @default("http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png") // 登录页面LOGO URL(浅色背景用)
siteName String @default("由壹点3D素材") // 网站名称
siteName String @default("FLNXI素材库") // 网站名称
// S3存储桶配置
s3Endpoint String @default("") // S3端点
@ -174,7 +174,7 @@ export async function GET() {
headerBgColor: '#1f2937',
headerLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',
loginLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',
siteName: '由壹点3D素材'
siteName: 'FLNXI素材库'
}
})
}
@ -225,7 +225,7 @@ export async function PUT(request: NextRequest) {
headerBgColor: headerBgColor || '#1f2937',
headerLogoUrl: headerLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',
loginLogoUrl: loginLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',
siteName: siteName || '由壹点3D素材'
siteName: siteName || 'FLNXI素材库'
}
})
} else {
@ -273,7 +273,7 @@ export async function GET() {
headerBgColor: '#1f2937',
headerLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',
loginLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',
siteName: '由壹点3D素材'
siteName: 'FLNXI素材库'
}
})
}
@ -324,7 +324,7 @@ export async function PUT(request: NextRequest) {
headerBgColor: headerBgColor || '#1f2937',
headerLogoUrl: headerLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',
loginLogoUrl: loginLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',
siteName: siteName || '由壹点3D素材'
siteName: siteName || 'FLNXI素材库'
}
})
} else {
@ -460,7 +460,7 @@ model SystemSettings {
headerBgColor String @default("#1f2937") // 顶部导航栏背景色
headerLogoUrl String @default("http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png") // 导航栏LOGO URL(深色背景用)
loginLogoUrl String @default("http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png") // 登录页面LOGO URL(浅色背景用)
siteName String @default("由壹点3D素材") // 网站名称
siteName String @default("FLNXI素材库") // 网站名称
// S3存储桶配置
s3Endpoint String @default("") // S3端点
@ -2859,7 +2859,7 @@ export default function SystemSettings() {
// 设置状态
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
const [siteName, setSiteName] = useState('由壹点3D素材')
const [siteName, setSiteName] = useState('FLNXI素材库')
const [headerLogoUrl, setHeaderLogoUrl] = useState('')
const [headerLogoPreview, setHeaderLogoPreview] = useState('')
const [loginLogoUrl, setLoginLogoUrl] = useState('')
@ -2897,7 +2897,7 @@ export default function SystemSettings() {
if (response.ok) {
const data = await response.json()
setHeaderBgColor(data.headerBgColor || '#1f2937')
setSiteName(data.siteName || '由壹点3D素材')
setSiteName(data.siteName || 'FLNXI素材库')
setHeaderLogoUrl(data.headerLogoUrl || '')
setHeaderLogoPreview(data.headerLogoUrl || '')
setLoginLogoUrl(data.loginLogoUrl || '')
@ -3083,7 +3083,7 @@ export default function SystemSettings() {
type="text"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="由壹点3D素材"
placeholder="FLNXI素材库"
className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
/>
<p className="text-sm text-gray-500 dark:text-gray-400">
@ -3419,7 +3419,7 @@ export default function SystemSettings() {
// 设置状态
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
const [siteName, setSiteName] = useState('由壹点3D素材')
const [siteName, setSiteName] = useState('FLNXI素材库')
const [headerLogoUrl, setHeaderLogoUrl] = useState('')
const [headerLogoPreview, setHeaderLogoPreview] = useState('')
const [loginLogoUrl, setLoginLogoUrl] = useState('')
@ -3457,7 +3457,7 @@ export default function SystemSettings() {
if (response.ok) {
const data = await response.json()
setHeaderBgColor(data.headerBgColor || '#1f2937')
setSiteName(data.siteName || '由壹点3D素材')
setSiteName(data.siteName || 'FLNXI素材库')
setHeaderLogoUrl(data.headerLogoUrl || '')
setHeaderLogoPreview(data.headerLogoUrl || '')
setLoginLogoUrl(data.loginLogoUrl || '')
@ -3649,7 +3649,7 @@ export default function SystemSettings() {
type="text"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="由壹点3D素材"
placeholder="FLNXI素材库"
className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
/>
<p className="text-sm text-gray-500 dark:text-gray-400">
@ -3985,7 +3985,7 @@ export default function SystemSettings() {
// 设置状态
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
const [siteName, setSiteName] = useState('由壹点3D素材')
const [siteName, setSiteName] = useState('FLNXI素材库')
const [headerLogoUrl, setHeaderLogoUrl] = useState('')
const [headerLogoPreview, setHeaderLogoPreview] = useState('')
const [loginLogoUrl, setLoginLogoUrl] = useState('')
@ -4023,7 +4023,7 @@ export default function SystemSettings() {
if (response.ok) {
const data = await response.json()
setHeaderBgColor(data.headerBgColor || '#1f2937')
setSiteName(data.siteName || '由壹点3D素材')
setSiteName(data.siteName || 'FLNXI素材库')
setHeaderLogoUrl(data.headerLogoUrl || '')
setHeaderLogoPreview(data.headerLogoUrl || '')
setLoginLogoUrl(data.loginLogoUrl || '')
@ -4223,7 +4223,7 @@ export default function SystemSettings() {
type="text"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="由壹点3D素材"
placeholder="FLNXI素材库"
className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
/>
<p className="text-sm text-gray-500 dark:text-gray-400">
@ -4559,7 +4559,7 @@ export default function SystemSettings() {
// 设置状态
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
const [siteName, setSiteName] = useState('由壹点3D素材')
const [siteName, setSiteName] = useState('FLNXI素材库')
const [headerLogoUrl, setHeaderLogoUrl] = useState('')
const [headerLogoPreview, setHeaderLogoPreview] = useState('')
const [loginLogoUrl, setLoginLogoUrl] = useState('')
@ -4597,7 +4597,7 @@ export default function SystemSettings() {
if (response.ok) {
const data = await response.json()
setHeaderBgColor(data.headerBgColor || '#1f2937')
setSiteName(data.siteName || '由壹点3D素材')
setSiteName(data.siteName || 'FLNXI素材库')
setHeaderLogoUrl(data.headerLogoUrl || '')
setHeaderLogoPreview(data.headerLogoUrl || '')
setLoginLogoUrl(data.loginLogoUrl || '')
@ -4797,7 +4797,7 @@ export default function SystemSettings() {
type="text"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="由壹点3D素材"
placeholder="FLNXI素材库"
className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
/>
<p className="text-sm text-gray-500 dark:text-gray-400">
@ -49120,7 +49120,7 @@ export default class Service {
让我检查一下文件被部署到了哪里:
我看到容器`youyidian-3d-material`正在运行。让我手动更新并重启容器:
我看到容器`flnxi-material`正在运行。让我手动更新并重启容器:
让我检查一下项目目录在哪里:
@ -54153,8 +54153,8 @@ window.console.error @ 117-e61c6f8281882622.js:1
**Cursor**
```plaintext
Container youyidian-3d-material Starting
Container youyidian-3d-material Started
Container flnxi-material Starting
Container flnxi-material Started
```
是的,容器已经启动成功了!让我检查一下运行状态:
@ -54173,7 +54173,7 @@ services:
build:
context: .
dockerfile: Dockerfile
container_name: youyidian-3d-material
container_name: flnxi-material
restart: unless-stopped
ports:
- "4320:3000"
@ -54335,7 +54335,7 @@ services:
build:
context: .
dockerfile: Dockerfile
container_name: youyidian-3d-material
container_name: flnxi-material
restart: unless-stopped
ports:
- "4320:3000"
@ -54444,7 +54444,7 @@ services:
build:
context: .
dockerfile: Dockerfile
container_name: youyidian-3d-material
container_name: flnxi-material
restart: unless-stopped
ports:
- "4320:3000"
@ -61552,7 +61552,7 @@ export default function SystemSettings() {
// 设置状态
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
const [siteName, setSiteName] = useState('由壹点3D素材')
const [siteName, setSiteName] = useState('FLNXI素材库')
const [headerLogoUrl, setHeaderLogoUrl] = useState('')
const [headerLogoPreview, setHeaderLogoPreview] = useState('')
const [loginLogoUrl, setLoginLogoUrl] = useState('')
@ -61590,7 +61590,7 @@ export default function SystemSettings() {
if (response.ok) {
const data = await response.json()
setHeaderBgColor(data.headerBgColor || '#1f2937')
setSiteName(data.siteName || '由壹点3D素材')
setSiteName(data.siteName || 'FLNXI素材库')
setHeaderLogoUrl(data.headerLogoUrl || '')
setHeaderLogoPreview(data.headerLogoUrl || '')
setLoginLogoUrl(data.loginLogoUrl || '')
@ -61790,7 +61790,7 @@ export default function SystemSettings() {
type="text"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="由壹点3D素材"
placeholder="FLNXI素材库"
className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
/>
<p className="text-sm text-gray-500 dark:text-gray-400">
@ -62246,7 +62246,7 @@ export default function SystemSettings() {
// 设置状态
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
const [siteName, setSiteName] = useState('由壹点3D素材')
const [siteName, setSiteName] = useState('FLNXI素材库')
const [headerLogoUrl, setHeaderLogoUrl] = useState('')
const [headerLogoPreview, setHeaderLogoPreview] = useState('')
const [loginLogoUrl, setLoginLogoUrl] = useState('')
@ -62284,7 +62284,7 @@ export default function SystemSettings() {
if (response.ok) {
const data = await response.json()
setHeaderBgColor(data.headerBgColor || '#1f2937')
setSiteName(data.siteName || '由壹点3D素材')
setSiteName(data.siteName || 'FLNXI素材库')
setHeaderLogoUrl(data.headerLogoUrl || '')
setHeaderLogoPreview(data.headerLogoUrl || '')
setLoginLogoUrl(data.loginLogoUrl || '')
@ -62484,7 +62484,7 @@ export default function SystemSettings() {
type="text"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="由壹点3D素材"
placeholder="FLNXI素材库"
className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
/>
<p className="text-sm text-gray-500 dark:text-gray-400">
@ -81257,3 +81257,5 @@ Type error: Cannot find module 'dotenv' or its corresponding type declarations.
检查nas服务器这个项目的终端日志报错,有很多用户反馈上传失败,还有上次网速特别慢的问题,检查部署日志,具体之前的部署更新记录查看聊天记录10.md

View File

@ -1144,7 +1144,7 @@ failed to solve: node:18-alpine: failed to resolve source metadata for docker.io
## 📊 部署状态
### ✅ 容器运行状态
- **容器名称**: youyidian-3d-material
- **容器名称**: flnxi-material
- **状态**: 正在运行 (Up 7 seconds)
- **端口映射**: 0.0.0.0:4320 → 3000
@ -1350,7 +1350,7 @@ NAS_PASSWORD="jsjj8888"
NAS_PROJECT_DIR="/mnt/tank/apps/youyidian-3d"
echo "========================================="
echo " 由壹点3D素材系统 - 功能更新部署"
echo " FLNXI素材库系统 - 功能更新部署"
echo " 版本:v2.0.0"
echo " 更新日期:2025-10-16"
echo "========================================="
@ -2741,7 +2741,7 @@ NAS_PASSWORD="jsjj8888"
NAS_PROJECT_DIR="/mnt/tank/apps/youyidian-3d"
echo "========================================="
echo " 由壹点3D素材系统 - 功能更新部署"
echo " FLNXI素材库系统 - 功能更新部署"
echo " 版本:v2.0.0"
echo " 更新日期:2025-10-16"
echo "========================================="
@ -2899,7 +2899,7 @@ NAS_PASSWORD="jsjj8888"
NAS_PROJECT_DIR="/mnt/tank/apps/youyidian-3d"
echo "========================================="
echo " 由壹点3D素材系统 - 功能更新部署"
echo " FLNXI素材库系统 - 功能更新部署"
echo " 版本:v2.0.0"
echo " 更新日期:2025-10-16"
echo "========================================="
@ -3053,7 +3053,7 @@ NAS_PASSWORD="jsjj8888"
NAS_PROJECT_DIR="/mnt/tank/apps/youyidian-3d"
echo "========================================="
echo " 由壹点3D素材系统 - 功能更新部署"
echo " FLNXI素材库系统 - 功能更新部署"
echo " 版本:v2.0.0"
echo " 更新日期:2025-10-16"
echo "========================================="
@ -15770,7 +15770,7 @@ NAS_PASSWORD="jsjj8888"
NAS_PROJECT_DIR="/mnt/tank/apps/youyidian-3d"
echo "========================================="
echo " 由壹点3D素材系统 - 功能更新部署"
echo " FLNXI素材库系统 - 功能更新部署"
echo " 版本:v2.0.0"
echo " 更新日期:2025-10-16"
echo "========================================="
@ -16003,13 +16003,13 @@ export default function Header({ session }: HeaderProps) {
<Link href="/" className="flex items-center space-x-3">
<Image
src="http://s3.1080p.hk/shuzhou/2025/05/18/f6f20d120a4e45aeaae3411fe0000b6a.png"
alt="由壹点3D素材"
alt="FLNXI素材库"
width={40}
height={40}
className="rounded-lg"
/>
<span className="text-2xl font-semibold text-white">
由壹点3D素材
FLNXI素材库
</span>
</Link>
@ -19059,7 +19059,7 @@ NAS_PASSWORD="jsjj8888"
NAS_PROJECT_DIR="/mnt/tank/apps/youyidian-3d"
echo "========================================="
echo " 由壹点3D素材系统 - 功能更新部署"
echo " FLNXI素材库系统 - 功能更新部署"
echo " 版本:v2.0.0"
echo " 更新日期:2025-10-16"
echo "========================================="
@ -25740,13 +25740,13 @@ export default function Header({ session }: HeaderProps) {
<Link href="/" className="flex items-center space-x-3">
<Image
src="http://s3.1080p.hk/shuzhou/2025/05/18/f6f20d120a4e45aeaae3411fe0000b6a.png"
alt="由壹点3D素材"
alt="FLNXI素材库"
width={40}
height={40}
className="rounded-lg"
/>
<span className="text-2xl font-semibold text-white">
由壹点3D素材
FLNXI素材库
</span>
</Link>
@ -25980,13 +25980,13 @@ export default function Header({ session }: HeaderProps) {
<Link href="/" className="flex items-center space-x-3">
<Image
src="http://s3.1080p.hk/shuzhou/2025/05/18/f6f20d120a4e45aeaae3411fe0000b6a.png"
alt="由壹点3D素材"
alt="FLNXI素材库"
width={40}
height={40}
className="rounded-lg"
/>
<span className="text-2xl font-semibold text-white">
由壹点3D素材
FLNXI素材库
</span>
</Link>
@ -26220,13 +26220,13 @@ export default function Header({ session }: HeaderProps) {
<Link href="/" className="flex items-center space-x-3">
<Image
src="http://s3.1080p.hk/shuzhou/2025/05/18/f6f20d120a4e45aeaae3411fe0000b6a.png"
alt="由壹点3D素材"
alt="FLNXI素材库"
width={40}
height={40}
className="rounded-lg"
/>
<span className="text-2xl font-semibold text-white">
由壹点3D素材
FLNXI素材库
</span>
</Link>
@ -30047,7 +30047,7 @@ export default function Home() {
1. 等待SSH连接成功(输入密码后)
2. 打开浏览器访问:`http://localhost:8888`
3. 您就能看到内网的由壹点3D素材系统了
3. 您就能看到内网的FLNXI素材库系统了
4. 可以测试上传、下载功能
**说明:**
@ -30235,7 +30235,7 @@ FRP服务端已重启成功!现在检查防火墙是否开放2322端口:
```shellscript
#!/bin/bash
# 由壹点3D素材系统 - NAS服务器快速连接脚本
# FLNXI素材库系统 - NAS服务器快速连接脚本
# 通过FRP穿透直接连接到内网NAS服务器
echo "🚀 正在连接到NAS服务器..."
@ -30751,7 +30751,7 @@ NAS_PASSWORD="jsjj8888"
NAS_PROJECT_DIR="/mnt/tank/apps/youyidian-3d"
echo "========================================="
echo " 由壹点3D素材系统 - 功能更新部署"
echo " FLNXI素材库系统 - 功能更新部署"
echo " 版本:v2.0.0"
echo " 更新日期:2025-10-16"
echo "========================================="
@ -30914,7 +30914,7 @@ NAS_PASSWORD="jsjj8888"
NAS_PROJECT_DIR="/mnt/tank/apps/youyidian-3d"
echo "========================================="
echo " 由壹点3D素材系统 - 功能更新部署"
echo " FLNXI素材库系统 - 功能更新部署"
echo " 版本:v2.0.0"
echo " 更新日期:2025-10-16"
echo "========================================="
@ -53446,7 +53446,7 @@ NAS_PASSWORD="jsjj8888"
NAS_PROJECT_DIR="/mnt/tank/apps/youyidian-3d"
echo "========================================="
echo " 由壹点3D素材系统 - 功能更新部署"
echo " FLNXI素材库系统 - 功能更新部署"
echo " 版本:v2.0.0"
echo " 更新日期:2025-10-16"
echo "========================================="
@ -53634,7 +53634,7 @@ NAS_PASSWORD="jsjj8888"
NAS_PROJECT_DIR="/mnt/tank/apps/youyidian-3d"
echo "========================================="
echo " 由壹点3D素材系统 - 功能更新部署"
echo " FLNXI素材库系统 - 功能更新部署"
echo " 版本:v2.0.0"
echo " 更新日期:2025-10-16"
echo "========================================="
@ -53937,3 +53937,5 @@ bash update-deploy.sh
检查构建日志是否有报错

Some files were not shown because too many files have changed in this diff Show More