You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

549 lines
13 KiB

<script setup lang="ts">
import type { CategoryNode } from './CategoryTreeNode.vue'
export type CategoryFormMode = 'create' | 'rename' | 'cover' | 'delete' | 'move'
const props = defineProps<{
mode: CategoryFormMode
node: CategoryNode | null // target node (null for top-level create)
parentNode: CategoryNode | null // parent for create-child
categories: CategoryNode[] // full tree for move-picker
visible: boolean
}>()
const emit = defineEmits<{
'update:visible': [v: boolean]
submit: [data: { mode: CategoryFormMode; nodeId: string | null; name?: string; slug?: string; image?: string; targetParentId?: string | null }]
}>()
// ── Form state ──
const name = ref('')
const image = ref('')
const slugManual = ref('')
const slugTouched = ref(false)
const saving = ref(false)
const error = ref('')
const moveTargetId = ref<string | null>(null)
// ── Computed ──
const title = computed(() => {
switch (props.mode) {
case 'create': return props.parentNode ? `在「${props.parentNode.name}」下新建` : '新建分类'
case 'rename': return '重命名分类'
case 'cover': return '更换封面'
case 'delete': return '删除分类'
case 'move': return '移动到…'
default: return ''
}
})
const autoSlug = computed(() => {
return name.value
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.slice(0, 100)
})
// Sync auto-slug to manual field if user hasn't touched it
watch(autoSlug, (val) => {
if (!slugTouched.value && val) {
slugManual.value = val
}
})
function onSlugInput() {
slugTouched.value = true
}
function fallbackSlug(): string {
return `cat-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
}
const effectiveSlug = computed(() => {
return slugManual.value.trim() || autoSlug.value || fallbackSlug()
})
// Flatten tree for move picker
interface FlatOption { id: string; name: string; depth: number }
const flatOptions = computed<FlatOption[]>(() => {
const result: FlatOption[] = [{ id: '', name: '(根目录)', depth: 0 }]
function walk(nodes: CategoryNode[], depth: number) {
for (const n of nodes) {
if (n.id === '__uncategorized__') continue
if (n.id === props.node?.id) continue // can't move to self
result.push({ id: n.id, name: n.name, depth })
if (n.children) walk(n.children, depth + 1)
}
}
walk(props.categories, 1)
return result
})
// ── Init ──
watch(() => props.visible, (v) => {
if (!v) return
error.value = ''
saving.value = false
moveTargetId.value = null
slugManual.value = ''
slugTouched.value = false
switch (props.mode) {
case 'create':
name.value = ''
image.value = ''
break
case 'rename':
name.value = props.node?.name ?? ''
image.value = ''
break
case 'cover':
name.value = ''
image.value = props.node?.image ?? ''
break
case 'delete':
name.value = ''
image.value = ''
break
case 'move':
name.value = ''
image.value = ''
moveTargetId.value = props.node?.id ? findParentId(props.categories, props.node.id) : null
break
}
})
function findParentId(nodes: CategoryNode[], childId: string): string | null {
for (const n of nodes) {
if (n.children?.some(c => c.id === childId)) return n.id
if (n.children) {
const r = findParentId(n.children, childId)
if (r) return r
}
}
return null
}
// ── Submit ──
async function onSubmit() {
error.value = ''
switch (props.mode) {
case 'create': {
const n = name.value.trim()
if (!n) { error.value = '请输入分类名称'; return }
break
}
case 'rename': {
const n = name.value.trim()
if (!n) { error.value = '请输入分类名称'; return }
break
}
case 'cover': {
if (!image.value.trim()) { error.value = '请输入封面图片 URL'; return }
break
}
case 'delete':
break
case 'move':
break
}
saving.value = true
try {
emit('submit', {
mode: props.mode,
nodeId: props.node?.id ?? null,
name: props.mode === 'create' || props.mode === 'rename' ? name.value.trim() : undefined,
slug: props.mode === 'create' ? effectiveSlug.value : (props.mode === 'rename' ? effectiveSlug.value : undefined),
image: props.mode === 'cover' ? image.value.trim() : undefined,
targetParentId: props.mode === 'move' ? moveTargetId.value : (props.mode === 'create' ? (props.parentNode?.id ?? null) : undefined),
})
} finally {
saving.value = false
}
}
function close() {
emit('update:visible', false)
}
</script>
<template>
<BoDialog
:show="visible"
:mask-can-close="!saving"
:style="{ width: '420px' }"
@update:show="(v: boolean) => emit('update:visible', v)"
>
<div class="cat-modal">
<!-- Header -->
<div class="cat-modal-header">
<h2 class="cat-modal-title">{{ title }}</h2>
<button type="button" class="cat-modal-close" :disabled="saving" @click="close">
<Icon name="lucide:x" />
</button>
</div>
<!-- Body -->
<div class="cat-modal-body">
<div v-if="error" class="cat-error">{{ error }}</div>
<!-- Create / Rename -->
<template v-if="mode === 'create' || mode === 'rename'">
<div class="cat-field">
<label class="cat-label"><span class="cat-required">*</span> 名称</label>
<input
v-model="name"
type="text"
class="cat-input"
placeholder="分类名称"
:disabled="saving"
autofocus
@keyup.enter="onSubmit"
/>
</div>
<div class="cat-field">
<label class="cat-label">标识(slug)</label>
<input
v-model="slugManual"
type="text"
class="cat-input"
:class="{ 'cat-input-muted': !slugTouched && !!autoSlug }"
placeholder="自动生成或手动输入"
:disabled="saving"
@input="onSlugInput"
/>
<span class="cat-hint">
<template v-if="!slugManual.trim() && !autoSlug">名称无法自动生成,将随机生成标识</template>
<template v-else>用于 URL 路径,可手动修改</template>
</span>
</div>
</template>
<!-- Change Cover -->
<template v-if="mode === 'cover'">
<div class="cat-field">
<label class="cat-label"><span class="cat-required">*</span> 封面图片 URL</label>
<input
v-model="image"
type="url"
class="cat-input"
placeholder="https://..."
:disabled="saving"
autofocus
@keyup.enter="onSubmit"
/>
</div>
<div v-if="image" class="cat-cover-preview">
<img :src="image" alt="封面预览" @error="(e) => { (e.target as HTMLImageElement).style.display = 'none' }" />
</div>
</template>
<!-- Delete -->
<template v-if="mode === 'delete'">
<div class="cat-delete-warn">
<Icon name="lucide:triangle-alert" class="cat-delete-icon" />
<p>确定要删除「<strong>{{ node?.name }}</strong>」吗?</p>
<p v-if="node?.children?.length" class="cat-delete-sub">
将同时删除其 {{ node.children.length }} 个子分类
</p>
<p class="cat-delete-sub">此操作不可撤销</p>
</div>
</template>
<!-- Move -->
<template v-if="mode === 'move'">
<div class="cat-field">
<label class="cat-label">
将「{{ node?.name }}」移动到
</label>
<select v-model="moveTargetId" class="cat-select" :disabled="saving">
<option
v-for="opt in flatOptions"
:key="opt.id"
:value="opt.id"
>
{{ '\u00A0\u00A0'.repeat(opt.depth) }}{{ opt.name }}
</option>
</select>
</div>
</template>
</div>
<!-- Footer -->
<div class="cat-modal-footer">
<button type="button" class="cat-btn cat-btn-cancel" :disabled="saving" @click="close">
取消
</button>
<button
v-if="mode === 'delete'"
type="button"
class="cat-btn cat-btn-danger"
:disabled="saving"
@click="onSubmit"
>
{{ saving ? '删除中…' : '确认删除' }}
</button>
<button
v-else
type="button"
class="cat-btn cat-btn-primary"
:disabled="saving"
@click="onSubmit"
>
{{ saving ? '保存中…' : (mode === 'create' ? '创建' : '保存') }}
</button>
</div>
</div>
</BoDialog>
</template>
<style scoped>
.cat-modal {
display: flex;
flex-direction: column;
max-height: 70vh;
background: var(--color-canvas, #faf9f5);
border-radius: 12px;
overflow: hidden;
}
/* ── Header ── */
.cat-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 0;
}
.cat-modal-title {
font-family: var(--font-display);
font-size: 18px;
font-weight: 500;
color: var(--color-ink);
margin: 0;
}
.cat-modal-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
border-radius: 8px;
color: var(--color-muted);
cursor: pointer;
flex-shrink: 0;
transition: all 0.12s ease;
}
.cat-modal-close :deep(svg) {
width: 18px;
height: 18px;
}
.cat-modal-close:hover {
background: var(--color-surface-soft);
color: var(--color-ink);
}
/* ── Body ── */
.cat-modal-body {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
.cat-error {
padding: 10px 14px;
background: rgba(198, 69, 69, 0.08);
color: var(--color-error, #c64545);
border-radius: 8px;
font-size: 13px;
line-height: 1.4;
}
.cat-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.cat-label {
font-size: 13px;
font-weight: 500;
color: var(--color-body, #3d3d3a);
}
.cat-label-optional {
font-weight: 400;
color: var(--color-muted, #6c6a64);
}
.cat-required {
color: var(--color-error, #c64545);
font-weight: 600;
}
.cat-input,
.cat-select {
width: 100%;
height: 40px;
padding: 0 14px;
font-size: 14px;
color: var(--color-ink);
background: var(--color-canvas, #faf9f5);
border: 1px solid var(--color-hairline);
border-radius: 8px;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.cat-input:focus,
.cat-select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.15);
}
.cat-input-muted {
color: var(--color-muted);
background: var(--color-surface-soft, #f5f0e8);
cursor: default;
user-select: none;
}
.cat-select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236c6a64' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
.cat-hint {
font-size: 11px;
color: var(--color-muted-soft);
line-height: 1.3;
}
.cat-cover-preview {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 8px;
overflow: hidden;
background: var(--color-surface-soft);
border: 1px solid var(--color-hairline);
}
.cat-cover-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* ── Delete warn ── */
.cat-delete-warn {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
padding: 8px 0;
}
.cat-delete-icon {
width: 36px;
height: 36px;
color: var(--color-error, #c64545);
opacity: 0.7;
margin-bottom: 4px;
}
.cat-delete-warn p {
margin: 0;
font-size: 14px;
color: var(--color-body);
line-height: 1.5;
}
.cat-delete-sub {
font-size: 12px !important;
color: var(--color-muted) !important;
}
/* ── Footer ── */
.cat-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 24px;
border-top: 1px solid var(--color-hairline);
background: var(--color-surface-soft);
}
.cat-btn {
height: 40px;
padding: 0 20px;
font-size: 14px;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.cat-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cat-btn-cancel {
color: var(--color-body);
background: var(--color-canvas);
border: 1px solid var(--color-hairline);
}
.cat-btn-cancel:hover:not(:disabled) {
background: var(--color-surface-soft);
border-color: var(--color-muted);
}
.cat-btn-primary {
color: var(--color-on-primary, #fff);
background: var(--color-primary);
border: none;
}
.cat-btn-primary:hover:not(:disabled) {
background: var(--color-primary-active, #b5654f);
}
.cat-btn-danger {
color: #fff;
background: var(--color-error, #c64545);
border: none;
}
.cat-btn-danger:hover:not(:disabled) {
background: #a83838;
}
</style>