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"
},
"members": {
"title": "Album Members",
"title": "Album members",
"searchPlaceholder": "Type an account prefix (min 2 chars) to search",
"searching": "Searching…",
"noMatch": "No matching account",
@@ -407,10 +407,15 @@
"roleOwner": "Owner",
"roleCollaborator": "Collaborator",
"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": {
"title": "Categories",
"title": "Photo categories",
"create": "New Category",
"edit": "Edit Category",
"colSort": "Sort",
@@ -428,7 +433,10 @@
"deleteSuccess": "Category deleted",
"empty": "No categories",
"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": {
"title": "Photo Management",
@@ -521,7 +529,9 @@
"state_done": "Done",
"state_failed": "Failed",
"failedCount": "{count} failed",
"jobsEmpty": "No regeneration jobs"
"jobsEmpty": "No regeneration jobs",
"formTitle": "Watermark settings",
"previewBadge": "CSS simulation"
},
"share": {
"viewerUrl": "Viewer URL",
+14 -4
View File
@@ -407,10 +407,15 @@
"roleOwner": "创建者",
"roleCollaborator": "协作者",
"remove": "移除",
"empty": "暂无成员"
"empty": "暂无成员",
"subtitle": "创建者管理全部照片;协作者管理自己上传的照片。",
"colMember": "成员",
"colScope": "权限范围",
"scopeOwner": "全部照片及成员",
"scopeCollaborator": "自己上传的照片"
},
"categories": {
"title": "分类管理",
"title": "照片分类",
"create": "新建分类",
"edit": "编辑分类",
"colSort": "排序",
@@ -428,7 +433,10 @@
"deleteSuccess": "分类已删除",
"empty": "暂无分类",
"submit": "保存",
"cancel": "取消"
"cancel": "取消",
"subtitle": "保存时保留所有语言内容,避免覆盖其他语言。",
"colCategory": "分类名称",
"deleteConstraint": "已有照片的分类不能直接删除。"
},
"photos": {
"title": "照片管理",
@@ -521,7 +529,9 @@
"state_done": "已完成",
"state_failed": "已失败",
"failedCount": "失败 {count} 张",
"jobsEmpty": "暂无重生成任务"
"jobsEmpty": "暂无重生成任务",
"formTitle": "水印配置",
"previewBadge": "CSS 示意"
},
"share": {
"viewerUrl": "观众端链接",
@@ -1,40 +1,63 @@
<!-- 分类管理:列表 + 新建/编辑(sortOrder + zh/en 名称)+ 删除确认。
分类数据来自相册详情(album.categories,按 sortOrder 升序),变更后通知
父级重载详情。translations 全量替换语义(至少一个语言名称)。 -->
<!-- 分类管理(UI06 对齐示例 05:卡片头 + 双语同屏 + 底部删除约束提示)。
功能不变:新建/编辑(sortOrder + zh/en)+ 删除确认;translations 全量替换语义。 -->
<template>
<div data-test="categories-panel">
<div class="mb-4">
<ElButton type="primary" data-test="category-create-btn" @click="openCreate">
{{ $t('albumsManage.categories.create') }}
</ElButton>
</div>
<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.colZh')" min-width="160">
<template #default="{ row }">
<span data-test="category-name-zh">{{ nameOf(row, 'zh-CN') }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('albumsManage.categories.colEn')" min-width="160">
<template #default="{ row }">
<span data-test="category-name-en">{{ nameOf(row, 'en') }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('albumsManage.categories.colActions')" width="140">
<template #default="{ row }">
<ElButton link type="primary" data-test="category-edit-btn" @click="openEdit(row)">
{{ $t('albumsManage.categories.edit') }}
<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">
{{ t('albumsManage.categories.create') }}
</ElButton>
<ElButton link type="danger" data-test="category-delete-btn" @click="remove(row)">
{{ $t('albumsManage.categories.delete') }}
</ElButton>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty :description="$t('albumsManage.categories.empty')" />
</div>
</template>
</ElTable>
<ElTable :data="props.album.categories" data-test="category-list-table">
<ElTableColumn :label="t('albumsManage.categories.colCategory')" min-width="180">
<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 }">
<span data-test="category-name-zh">{{ nameOf(row, 'zh-CN') }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.categories.colEn')" min-width="150">
<template #default="{ row }">
<span data-test="category-name-en">{{ nameOf(row, 'en') }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.categories.colActions')" width="140">
<template #default="{ row }">
<ElButton link type="primary" data-test="category-edit-btn" @click="openEdit(row)">
{{ t('albumsManage.categories.edit') }}
</ElButton>
<ElButton link type="danger" data-test="category-delete-btn" @click="remove(row)">
{{ t('albumsManage.categories.delete') }}
</ElButton>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty :description="$t('albumsManage.categories.empty')" />
</template>
</ElTable>
<p class="table-note">
{{ t('albumsManage.categories.deleteConstraint') }}
</p>
</ElCard>
<ElDialog
v-model="dialogVisible"
@@ -80,6 +103,7 @@
import { reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CollectionTag } from '@element-plus/icons-vue'
import { fetchCreateCategory, fetchDeleteCategory, fetchUpdateCategory } from '@/api/albums'
const props = defineProps<{
@@ -174,3 +198,55 @@
emit('changed')
}
</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,94 +1,132 @@
<!-- 成员管理:列表(角色标签)+ 账号前缀搜索邀请(lookupUsersByAccount,
≥2 字符)+ 移除确认。owner 不可移除(服务端 403,前端预置禁用)。 -->
<!-- 成员管理(UI06 对齐示例 04:卡片头 + 邀请同事 + 成员表[头像/角色/权限范围/交接])。
功能不变:账号前缀搜索邀请(lookupUsersByAccount ≥2 字符)+ 移除确认;
owner 不可移除(服务端 403,前端预置禁用);交接由父组件弹确认。 -->
<template>
<div data-test="members-panel">
<div class="flex flex-wrap items-center gap-2 mb-4">
<ElSelect
v-model="selectedUser"
filterable
remote
clearable
:remote-method="searchUsers"
:loading="searching"
:placeholder="t('albumsManage.members.searchPlaceholder')"
style="width: 320px"
data-test="member-search-select"
>
<ElOption
v-for="user in options"
:key="user.id"
:value="user.id"
:label="`${user.account}(${user.displayName})`"
:data-test="`member-option-${user.account}`"
/>
<template #empty>
<span class="px-4 py-2 text-gray-400 text-sm">
{{
query.length < 2
? t('albumsManage.members.searchPlaceholder')
: searching
? t('albumsManage.members.searching')
: t('albumsManage.members.noMatch')
}}
</span>
</template>
</ElSelect>
<ElButton
type="primary"
:disabled="!selectedUser"
:loading="inviting"
data-test="member-invite-btn"
@click="invite"
>
{{ t('albumsManage.members.invite') }}
</ElButton>
</div>
<ElAlert v-if="error && !items.length" type="error" :closable="false">
{{ error.message }}
</ElAlert>
<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.colDisplayName')"
prop="displayName"
min-width="140"
/>
<ElTableColumn :label="t('albumsManage.members.colRole')" width="120">
<template #default="{ row }">
<ElTag :type="row.role === 'owner' ? 'warning' : 'info'" data-test="member-role-tag">
{{
row.role === 'owner'
? t('albumsManage.members.roleOwner')
: t('albumsManage.members.roleCollaborator')
}}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.members.colActions')" width="100">
<template #default="{ row }">
<ElButton
v-if="row.role !== 'owner'"
link
type="danger"
data-test="member-remove-btn"
@click="remove(row)"
>
{{ t('albumsManage.members.remove') }}
</ElButton>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty v-if="!loading" :description="$t('albumsManage.members.empty')" />
<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
v-model="selectedUser"
filterable
remote
clearable
:remote-method="searchUsers"
:loading="searching"
:placeholder="t('albumsManage.members.searchPlaceholder')"
style="width: 260px"
data-test="member-search-select"
>
<ElOption
v-for="user in options"
:key="user.id"
:value="user.id"
:label="`${user.account}(${user.displayName})`"
:data-test="`member-option-${user.account}`"
/>
<template #empty>
<span class="px-4 py-2 text-gray-400 text-sm">
{{
query.length < 2
? t('albumsManage.members.searchPlaceholder')
: searching
? t('albumsManage.members.searching')
: t('albumsManage.members.noMatch')
}}
</span>
</template>
</ElSelect>
<ElButton
type="primary"
:disabled="!selectedUser"
:loading="inviting"
data-test="member-invite-btn"
@click="invite"
>
{{ t('albumsManage.members.invite') }}
</ElButton>
</div>
</div>
</template>
</ElTable>
<div v-if="items.length && hasMore" class="mt-3 text-center">
<ElButton :loading="loading" data-test="member-load-more" @click="loadMore()">
{{ $t('albumsManage.list.loadMore') }}
</ElButton>
</div>
<ElAlert v-if="error && !items.length" type="error" :closable="false">
{{ error.message }}
</ElAlert>
<ElTable v-else v-loading="loading" :data="items" data-test="member-list-table">
<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
:label="t('albumsManage.members.colAccount')"
prop="account"
min-width="140"
/>
<ElTableColumn :label="t('albumsManage.members.colRole')" width="110">
<template #default="{ row }">
<span
class="pill"
:class="row.role === 'owner' ? '' : 'gray'"
data-test="member-role-tag"
>
{{
row.role === 'owner'
? t('albumsManage.members.roleOwner')
: t('albumsManage.members.roleCollaborator')
}}
</span>
</template>
</ElTableColumn>
<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 }">
<ElButton
v-if="row.role === 'owner'"
link
type="primary"
data-test="member-transfer-btn"
@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') }}
</ElButton>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty v-if="!loading" :description="$t('albumsManage.members.empty')" />
</template>
</ElTable>
<div v-if="items.length && hasMore" class="load-more">
<ElButton :loading="loading" data-test="member-load-more" @click="loadMore()">
{{ $t('albumsManage.list.loadMore') }}
</ElButton>
</div>
</ElCard>
</div>
</template>
@@ -104,10 +142,17 @@
} from '@/api/albums'
import { useCursorList } from '@/views/albums/composables/useCursorList'
defineOptions({ name: 'AlbumMembers' })
const props = defineProps<{
album: Api.Album.Detail
}>()
defineEmits<{
/** owner 行「交接相册」入口(父组件负责打开交接确认) */
(e: 'transfer'): void
}>()
const { t } = useI18n()
const { items, hasMore, loading, error, loadFirst, loadMore } = useCursorList<Api.Album.Member>(
@@ -115,6 +160,11 @@
(item) => item.userId
)
/** 头像首字(无头像数据时的中性回退) */
function initialOf(name: string): string {
return name.trim().charAt(0).toUpperCase() || '?'
}
// ── 账号前缀搜索(≥2 字符才发起;短前缀清空候选) ──
const query = ref('')
const options = ref<Api.Album.UserLookupItem[]>([])
@@ -172,3 +222,90 @@
loadFirst()
})
</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
(VITE_VIEWER_BASE + /a/{publicToken})+ qrcode canvas 渲染(相册切换
重绘)+ 复制链接。公开开关关闭时提示不可分享(链接仍展示——开关由
编辑表单控制,此处只读提示)。 -->
<!-- 分享链接与二维码卡(UI06 对齐示例 07:居中分享卡——封面/标题/日期地点/QR/复制)。
功能不变:viewer URL + qrcode canvas(相册切换重绘)+ 复制链接;公开关闭时只读提示。 -->
<template>
<div data-test="share-panel" class="max-w-xl">
<div data-test="share-panel">
<ElAlert
v-if="!album.publicEnabled"
type="info"
@@ -13,27 +11,33 @@
data-test="share-public-off-hint"
/>
<div class="flex flex-wrap items-start gap-6">
<div class="flex-1 min-w-260px">
<div class="text-gray-500 mb-1">{{ t('albumsManage.share.viewerUrl') }}</div>
<div class="flex items-center gap-2">
<code
class="bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded text-sm font-mono break-all"
data-test="share-url"
>
{{ viewerUrl }}
</code>
<ElButton type="primary" plain data-test="share-copy-btn" @click="copyLink">
{{ t('albumsManage.share.copy') }}
</ElButton>
<ElCard class="share-card" shadow="never">
<!-- 封面(无封面中性占位) -->
<div class="cover" data-test="share-cover">
<img v-if="album.cover?.url" :src="album.cover.url" alt="" />
<div v-else class="cover-placeholder">
<ElIcon :size="30"><Picture /></ElIcon>
</div>
<div class="text-gray-400 text-xs mt-2">{{ t('albumsManage.share.hint') }}</div>
</div>
<div class="text-center">
<h2>{{ titleOf(album) }}</h2>
<p class="meta">{{ metaLine }}</p>
<!-- 二维码(真实编码 viewer 链接;渲染失败保留空 canvas) -->
<div class="qr-wrap">
<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>
<p class="qr-note">{{ t('albumsManage.share.qrTitle') }}</p>
</div>
</div>
<ElButton type="primary" class="copy-btn" data-test="share-copy-btn" @click="copyLink">
{{ t('albumsManage.share.copy') }}
</ElButton>
<div class="url-row">
<code data-test="share-url">{{ viewerUrl }}</code>
</div>
<div class="hint">{{ t('albumsManage.share.hint') }}</div>
</ElCard>
</div>
</template>
@@ -41,6 +45,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { Picture } from '@element-plus/icons-vue'
import QRCode from 'qrcode'
import {
buildViewerAlbumUrl,
@@ -59,6 +64,19 @@
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)
/** QRCode.toCanvas 适配到注入签名(callback 重载在箭头内按实参解析) */
@@ -90,3 +108,85 @@
ElMessage.success(t('albumsManage.list.copied'))
}
</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>
<template v-else>
<!-- ── 当前配置 ── -->
<div class="mb-4" data-test="watermark-current">
<span class="text-gray-500">{{ t('albumsManage.watermark.currentVersion') }}</span>
<ElTag
class="ml-2"
:type="form.enabled ? 'success' : 'info'"
data-test="watermark-current-tag"
>
{{
status?.current
? t('albumsManage.watermark.versionTag', {
version: status.current.configVersion,
state: form.enabled
? t('albumsManage.watermark.enabled')
: t('albumsManage.watermark.disabled')
})
: t('albumsManage.watermark.neverConfigured')
}}
</ElTag>
<span v-if="status?.current" class="ml-3 text-gray-400 text-sm">
{{ configSummary(status.current.config) }}
</span>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<!-- ── 参数表单 ── -->
<div>
<div class="ui06-two-col">
<!-- ── 参数表单卡 ── -->
<ElCard class="ui06-card" shadow="never">
<template #header>
<div class="card-head">
<h2>{{ t('albumsManage.watermark.formTitle') }}</h2>
<span
class="pill"
:class="form.enabled ? '' : 'gray'"
data-test="watermark-current-tag"
>
{{
status?.current
? `v${status.current.configVersion} · ${
form.enabled
? t('albumsManage.watermark.enabled')
: t('albumsManage.watermark.disabled')
}`
: t('albumsManage.watermark.neverConfigured')
}}
</span>
</div>
</template>
<div data-test="watermark-current" class="config-summary">
<span v-if="status?.current">{{ configSummary(status.current.config) }}</span>
</div>
<ElForm label-width="130px">
<ElFormItem :label="t('albumsManage.watermark.switchLabel')">
<ElSwitch v-model="form.enabled" data-test="watermark-enabled-switch" />
@@ -114,47 +113,58 @@
</ElButton>
<span class="text-gray-400 text-sm">{{ t('albumsManage.watermark.saveHint') }}</span>
</div>
</div>
</ElCard>
<!-- ── 示意预览(CSS 叠加,非实际渲染) ── -->
<div>
<div class="text-gray-500 mb-1">{{ t('albumsManage.watermark.previewTitle') }}</div>
<!-- ── 效果预览卡(摄影底 + CSS 叠加示意,非实际渲染) ── -->
<ElCard class="ui06-card" shadow="never">
<template #header>
<div class="card-head">
<h2>{{ t('albumsManage.watermark.previewTitle') }}</h2>
<span class="head-note">{{ t('albumsManage.watermark.previewBadge') }}</span>
</div>
</template>
<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="{
width: `${WATERMARK_PREVIEW_WIDTH}px`,
maxWidth: '100%',
height: `${WATERMARK_PREVIEW_HEIGHT}px`
}"
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
class="text-slate-800 select-none whitespace-nowrap"
class="preview-text"
:style="previewTextStyle(form)"
data-test="watermark-preview-text"
>
{{ previewText }}
</span>
</div>
<div class="text-gray-400 text-xs mt-1">
<div class="text-gray-400 text-xs mt-2">
{{ t('albumsManage.watermark.previewHint') }}
</div>
</div>
</ElCard>
</div>
<!-- ── 版本历史 ── -->
<div class="mt-6">
<div class="flex items-center justify-between mb-2">
<span class="font-medium">{{ t('albumsManage.watermark.jobsTitle') }}</span>
<ElButton
plain
:loading="submitting"
:disabled="!status?.current"
data-test="watermark-regen-btn"
@click="save"
>
{{ t('albumsManage.watermark.regen') }}
</ElButton>
</div>
<!-- ── 生成任务卡 ── -->
<ElCard class="ui06-card jobs-card" shadow="never">
<template #header>
<div class="card-head">
<h2>{{ t('albumsManage.watermark.jobsTitle') }}</h2>
<ElButton
plain
:loading="submitting"
:disabled="!status?.current"
data-test="watermark-regen-btn"
@click="save"
>
{{ t('albumsManage.watermark.regen') }}
</ElButton>
</div>
</template>
<ElTable
v-loading="loading"
:data="status?.jobs ?? []"
@@ -197,7 +207,7 @@
<ElEmpty v-if="!loading" :description="$t('albumsManage.watermark.jobsEmpty')" />
</template>
</ElTable>
</div>
</ElCard>
</template>
</div>
</template>
@@ -348,3 +358,102 @@
}
})
</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>
<span data-test="album-members-tab">{{ $t('albumsManage.detail.tabMembers') }}</span>
</template>
<AlbumMembers :album="album" />
<AlbumMembers :album="album" @transfer="transferVisible = true" />
</ElTabPane>
<ElTabPane name="categories">
<template #label>