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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user