Files
photo-live/apps/admin/src/views/albums/form/index.vue
T
浅念 352d7405c2 feat(ui06): B3c form batch — full-page create/edit form + assets tab
- 新增 /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>
2026-09-23 06:58:01 +08:00

467 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 相册整页表单(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>