Browse Source

feat: 调整 CardDetailModal 和 CardFormModal 的样式,优化响应式布局和卡片保存逻辑

as
npmrun 1 week ago
parent
commit
5b170b7761
  1. 4
      app/components/index/CardDetailModal.vue
  2. 579
      app/components/index/CardFormModal.vue
  3. 57
      app/pages/collect/index.vue
  4. BIN
      packages/drizzle-pkg/db.sqlite

4
app/components/index/CardDetailModal.vue

@ -28,7 +28,7 @@ const props = withDefaults(defineProps<{
const dialogStyle = computed(() => { const dialogStyle = computed(() => {
if (props.size === 'expanded') { if (props.size === 'expanded') {
return { width: '80vw' } return { width: '85vw' }
} }
return { width: '720px' } return { width: '720px' }
}) })
@ -472,7 +472,7 @@ function formatDate(d?: string) {
/* ── Expanded mode ── */ /* ── Expanded mode ── */
.detail--expanded { .detail--expanded {
height: 80vh; height: 85vh;
max-height: none; max-height: none;
} }

579
app/components/index/CardFormModal.vue

@ -21,7 +21,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'update:visible': [v: boolean] 'update:visible': [v: boolean]
saved: [] saved: [cardData: any]
}>() }>()
// Tag fetching // Tag fetching
@ -256,12 +256,13 @@ async function onSubmit() {
body.articleIds = selectedArticleIds.value.length > 0 ? [...selectedArticleIds.value] : null body.articleIds = selectedArticleIds.value.length > 0 ? [...selectedArticleIds.value] : null
try { try {
let res: any
if (isEdit.value && props.card) { if (isEdit.value && props.card) {
await request(`/api/cards/${props.card.id}`, { method: 'PUT', body }) res = await request(`/api/cards/${props.card.id}`, { method: 'PUT', body })
} else { } else {
await request('/api/cards', { method: 'POST', body }) res = await request('/api/cards', { method: 'POST', body })
} }
emit('saved') emit('saved', res.data)
emit('update:visible', false) emit('update:visible', false)
} catch (err: any) { } catch (err: any) {
error.value = err?.data?.message ?? err?.message ?? '保存失败' error.value = err?.data?.message ?? err?.message ?? '保存失败'
@ -275,7 +276,7 @@ async function onSubmit() {
<BoDialog <BoDialog
:show="visible" :show="visible"
:mask-can-close="!saving" :mask-can-close="!saving"
:style="{ width: '600px' }" :style="{ width: '85vw', maxWidth: '1400px' }"
@update:show="(v: boolean) => emit('update:visible', v)" @update:show="(v: boolean) => emit('update:visible', v)"
> >
<div class="card-form"> <div class="card-form">
@ -294,288 +295,285 @@ async function onSubmit() {
<!-- Body --> <!-- Body -->
<div class="form-body"> <div class="form-body">
<!-- Error --> <!-- Error: full width -->
<div v-if="error" class="form-error"> <div v-if="error" class="form-error">
<Icon name="lucide:alert-circle" class="form-error-icon" /> <Icon name="lucide:alert-circle" class="form-error-icon" />
<span>{{ error }}</span> <span>{{ error }}</span>
</div> </div>
<!-- 卡片类型 --> <div class="form-columns">
<div class="form-group"> <!-- 左栏基础信息 -->
<div class="form-group-label">卡片类型</div> <div class="form-left">
<div class="type-grid"> <!-- 卡片类型 -->
<button <div class="form-group">
v-for="ct in CARD_TYPE_OPTIONS" <div class="form-group-label">卡片类型</div>
:key="ct.value" <div class="type-grid">
type="button" <button
class="type-card" v-for="ct in CARD_TYPE_OPTIONS"
:class="{ active: formType === ct.value }" :key="ct.value"
:disabled="saving" type="button"
@click="formType = ct.value" class="type-card"
> :class="{ active: formType === ct.value }"
<Icon :name="ct.icon" class="type-card-icon" /> :disabled="saving"
<span class="type-card-label">{{ ct.label }}</span> @click="formType = ct.value"
</button> >
</div> <Icon :name="ct.icon" class="type-card-icon" />
</div> <span class="type-card-label">{{ ct.label }}</span>
</button>
<!-- 基本信息 --> </div>
<div class="form-group">
<div class="form-group-label">基本信息</div>
<!-- Title -->
<div class="form-field">
<div class="field-header">
<label class="field-label">
标题 <span class="field-required">*</span>
</label>
<span class="field-count">{{ title.length }}/60</span>
</div> </div>
<input
v-model="title"
type="text"
class="field-input"
placeholder="输入卡片标题"
:disabled="saving"
maxlength="60"
@keyup.enter="onSubmit"
/>
</div>
<!-- Category -->
<div class="form-field">
<label class="field-label">分类</label>
<select v-model="categoryId" class="field-select" :disabled="saving">
<option
v-for="fc in flatCategories"
:key="fc.id"
:value="fc.id || null"
>
{{ '\u00A0\u00A0'.repeat(fc.depth) }}{{ fc.name }}
</option>
</select>
</div>
</div>
<!-- 图片单张 -->
<div v-if="needsImage" class="form-group">
<div class="form-group-label">图片 <span class="field-required">*</span></div>
<div class="image-upload-row">
<label class="image-dropzone">
<Icon name="lucide:image-plus" class="dropzone-icon" />
<span class="dropzone-text">点击上传</span>
<input
type="file"
accept="image/*"
:disabled="saving"
hidden
@change="onFileUpload"
/>
</label>
<input
v-model="imageUrl"
type="url"
class="field-input"
placeholder="或粘贴图片 URL…"
:disabled="saving"
/>
</div>
<div v-if="imageUrl" class="image-preview-block"> <!-- 标题 -->
<img <div class="form-group">
:src="imageUrl" <div class="form-group-label">标题 <span class="field-required">*</span></div>
alt="预览" <div class="field-header">
class="image-preview-img" <span class="field-count">{{ title.length }}/60</span>
@error="(e) => { (e.target as HTMLImageElement).style.display = 'none' }" </div>
/>
<button
type="button"
class="image-preview-clear"
:disabled="saving"
@click="imageUrl = ''"
>
<Icon name="lucide:x" />
</button>
</div>
</div>
<!-- 图片列表图集 -->
<div v-if="needsMultiImage" class="form-group">
<div class="form-group-label">图片列表 <span class="field-required">*</span></div>
<div class="multi-image-list">
<div v-for="(_, idx) in imageUrls" :key="idx" class="multi-image-row">
<span class="multi-image-index">{{ idx + 1 }}</span>
<input <input
v-model="imageUrls[idx]" v-model="title"
type="url" type="text"
class="field-input" class="field-input"
:placeholder="`图片 ${idx + 1} 的 URL`" placeholder="输入卡片标题"
:disabled="saving" :disabled="saving"
maxlength="60"
@keyup.enter="onSubmit"
/> />
<button
v-if="imageUrls.length > 1"
type="button"
class="multi-image-remove"
:disabled="saving"
@click="removeImageUrl(idx)"
>
<Icon name="lucide:trash-2" />
</button>
</div> </div>
</div>
<button type="button" class="add-row-btn" :disabled="saving" @click="addImageUrl"> <!-- 分类 -->
<Icon name="lucide:plus" /> <div class="form-group">
<span>添加一张</span> <div class="form-group-label">分类</div>
</button> <select v-model="categoryId" class="field-select" :disabled="saving">
<option
<div v-if="imageUrls.some(u => u.trim())" class="image-previews-grid"> v-for="fc in flatCategories"
<div :key="fc.id"
v-for="(url, idx) in imageUrls.filter(u => u.trim())" :value="fc.id || null"
:key="idx" >
class="image-preview-thumb" {{ '\u00A0\u00A0'.repeat(fc.depth) }}{{ fc.name }}
> </option>
<img </select>
:src="url"
:alt="`预览 ${idx + 1}`"
@error="(e) => { (e.target as HTMLImageElement).style.display = 'none' }"
/>
</div> </div>
</div>
</div>
<!-- 描述 -->
<div v-if="needsDescription" class="form-group">
<div class="field-header">
<label class="form-group-label" style="margin-bottom:0">描述</label>
<span class="field-count">{{ description.length }}/200</span>
</div>
<textarea
v-model="description"
class="field-textarea"
rows="4"
placeholder="写点什么…"
:disabled="saving"
maxlength="200"
></textarea>
</div>
<!-- 宽高比 --> <!-- 描述 -->
<div class="form-group"> <div v-if="needsDescription" class="form-group">
<div class="form-group-label">宽高比</div> <div class="field-header">
<div class="ratio-bar"> <label class="form-group-label" style="margin-bottom:0">描述</label>
<button <span class="field-count">{{ description.length }}/200</span>
v-for="r in ASPECT_PRESETS" </div>
:key="r.value" <textarea
type="button" v-model="description"
class="ratio-btn" class="field-textarea"
:class="{ active: aspectRatio === r.value }" rows="5"
:disabled="saving" placeholder="写点什么…"
@click="aspectRatio = r.value"
>
{{ r.label }}
</button>
</div>
</div>
<!-- 标签仅项目类型 -->
<div v-if="needsTags" class="form-group">
<div class="form-group-label">标签</div>
<!-- 已选标签 -->
<div v-if="selectedTagIds.length > 0" class="chip-row">
<span
v-for="tid in selectedTagIds"
:key="tid"
class="chip chip-active"
>
{{ allTags.find(t => t.id === tid)?.name ?? `#${tid}` }}
<button
type="button"
class="chip-remove"
:disabled="saving" :disabled="saving"
@click="toggleTag(tid)" maxlength="200"
> ></textarea>
<Icon name="lucide:x" /> </div>
</button>
</span>
</div>
<!-- 可选标签池 --> <!-- 宽高比 -->
<div v-if="availableTags.length > 0" class="tag-pool"> <div class="form-group">
<button <div class="form-group-label">宽高比</div>
v-for="tag in availableTags" <div class="ratio-bar">
:key="tag.id" <button
type="button" v-for="r in ASPECT_PRESETS"
class="tag-chip" :key="r.value"
:disabled="saving" type="button"
@click="toggleTag(tag.id)" class="ratio-btn"
> :class="{ active: aspectRatio === r.value }"
{{ tag.name }} :disabled="saving"
</button> @click="aspectRatio = r.value"
>
{{ r.label }}
</button>
</div>
</div>
</div> </div>
<span v-if="allTags.length === 0 && tagsLoaded" class="hint-text"> <!-- 右栏媒体 & 关联 -->
暂无可用标签请先在标签管理中创建 <div class="form-right">
</span> <!-- 图片单张 -->
</div> <div v-if="needsImage" class="form-group">
<div class="form-group-label">图片 <span class="field-required">*</span></div>
<div class="image-upload-row">
<label class="image-dropzone">
<Icon name="lucide:image-plus" class="dropzone-icon" />
<span class="dropzone-text">点击上传</span>
<input
type="file"
accept="image/*"
:disabled="saving"
hidden
@change="onFileUpload"
/>
</label>
<input
v-model="imageUrl"
type="url"
class="field-input"
placeholder="或粘贴图片 URL…"
:disabled="saving"
/>
</div>
<div v-if="imageUrl" class="image-preview-block">
<img
:src="imageUrl"
alt="预览"
class="image-preview-img"
@error="(e) => { (e.target as HTMLImageElement).style.display = 'none' }"
/>
<button
type="button"
class="image-preview-clear"
:disabled="saving"
@click="imageUrl = ''"
>
<Icon name="lucide:x" />
</button>
</div>
</div>
<!-- 关联文章 --> <!-- 图片列表图集 -->
<div class="form-group"> <div v-if="needsMultiImage" class="form-group">
<div class="form-group-label">关联文章</div> <div class="form-group-label">图片列表 <span class="field-required">*</span></div>
<!-- 已选文章 --> <div class="multi-image-list">
<div v-if="selectedArticleIds.length > 0" class="chip-row"> <div v-for="(_, idx) in imageUrls" :key="idx" class="multi-image-row">
<span <span class="multi-image-index">{{ idx + 1 }}</span>
v-for="aid in selectedArticleIds" <input
:key="aid" v-model="imageUrls[idx]"
class="chip chip-article" type="url"
> class="field-input"
{{ allArticles.find(a => a.id === aid)?.title ?? `文章 #${aid}` }} :placeholder="`图片 ${idx + 1} 的 URL`"
<button :disabled="saving"
type="button" />
class="chip-remove" <button
:disabled="saving" v-if="imageUrls.length > 1"
@click="selectedArticleIds = selectedArticleIds.filter(id => id !== aid)" type="button"
> class="multi-image-remove"
<Icon name="lucide:x" /> :disabled="saving"
@click="removeImageUrl(idx)"
>
<Icon name="lucide:trash-2" />
</button>
</div>
</div>
<button type="button" class="add-row-btn" :disabled="saving" @click="addImageUrl">
<Icon name="lucide:plus" />
<span>添加一张</span>
</button> </button>
</span>
</div>
<!-- 文章搜索 --> <div v-if="imageUrls.some(u => u.trim())" class="image-previews-grid">
<div class="article-picker"> <div
<div class="article-search-wrap"> v-for="(url, idx) in imageUrls.filter(u => u.trim())"
<Icon name="lucide:search" class="article-search-icon" /> :key="idx"
<input class="image-preview-thumb"
v-model="articleSearch" >
type="text" <img
class="field-input" :src="url"
placeholder="搜索文章然后点击添加…" :alt="`预览 ${idx + 1}`"
:disabled="saving" @error="(e) => { (e.target as HTMLImageElement).style.display = 'none' }"
@focus="onArticleFocus" />
@blur="onArticleBlur" </div>
/> </div>
</div> </div>
<div v-if="showArticlePicker && availableArticles.length > 0" class="article-dropdown">
<button <!-- 标签仅项目类型 -->
v-for="a in availableArticles.slice(0, 12)" <div v-if="needsTags" class="form-group">
:key="a.id" <div class="form-group-label">标签</div>
type="button"
class="article-option" <div v-if="selectedTagIds.length > 0" class="chip-row">
:disabled="saving" <span
@mousedown.prevent="addArticleId(a.id)" v-for="tid in selectedTagIds"
> :key="tid"
{{ a.title }} class="chip chip-active"
</button> >
{{ allTags.find(t => t.id === tid)?.name ?? `#${tid}` }}
<button
type="button"
class="chip-remove"
:disabled="saving"
@click="toggleTag(tid)"
>
<Icon name="lucide:x" />
</button>
</span>
</div>
<div v-if="availableTags.length > 0" class="tag-pool">
<button
v-for="tag in availableTags"
:key="tag.id"
type="button"
class="tag-chip"
:disabled="saving"
@click="toggleTag(tag.id)"
>
{{ tag.name }}
</button>
</div>
<span v-if="allTags.length === 0 && tagsLoaded" class="hint-text">
暂无可用标签请先在标签管理中创建
</span>
</div> </div>
<div
v-else-if="showArticlePicker && articleSearch && availableArticles.length === 0" <!-- 关联文章 -->
class="article-dropdown" <div class="form-group">
> <div class="form-group-label">关联文章</div>
<span class="article-empty">未找到匹配文章</span>
<div v-if="selectedArticleIds.length > 0" class="chip-row">
<span
v-for="aid in selectedArticleIds"
:key="aid"
class="chip chip-article"
>
{{ allArticles.find(a => a.id === aid)?.title ?? `文章 #${aid}` }}
<button
type="button"
class="chip-remove"
:disabled="saving"
@click="selectedArticleIds = selectedArticleIds.filter(id => id !== aid)"
>
<Icon name="lucide:x" />
</button>
</span>
</div>
<div class="article-picker">
<div class="article-search-wrap">
<Icon name="lucide:search" class="article-search-icon" />
<input
v-model="articleSearch"
type="text"
class="field-input"
placeholder="搜索文章然后点击添加…"
:disabled="saving"
@focus="onArticleFocus"
@blur="onArticleBlur"
/>
</div>
<div v-if="showArticlePicker && availableArticles.length > 0" class="article-dropdown">
<button
v-for="a in availableArticles.slice(0, 12)"
:key="a.id"
type="button"
class="article-option"
:disabled="saving"
@mousedown.prevent="addArticleId(a.id)"
>
{{ a.title }}
</button>
</div>
<div
v-else-if="showArticlePicker && articleSearch && availableArticles.length === 0"
class="article-dropdown"
>
<span class="article-empty">未找到匹配文章</span>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -659,7 +657,25 @@ async function onSubmit() {
padding: 20px 28px; padding: 20px 28px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 16px;
max-height: calc(85vh - 130px);
overflow-y: auto;
}
/* ── Two-column grid ────────────────────────────────────────────── */
.form-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 28px;
}
.form-left,
.form-right {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
} }
/* ── Error ──────────────────────────────────────────────────────── */ /* ── Error ──────────────────────────────────────────────────────── */
@ -862,7 +878,7 @@ async function onSubmit() {
} }
.image-preview-img { .image-preview-img {
width: 100%; width: 100%;
max-height: 200px; max-height: 320px;
object-fit: cover; object-fit: cover;
display: block; display: block;
} }
@ -955,8 +971,8 @@ async function onSubmit() {
.image-previews-grid { .image-previews-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 6px; gap: 8px;
} }
.image-preview-thumb { .image-preview-thumb {
@ -1198,4 +1214,33 @@ async function onSubmit() {
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* ── Responsive: single column on mobile ────────────────────────── */
@media (max-width: 767.98px) {
.form-columns {
grid-template-columns: 1fr;
gap: 16px;
}
.form-body {
padding: 16px;
max-height: calc(85vh - 120px);
}
.form-header {
padding: 16px 16px 0;
}
.form-footer {
padding: 12px 16px;
flex-direction: column;
gap: 8px;
}
.form-footer .btn {
width: 100%;
justify-content: center;
}
}
</style> </style>

57
app/pages/collect/index.vue

@ -404,10 +404,17 @@ function onNewCard() {
showCardFormModal.value = true showCardFormModal.value = true
} }
function onCardSaved() { function onCardSaved(cardData: ServerCard) {
$toast.success(cardFormMode.value === 'create' ? '卡片已创建' : '卡片已更新') $toast.success(cardFormMode.value === 'create' ? '卡片已创建' : '卡片已更新')
// /
upsertCardInList(cardData)
//
removeCardIfFiltered(cardData.id, cardData.categoryId)
//
fetchCategories() fetchCategories()
resetAndReload()
} }
// Drag & Drop to category // Drag & Drop to category
@ -458,14 +465,23 @@ function onCardDragEnd(e: DragEvent) {
async function onCardDropToCategory(categoryId: string | null, categoryName: string) { async function onCardDropToCategory(categoryId: string | null, categoryName: string) {
if (dragCardId === null) return if (dragCardId === null) return
const cardId = dragCardId
try { try {
await request(`/api/cards/${dragCardId}`, { await request(`/api/cards/${cardId}`, {
method: 'PUT', method: 'PUT',
body: { categoryId }, body: { categoryId },
}) })
$toast.success(`已移至「${categoryName}`) $toast.success(`已移至「${categoryName}`)
//
const card = allItems.value.find((item) => item.id === cardId)
if (card) {
card.categoryId = categoryId
}
//
removeCardIfFiltered(cardId, categoryId)
//
fetchCategories() fetchCategories()
resetAndReload()
} catch { } catch {
$toast.error('移动失败') $toast.error('移动失败')
} finally { } finally {
@ -550,6 +566,39 @@ function removeCardFromList(cardId: number) {
distributeAll() distributeAll()
} }
/**
* 就地更新 allItems 中的卡片编辑或前置插入创建不触发服务端重新加载
* 用于拖拽修改分类 / 表单编辑保存后免刷新更新布局
*/
function upsertCardInList(cardData: ServerCard) {
const mapped = mapCard(cardData)
const idx = allItems.value.findIndex((item) => item.id === mapped.id)
if (idx >= 0) {
allItems.value[idx] = mapped
} else {
//
allItems.value.unshift(mapped)
}
distributeAll()
}
/**
* 如果当前视图按分类筛选且卡片的新分类不匹配当前筛选条件
* 则从列表中移除该卡片并重新布局
*/
function removeCardIfFiltered(cardId: number, newCategoryId: string | null) {
// / /
if (activeCategoryId.value === 'all') return
if (activeToolKey.value === 'collect') return
if (activeToolKey.value === 'search') return
//
const currentFilter = activeCategoryId.value === '__uncategorized__' ? null : activeCategoryId.value
if (newCategoryId !== currentFilter) {
removeCardFromList(cardId)
}
}
async function handleToggleFavorite(cardId: number) { async function handleToggleFavorite(cardId: number) {
const wasFavorited = isFavorited(cardId) const wasFavorited = isFavorited(cardId)
const result = await toggleFavorite(cardId) const result = await toggleFavorite(cardId)

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.
Loading…
Cancel
Save