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

579
app/components/index/CardFormModal.vue

@ -21,7 +21,7 @@ const props = defineProps<{
const emit = defineEmits<{
'update:visible': [v: boolean]
saved: []
saved: [cardData: any]
}>()
// Tag fetching
@ -256,12 +256,13 @@ async function onSubmit() {
body.articleIds = selectedArticleIds.value.length > 0 ? [...selectedArticleIds.value] : null
try {
let res: any
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 {
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)
} catch (err: any) {
error.value = err?.data?.message ?? err?.message ?? '保存失败'
@ -275,7 +276,7 @@ async function onSubmit() {
<BoDialog
:show="visible"
:mask-can-close="!saving"
:style="{ width: '600px' }"
:style="{ width: '85vw', maxWidth: '1400px' }"
@update:show="(v: boolean) => emit('update:visible', v)"
>
<div class="card-form">
@ -294,288 +295,285 @@ async function onSubmit() {
<!-- Body -->
<div class="form-body">
<!-- Error -->
<!-- Error: full width -->
<div v-if="error" class="form-error">
<Icon name="lucide:alert-circle" class="form-error-icon" />
<span>{{ error }}</span>
</div>
<!-- 卡片类型 -->
<div class="form-group">
<div class="form-group-label">卡片类型</div>
<div class="type-grid">
<button
v-for="ct in CARD_TYPE_OPTIONS"
:key="ct.value"
type="button"
class="type-card"
:class="{ active: formType === ct.value }"
:disabled="saving"
@click="formType = ct.value"
>
<Icon :name="ct.icon" class="type-card-icon" />
<span class="type-card-label">{{ ct.label }}</span>
</button>
</div>
</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 class="form-columns">
<!-- 左栏基础信息 -->
<div class="form-left">
<!-- 卡片类型 -->
<div class="form-group">
<div class="form-group-label">卡片类型</div>
<div class="type-grid">
<button
v-for="ct in CARD_TYPE_OPTIONS"
:key="ct.value"
type="button"
class="type-card"
:class="{ active: formType === ct.value }"
:disabled="saving"
@click="formType = ct.value"
>
<Icon :name="ct.icon" class="type-card-icon" />
<span class="type-card-label">{{ ct.label }}</span>
</button>
</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
: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 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>
<!-- 标题 -->
<div class="form-group">
<div class="form-group-label">标题 <span class="field-required">*</span></div>
<div class="field-header">
<span class="field-count">{{ title.length }}/60</span>
</div>
<input
v-model="imageUrls[idx]"
type="url"
v-model="title"
type="text"
class="field-input"
:placeholder="`图片 ${idx + 1} 的 URL`"
placeholder="输入卡片标题"
: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>
<button type="button" class="add-row-btn" :disabled="saving" @click="addImageUrl">
<Icon name="lucide:plus" />
<span>添加一张</span>
</button>
<div v-if="imageUrls.some(u => u.trim())" class="image-previews-grid">
<div
v-for="(url, idx) in imageUrls.filter(u => u.trim())"
:key="idx"
class="image-preview-thumb"
>
<img
:src="url"
:alt="`预览 ${idx + 1}`"
@error="(e) => { (e.target as HTMLImageElement).style.display = 'none' }"
/>
<!-- 分类 -->
<div class="form-group">
<div class="form-group-label">分类</div>
<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>
<!-- 描述 -->
<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 class="form-group-label">宽高比</div>
<div class="ratio-bar">
<button
v-for="r in ASPECT_PRESETS"
:key="r.value"
type="button"
class="ratio-btn"
:class="{ active: aspectRatio === r.value }"
:disabled="saving"
@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"
<!-- 描述 -->
<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="5"
placeholder="写点什么…"
:disabled="saving"
@click="toggleTag(tid)"
>
<Icon name="lucide:x" />
</button>
</span>
</div>
maxlength="200"
></textarea>
</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 class="form-group">
<div class="form-group-label">宽高比</div>
<div class="ratio-bar">
<button
v-for="r in ASPECT_PRESETS"
:key="r.value"
type="button"
class="ratio-btn"
:class="{ active: aspectRatio === r.value }"
:disabled="saving"
@click="aspectRatio = r.value"
>
{{ r.label }}
</button>
</div>
</div>
</div>
<span v-if="allTags.length === 0 && tagsLoaded" class="hint-text">
暂无可用标签请先在标签管理中创建
</span>
</div>
<!-- 右栏媒体 & 关联 -->
<div class="form-right">
<!-- 图片单张 -->
<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 class="form-group-label">关联文章</div>
<!-- 已选文章 -->
<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" />
<!-- 图片列表图集 -->
<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
v-model="imageUrls[idx]"
type="url"
class="field-input"
:placeholder="`图片 ${idx + 1} 的 URL`"
:disabled="saving"
/>
<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>
<button type="button" class="add-row-btn" :disabled="saving" @click="addImageUrl">
<Icon name="lucide:plus" />
<span>添加一张</span>
</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 v-if="imageUrls.some(u => u.trim())" class="image-previews-grid">
<div
v-for="(url, idx) in imageUrls.filter(u => u.trim())"
:key="idx"
class="image-preview-thumb"
>
<img
:src="url"
:alt="`预览 ${idx + 1}`"
@error="(e) => { (e.target as HTMLImageElement).style.display = 'none' }"
/>
</div>
</div>
</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 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"
@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
v-else-if="showArticlePicker && articleSearch && availableArticles.length === 0"
class="article-dropdown"
>
<span class="article-empty">未找到匹配文章</span>
<!-- 关联文章 -->
<div class="form-group">
<div class="form-group-label">关联文章</div>
<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>
@ -659,7 +657,25 @@ async function onSubmit() {
padding: 20px 28px;
display: flex;
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 ──────────────────────────────────────────────────────── */
@ -862,7 +878,7 @@ async function onSubmit() {
}
.image-preview-img {
width: 100%;
max-height: 200px;
max-height: 320px;
object-fit: cover;
display: block;
}
@ -955,8 +971,8 @@ async function onSubmit() {
.image-previews-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 6px;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 8px;
}
.image-preview-thumb {
@ -1198,4 +1214,33 @@ async function onSubmit() {
@keyframes spin {
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>

57
app/pages/collect/index.vue

@ -404,10 +404,17 @@ function onNewCard() {
showCardFormModal.value = true
}
function onCardSaved() {
function onCardSaved(cardData: ServerCard) {
$toast.success(cardFormMode.value === 'create' ? '卡片已创建' : '卡片已更新')
// /
upsertCardInList(cardData)
//
removeCardIfFiltered(cardData.id, cardData.categoryId)
//
fetchCategories()
resetAndReload()
}
// Drag & Drop to category
@ -458,14 +465,23 @@ function onCardDragEnd(e: DragEvent) {
async function onCardDropToCategory(categoryId: string | null, categoryName: string) {
if (dragCardId === null) return
const cardId = dragCardId
try {
await request(`/api/cards/${dragCardId}`, {
await request(`/api/cards/${cardId}`, {
method: 'PUT',
body: { categoryId },
})
$toast.success(`已移至「${categoryName}`)
//
const card = allItems.value.find((item) => item.id === cardId)
if (card) {
card.categoryId = categoryId
}
//
removeCardIfFiltered(cardId, categoryId)
//
fetchCategories()
resetAndReload()
} catch {
$toast.error('移动失败')
} finally {
@ -550,6 +566,39 @@ function removeCardFromList(cardId: number) {
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) {
const wasFavorited = isFavorited(cardId)
const result = await toggleFavorite(cardId)

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.
Loading…
Cancel
Save