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(() => {
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;
}

163
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,12 +295,15 @@ 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-columns">
<!-- 左栏基础信息 -->
<div class="form-left">
<!-- 卡片类型 -->
<div class="form-group">
<div class="form-group-label">卡片类型</div>
@ -319,16 +323,10 @@ async function onSubmit() {
</div>
</div>
<!-- 基本信息 -->
<!-- 标题 -->
<div class="form-group">
<div class="form-group-label">基本信息</div>
<!-- Title -->
<div class="form-field">
<div class="form-group-label">标题 <span class="field-required">*</span></div>
<div class="field-header">
<label class="field-label">
标题 <span class="field-required">*</span>
</label>
<span class="field-count">{{ title.length }}/60</span>
</div>
<input
@ -342,9 +340,9 @@ async function onSubmit() {
/>
</div>
<!-- Category -->
<div class="form-field">
<label class="field-label">分类</label>
<!-- 分类 -->
<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"
@ -355,8 +353,44 @@ async function onSubmit() {
</option>
</select>
</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 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 class="form-group-label">图片 <span class="field-required">*</span></div>
@ -446,45 +480,10 @@ async function onSubmit() {
</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"
@ -503,7 +502,6 @@ async function onSubmit() {
</span>
</div>
<!-- 可选标签池 -->
<div v-if="availableTags.length > 0" class="tag-pool">
<button
v-for="tag in availableTags"
@ -526,7 +524,6 @@ async function onSubmit() {
<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"
@ -545,7 +542,6 @@ async function onSubmit() {
</span>
</div>
<!-- 文章搜索 -->
<div class="article-picker">
<div class="article-search-wrap">
<Icon name="lucide:search" class="article-search-icon" />
@ -580,6 +576,8 @@ async function onSubmit() {
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="form-footer">
@ -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