352d7405c2
- 新增 /albums/form/create 与 /albums/form/:albumId 整页表单路由(隐藏路由,父菜单高亮) - 编辑模式资料/访问页签(访问=开关行卡:标题+说明+开关);创建模式契约约束下载开关+解释提示 - 校验/提交逻辑与原对话框一致(契约镜像校验;translations 全量替换;创建仅 download 开关) - 保存成功跳相册详情;manage/detail 两处入口改路由跳转,删除 AlbumFormDialog - banner 上传收进详情「品牌资产」独立页签(15) - e2e 适配:整页表单流程(form-page/goback/访问页签)、详情锚点改 summary、banner 页签 - typecheck/lint/vitest/build 全绿 Co-Authored-By: Claude Code <noreply@anthropic.com>
467 lines
15 KiB
Vue
467 lines
15 KiB
Vue
<!-- 相册整页表单(UI06 对齐示例 08/09:对话框改整页路由)。
|
||
create:/albums/form/create;edit:/albums/form/:albumId。
|
||
校验/提交逻辑与原 AlbumFormDialog 一致(契约镜像校验 utils/albums/albumValidation);
|
||
编辑 = PATCH 全量可编辑字段(translations 全量替换);创建仅 download 两开关(契约)。 -->
|
||
<template>
|
||
<div class="ui06-form-page" data-test="album-form-page">
|
||
<!-- 页头:返回 + 标题 + 主操作 -->
|
||
<div class="page-heading">
|
||
<div class="heading-left">
|
||
<ElButton link data-test="album-form-back" @click="goBack">
|
||
<ElIcon><ArrowLeft /></ElIcon>
|
||
</ElButton>
|
||
<div>
|
||
<h1>{{
|
||
isEdit ? t('albumsManage.form.editTitle') : t('albumsManage.form.createTitle')
|
||
}}</h1>
|
||
<p>{{ t('albumsManage.form.pageSubtitle') }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 编辑模式:资料 / 访问 页签(示例 09);创建模式仅资料 -->
|
||
<ElTabs v-if="isEdit" v-model="formTab" class="form-tabs">
|
||
<ElTabPane :label="t('albumsManage.form.tabDetails')" name="details" />
|
||
<ElTabPane :label="t('albumsManage.form.tabAccess')" name="access" />
|
||
</ElTabs>
|
||
|
||
<template v-if="!isEdit || formTab === 'details'">
|
||
<!-- 基础资料卡(示例 08 基础卡:翻译 + 日期 + 时区) -->
|
||
<ElCard class="ui06-card" shadow="never">
|
||
<template #header>
|
||
<div class="card-head"
|
||
><h2>{{ t('albumsManage.form.basicCard') }}</h2></div
|
||
>
|
||
</template>
|
||
<ElForm :model="form" label-width="130px">
|
||
<ElFormItem :label="t('albumsManage.form.fieldDefaultLocale')">
|
||
<ElRadioGroup v-model="form.defaultLocale" data-test="album-form-default-locale">
|
||
<ElRadioButton value="zh-CN">zh-CN</ElRadioButton>
|
||
<ElRadioButton value="en">en</ElRadioButton>
|
||
</ElRadioGroup>
|
||
</ElFormItem>
|
||
|
||
<ElTabs v-model="activeTab">
|
||
<ElTabPane :label="t('albumsManage.form.tabZh')" name="zh-CN">
|
||
<TranslationFields v-model="drafts['zh-CN']" :locale="'zh-CN'" />
|
||
</ElTabPane>
|
||
<ElTabPane :label="t('albumsManage.form.tabEn')" name="en">
|
||
<TranslationFields v-model="drafts['en']" :locale="'en'" />
|
||
</ElTabPane>
|
||
</ElTabs>
|
||
|
||
<ElFormItem
|
||
:label="t('albumsManage.form.fieldStartAt')"
|
||
required
|
||
:error="fieldErrors.startAt"
|
||
>
|
||
<!-- ElDatePicker 的透传 attr 被 ElTooltip 吞掉,data-test 挂不到 DOM:
|
||
外层普通 div 承载选择器,测试经 div 内 input 定位 -->
|
||
<div data-test="album-form-start-at">
|
||
<ElDatePicker
|
||
v-model="form.startAt"
|
||
type="datetime"
|
||
:placeholder="t('albumsManage.form.fieldStartAt')"
|
||
/>
|
||
</div>
|
||
</ElFormItem>
|
||
<ElFormItem :label="t('albumsManage.form.fieldEndAt')" :error="fieldErrors.endAt">
|
||
<div data-test="album-form-end-at">
|
||
<ElDatePicker
|
||
v-model="form.endAt"
|
||
type="datetime"
|
||
clearable
|
||
:placeholder="t('albumsManage.form.fieldEndAt')"
|
||
/>
|
||
</div>
|
||
</ElFormItem>
|
||
<ElFormItem :label="t('albumsManage.form.fieldTimezone')">
|
||
<ElInput
|
||
v-model="form.timezone"
|
||
data-test="album-form-timezone"
|
||
placeholder="Asia/Shanghai"
|
||
/>
|
||
</ElFormItem>
|
||
</ElForm>
|
||
</ElCard>
|
||
|
||
<!-- 创建模式:下载开关卡(契约仅 download 两项;上传/公开创建后经编辑调整) -->
|
||
<ElCard v-if="!isEdit" class="ui06-card" shadow="never">
|
||
<template #header>
|
||
<div class="card-head"
|
||
><h2>{{ t('albumsManage.form.switchesCard') }}</h2></div
|
||
>
|
||
</template>
|
||
<div data-test="album-form-switches" class="switch-list">
|
||
<ElSwitch
|
||
v-model="form.downloadEnabled"
|
||
:active-text="t('albumsManage.form.switchDownload')"
|
||
data-test="album-form-switch-download"
|
||
/>
|
||
<ElSwitch
|
||
v-model="form.originalDownloadEnabled"
|
||
:active-text="t('albumsManage.form.switchOriginal')"
|
||
data-test="album-form-switch-original"
|
||
/>
|
||
</div>
|
||
<ElAlert
|
||
type="info"
|
||
:closable="false"
|
||
:title="t('albumsManage.form.createSwitchesHint')"
|
||
class="mt-3"
|
||
/>
|
||
</ElCard>
|
||
|
||
<ElAlert
|
||
v-if="isEdit"
|
||
type="warning"
|
||
:closable="false"
|
||
:title="t('albumsManage.form.translationsReplaceHint')"
|
||
data-test="album-form-replace-hint"
|
||
class="mt-4"
|
||
/>
|
||
</template>
|
||
|
||
<!-- 编辑模式访问页签(示例 09 开关行卡:标题+说明+开关) -->
|
||
<template v-if="isEdit && formTab === 'access'">
|
||
<ElCard class="ui06-card" shadow="never">
|
||
<template #header>
|
||
<div class="card-head"
|
||
><h2>{{ t('albumsManage.form.accessCard') }}</h2></div
|
||
>
|
||
</template>
|
||
<div data-test="album-form-switches" class="switch-rows">
|
||
<div class="switch-row">
|
||
<div class="row-text">
|
||
<strong>{{ t('albumsManage.form.switchUpload') }}</strong>
|
||
<p>{{ t('albumsManage.form.switchUploadDesc') }}</p>
|
||
</div>
|
||
<ElSwitch v-model="form.uploadEnabled" data-test="album-form-switch-upload" />
|
||
</div>
|
||
<div class="switch-row">
|
||
<div class="row-text">
|
||
<strong>{{ t('albumsManage.form.switchPublic') }}</strong>
|
||
<p>{{ t('albumsManage.form.switchPublicDesc') }}</p>
|
||
</div>
|
||
<ElSwitch v-model="form.publicEnabled" data-test="album-form-switch-public" />
|
||
</div>
|
||
<div class="switch-row">
|
||
<div class="row-text">
|
||
<strong>{{ t('albumsManage.form.switchDownload') }}</strong>
|
||
<p>{{ t('albumsManage.form.switchDownloadDesc') }}</p>
|
||
</div>
|
||
<ElSwitch v-model="form.downloadEnabled" data-test="album-form-switch-download" />
|
||
</div>
|
||
<div class="switch-row">
|
||
<div class="row-text">
|
||
<strong>{{ t('albumsManage.form.switchOriginal') }}</strong>
|
||
<p>{{ t('albumsManage.form.switchOriginalDesc') }}</p>
|
||
</div>
|
||
<ElSwitch
|
||
v-model="form.originalDownloadEnabled"
|
||
data-test="album-form-switch-original"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</ElCard>
|
||
</template>
|
||
|
||
<!-- 底部操作条(示例 09 Save 固定底栏语义) -->
|
||
<div class="form-footer">
|
||
<ElButton data-test="album-form-cancel" @click="goBack">
|
||
{{ t('albumsManage.form.cancel') }}
|
||
</ElButton>
|
||
<ElButton type="primary" :loading="submitting" data-test="album-form-submit" @click="submit">
|
||
{{ t('albumsManage.form.submit') }}
|
||
</ElButton>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { ElMessage } from 'element-plus'
|
||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||
import TranslationFields from '@/views/albums/manage/TranslationFields.vue'
|
||
import { fetchAlbum, fetchCreateAlbum, fetchUpdateAlbum } from '@/api/albums'
|
||
import {
|
||
toTranslationPayload,
|
||
validateTimeRange,
|
||
validateTranslations,
|
||
type TranslationDraft
|
||
} from '@/utils/albums/albumValidation'
|
||
|
||
defineOptions({ name: 'AlbumsForm' })
|
||
|
||
/** 翻译草稿形状(两语言固定键,模板按键取值) */
|
||
type DraftMap = Record<'zh-CN' | 'en', TranslationDraft>
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const { t } = useI18n()
|
||
|
||
/** 路由参数决定模式:/albums/form/create | /albums/form/:albumId */
|
||
const albumId = computed(() => {
|
||
const raw = String(route.params.albumId ?? '')
|
||
return raw === '' || raw === 'create' ? null : raw
|
||
})
|
||
const isEdit = computed(() => albumId.value !== null)
|
||
|
||
/** 编辑目标摘要(fetchAlbum 装载) */
|
||
const album = ref<Api.Album.Detail | null>(null)
|
||
const loading = ref(false)
|
||
const loadError = ref<Error | null>(null)
|
||
|
||
const formTab = ref<'details' | 'access'>('details')
|
||
const activeTab = ref<'zh-CN' | 'en'>('zh-CN')
|
||
|
||
const emptyDraft = (locale: 'zh-CN' | 'en'): TranslationDraft => ({
|
||
locale,
|
||
title: '',
|
||
description: '',
|
||
venue: '',
|
||
address: ''
|
||
})
|
||
|
||
const drafts = reactive<DraftMap>({
|
||
'zh-CN': emptyDraft('zh-CN'),
|
||
en: emptyDraft('en')
|
||
})
|
||
|
||
const form = reactive({
|
||
defaultLocale: 'zh-CN' as Api.Album.Locale,
|
||
startAt: null as Date | null,
|
||
endAt: null as Date | null,
|
||
timezone: 'Asia/Shanghai',
|
||
uploadEnabled: true,
|
||
publicEnabled: true,
|
||
downloadEnabled: true,
|
||
originalDownloadEnabled: false
|
||
})
|
||
|
||
const fieldErrors = reactive<{ startAt: string; endAt: string }>({ startAt: '', endAt: '' })
|
||
const submitting = ref(false)
|
||
|
||
async function load(): Promise<void> {
|
||
if (!albumId.value) return
|
||
loading.value = true
|
||
loadError.value = null
|
||
try {
|
||
const target = await fetchAlbum(albumId.value)
|
||
album.value = target
|
||
form.defaultLocale = target.defaultLocale
|
||
form.startAt = new Date(target.startAt)
|
||
form.endAt = target.endAt ? new Date(target.endAt) : null
|
||
form.timezone = target.timezone
|
||
form.uploadEnabled = target.uploadEnabled
|
||
form.publicEnabled = target.publicEnabled
|
||
form.downloadEnabled = target.downloadEnabled
|
||
form.originalDownloadEnabled = target.originalDownloadEnabled
|
||
for (const locale of ['zh-CN', 'en'] as const) {
|
||
const translation = target.translations.find((item) => item.locale === locale)
|
||
drafts[locale] = {
|
||
...emptyDraft(locale),
|
||
title: translation?.title ?? '',
|
||
description: translation?.description ?? '',
|
||
venue: translation?.venue ?? '',
|
||
address: translation?.address ?? ''
|
||
}
|
||
}
|
||
} catch (e) {
|
||
loadError.value = e as Error
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
/** 提交前校验(镜像契约:默认语言覆盖 + endAt>startAt) */
|
||
function validate(): boolean {
|
||
fieldErrors.startAt = ''
|
||
fieldErrors.endAt = ''
|
||
|
||
if (!form.startAt) {
|
||
fieldErrors.startAt = t('albumsManage.form.errors.startAtRequired')
|
||
return false
|
||
}
|
||
|
||
const translationErrors = validateTranslations([drafts['zh-CN'], drafts.en], form.defaultLocale)
|
||
if (translationErrors.length > 0) {
|
||
const code = translationErrors[0]
|
||
// 定位到出错的页签并切换,便于用户直接修补
|
||
const other =
|
||
code === 'titleRequiredWithFields' &&
|
||
!drafts.en.title.trim() &&
|
||
drafts['zh-CN'].title.trim()
|
||
? 'en'
|
||
: null
|
||
activeTab.value = other ?? form.defaultLocale
|
||
formTab.value = 'details'
|
||
ElMessage.error(
|
||
code === 'defaultLocaleMissing'
|
||
? t('albumsManage.form.errors.defaultLocaleMissing', { locale: form.defaultLocale })
|
||
: t(`albumsManage.form.errors.${code}`)
|
||
)
|
||
return false
|
||
}
|
||
|
||
const startIso = form.startAt.toISOString()
|
||
const endIso = form.endAt ? form.endAt.toISOString() : null
|
||
if (!validateTimeRange(startIso, endIso)) {
|
||
fieldErrors.endAt = t('albumsManage.form.errors.endAtNotAfterStartAt')
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
/** 保存后返回来源(列表或详情) */
|
||
function goBack(): void {
|
||
if (window.history.length > 1) {
|
||
router.back()
|
||
} else {
|
||
void router.push({ name: 'AlbumsManage' })
|
||
}
|
||
}
|
||
|
||
async function submit() {
|
||
if (!validate()) return
|
||
submitting.value = true
|
||
try {
|
||
const translations = toTranslationPayload([
|
||
drafts['zh-CN'],
|
||
drafts.en
|
||
]) as Api.Album.Translation[]
|
||
const endAt = form.endAt ? form.endAt.toISOString() : null
|
||
let saved: Api.Album.Detail
|
||
if (albumId.value) {
|
||
// 编辑:PATCH 全量可编辑字段(开关独立修改;translations 全量替换)
|
||
saved = await fetchUpdateAlbum(albumId.value, {
|
||
translations,
|
||
defaultLocale: form.defaultLocale,
|
||
startAt: form.startAt!.toISOString(),
|
||
endAt,
|
||
timezone: form.timezone.trim() || 'Asia/Shanghai',
|
||
uploadEnabled: form.uploadEnabled,
|
||
publicEnabled: form.publicEnabled,
|
||
downloadEnabled: form.downloadEnabled,
|
||
originalDownloadEnabled: form.originalDownloadEnabled
|
||
})
|
||
} else {
|
||
// 创建:契约仅 download 两开关(upload/public 服务端固定初始 true)
|
||
saved = await fetchCreateAlbum({
|
||
translations,
|
||
defaultLocale: form.defaultLocale,
|
||
startAt: form.startAt!.toISOString(),
|
||
endAt,
|
||
timezone: form.timezone.trim() || 'Asia/Shanghai',
|
||
downloadEnabled: form.downloadEnabled,
|
||
originalDownloadEnabled: form.originalDownloadEnabled
|
||
})
|
||
}
|
||
ElMessage.success(
|
||
isEdit.value ? t('albumsManage.form.updateSuccess') : t('albumsManage.form.createSuccess')
|
||
)
|
||
void router.replace({ name: 'AlbumsDetail', params: { albumId: saved.id } })
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
void load()
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
@reference '@styles/core/tailwind.css';
|
||
|
||
.ui06-form-page {
|
||
max-width: 900px;
|
||
padding: 28px 30px 90px;
|
||
margin: auto;
|
||
}
|
||
|
||
.page-heading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 20px;
|
||
margin-bottom: 22px;
|
||
|
||
.heading-left {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
|
||
h1 {
|
||
font-size: 24px;
|
||
letter-spacing: -0.6px;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
p {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
color: #7b899f;
|
||
}
|
||
}
|
||
}
|
||
|
||
.card-head h2 {
|
||
font-size: 17px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.switch-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
/* 示例 .switch-row:标题+说明+右开关 */
|
||
.switch-rows .switch-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 20px;
|
||
padding: 17px 0;
|
||
border-bottom: 1px solid #e5eaf1;
|
||
|
||
&:last-child {
|
||
border-bottom: 0;
|
||
}
|
||
|
||
.row-text {
|
||
strong {
|
||
font-size: 13px;
|
||
font-weight: 550;
|
||
}
|
||
|
||
p {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
color: #8a97ac;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 底部操作条 */
|
||
.form-footer {
|
||
display: flex;
|
||
gap: 12px;
|
||
justify-content: flex-end;
|
||
margin-top: 22px;
|
||
|
||
.el-button {
|
||
min-width: 120px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.ui06-form-page {
|
||
padding: 20px 14px 80px;
|
||
}
|
||
}
|
||
</style>
|