Browse Source

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

as
npmrun 1 week ago
parent
commit
5b170b7761
  1. 4
      app/components/index/CardDetailModal.vue
  2. 163
      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;
} }

163
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,12 +295,15 @@ 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-left">
<!-- 卡片类型 --> <!-- 卡片类型 -->
<div class="form-group"> <div class="form-group">
<div class="form-group-label">卡片类型</div> <div class="form-group-label">卡片类型</div>
@ -319,16 +323,10 @@ async function onSubmit() {
</div> </div>
</div> </div>
<!-- 基本信息 --> <!-- 标题 -->
<div class="form-group"> <div class="form-group">
<div class="form-group-label">基本信息</div> <div class="form-group-label">标题 <span class="field-required">*</span></div>
<!-- Title -->
<div class="form-field">
<div class="field-header"> <div class="field-header">
<label class="field-label">
标题 <span class="field-required">*</span>
</label>
<span class="field-count">{{ title.length }}/60</span> <span class="field-count">{{ title.length }}/60</span>
</div> </div>
<input <input
@ -342,9 +340,9 @@ async function onSubmit() {
/> />
</div> </div>
<!-- Category --> <!-- 分类 -->
<div class="form-field"> <div class="form-group">
<label class="field-label">分类</label> <div class="form-group-label">分类</div>
<select v-model="categoryId" class="field-select" :disabled="saving"> <select v-model="categoryId" class="field-select" :disabled="saving">
<option <option
v-for="fc in flatCategories" v-for="fc in flatCategories"
@ -355,8 +353,44 @@ async function onSubmit() {
</option> </option>
</select> </select>
</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="5"
placeholder="写点什么…"
:disabled="saving"
maxlength="200"
></textarea>
</div> </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>
<!-- 右栏媒体 & 关联 -->
<div class="form-right">
<!-- 图片单张 --> <!-- 图片单张 -->
<div v-if="needsImage" class="form-group"> <div v-if="needsImage" class="form-group">
<div class="form-group-label">图片 <span class="field-required">*</span></div> <div class="form-group-label">图片 <span class="field-required">*</span></div>
@ -446,45 +480,10 @@ async function onSubmit() {
</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 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 v-if="needsTags" class="form-group">
<div class="form-group-label">标签</div> <div class="form-group-label">标签</div>
<!-- 已选标签 -->
<div v-if="selectedTagIds.length > 0" class="chip-row"> <div v-if="selectedTagIds.length > 0" class="chip-row">
<span <span
v-for="tid in selectedTagIds" v-for="tid in selectedTagIds"
@ -503,7 +502,6 @@ async function onSubmit() {
</span> </span>
</div> </div>
<!-- 可选标签池 -->
<div v-if="availableTags.length > 0" class="tag-pool"> <div v-if="availableTags.length > 0" class="tag-pool">
<button <button
v-for="tag in availableTags" v-for="tag in availableTags"
@ -526,7 +524,6 @@ async function onSubmit() {
<div class="form-group"> <div class="form-group">
<div class="form-group-label">关联文章</div> <div class="form-group-label">关联文章</div>
<!-- 已选文章 -->
<div v-if="selectedArticleIds.length > 0" class="chip-row"> <div v-if="selectedArticleIds.length > 0" class="chip-row">
<span <span
v-for="aid in selectedArticleIds" v-for="aid in selectedArticleIds"
@ -545,7 +542,6 @@ async function onSubmit() {
</span> </span>
</div> </div>
<!-- 文章搜索 -->
<div class="article-picker"> <div class="article-picker">
<div class="article-search-wrap"> <div class="article-search-wrap">
<Icon name="lucide:search" class="article-search-icon" /> <Icon name="lucide:search" class="article-search-icon" />
@ -580,6 +576,8 @@ async function onSubmit() {
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- Footer --> <!-- Footer -->
<div class="form-footer"> <div class="form-footer">
@ -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