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>
@@ -18,14 +18,17 @@ test.beforeAll(() => {
|
|||||||
async function createAlbumViaUi(page: Page): Promise<string> {
|
async function createAlbumViaUi(page: Page): Promise<string> {
|
||||||
const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
||||||
await page.getByTestId('album-create-btn').click()
|
await page.getByTestId('album-create-btn').click()
|
||||||
await expect(page.getByTestId('album-form-dialog')).toBeVisible()
|
// UI06:创建改整页表单路由
|
||||||
|
await expect(page.getByTestId('album-form-page')).toBeVisible()
|
||||||
await page.getByTestId('album-form-title-zh-CN').fill(`E2E治理相册 ${suffix}`)
|
await page.getByTestId('album-form-title-zh-CN').fill(`E2E治理相册 ${suffix}`)
|
||||||
await page.getByRole('tab', { name: '英文' }).click()
|
await page.getByRole('tab', { name: '英文' }).click()
|
||||||
await page.getByTestId('album-form-title-en').fill(`E2E Gov Album ${suffix}`)
|
await page.getByTestId('album-form-title-en').fill(`E2E Gov Album ${suffix}`)
|
||||||
await page.getByTestId('album-form-start-at').locator('input').fill('2026-10-01 10:00:00')
|
await page.getByTestId('album-form-start-at').locator('input').fill('2026-10-01 10:00:00')
|
||||||
await page.keyboard.press('Enter')
|
await page.keyboard.press('Enter')
|
||||||
await page.getByTestId('album-form-submit').click()
|
await page.getByTestId('album-form-submit').click()
|
||||||
await expect(page.getByTestId('album-form-dialog')).toBeHidden()
|
// 保存成功后跳相册详情;返回列表做断言
|
||||||
|
await expect(page.getByTestId('album-form-page')).toBeHidden()
|
||||||
|
await page.goBack()
|
||||||
// UI06:列表默认封面卡片网格;表格断言前先切换
|
// UI06:列表默认封面卡片网格;表格断言前先切换
|
||||||
await page.getByTestId('album-view-table').click()
|
await page.getByTestId('album-view-table').click()
|
||||||
await expect(
|
await expect(
|
||||||
@@ -42,7 +45,7 @@ function albumRow(page: Page, suffix: string) {
|
|||||||
/** 进入详情页,返回 albumId */
|
/** 进入详情页,返回 albumId */
|
||||||
async function openAlbumDetail(page: Page, suffix: string): Promise<string> {
|
async function openAlbumDetail(page: Page, suffix: string): Promise<string> {
|
||||||
await albumRow(page, suffix).getByTestId('album-detail-btn').click()
|
await albumRow(page, suffix).getByTestId('album-detail-btn').click()
|
||||||
await expect(page.getByTestId('album-detail-card')).toBeVisible()
|
await expect(page.getByTestId('album-summary-cover')).toBeVisible()
|
||||||
const albumId = /\/albums\/detail\/([^/?#]+)/.exec(page.url())?.[1] ?? ''
|
const albumId = /\/albums\/detail\/([^/?#]+)/.exec(page.url())?.[1] ?? ''
|
||||||
expect(albumId).not.toBe('')
|
expect(albumId).not.toBe('')
|
||||||
return albumId
|
return albumId
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ test.beforeAll(() => {
|
|||||||
async function createAlbumViaUi(page: Page): Promise<string> {
|
async function createAlbumViaUi(page: Page): Promise<string> {
|
||||||
const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
||||||
await page.getByTestId('album-create-btn').click()
|
await page.getByTestId('album-create-btn').click()
|
||||||
await expect(page.getByTestId('album-form-dialog')).toBeVisible()
|
// UI06:创建改整页表单路由
|
||||||
|
await expect(page.getByTestId('album-form-page')).toBeVisible()
|
||||||
await page.getByTestId('album-form-title-zh-CN').fill(`E2E相册 ${suffix}`)
|
await page.getByTestId('album-form-title-zh-CN').fill(`E2E相册 ${suffix}`)
|
||||||
// 非激活页签的内容被隐藏(v-show):先切到英文页签再填写
|
// 非激活页签的内容被隐藏(v-show):先切到英文页签再填写
|
||||||
await page.getByRole('tab', { name: '英文' }).click()
|
await page.getByRole('tab', { name: '英文' }).click()
|
||||||
@@ -25,7 +26,9 @@ async function createAlbumViaUi(page: Page): Promise<string> {
|
|||||||
await page.getByTestId('album-form-start-at').locator('input').fill('2026-10-01 10:00:00')
|
await page.getByTestId('album-form-start-at').locator('input').fill('2026-10-01 10:00:00')
|
||||||
await page.keyboard.press('Enter')
|
await page.keyboard.press('Enter')
|
||||||
await page.getByTestId('album-form-submit').click()
|
await page.getByTestId('album-form-submit').click()
|
||||||
await expect(page.getByTestId('album-form-dialog')).toBeHidden()
|
// 保存成功后跳相册详情;返回列表做断言
|
||||||
|
await expect(page.getByTestId('album-form-page')).toBeHidden()
|
||||||
|
await page.goBack()
|
||||||
// UI06:列表默认封面卡片网格;表格断言前先切换
|
// UI06:列表默认封面卡片网格;表格断言前先切换
|
||||||
await page.getByTestId('album-view-table').click()
|
await page.getByTestId('album-view-table').click()
|
||||||
await expect(page.getByTestId('album-list-table').getByText(`E2E相册 ${suffix}`)).toBeVisible()
|
await expect(page.getByTestId('album-list-table').getByText(`E2E相册 ${suffix}`)).toBeVisible()
|
||||||
@@ -43,12 +46,16 @@ test('创建相册(zh+en)→ 列表可见 → 关闭上传开关 → 标签
|
|||||||
|
|
||||||
const row = albumRow(page, suffix)
|
const row = albumRow(page, suffix)
|
||||||
await row.getByTestId('album-edit-btn').click()
|
await row.getByTestId('album-edit-btn').click()
|
||||||
await expect(page.getByTestId('album-form-dialog')).toBeVisible()
|
// UI06:编辑改整页表单(资料/访问页签)
|
||||||
|
await expect(page.getByTestId('album-form-page')).toBeVisible()
|
||||||
|
await page.getByRole('tab', { name: '访问' }).click()
|
||||||
await page.getByTestId('album-form-switch-upload').click()
|
await page.getByTestId('album-form-switch-upload').click()
|
||||||
await page.getByTestId('album-form-submit').click()
|
await page.getByTestId('album-form-submit').click()
|
||||||
await expect(page.getByTestId('album-form-dialog')).toBeHidden()
|
await expect(page.getByTestId('album-form-page')).toBeHidden()
|
||||||
|
await page.goBack()
|
||||||
|
await page.getByTestId('album-view-table').click()
|
||||||
|
|
||||||
await expect(row.getByTestId('album-switch-upload')).toContainText('关')
|
await expect(albumRow(page, suffix).getByTestId('album-switch-upload')).toContainText('关')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('详情:邀请成员 → 建分类 → 传 banner → 照片管理(隐藏)', async ({ page }) => {
|
test('详情:邀请成员 → 建分类 → 传 banner → 照片管理(隐藏)', async ({ page }) => {
|
||||||
@@ -57,7 +64,7 @@ test('详情:邀请成员 → 建分类 → 传 banner → 照片管理(隐
|
|||||||
|
|
||||||
// ── 进入详情 ──
|
// ── 进入详情 ──
|
||||||
await albumRow(page, suffix).getByTestId('album-detail-btn').click()
|
await albumRow(page, suffix).getByTestId('album-detail-btn').click()
|
||||||
await expect(page.getByTestId('album-detail-card')).toBeVisible()
|
await expect(page.getByTestId('album-summary-cover')).toBeVisible()
|
||||||
const albumId = /\/albums\/detail\/([^/?#]+)/.exec(page.url())?.[1] ?? ''
|
const albumId = /\/albums\/detail\/([^/?#]+)/.exec(page.url())?.[1] ?? ''
|
||||||
expect(albumId).not.toBe('')
|
expect(albumId).not.toBe('')
|
||||||
|
|
||||||
@@ -84,6 +91,8 @@ test('详情:邀请成员 → 建分类 → 传 banner → 照片管理(隐
|
|||||||
await expect(page.getByTestId('category-list-table').getByText(`E2E分类 ${suffix}`)).toBeVisible()
|
await expect(page.getByTestId('category-list-table').getByText(`E2E分类 ${suffix}`)).toBeVisible()
|
||||||
|
|
||||||
// ── banner:真实小 jpeg 走 file chooser → init/PUT/complete → 预览 ──
|
// ── banner:真实小 jpeg 走 file chooser → init/PUT/complete → 预览 ──
|
||||||
|
// UI06:banner 收进「品牌资产」页签
|
||||||
|
await page.getByTestId('album-assets-tab').click()
|
||||||
const fileChooserPromise = page.waitForEvent('filechooser')
|
const fileChooserPromise = page.waitForEvent('filechooser')
|
||||||
await page.getByTestId('album-banner-pick').click()
|
await page.getByTestId('album-banner-pick').click()
|
||||||
const fileChooser = await fileChooserPromise
|
const fileChooser = await fileChooserPromise
|
||||||
|
|||||||
@@ -240,7 +240,9 @@
|
|||||||
"albums": {
|
"albums": {
|
||||||
"title": "Albums",
|
"title": "Albums",
|
||||||
"manage": "Album Management",
|
"manage": "Album Management",
|
||||||
"detail": "Album Detail"
|
"detail": "Album Detail",
|
||||||
|
"formCreate": "Create album",
|
||||||
|
"formEdit": "Edit album"
|
||||||
},
|
},
|
||||||
"group": {
|
"group": {
|
||||||
"workspace": "Workspace",
|
"workspace": "Workspace",
|
||||||
@@ -345,7 +347,18 @@
|
|||||||
"titleRequired": "Title is required",
|
"titleRequired": "Title is required",
|
||||||
"titleRequiredWithFields": "Title is required when other fields of this locale are filled",
|
"titleRequiredWithFields": "Title is required when other fields of this locale are filled",
|
||||||
"endAtNotAfterStartAt": "End time must be later than start time"
|
"endAtNotAfterStartAt": "End time must be later than start time"
|
||||||
}
|
},
|
||||||
|
"pageSubtitle": "Fill both languages by tab; default locale is required and all languages are preserved on save.",
|
||||||
|
"basicCard": "Basics",
|
||||||
|
"switchesCard": "Download switches",
|
||||||
|
"createSwitchesHint": "Upload & public switches are adjusted after creation via Edit · Access (create contract includes download switches only).",
|
||||||
|
"tabDetails": "Details",
|
||||||
|
"tabAccess": "Access",
|
||||||
|
"accessCard": "Access & download",
|
||||||
|
"switchUploadDesc": "Staff can upload photos in the app when on",
|
||||||
|
"switchPublicDesc": "Viewers can browse published photos via link when on",
|
||||||
|
"switchDownloadDesc": "Allow viewers to download watermarked variants",
|
||||||
|
"switchOriginalDesc": "Allow original download (use with care)"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"back": "Back to List",
|
"back": "Back to List",
|
||||||
@@ -371,7 +384,8 @@
|
|||||||
"statsNotSynced": "Not synced yet",
|
"statsNotSynced": "Not synced yet",
|
||||||
"fieldOwnerId": "Owner",
|
"fieldOwnerId": "Owner",
|
||||||
"subtitle": "Album workspace · Manage the moments that matter",
|
"subtitle": "Album workspace · Manage the moments that matter",
|
||||||
"share": "Share album"
|
"share": "Share album",
|
||||||
|
"tabAssets": "Branding assets"
|
||||||
},
|
},
|
||||||
"banner": {
|
"banner": {
|
||||||
"kind": "Asset Kind",
|
"kind": "Asset Kind",
|
||||||
|
|||||||
@@ -240,7 +240,9 @@
|
|||||||
"albums": {
|
"albums": {
|
||||||
"title": "相册管理",
|
"title": "相册管理",
|
||||||
"manage": "相册管理",
|
"manage": "相册管理",
|
||||||
"detail": "相册详情"
|
"detail": "相册详情",
|
||||||
|
"formCreate": "新建相册",
|
||||||
|
"formEdit": "编辑相册"
|
||||||
},
|
},
|
||||||
"group": {
|
"group": {
|
||||||
"workspace": "工作空间",
|
"workspace": "工作空间",
|
||||||
@@ -345,7 +347,18 @@
|
|||||||
"titleRequired": "标题必填",
|
"titleRequired": "标题必填",
|
||||||
"titleRequiredWithFields": "该语言已填写其他字段,标题也必填",
|
"titleRequiredWithFields": "该语言已填写其他字段,标题也必填",
|
||||||
"endAtNotAfterStartAt": "结束时间必须晚于开始时间"
|
"endAtNotAfterStartAt": "结束时间必须晚于开始时间"
|
||||||
}
|
},
|
||||||
|
"pageSubtitle": "双语内容按语言分页填写;默认语言必填,保存时保留全部语言。",
|
||||||
|
"basicCard": "基础资料",
|
||||||
|
"switchesCard": "下载开关",
|
||||||
|
"createSwitchesHint": "上传与公开访问开关在创建后经「编辑 · 访问」调整(创建契约仅含下载开关)。",
|
||||||
|
"tabDetails": "资料",
|
||||||
|
"tabAccess": "访问",
|
||||||
|
"accessCard": "访问与下载",
|
||||||
|
"switchUploadDesc": "开启后工作人员可在 App 上传照片",
|
||||||
|
"switchPublicDesc": "开启后观众可通过链接浏览已公开照片",
|
||||||
|
"switchDownloadDesc": "允许观众下载带水印变体",
|
||||||
|
"switchOriginalDesc": "允许下载原图(谨慎开启)"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"back": "返回列表",
|
"back": "返回列表",
|
||||||
@@ -371,7 +384,8 @@
|
|||||||
"statsNotSynced": "尚无回传",
|
"statsNotSynced": "尚无回传",
|
||||||
"fieldOwnerId": "负责人",
|
"fieldOwnerId": "负责人",
|
||||||
"subtitle": "相册工作区 · 管理活动中的精彩瞬间",
|
"subtitle": "相册工作区 · 管理活动中的精彩瞬间",
|
||||||
"share": "分享相册"
|
"share": "分享相册",
|
||||||
|
"tabAssets": "品牌资产"
|
||||||
},
|
},
|
||||||
"banner": {
|
"banner": {
|
||||||
"kind": "资产类型",
|
"kind": "资产类型",
|
||||||
|
|||||||
@@ -36,6 +36,30 @@ export const albumsRoutes: AppRouteRecord = {
|
|||||||
keepAlive: false,
|
keepAlive: false,
|
||||||
activePath: '/albums/manage'
|
activePath: '/albums/manage'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'form/create',
|
||||||
|
name: 'AlbumsFormCreate',
|
||||||
|
component: '/albums/form',
|
||||||
|
meta: {
|
||||||
|
title: 'menus.albums.formCreate',
|
||||||
|
icon: 'ri:edit-2-line',
|
||||||
|
isHide: true,
|
||||||
|
keepAlive: false,
|
||||||
|
activePath: '/albums/manage'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'form/:albumId',
|
||||||
|
name: 'AlbumsFormEdit',
|
||||||
|
component: '/albums/form',
|
||||||
|
meta: {
|
||||||
|
title: 'menus.albums.formEdit',
|
||||||
|
icon: 'ri:edit-2-line',
|
||||||
|
isHide: true,
|
||||||
|
keepAlive: false,
|
||||||
|
activePath: '/albums/manage'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
type="primary"
|
type="primary"
|
||||||
plain
|
plain
|
||||||
data-test="album-detail-edit"
|
data-test="album-detail-edit"
|
||||||
@click="editVisible = true"
|
@click="router.push({ name: 'AlbumsFormEdit', params: { albumId: albumId } })"
|
||||||
>
|
>
|
||||||
{{ $t('albumsManage.detail.edit') }}
|
{{ $t('albumsManage.detail.edit') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
@@ -44,11 +44,6 @@
|
|||||||
|
|
||||||
<!-- 摘要头:封面 + 紧凑资料 + 高级折叠(替换原 ElDescriptions 大区) -->
|
<!-- 摘要头:封面 + 紧凑资料 + 高级折叠(替换原 ElDescriptions 大区) -->
|
||||||
<AlbumSummaryHeader v-if="album" :album="album" />
|
<AlbumSummaryHeader v-if="album" :album="album" />
|
||||||
|
|
||||||
<!-- 品牌资产(banner 上传;B3c 收进独立页签,本批保持原位) -->
|
|
||||||
<div v-if="album" class="mt-5">
|
|
||||||
<AlbumBannerUpload :album-id="albumId" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ElTabs v-if="album" v-model="activeTab" class="mt-4" data-test="album-detail-tabs">
|
<ElTabs v-if="album" v-model="activeTab" class="mt-4" data-test="album-detail-tabs">
|
||||||
@@ -88,10 +83,15 @@
|
|||||||
</template>
|
</template>
|
||||||
<AlbumShareCard :album="album" />
|
<AlbumShareCard :album="album" />
|
||||||
</ElTabPane>
|
</ElTabPane>
|
||||||
|
<ElTabPane name="assets">
|
||||||
|
<template #label>
|
||||||
|
<span data-test="album-assets-tab">{{ $t('albumsManage.detail.tabAssets') }}</span>
|
||||||
|
</template>
|
||||||
|
<AlbumBannerUpload :album-id="albumId" />
|
||||||
|
</ElTabPane>
|
||||||
</ElTabs>
|
</ElTabs>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<AlbumFormDialog v-if="album" v-model="editVisible" :album="album" @saved="onAlbumSaved" />
|
|
||||||
<AlbumTransferDialog
|
<AlbumTransferDialog
|
||||||
v-if="album"
|
v-if="album"
|
||||||
v-model="transferVisible"
|
v-model="transferVisible"
|
||||||
@@ -105,7 +105,6 @@
|
|||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||||
import AlbumFormDialog from '@/views/albums/manage/AlbumFormDialog.vue'
|
|
||||||
import AlbumSummaryHeader from '@/components/business/albums/AlbumSummaryHeader.vue'
|
import AlbumSummaryHeader from '@/components/business/albums/AlbumSummaryHeader.vue'
|
||||||
import AlbumBannerUpload from './AlbumBannerUpload.vue'
|
import AlbumBannerUpload from './AlbumBannerUpload.vue'
|
||||||
import AlbumMembers from './AlbumMembers.vue'
|
import AlbumMembers from './AlbumMembers.vue'
|
||||||
@@ -126,7 +125,6 @@
|
|||||||
const album = ref<Api.Album.Detail | null>(null)
|
const album = ref<Api.Album.Detail | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<Error | null>(null)
|
const error = ref<Error | null>(null)
|
||||||
const editVisible = ref(false)
|
|
||||||
const transferVisible = ref(false)
|
const transferVisible = ref(false)
|
||||||
const activeTab = ref('photos')
|
const activeTab = ref('photos')
|
||||||
|
|
||||||
@@ -143,10 +141,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAlbumSaved(saved: Api.Album.Detail) {
|
|
||||||
album.value = saved
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 交接成功:回包 ownerId 已变更,原位替换相册详情 */
|
/** 交接成功:回包 ownerId 已变更,原位替换相册详情 */
|
||||||
function onTransferred(transferred: Api.Album.Detail) {
|
function onTransferred(transferred: Api.Album.Detail) {
|
||||||
album.value = transferred
|
album.value = transferred
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
<!-- 相册整页表单(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>
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
<!-- 创建/编辑相册对话框:zh-CN/en 翻译分页签 + 契约镜像校验(utils/albums/albumValidation)。
|
|
||||||
编辑为 PATCH 语义;translations 全量替换(提示 i18n)。 -->
|
|
||||||
<template>
|
|
||||||
<ElDialog
|
|
||||||
v-model="visible"
|
|
||||||
:title="isEdit ? t('albumsManage.form.editTitle') : t('albumsManage.form.createTitle')"
|
|
||||||
width="720px"
|
|
||||||
destroy-on-close
|
|
||||||
data-test="album-form-dialog"
|
|
||||||
@closed="resetForm"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<ElFormItem :label="t('albumsManage.form.fieldSwitches')">
|
|
||||||
<div class="flex flex-col gap-2" data-test="album-form-switches">
|
|
||||||
<template v-if="isEdit">
|
|
||||||
<ElSwitch
|
|
||||||
v-model="form.uploadEnabled"
|
|
||||||
:active-text="t('albumsManage.form.switchUpload')"
|
|
||||||
data-test="album-form-switch-upload"
|
|
||||||
/>
|
|
||||||
<ElSwitch
|
|
||||||
v-model="form.publicEnabled"
|
|
||||||
:active-text="t('albumsManage.form.switchPublic')"
|
|
||||||
data-test="album-form-switch-public"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<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>
|
|
||||||
</ElFormItem>
|
|
||||||
|
|
||||||
<ElAlert
|
|
||||||
v-if="isEdit"
|
|
||||||
type="warning"
|
|
||||||
:closable="false"
|
|
||||||
:title="t('albumsManage.form.translationsReplaceHint')"
|
|
||||||
data-test="album-form-replace-hint"
|
|
||||||
/>
|
|
||||||
</ElForm>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<ElButton data-test="album-form-cancel" @click="visible = false">
|
|
||||||
{{ t('albumsManage.form.cancel') }}
|
|
||||||
</ElButton>
|
|
||||||
<ElButton type="primary" :loading="submitting" data-test="album-form-submit" @click="submit">
|
|
||||||
{{ t('albumsManage.form.submit') }}
|
|
||||||
</ElButton>
|
|
||||||
</template>
|
|
||||||
</ElDialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import TranslationFields from './TranslationFields.vue'
|
|
||||||
import { fetchCreateAlbum, fetchUpdateAlbum } from '@/api/albums'
|
|
||||||
import {
|
|
||||||
toTranslationPayload,
|
|
||||||
validateTimeRange,
|
|
||||||
validateTranslations,
|
|
||||||
type TranslationDraft
|
|
||||||
} from '@/utils/albums/albumValidation'
|
|
||||||
|
|
||||||
/** 翻译草稿形状(两语言固定键,模板按键取值) */
|
|
||||||
type DraftMap = Record<'zh-CN' | 'en', TranslationDraft>
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
/** 编辑目标(null = 创建) */
|
|
||||||
album: Api.Album.Summary | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: boolean): void
|
|
||||||
(e: 'saved', album: Api.Album.Detail): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const visible = defineModel<boolean>({ default: false })
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const isEdit = computed(() => props.album !== null)
|
|
||||||
|
|
||||||
const emptyDraft = (locale: 'zh-CN' | 'en'): TranslationDraft => ({
|
|
||||||
locale,
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
venue: '',
|
|
||||||
address: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const activeTab = ref<'zh-CN' | 'en'>('zh-CN')
|
|
||||||
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)
|
|
||||||
|
|
||||||
/** 打开时装载编辑目标(或重置为创建默认值) */
|
|
||||||
watch(visible, (open) => {
|
|
||||||
if (!open) return
|
|
||||||
resetForm()
|
|
||||||
const album = props.album
|
|
||||||
if (!album) return
|
|
||||||
form.defaultLocale = album.defaultLocale
|
|
||||||
form.startAt = new Date(album.startAt)
|
|
||||||
form.endAt = album.endAt ? new Date(album.endAt) : null
|
|
||||||
form.timezone = album.timezone
|
|
||||||
form.uploadEnabled = album.uploadEnabled
|
|
||||||
form.publicEnabled = album.publicEnabled
|
|
||||||
form.downloadEnabled = album.downloadEnabled
|
|
||||||
form.originalDownloadEnabled = album.originalDownloadEnabled
|
|
||||||
for (const locale of ['zh-CN', 'en'] as const) {
|
|
||||||
const translation = album.translations.find((item) => item.locale === locale)
|
|
||||||
drafts[locale] = {
|
|
||||||
...emptyDraft(locale),
|
|
||||||
title: translation?.title ?? '',
|
|
||||||
description: translation?.description ?? '',
|
|
||||||
venue: translation?.venue ?? '',
|
|
||||||
address: translation?.address ?? ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function resetForm() {
|
|
||||||
activeTab.value = 'zh-CN'
|
|
||||||
drafts['zh-CN'] = emptyDraft('zh-CN')
|
|
||||||
drafts.en = emptyDraft('en')
|
|
||||||
Object.assign(form, {
|
|
||||||
defaultLocale: 'zh-CN',
|
|
||||||
startAt: null,
|
|
||||||
endAt: null,
|
|
||||||
timezone: 'Asia/Shanghai',
|
|
||||||
uploadEnabled: true,
|
|
||||||
publicEnabled: true,
|
|
||||||
downloadEnabled: true,
|
|
||||||
originalDownloadEnabled: false
|
|
||||||
})
|
|
||||||
fieldErrors.startAt = ''
|
|
||||||
fieldErrors.endAt = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 提交前校验(镜像契约:默认语言覆盖 + 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
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (props.album) {
|
|
||||||
// 编辑:PATCH 全量可编辑字段(开关独立修改;translations 全量替换)
|
|
||||||
saved = await fetchUpdateAlbum(props.album.id, {
|
|
||||||
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')
|
|
||||||
)
|
|
||||||
emit('saved', saved)
|
|
||||||
visible.value = false
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -170,8 +170,6 @@
|
|||||||
</ElButton>
|
</ElButton>
|
||||||
<span v-else>{{ $t('albumsManage.list.noMore') }}</span>
|
<span v-else>{{ $t('albumsManage.list.noMore') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AlbumFormDialog v-model="formVisible" :album="editing" @saved="onSaved" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -180,7 +178,6 @@
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Search, Menu, Grid } from '@element-plus/icons-vue'
|
import { Search, Menu, Grid } from '@element-plus/icons-vue'
|
||||||
import AlbumFormDialog from './AlbumFormDialog.vue'
|
|
||||||
import AlbumCoverCard from '@/components/business/albums/AlbumCoverCard.vue'
|
import AlbumCoverCard from '@/components/business/albums/AlbumCoverCard.vue'
|
||||||
import { fetchAlbumList } from '@/api/albums'
|
import { fetchAlbumList } from '@/api/albums'
|
||||||
import { useCursorList } from '@/views/albums/composables/useCursorList'
|
import { useCursorList } from '@/views/albums/composables/useCursorList'
|
||||||
@@ -243,22 +240,13 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 创建 / 编辑 ──
|
// ── 创建 / 编辑(UI06:整页表单路由) ──
|
||||||
const formVisible = ref(false)
|
|
||||||
const editing = ref<Api.Album.Summary | null>(null)
|
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editing.value = null
|
router.push({ name: 'AlbumsFormCreate' })
|
||||||
formVisible.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(album: Api.Album.Summary) {
|
function openEdit(album: Api.Album.Summary) {
|
||||||
editing.value = album
|
router.push({ name: 'AlbumsFormEdit', params: { albumId: album.id } })
|
||||||
formVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSaved() {
|
|
||||||
loadFirst()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 跳转 ──
|
// ── 跳转 ──
|
||||||
|
|||||||
|
After Width: | Height: | Size: 626 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 415 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 109 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-09-22T22:54:34.211Z",
|
||||||
|
"base": "http://219.157.255.213:32005",
|
||||||
|
"viewport": "1440x1000",
|
||||||
|
"errors": [],
|
||||||
|
"notes": [
|
||||||
|
"15-branding: before 状态 banner 位于详情卡内,未独立页签",
|
||||||
|
"16-photo-viewer: before 状态无大图预览,以照片表格代替"
|
||||||
|
]
|
||||||
|
}
|
||||||