feat(ui06): B3b management tabs — members/categories/watermark/share aligned to preview

- 成员:卡片头(标题+说明+搜索邀请)+ 成员列(首字头像+姓名)+ 角色 pill + 权限范围
  + owner 行「交接相册」入口(父组件开确认);移除/邀请语义不变
- 分类:卡片头(双语提示+新增)+ 分类列(图标 avatar+默认语言名)+ 中英同屏列
  + 底部「已有照片的分类不能直接删除」提示
- 水印:双栏卡片(配置卡 v{version} pill / 预览卡)——预览改摄影底(相册 cover)+
  白字水印叠加;生成任务独立卡片 + 重新生成入口;轮询/受理语义不变
- 分享:居中分享卡(封面/标题/日期地点/QR 160px/Copy link 全宽/小字链接);
  QR 仍真实编码 viewer 链接
- i18n zh/en 补全;typecheck/lint/vitest/build 全绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-23 06:52:23 +08:00
parent f2002e1d21
commit d6d012b992
7 changed files with 644 additions and 202 deletions
+15 -5
View File
@@ -392,7 +392,7 @@
"invalidType": "Only jpg / png / heic images are supported" "invalidType": "Only jpg / png / heic images are supported"
}, },
"members": { "members": {
"title": "Album Members", "title": "Album members",
"searchPlaceholder": "Type an account prefix (min 2 chars) to search", "searchPlaceholder": "Type an account prefix (min 2 chars) to search",
"searching": "Searching…", "searching": "Searching…",
"noMatch": "No matching account", "noMatch": "No matching account",
@@ -407,10 +407,15 @@
"roleOwner": "Owner", "roleOwner": "Owner",
"roleCollaborator": "Collaborator", "roleCollaborator": "Collaborator",
"remove": "Remove", "remove": "Remove",
"empty": "No members" "empty": "No members",
"subtitle": "Owners manage all photos. Collaborators manage their own uploads.",
"colMember": "Member",
"colScope": "Scope",
"scopeOwner": "All photos and members",
"scopeCollaborator": "Own uploaded photos"
}, },
"categories": { "categories": {
"title": "Categories", "title": "Photo categories",
"create": "New Category", "create": "New Category",
"edit": "Edit Category", "edit": "Edit Category",
"colSort": "Sort", "colSort": "Sort",
@@ -428,7 +433,10 @@
"deleteSuccess": "Category deleted", "deleteSuccess": "Category deleted",
"empty": "No categories", "empty": "No categories",
"submit": "Save", "submit": "Save",
"cancel": "Cancel" "cancel": "Cancel",
"subtitle": "Keep both content languages together when saving.",
"colCategory": "Category",
"deleteConstraint": "A category with photos cannot be deleted."
}, },
"photos": { "photos": {
"title": "Photo Management", "title": "Photo Management",
@@ -521,7 +529,9 @@
"state_done": "Done", "state_done": "Done",
"state_failed": "Failed", "state_failed": "Failed",
"failedCount": "{count} failed", "failedCount": "{count} failed",
"jobsEmpty": "No regeneration jobs" "jobsEmpty": "No regeneration jobs",
"formTitle": "Watermark settings",
"previewBadge": "CSS simulation"
}, },
"share": { "share": {
"viewerUrl": "Viewer URL", "viewerUrl": "Viewer URL",
+14 -4
View File
@@ -407,10 +407,15 @@
"roleOwner": "创建者", "roleOwner": "创建者",
"roleCollaborator": "协作者", "roleCollaborator": "协作者",
"remove": "移除", "remove": "移除",
"empty": "暂无成员" "empty": "暂无成员",
"subtitle": "创建者管理全部照片;协作者管理自己上传的照片。",
"colMember": "成员",
"colScope": "权限范围",
"scopeOwner": "全部照片及成员",
"scopeCollaborator": "自己上传的照片"
}, },
"categories": { "categories": {
"title": "分类管理", "title": "照片分类",
"create": "新建分类", "create": "新建分类",
"edit": "编辑分类", "edit": "编辑分类",
"colSort": "排序", "colSort": "排序",
@@ -428,7 +433,10 @@
"deleteSuccess": "分类已删除", "deleteSuccess": "分类已删除",
"empty": "暂无分类", "empty": "暂无分类",
"submit": "保存", "submit": "保存",
"cancel": "取消" "cancel": "取消",
"subtitle": "保存时保留所有语言内容,避免覆盖其他语言。",
"colCategory": "分类名称",
"deleteConstraint": "已有照片的分类不能直接删除。"
}, },
"photos": { "photos": {
"title": "照片管理", "title": "照片管理",
@@ -521,7 +529,9 @@
"state_done": "已完成", "state_done": "已完成",
"state_failed": "已失败", "state_failed": "已失败",
"failedCount": "失败 {count} 张", "failedCount": "失败 {count} 张",
"jobsEmpty": "暂无重生成任务" "jobsEmpty": "暂无重生成任务",
"formTitle": "水印配置",
"previewBadge": "CSS 示意"
}, },
"share": { "share": {
"viewerUrl": "观众端链接", "viewerUrl": "观众端链接",
@@ -1,33 +1,51 @@
<!-- 分类管理:列表 + 新建/编辑(sortOrder + zh/en 名称)+ 删除确认。 <!-- 分类管理(UI06 对齐示例 05:卡片头 + 双语同屏 + 底部删除约束提示)。
分类数据来自相册详情(album.categories,按 sortOrder 升序),变更后通知 功能不变:新建/编辑(sortOrder + zh/en)+ 删除确认;translations 全量替换语义。 -->
父级重载详情。translations 全量替换语义(至少一个语言名称)。 -->
<template> <template>
<div data-test="categories-panel"> <div data-test="categories-panel">
<div class="mb-4"> <ElCard class="ui06-card" shadow="never">
<template #header>
<div class="card-head">
<div>
<h2>{{ t('albumsManage.categories.title') }}</h2>
<p class="head-desc">{{ t('albumsManage.categories.subtitle') }}</p>
</div>
<ElButton type="primary" data-test="category-create-btn" @click="openCreate"> <ElButton type="primary" data-test="category-create-btn" @click="openCreate">
{{ $t('albumsManage.categories.create') }} {{ t('albumsManage.categories.create') }}
</ElButton> </ElButton>
</div> </div>
</template>
<ElTable :data="props.album.categories" data-test="category-list-table"> <ElTable :data="props.album.categories" data-test="category-list-table">
<ElTableColumn :label="$t('albumsManage.categories.colSort')" prop="sortOrder" width="100" /> <ElTableColumn :label="t('albumsManage.categories.colCategory')" min-width="180">
<ElTableColumn :label="$t('albumsManage.categories.colZh')" min-width="160"> <template #default="{ row }">
<div class="person">
<span class="avatar">
<ElIcon :size="15"><CollectionTag /></ElIcon>
</span>
<strong data-test="category-name-default">{{
nameOf(row, props.album.defaultLocale)
}}</strong>
</div>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.categories.colSort')" prop="sortOrder" width="90" />
<ElTableColumn :label="t('albumsManage.categories.colZh')" min-width="150">
<template #default="{ row }"> <template #default="{ row }">
<span data-test="category-name-zh">{{ nameOf(row, 'zh-CN') }}</span> <span data-test="category-name-zh">{{ nameOf(row, 'zh-CN') }}</span>
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn :label="$t('albumsManage.categories.colEn')" min-width="160"> <ElTableColumn :label="t('albumsManage.categories.colEn')" min-width="150">
<template #default="{ row }"> <template #default="{ row }">
<span data-test="category-name-en">{{ nameOf(row, 'en') }}</span> <span data-test="category-name-en">{{ nameOf(row, 'en') }}</span>
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn :label="$t('albumsManage.categories.colActions')" width="140"> <ElTableColumn :label="t('albumsManage.categories.colActions')" width="140">
<template #default="{ row }"> <template #default="{ row }">
<ElButton link type="primary" data-test="category-edit-btn" @click="openEdit(row)"> <ElButton link type="primary" data-test="category-edit-btn" @click="openEdit(row)">
{{ $t('albumsManage.categories.edit') }} {{ t('albumsManage.categories.edit') }}
</ElButton> </ElButton>
<ElButton link type="danger" data-test="category-delete-btn" @click="remove(row)"> <ElButton link type="danger" data-test="category-delete-btn" @click="remove(row)">
{{ $t('albumsManage.categories.delete') }} {{ t('albumsManage.categories.delete') }}
</ElButton> </ElButton>
</template> </template>
</ElTableColumn> </ElTableColumn>
@@ -36,6 +54,11 @@
</template> </template>
</ElTable> </ElTable>
<p class="table-note">
{{ t('albumsManage.categories.deleteConstraint') }}
</p>
</ElCard>
<ElDialog <ElDialog
v-model="dialogVisible" v-model="dialogVisible"
:title="editingId ? t('albumsManage.categories.edit') : t('albumsManage.categories.create')" :title="editingId ? t('albumsManage.categories.edit') : t('albumsManage.categories.create')"
@@ -80,6 +103,7 @@
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { CollectionTag } from '@element-plus/icons-vue'
import { fetchCreateCategory, fetchDeleteCategory, fetchUpdateCategory } from '@/api/albums' import { fetchCreateCategory, fetchDeleteCategory, fetchUpdateCategory } from '@/api/albums'
const props = defineProps<{ const props = defineProps<{
@@ -174,3 +198,55 @@
emit('changed') emit('changed')
} }
</script> </script>
<style lang="scss" scoped>
@reference '@styles/core/tailwind.css';
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
h2 {
font-size: 17px;
line-height: 1.4;
}
.head-desc {
margin-top: 6px;
font-size: 11px;
color: #7b899f;
}
}
.person {
display: flex;
gap: 10px;
align-items: center;
.avatar {
display: grid;
flex: none;
place-items: center;
width: 34px;
height: 34px;
color: #8063a9;
background: #efeaf9;
border-radius: 50%;
}
}
.table-note {
margin-top: 18px;
font-size: 11px;
color: #7b899f;
}
@media (max-width: 640px) {
.card-head {
flex-direction: column;
align-items: stretch;
}
}
</style>
@@ -1,8 +1,16 @@
<!-- 成员管理:列表(角色标签)+ 账号前缀搜索邀请(lookupUsersByAccount, <!-- 成员管理(UI06 对齐示例 04:卡片头 + 邀请同事 + 成员表[头像/角色/权限范围/交接])。
≥2 字符)+ 移除确认。owner 不可移除(服务端 403,前端预置禁用)。 --> 功能不变:账号前缀搜索邀请(lookupUsersByAccount ≥2 字符)+ 移除确认;
owner 不可移除(服务端 403,前端预置禁用);交接由父组件弹确认。 -->
<template> <template>
<div data-test="members-panel"> <div data-test="members-panel">
<div class="flex flex-wrap items-center gap-2 mb-4"> <ElCard class="ui06-card" shadow="never">
<template #header>
<div class="card-head">
<div>
<h2>{{ t('albumsManage.members.title') }}</h2>
<p class="head-desc">{{ t('albumsManage.members.subtitle') }}</p>
</div>
<div class="head-actions">
<ElSelect <ElSelect
v-model="selectedUser" v-model="selectedUser"
filterable filterable
@@ -11,7 +19,7 @@
:remote-method="searchUsers" :remote-method="searchUsers"
:loading="searching" :loading="searching"
:placeholder="t('albumsManage.members.searchPlaceholder')" :placeholder="t('albumsManage.members.searchPlaceholder')"
style="width: 320px" style="width: 260px"
data-test="member-search-select" data-test="member-search-select"
> >
<ElOption <ElOption
@@ -43,38 +51,67 @@
{{ t('albumsManage.members.invite') }} {{ t('albumsManage.members.invite') }}
</ElButton> </ElButton>
</div> </div>
</div>
</template>
<ElAlert v-if="error && !items.length" type="error" :closable="false"> <ElAlert v-if="error && !items.length" type="error" :closable="false">
{{ error.message }} {{ error.message }}
</ElAlert> </ElAlert>
<ElTable v-else v-loading="loading" :data="items" data-test="member-list-table"> <ElTable v-else v-loading="loading" :data="items" data-test="member-list-table">
<ElTableColumn :label="t('albumsManage.members.colAccount')" prop="account" min-width="140" /> <ElTableColumn :label="t('albumsManage.members.colMember')" min-width="180">
<template #default="{ row }">
<div class="person">
<span class="avatar" :class="row.role === 'owner' ? 'green' : ''">
{{ initialOf(row.displayName || row.account) }}
</span>
<strong>{{ row.displayName || row.account }}</strong>
</div>
</template>
</ElTableColumn>
<ElTableColumn <ElTableColumn
:label="t('albumsManage.members.colDisplayName')" :label="t('albumsManage.members.colAccount')"
prop="displayName" prop="account"
min-width="140" min-width="140"
/> />
<ElTableColumn :label="t('albumsManage.members.colRole')" width="120"> <ElTableColumn :label="t('albumsManage.members.colRole')" width="110">
<template #default="{ row }"> <template #default="{ row }">
<ElTag :type="row.role === 'owner' ? 'warning' : 'info'" data-test="member-role-tag"> <span
class="pill"
:class="row.role === 'owner' ? '' : 'gray'"
data-test="member-role-tag"
>
{{ {{
row.role === 'owner' row.role === 'owner'
? t('albumsManage.members.roleOwner') ? t('albumsManage.members.roleOwner')
: t('albumsManage.members.roleCollaborator') : t('albumsManage.members.roleCollaborator')
}} }}
</ElTag> </span>
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn :label="t('albumsManage.members.colActions')" width="100"> <ElTableColumn :label="t('albumsManage.members.colScope')" min-width="180">
<template #default="{ row }">
<span class="scope-text">
{{
row.role === 'owner'
? t('albumsManage.members.scopeOwner')
: t('albumsManage.members.scopeCollaborator')
}}
</span>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.members.colActions')" width="130" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<ElButton <ElButton
v-if="row.role !== 'owner'" v-if="row.role === 'owner'"
link link
type="danger" type="primary"
data-test="member-remove-btn" data-test="member-transfer-btn"
@click="remove(row)" @click="$emit('transfer')"
> >
{{ t('albumsManage.transfer.entry') }}
</ElButton>
<ElButton v-else link type="danger" data-test="member-remove-btn" @click="remove(row)">
{{ t('albumsManage.members.remove') }} {{ t('albumsManage.members.remove') }}
</ElButton> </ElButton>
</template> </template>
@@ -84,11 +121,12 @@
</template> </template>
</ElTable> </ElTable>
<div v-if="items.length && hasMore" class="mt-3 text-center"> <div v-if="items.length && hasMore" class="load-more">
<ElButton :loading="loading" data-test="member-load-more" @click="loadMore()"> <ElButton :loading="loading" data-test="member-load-more" @click="loadMore()">
{{ $t('albumsManage.list.loadMore') }} {{ $t('albumsManage.list.loadMore') }}
</ElButton> </ElButton>
</div> </div>
</ElCard>
</div> </div>
</template> </template>
@@ -104,10 +142,17 @@
} from '@/api/albums' } from '@/api/albums'
import { useCursorList } from '@/views/albums/composables/useCursorList' import { useCursorList } from '@/views/albums/composables/useCursorList'
defineOptions({ name: 'AlbumMembers' })
const props = defineProps<{ const props = defineProps<{
album: Api.Album.Detail album: Api.Album.Detail
}>() }>()
defineEmits<{
/** owner 行「交接相册」入口(父组件负责打开交接确认) */
(e: 'transfer'): void
}>()
const { t } = useI18n() const { t } = useI18n()
const { items, hasMore, loading, error, loadFirst, loadMore } = useCursorList<Api.Album.Member>( const { items, hasMore, loading, error, loadFirst, loadMore } = useCursorList<Api.Album.Member>(
@@ -115,6 +160,11 @@
(item) => item.userId (item) => item.userId
) )
/** 头像首字(无头像数据时的中性回退) */
function initialOf(name: string): string {
return name.trim().charAt(0).toUpperCase() || '?'
}
// ── 账号前缀搜索(≥2 字符才发起;短前缀清空候选) ── // ── 账号前缀搜索(≥2 字符才发起;短前缀清空候选) ──
const query = ref('') const query = ref('')
const options = ref<Api.Album.UserLookupItem[]>([]) const options = ref<Api.Album.UserLookupItem[]>([])
@@ -172,3 +222,90 @@
loadFirst() loadFirst()
}) })
</script> </script>
<style lang="scss" scoped>
@reference '@styles/core/tailwind.css';
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
h2 {
font-size: 17px;
line-height: 1.4;
}
.head-desc {
margin-top: 6px;
font-size: 11px;
color: #7b899f;
}
.head-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
}
/* 示例 .person / .avatar */
.person {
display: flex;
gap: 10px;
align-items: center;
.avatar {
display: grid;
flex: none;
place-items: center;
width: 34px;
height: 34px;
font-size: 11px;
font-weight: 650;
color: #4f75ac;
background: #e4edfc;
border-radius: 50%;
&.green {
color: #32846c;
background: #e4f3ed;
}
}
}
.pill {
display: inline-flex;
align-items: center;
padding: 4px 8px;
font-size: 10px;
color: #5676a5;
background: #edf3fd;
border-radius: 6px;
white-space: nowrap;
&.gray {
color: #8b96a7;
background: #f0f2f6;
}
}
.scope-text {
font-size: 12px;
color: #52617a;
}
.load-more {
margin-top: 16px;
text-align: center;
}
@media (max-width: 640px) {
.card-head {
flex-direction: column;
align-items: stretch;
}
}
</style>
@@ -1,9 +1,7 @@
<!-- 分享链接与二维码卡(P4a Task 9,评审缺口补齐):viewer URL <!-- 分享链接与二维码卡(UI06 对齐示例 07:居中分享卡——封面/标题/日期地点/QR/复制)。
(VITE_VIEWER_BASE + /a/{publicToken})+ qrcode canvas 渲染(相册切换 功能不变:viewer URL + qrcode canvas(相册切换重绘)+ 复制链接;公开关闭时只读提示。 -->
重绘)+ 复制链接。公开开关关闭时提示不可分享(链接仍展示——开关由
编辑表单控制,此处只读提示)。 -->
<template> <template>
<div data-test="share-panel" class="max-w-xl"> <div data-test="share-panel">
<ElAlert <ElAlert
v-if="!album.publicEnabled" v-if="!album.publicEnabled"
type="info" type="info"
@@ -13,27 +11,33 @@
data-test="share-public-off-hint" data-test="share-public-off-hint"
/> />
<div class="flex flex-wrap items-start gap-6"> <ElCard class="share-card" shadow="never">
<div class="flex-1 min-w-260px"> <!-- 封面(无封面中性占位) -->
<div class="text-gray-500 mb-1">{{ t('albumsManage.share.viewerUrl') }}</div> <div class="cover" data-test="share-cover">
<div class="flex items-center gap-2"> <img v-if="album.cover?.url" :src="album.cover.url" alt="" />
<code <div v-else class="cover-placeholder">
class="bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded text-sm font-mono break-all" <ElIcon :size="30"><Picture /></ElIcon>
data-test="share-url" </div>
> </div>
{{ viewerUrl }}
</code> <h2>{{ titleOf(album) }}</h2>
<ElButton type="primary" plain data-test="share-copy-btn" @click="copyLink"> <p class="meta">{{ metaLine }}</p>
<!-- 二维码(真实编码 viewer 链接;渲染失败保留空 canvas) -->
<div class="qr-wrap">
<canvas ref="qrCanvas" width="200" height="200" data-test="share-qr-canvas" />
<p class="qr-note">{{ t('albumsManage.share.qrTitle') }}</p>
</div>
<ElButton type="primary" class="copy-btn" data-test="share-copy-btn" @click="copyLink">
{{ t('albumsManage.share.copy') }} {{ t('albumsManage.share.copy') }}
</ElButton> </ElButton>
<div class="url-row">
<code data-test="share-url">{{ viewerUrl }}</code>
</div> </div>
<div class="text-gray-400 text-xs mt-2">{{ t('albumsManage.share.hint') }}</div> <div class="hint">{{ t('albumsManage.share.hint') }}</div>
</div> </ElCard>
<div class="text-center">
<canvas ref="qrCanvas" width="200" height="200" data-test="share-qr-canvas" />
<div class="text-gray-400 text-xs mt-1">{{ t('albumsManage.share.qrTitle') }}</div>
</div>
</div>
</div> </div>
</template> </template>
@@ -41,6 +45,7 @@
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Picture } from '@element-plus/icons-vue'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { import {
buildViewerAlbumUrl, buildViewerAlbumUrl,
@@ -59,6 +64,19 @@
buildViewerAlbumUrl(viewerBaseFromEnv(import.meta.env), props.album.publicToken) buildViewerAlbumUrl(viewerBaseFromEnv(import.meta.env), props.album.publicToken)
) )
function titleOf(target: Api.Album.Detail): string {
const preferred = target.translations.find((item) => item.locale === target.defaultLocale)
return preferred?.title ?? target.translations[0]?.title ?? target.id
}
const metaLine = computed(() => {
const venue = props.album.translations.find(
(item) => item.locale === props.album.defaultLocale
)?.venue
const date = props.album.startAt?.slice(0, 10) ?? ''
return [date, venue].filter(Boolean).join(' · ')
})
const qrCanvas = ref<HTMLCanvasElement | null>(null) const qrCanvas = ref<HTMLCanvasElement | null>(null)
/** QRCode.toCanvas 适配到注入签名(callback 重载在箭头内按实参解析) */ /** QRCode.toCanvas 适配到注入签名(callback 重载在箭头内按实参解析) */
@@ -90,3 +108,85 @@
ElMessage.success(t('albumsManage.list.copied')) ElMessage.success(t('albumsManage.list.copied'))
} }
</script> </script>
<style lang="scss" scoped>
@reference '@styles/core/tailwind.css';
/* 示例 .share-card:居中布局 */
.share-card {
max-width: 420px;
margin: 0 auto;
text-align: center;
.cover {
height: 172px;
margin-bottom: 17px;
overflow: hidden;
background: #dce5ef;
border-radius: 8px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
display: grid;
place-items: center;
height: 100%;
color: #9fb0c4;
}
}
h2 {
font-size: 18px;
line-height: 1.4;
}
.meta {
margin-top: 6px;
font-size: 12px;
color: #7b899f;
}
.qr-wrap {
margin: 20px auto 10px;
canvas {
width: 160px;
height: 160px;
padding: 8px;
background: #ffffff;
border: 1px solid #e5eaf1;
border-radius: 8px;
}
.qr-note {
margin-top: 8px;
font-size: 11px;
color: #7b899f;
}
}
.copy-btn {
width: 100%;
}
.url-row {
margin-top: 14px;
code {
font-size: 10px;
color: #64809f;
word-break: break-all;
}
}
.hint {
margin-top: 6px;
font-size: 10px;
color: #97a4b5;
}
}
</style>
@@ -14,33 +14,32 @@
</ElAlert> </ElAlert>
<template v-else> <template v-else>
<!-- ── 当前配置 ── --> <div class="ui06-two-col">
<div class="mb-4" data-test="watermark-current"> <!-- ── 参数表单卡 ── -->
<span class="text-gray-500">{{ t('albumsManage.watermark.currentVersion') }}</span> <ElCard class="ui06-card" shadow="never">
<ElTag <template #header>
class="ml-2" <div class="card-head">
:type="form.enabled ? 'success' : 'info'" <h2>{{ t('albumsManage.watermark.formTitle') }}</h2>
<span
class="pill"
:class="form.enabled ? '' : 'gray'"
data-test="watermark-current-tag" data-test="watermark-current-tag"
> >
{{ {{
status?.current status?.current
? t('albumsManage.watermark.versionTag', { ? `v${status.current.configVersion} · ${
version: status.current.configVersion, form.enabled
state: form.enabled
? t('albumsManage.watermark.enabled') ? t('albumsManage.watermark.enabled')
: t('albumsManage.watermark.disabled') : t('albumsManage.watermark.disabled')
}) }`
: t('albumsManage.watermark.neverConfigured') : t('albumsManage.watermark.neverConfigured')
}} }}
</ElTag>
<span v-if="status?.current" class="ml-3 text-gray-400 text-sm">
{{ configSummary(status.current.config) }}
</span> </span>
</div> </div>
</template>
<div class="grid gap-6 lg:grid-cols-2"> <div data-test="watermark-current" class="config-summary">
<!-- ── 参数表单 ── --> <span v-if="status?.current">{{ configSummary(status.current.config) }}</span>
<div> </div>
<ElForm label-width="130px"> <ElForm label-width="130px">
<ElFormItem :label="t('albumsManage.watermark.switchLabel')"> <ElFormItem :label="t('albumsManage.watermark.switchLabel')">
<ElSwitch v-model="form.enabled" data-test="watermark-enabled-switch" /> <ElSwitch v-model="form.enabled" data-test="watermark-enabled-switch" />
@@ -114,37 +113,47 @@
</ElButton> </ElButton>
<span class="text-gray-400 text-sm">{{ t('albumsManage.watermark.saveHint') }}</span> <span class="text-gray-400 text-sm">{{ t('albumsManage.watermark.saveHint') }}</span>
</div> </div>
</div> </ElCard>
<!-- ── 示意预览(CSS 叠加,非实际渲染) ── --> <!-- ── 效果预览卡(摄影底 + CSS 叠加示意,非实际渲染) ── -->
<div> <ElCard class="ui06-card" shadow="never">
<div class="text-gray-500 mb-1">{{ t('albumsManage.watermark.previewTitle') }}</div> <template #header>
<div class="card-head">
<h2>{{ t('albumsManage.watermark.previewTitle') }}</h2>
<span class="head-note">{{ t('albumsManage.watermark.previewBadge') }}</span>
</div>
</template>
<div <div
class="relative overflow-hidden rounded border border-gray-200 dark:border-gray-700 bg-gradient-to-br from-sky-200 via-indigo-200 to-rose-200" class="watermark-preview"
:style="{ :style="{
width: `${WATERMARK_PREVIEW_WIDTH}px`, width: `${WATERMARK_PREVIEW_WIDTH}px`,
maxWidth: '100%',
height: `${WATERMARK_PREVIEW_HEIGHT}px` height: `${WATERMARK_PREVIEW_HEIGHT}px`
}" }"
data-test="watermark-preview-box" data-test="watermark-preview-box"
> >
<img v-if="album.cover?.url" :src="album.cover.url" alt="" class="preview-photo" />
<div v-else class="preview-placeholder" />
<div class="preview-shade" />
<span <span
class="text-slate-800 select-none whitespace-nowrap" class="preview-text"
:style="previewTextStyle(form)" :style="previewTextStyle(form)"
data-test="watermark-preview-text" data-test="watermark-preview-text"
> >
{{ previewText }} {{ previewText }}
</span> </span>
</div> </div>
<div class="text-gray-400 text-xs mt-1"> <div class="text-gray-400 text-xs mt-2">
{{ t('albumsManage.watermark.previewHint') }} {{ t('albumsManage.watermark.previewHint') }}
</div> </div>
</div> </ElCard>
</div> </div>
<!-- ── 版本历史 ── --> <!-- ── 生成任务卡 ── -->
<div class="mt-6"> <ElCard class="ui06-card jobs-card" shadow="never">
<div class="flex items-center justify-between mb-2"> <template #header>
<span class="font-medium">{{ t('albumsManage.watermark.jobsTitle') }}</span> <div class="card-head">
<h2>{{ t('albumsManage.watermark.jobsTitle') }}</h2>
<ElButton <ElButton
plain plain
:loading="submitting" :loading="submitting"
@@ -155,6 +164,7 @@
{{ t('albumsManage.watermark.regen') }} {{ t('albumsManage.watermark.regen') }}
</ElButton> </ElButton>
</div> </div>
</template>
<ElTable <ElTable
v-loading="loading" v-loading="loading"
:data="status?.jobs ?? []" :data="status?.jobs ?? []"
@@ -197,7 +207,7 @@
<ElEmpty v-if="!loading" :description="$t('albumsManage.watermark.jobsEmpty')" /> <ElEmpty v-if="!loading" :description="$t('albumsManage.watermark.jobsEmpty')" />
</template> </template>
</ElTable> </ElTable>
</div> </ElCard>
</template> </template>
</div> </div>
</template> </template>
@@ -348,3 +358,102 @@
} }
}) })
</script> </script>
<style lang="scss" scoped>
@reference '@styles/core/tailwind.css';
/* UI06:双栏卡片(示例 .two-col.equal + .card) */
.ui06-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 22px;
align-items: start;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
h2 {
font-size: 17px;
line-height: 1.4;
}
.head-note {
font-size: 11px;
color: #7b899f;
}
}
.pill {
display: inline-flex;
align-items: center;
padding: 4px 8px;
font-size: 10px;
color: #188761;
background: #e9f7f1;
border-radius: 6px;
white-space: nowrap;
&.gray {
color: #8b96a7;
background: #f0f2f6;
}
}
.config-summary {
margin-bottom: 14px;
font-size: 11px;
color: #7b899f;
}
/* 预览:摄影底 + 底部渐变 + 白字水印(示例 .watermark-preview) */
.watermark-preview {
position: relative;
overflow: hidden;
background: #dce5ef;
border-radius: 10px;
}
.preview-photo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-placeholder {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #cdd9e6, #aebfd2);
}
.preview-shade {
position: absolute;
inset: 0;
background: linear-gradient(transparent 40%, #0d1c3555);
}
.preview-text {
position: absolute;
right: 18px;
bottom: 16px;
color: #ffffff !important;
text-shadow: 0 1px 3px #00000066;
white-space: nowrap;
user-select: none;
}
.jobs-card {
margin-top: 20px;
}
@media (max-width: 900px) {
.ui06-two-col {
grid-template-columns: 1fr;
}
}
</style>
+1 -1
View File
@@ -64,7 +64,7 @@
<template #label> <template #label>
<span data-test="album-members-tab">{{ $t('albumsManage.detail.tabMembers') }}</span> <span data-test="album-members-tab">{{ $t('albumsManage.detail.tabMembers') }}</span>
</template> </template>
<AlbumMembers :album="album" /> <AlbumMembers :album="album" @transfer="transferVisible = true" />
</ElTabPane> </ElTabPane>
<ElTabPane name="categories"> <ElTabPane name="categories">
<template #label> <template #label>