feat(admin): batch ops, transfer, watermark, usage and qr share pages

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 21:10:30 +08:00
parent 618d1d796c
commit f08d68bebf
31 changed files with 2717 additions and 34 deletions
@@ -1,6 +1,10 @@
<!-- 照片管理(listAlbumPhotos):全状态可见 + uploader 脱敏 + 可见性/分类过滤。
单条操作:隐藏(published→hidden)/恢复发布(hidden→published)/软删除;
批量隐藏属 P4a,此处仅单条。操作结果按条回显(行内状态即时更新 + 消息)。 -->
单条操作:隐藏/恢复发布/软删除(行内回显);P4a Task 9 增量:
· 多选(仅 published 可选——批量隐藏的状态机前置)+ 批量隐藏(逐项结果
toast 汇总 成功x/失败y + 失败详情弹窗逐条错误码本地化;成功行原位
置 hidden,失败行不动——A16 部分成功语义)
· likeCount 副本列(P2 PhotoRecord 回传;相册级 statsSyncedAt 在详情
信息卡展示,照片行无单行同步时间)。 -->
<template>
<div data-test="photos-panel">
<div class="flex flex-wrap items-center gap-3 mb-4">
@@ -32,13 +36,31 @@
:label="categoryLabel(category)"
/>
</ElSelect>
<ElButton
type="warning"
plain
:disabled="selectedPublished.length === 0"
:loading="batchHiding"
data-test="photo-batch-hide-btn"
@click="batchHide"
>
{{ t('albumsManage.photos.batchHide') }}
<span v-if="selectedPublished.length" class="ml-1">({{ selectedPublished.length }})</span>
</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="photo-list-table">
<ElTable
v-else
v-loading="loading"
:data="items"
data-test="photo-list-table"
@selection-change="onSelectionChange"
>
<ElTableColumn type="selection" width="44" :selectable="batchSelectable" />
<ElTableColumn :label="t('albumsManage.photos.colState')" width="120">
<template #default="{ row }">
<ElTag :type="stateTagType(row.processingState)" data-test="photo-state-tag">
@@ -70,6 +92,11 @@
<span>{{ row.uploader.displayName || row.uploader.account }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.photos.colLikeCount')" width="90">
<template #default="{ row }">
<span class="text-gray-500" data-test="photo-like-count">{{ row.likeCount }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.photos.colUploadedAt')" width="160">
<template #default="{ row }">
<span>{{ formatDateTime(row.uploadedAt) }}</span>
@@ -116,16 +143,59 @@
{{ $t('albumsManage.list.loadMore') }}
</ElButton>
</div>
<!-- 批量隐藏失败详情(逐条错误码本地化;成功行已原位回显) -->
<ElDialog
v-model="failuresVisible"
:title="t('albumsManage.photos.batchFailuresTitle')"
width="480px"
data-test="photo-batch-failures-dialog"
>
<ElTable :data="batchSummary?.failures ?? []" size="small">
<ElTableColumn :label="t('albumsManage.photos.batchColPhoto')" min-width="160">
<template #default="{ row }">
<span class="font-mono text-xs break-all">{{ row.photoId }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.photos.batchColReason')" min-width="160">
<template #default="{ row }">
<ElTag type="danger" effect="plain" size="small">
{{ localizeBatchHideCode(t, row.code) }}
</ElTag>
</template>
</ElTableColumn>
</ElTable>
<template #footer>
<ElButton
type="primary"
data-test="photo-batch-failures-close"
@click="failuresVisible = false"
>
{{ t('common.confirm') }}
</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchAlbumPhotos, fetchDeletePhoto, fetchUpdatePhotoVisibility } from '@/api/albums'
import {
fetchAlbumPhotos,
fetchDeletePhoto,
fetchHidePhotosBatch,
fetchUpdatePhotoVisibility
} from '@/api/albums'
import { useCursorList } from '@/views/albums/composables/useCursorList'
import { formatBytes, formatDateTime } from '@/utils/albums/format'
import {
indexOfPhotoId,
localizeBatchHideCode,
summarizeBatchHideResults,
type BatchHideSummary
} from '@/utils/albums/batchHide'
const props = defineProps<{
album: Api.Album.Detail
@@ -245,4 +315,69 @@
}
ElMessage.success(t('albumsManage.photos.deleted'))
}
// ── 批量隐藏(P4a A16:逐项结果部分成功) ──
/** 仅 published 可选(批量隐藏的目标态前置;其余状态行勾选禁用) */
function batchSelectable(row: Api.Album.AdminPhoto): boolean {
return row.visibility === 'published'
}
const selection = ref<Api.Album.AdminPhoto[]>([])
function onSelectionChange(rows: Api.Album.AdminPhoto[]) {
selection.value = rows
}
/** selection 经 selectable 约束本就全为 published;过滤一层兜底 */
const selectedPublished = computed(() =>
selection.value.filter((row) => row.visibility === 'published')
)
const batchHiding = ref(false)
const failuresVisible = ref(false)
const batchSummary = ref<BatchHideSummary | null>(null)
async function batchHide() {
const targets = selectedPublished.value
if (targets.length === 0) return
if (!(await confirmBox(t('albumsManage.photos.batchHideConfirm', { count: targets.length })))) {
return
}
batchHiding.value = true
try {
const result = await fetchHidePhotosBatch(
props.album.id,
targets.map((row) => row.photoId)
)
const summary = summarizeBatchHideResults(result.results)
batchSummary.value = summary
// 成功项原位置 hidden(结果与请求同序;失败行保持原样);过滤条件下
// 与目标态不符则移除
for (const item of result.results) {
if (!item.ok) continue
const index = indexOfPhotoId(items.value, item.photoId)
if (index < 0) continue
const row = items.value[index]!
if (visibilityFilter.value !== '' && visibilityFilter.value !== 'hidden') {
list.removeItem(index)
} else {
list.replaceItem(index, { ...row, visibility: 'hidden' as const })
}
}
if (summary.failCount > 0) {
failuresVisible.value = true
ElMessage.warning(
t('albumsManage.photos.batchResult', {
ok: summary.okCount,
fail: summary.failCount
})
)
} else {
ElMessage.success(t('albumsManage.photos.batchResultAllOk', { ok: summary.okCount }))
}
} finally {
batchHiding.value = false
}
}
</script>
@@ -0,0 +1,92 @@
<!-- 分享链接与二维码卡(P4a Task 9,评审缺口补齐):viewer URL
(VITE_VIEWER_BASE + /a/{publicToken})+ qrcode canvas 渲染(相册切换
重绘)+ 复制链接。公开开关关闭时提示不可分享(链接仍展示——开关由
编辑表单控制,此处只读提示)。 -->
<template>
<div data-test="share-panel" class="max-w-xl">
<ElAlert
v-if="!album.publicEnabled"
type="info"
:closable="false"
:title="t('albumsManage.share.publicOffHint')"
class="mb-4"
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>
</div>
<div class="text-gray-400 text-xs mt-2">{{ t('albumsManage.share.hint') }}</div>
</div>
<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>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import QRCode from 'qrcode'
import {
buildViewerAlbumUrl,
renderShareQr,
viewerBaseFromEnv,
type QrDraw
} from '@/utils/albums/shareLink'
const props = defineProps<{
album: Api.Album.Detail
}>()
const { t } = useI18n()
const viewerUrl = computed(() =>
buildViewerAlbumUrl(viewerBaseFromEnv(import.meta.env), props.album.publicToken)
)
const qrCanvas = ref<HTMLCanvasElement | null>(null)
/** QRCode.toCanvas 适配到注入签名(callback 重载在箭头内按实参解析) */
const drawQrCode: QrDraw = (canvas, text, options) => QRCode.toCanvas(canvas, text, options)
async function drawQr(): Promise<void> {
if (!qrCanvas.value) return
try {
await renderShareQr(qrCanvas.value, viewerUrl.value, drawQrCode)
} catch {
// 渲染失败保留空 canvas(复制链接仍可用);不打断页面
}
}
onMounted(() => {
void drawQr()
})
/** 相册变化(路由复用组件)即重绘二维码 */
watch(
() => props.album.publicToken,
() => {
void drawQr()
}
)
async function copyLink(): Promise<void> {
await navigator.clipboard.writeText(viewerUrl.value)
ElMessage.success(t('albumsManage.list.copied'))
}
</script>
@@ -0,0 +1,140 @@
<!-- 负责人交接对话框(P4a A17):账号前缀搜索目标用户(fetchLookupUsers,
≥2 字符)+ 是否保留原 owner 为协作者开关。警告:历史上传人不变。
POST /admin/albums/{id}/transfer → 200 相册详情(ownerId 已变更),
成功后 emit saved 由详情页刷新。 -->
<template>
<ElDialog
v-model="visible"
:title="$t('albumsManage.transfer.title')"
width="520px"
destroy-on-close
data-test="transfer-dialog"
>
<ElAlert
type="warning"
:closable="false"
:title="$t('albumsManage.transfer.historyHint')"
data-test="transfer-history-hint"
/>
<ElForm label-width="140px" class="mt-4">
<ElFormItem :label="$t('albumsManage.transfer.targetLabel')">
<ElSelect
v-model="targetUserId"
filterable
remote
clearable
:remote-method="searchUsers"
:loading="searching"
:placeholder="t('albumsManage.transfer.searchPlaceholder')"
style="width: 100%"
data-test="transfer-target-select"
>
<ElOption
v-for="user in options"
:key="user.id"
:value="user.id"
:label="`${user.account}(${user.displayName})`"
:data-test="`transfer-option-${user.account}`"
/>
<template #empty>
<span class="px-4 py-2 text-gray-400 text-sm">
{{
query.length < 2
? t('albumsManage.transfer.searchPlaceholder')
: searching
? t('albumsManage.members.searching')
: t('albumsManage.members.noMatch')
}}
</span>
</template>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('albumsManage.transfer.keepLabel')">
<div class="flex items-center gap-2">
<ElSwitch v-model="keepAsCollaborator" data-test="transfer-keep-switch" />
<span class="text-gray-400 text-sm">{{ t('albumsManage.transfer.keepHint') }}</span>
</div>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton data-test="transfer-cancel" @click="visible = false">
{{ t('common.cancel') }}
</ElButton>
<ElButton
type="primary"
:disabled="!targetUserId"
:loading="transferring"
data-test="transfer-submit"
@click="submit"
>
{{ t('albumsManage.transfer.submit') }}
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { fetchLookupUsers, fetchTransferAlbum } from '@/api/albums'
const props = defineProps<{
album: Api.Album.Detail
}>()
const emit = defineEmits<{
/** 交接成功(回传新相册详情,ownerId 已变更) */
saved: [album: Api.Album.Detail]
}>()
const visible = defineModel<boolean>({ default: false })
const { t } = useI18n()
// ── 目标账号前缀搜索(≥2 字符才发起;同成员邀请模式) ──
const query = ref('')
const options = ref<Api.Album.UserLookupItem[]>([])
const searching = ref(false)
const targetUserId = ref('')
const keepAsCollaborator = ref(true)
const transferring = ref(false)
watch(visible, (open) => {
if (open) {
query.value = ''
options.value = []
targetUserId.value = ''
keepAsCollaborator.value = true
}
})
async function searchUsers(prefix: string) {
query.value = prefix
options.value = []
if (prefix.trim().length < 2) return
searching.value = true
try {
const result = await fetchLookupUsers(prefix.trim())
options.value = result.items
} finally {
searching.value = false
}
}
async function submit(): Promise<void> {
if (!targetUserId.value) return
transferring.value = true
try {
const album = await fetchTransferAlbum(props.album.id, {
targetUserId: targetUserId.value,
keepAsCollaborator: keepAsCollaborator.value
})
ElMessage.success(t('albumsManage.transfer.success'))
visible.value = false
emit('saved', album)
} finally {
transferring.value = false
}
}
</script>
@@ -0,0 +1,350 @@
<!-- 水印管理卡(P4a Task 9 / A18 A20):当前配置展示 + 开关参数表单 →
POST watermark-jobs(202 受理)→ 版本历史(进度条轮询 3s,任一任务
pending/running 即续轮,全终态停)+ 发起重生成(=以当前表单再次 POST)。
「测试水印」为 CSS 叠加示意预览(非 sharp 实际渲染,明确标注)。 -->
<template>
<div data-test="watermark-panel">
<ElAlert v-if="error && !status" type="error" :closable="false">
<div class="flex items-center gap-2">
<span>{{ t('albumsManage.watermark.loadFailed') }}:{{ error.message }}</span>
<ElButton size="small" data-test="watermark-retry" @click="load">
{{ t('albumsManage.watermark.retry') }}
</ElButton>
</div>
</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>
<ElForm label-width="130px">
<ElFormItem :label="t('albumsManage.watermark.switchLabel')">
<ElSwitch v-model="form.enabled" data-test="watermark-enabled-switch" />
</ElFormItem>
<ElFormItem :label="t('albumsManage.watermark.positionLabel')">
<ElSelect
v-model="form.position"
:disabled="!form.enabled"
style="width: 180px"
data-test="watermark-position"
>
<ElOption
v-for="pos in POSITIONS"
:key="pos"
:value="pos"
:label="t(`albumsManage.watermark.position_${pos}`)"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="t('albumsManage.watermark.opacityLabel')">
<div class="flex items-center gap-3 w-full">
<ElSlider
v-model="form.opacity"
:min="0"
:max="100"
:disabled="!form.enabled"
data-test="watermark-opacity"
/>
<span class="text-gray-500 w-10 text-right">{{ form.opacity }}%</span>
</div>
</ElFormItem>
<ElFormItem :label="t('albumsManage.watermark.scaleLabel')">
<div class="flex items-center gap-3 w-full">
<ElSlider
v-model="form.scalePercent"
:min="5"
:max="50"
:disabled="!form.enabled"
data-test="watermark-scale"
/>
<span class="text-gray-500 w-10 text-right">{{ form.scalePercent }}%</span>
</div>
</ElFormItem>
<ElFormItem :label="t('albumsManage.watermark.textContentLabel')">
<ElInput
v-model="form.textContent"
:disabled="!form.enabled"
maxlength="64"
:placeholder="t('albumsManage.watermark.textContentPlaceholder')"
data-test="watermark-text-content"
/>
</ElFormItem>
<ElFormItem :label="t('albumsManage.watermark.fontSizeLabel')">
<ElInputNumber
v-model="form.fontSize"
:min="8"
:max="72"
:disabled="!form.enabled || form.textContent.trim() === ''"
data-test="watermark-font-size"
/>
</ElFormItem>
</ElForm>
<div class="flex items-center gap-2">
<ElButton
type="primary"
:loading="submitting"
data-test="watermark-save-btn"
@click="save"
>
{{ t('albumsManage.watermark.save') }}
</ElButton>
<span class="text-gray-400 text-sm">{{ t('albumsManage.watermark.saveHint') }}</span>
</div>
</div>
<!-- ── 示意预览(CSS 叠加,非实际渲染) ── -->
<div>
<div class="text-gray-500 mb-1">{{ t('albumsManage.watermark.previewTitle') }}</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"
:style="{
width: `${WATERMARK_PREVIEW_WIDTH}px`,
height: `${WATERMARK_PREVIEW_HEIGHT}px`
}"
data-test="watermark-preview-box"
>
<span
class="text-slate-800 select-none whitespace-nowrap"
:style="previewTextStyle(form)"
data-test="watermark-preview-text"
>
{{ previewText }}
</span>
</div>
<div class="text-gray-400 text-xs mt-1">
{{ t('albumsManage.watermark.previewHint') }}
</div>
</div>
</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>
<ElTable
v-loading="loading"
:data="status?.jobs ?? []"
size="small"
data-test="watermark-jobs-table"
>
<ElTableColumn :label="t('albumsManage.watermark.colVersion')" width="90">
<template #default="{ row }">
<span>v{{ row.configVersion }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.watermark.colState')" width="110">
<template #default="{ row }">
<ElTag :type="jobTagType(row.state)" :data-state="row.state">
{{ t(`albumsManage.watermark.state_${row.state}`) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.watermark.colProgress')" min-width="200">
<template #default="{ row }">
<ElProgress
:percentage="watermarkProgressPercent(row)"
:status="row.state === 'failed' ? 'exception' : undefined"
data-test="watermark-job-progress"
/>
<span class="text-gray-400 text-xs">
{{ row.progress }} / {{ row.total }}
<template v-if="row.failed > 0">
· {{ t('albumsManage.watermark.failedCount', { count: row.failed }) }}
</template>
</span>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.watermark.colCreatedAt')" width="160">
<template #default="{ row }">
<span>{{ formatDateTime(row.createdAt) }}</span>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty v-if="!loading" :description="$t('albumsManage.watermark.jobsEmpty')" />
</template>
</ElTable>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { fetchCreateWatermarkJob, fetchWatermarkJobs } from '@/api/albums'
import { formatDateTime } from '@/utils/albums/format'
import {
buildWatermarkPayload,
defaultWatermarkForm,
formFromConfig,
hasRunningWatermarkJob,
previewTextStyle,
watermarkProgressPercent,
WATERMARK_PREVIEW_HEIGHT,
WATERMARK_PREVIEW_WIDTH,
type WatermarkFormState
} from '@/utils/albums/watermarkForm'
const props = defineProps<{
album: Api.Album.Detail
}>()
const { t } = useI18n()
const POSITIONS: Api.Album.WatermarkPosition[] = [
'topLeft',
'topRight',
'bottomLeft',
'bottomRight',
'center'
]
const status = ref<Api.Album.WatermarkJobsStatus | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
const submitting = ref(false)
const form = reactive<WatermarkFormState>(defaultWatermarkForm())
/** 预览文案:表单文字缺省用相册标题示意 */
const previewText = computed(() => {
const content = form.textContent.trim()
if (content !== '') return content
const preferred = props.album.translations.find(
(item) => item.locale === props.album.defaultLocale
)
return preferred?.title ?? 'Watermark'
})
function jobTagType(
state: Api.Album.WatermarkJobState
): 'success' | 'warning' | 'danger' | 'info' {
switch (state) {
case 'done':
return 'success'
case 'running':
return 'warning'
case 'failed':
return 'danger'
default:
return 'info'
}
}
/** 当前配置参数摘要(一行文本) */
function configSummary(config: Api.Album.WatermarkConfig): string {
const parts = [
t(`albumsManage.watermark.position_${config.position ?? 'bottomRight'}`),
`${t('albumsManage.watermark.opacityLabel')} ${config.opacity ?? 50}%`,
`${t('albumsManage.watermark.scaleLabel')} ${config.scalePercent ?? 20}%`
]
if (config.text?.content) {
parts.push(`${t('albumsManage.watermark.fontSizeLabel')} ${config.text.fontSize ?? 24}`)
}
return parts.join(' · ')
}
async function load(): Promise<void> {
loading.value = true
error.value = null
try {
status.value = await fetchWatermarkJobs(props.album.id)
if (status.value.current) {
Object.assign(form, formFromConfig(status.value.current.config))
}
syncPolling()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
/** 保存配置并发起历史重生成(202 受理 → 刷新状态进入进度模式) */
async function save(): Promise<void> {
submitting.value = true
try {
const created = await fetchCreateWatermarkJob(
props.album.id,
buildWatermarkPayload({ ...form })
)
ElMessage.success(t('albumsManage.watermark.accepted', { version: created.configVersion }))
await load()
} finally {
submitting.value = false
}
}
// ── 进度轮询:任一 pending/running 每 3s 刷新,全终态即停 ──
let pollTimer: ReturnType<typeof setInterval> | null = null
let polling = false
function syncPolling(): void {
const running = hasRunningWatermarkJob(status.value?.jobs ?? [])
if (running && pollTimer === null) {
pollTimer = setInterval(() => void pollTick(), 3000)
} else if (!running && pollTimer !== null) {
clearInterval(pollTimer)
pollTimer = null
}
}
async function pollTick(): Promise<void> {
if (polling) return
polling = true
try {
status.value = await fetchWatermarkJobs(props.album.id)
syncPolling()
} catch {
// 单次轮询失败静默(下一周期重试;持续失败由用户手动刷新兜底)
} finally {
polling = false
}
}
onMounted(() => {
void load()
})
onUnmounted(() => {
if (pollTimer !== null) {
clearInterval(pollTimer)
pollTimer = null
}
})
</script>
+54 -3
View File
@@ -21,9 +21,19 @@
<span class="font-medium">{{ titleOf(album) }}</span>
<ElTag size="small" type="info">{{ album?.defaultLocale }}</ElTag>
</div>
<ElButton type="primary" plain data-test="album-detail-edit" @click="editVisible = true">
{{ $t('albumsManage.detail.edit') }}
</ElButton>
<div class="flex items-center gap-2">
<ElButton plain data-test="album-detail-transfer" @click="transferVisible = true">
{{ $t('albumsManage.transfer.entry') }}
</ElButton>
<ElButton
type="primary"
plain
data-test="album-detail-edit"
@click="editVisible = true"
>
{{ $t('albumsManage.detail.edit') }}
</ElButton>
</div>
</div>
</template>
@@ -82,6 +92,18 @@
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldUpdatedAt')">
{{ formatDateTime(album.updatedAt) }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldStatsSyncedAt')">
<span data-test="album-detail-stats-synced-at">
{{
album.statsSyncedAt
? formatDateTime(album.statsSyncedAt)
: $t('albumsManage.detail.statsNotSynced')
}}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldOwnerId')">
<span data-test="album-detail-owner-id">{{ album.ownerId }}</span>
</ElDescriptionsItem>
</ElDescriptions>
<div v-if="album" class="mt-5">
@@ -111,10 +133,30 @@
</template>
<AlbumCategories :album="album" @changed="load" />
</ElTabPane>
<ElTabPane name="watermark">
<template #label>
<span data-test="album-watermark-tab">
{{ $t('albumsManage.detail.tabWatermark') }}
</span>
</template>
<AlbumWatermark :album="album" />
</ElTabPane>
<ElTabPane name="share">
<template #label>
<span data-test="album-share-tab">{{ $t('albumsManage.detail.tabShare') }}</span>
</template>
<AlbumShareCard :album="album" />
</ElTabPane>
</ElTabs>
</ElCard>
<AlbumFormDialog v-if="album" v-model="editVisible" :album="album" @saved="onAlbumSaved" />
<AlbumTransferDialog
v-if="album"
v-model="transferVisible"
:album="album"
@saved="onTransferred"
/>
</div>
</template>
@@ -129,6 +171,9 @@
import AlbumMembers from './AlbumMembers.vue'
import AlbumCategories from './AlbumCategories.vue'
import AlbumPhotos from './AlbumPhotos.vue'
import AlbumWatermark from './AlbumWatermark.vue'
import AlbumShareCard from './AlbumShareCard.vue'
import AlbumTransferDialog from './AlbumTransferDialog.vue'
import { fetchAlbum } from '@/api/albums'
import { formatDateTime } from '@/utils/albums/format'
@@ -144,6 +189,7 @@
const loading = ref(false)
const error = ref<Error | null>(null)
const editVisible = ref(false)
const transferVisible = ref(false)
const activeTab = ref('photos')
async function load() {
@@ -163,6 +209,11 @@
album.value = saved
}
/** 交接成功:回包 ownerId 已变更,原位替换相册详情 */
function onTransferred(transferred: Api.Album.Detail) {
album.value = transferred
}
function titleOf(target: Api.Album.Detail | null): string {
if (!target) return ''
const preferred = target.translations.find((item) => item.locale === target.defaultLocale)
@@ -0,0 +1,102 @@
<!-- 阈值设置对话框(P4a Task 9 / A23):两维度各以「启用开关 + GB 输入」
表达——开关关闭=显式 null 清除(告警停用),开启=GB→bytes 提交。
保存后服务端即刻重新评估(超阈产生新告警/阈值上调解除旧告警)。 -->
<template>
<ElDialog
v-model="visible"
:title="$t('usage.thresholds.title')"
width="520px"
destroy-on-close
data-test="thresholds-dialog"
>
<ElForm label-width="150px">
<ElFormItem :label="$t('usage.thresholds.storageLabel')">
<div class="flex items-center gap-3">
<ElSwitch v-model="draft.storage.enabled" data-test="thresholds-storage-switch" />
<!-- key 随开关重挂:element-plus 2.11 的 input-number 只在 mount 时写
内层 input 的 aria-disabled,prop 后续变化不重写——重挂保持无障碍
状态与实际一致(e2e fill 亦据此判定可编辑) -->
<ElInputNumber
:key="`storage-${draft.storage.enabled}`"
v-model="draft.storage.gb"
:min="0"
:precision="3"
:step="1"
:disabled="!draft.storage.enabled"
data-test="thresholds-storage-input"
/>
<span class="text-gray-400">GB</span>
</div>
</ElFormItem>
<ElFormItem :label="$t('usage.thresholds.perAlbumLabel')">
<div class="flex items-center gap-3">
<ElSwitch v-model="draft.perAlbum.enabled" data-test="thresholds-per-album-switch" />
<ElInputNumber
:key="`per-album-${draft.perAlbum.enabled}`"
v-model="draft.perAlbum.gb"
:min="0"
:precision="9"
:step="1"
:disabled="!draft.perAlbum.enabled"
data-test="thresholds-per-album-input"
/>
<span class="text-gray-400">GB</span>
</div>
</ElFormItem>
</ElForm>
<div class="text-gray-400 text-sm">{{ t('usage.thresholds.hint') }}</div>
<template #footer>
<ElButton data-test="thresholds-cancel" @click="visible = false">
{{ t('common.cancel') }}
</ElButton>
<ElButton type="primary" :loading="submitting" data-test="thresholds-submit" @click="submit">
{{ t('usage.thresholds.submit') }}
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { fetchUpdateUsageThresholds } from '@/api/usage'
import {
buildThresholdsPayload,
draftFromThresholds,
type ThresholdsDraft
} from '@/utils/albums/usageStorage'
const props = defineProps<{
/** 当前阈值(打开时回填表单) */
thresholds: Api.Usage.Thresholds
}>()
const emit = defineEmits<{
/** 保存成功(回传服务端确认后的阈值) */
saved: [thresholds: Api.Usage.Thresholds]
}>()
const visible = defineModel<boolean>({ default: false })
const { t } = useI18n()
const draft = reactive<ThresholdsDraft>(draftFromThresholds(props.thresholds))
const submitting = ref(false)
watch(visible, (open) => {
if (open) Object.assign(draft, draftFromThresholds(props.thresholds))
})
async function submit(): Promise<void> {
submitting.value = true
try {
const saved = await fetchUpdateUsageThresholds(buildThresholdsPayload(draft))
ElMessage.success(t('usage.thresholds.saved'))
visible.value = false
emit('saved', saved)
} finally {
submitting.value = false
}
}
</script>
+204
View File
@@ -0,0 +1,204 @@
<!-- 用量页(P4a Task 9 / A23 后台侧):合计头卡(照片数/总存储/阈值)+
相册用量表(title/photoCount/originalBytes/variantBytes/totalBytes
人类可读)+ 阈值设置对话框 + 告警列表(未解决红/已解决灰,含信息与时间)。 -->
<template>
<div class="p-5">
<!-- ── 合计头卡 ── -->
<div class="grid gap-4 mb-4 sm:grid-cols-3" data-test="usage-totals">
<ElCard shadow="never">
<div class="text-gray-500 text-sm">{{ $t('usage.totals.photoCount') }}</div>
<div class="text-2xl font-medium mt-1" data-test="usage-total-photos">
{{ summary?.totals.photoCount ?? '-' }}
</div>
</ElCard>
<ElCard shadow="never">
<div class="text-gray-500 text-sm">{{ $t('usage.totals.totalBytes') }}</div>
<div class="text-2xl font-medium mt-1" data-test="usage-total-bytes">
{{ summary ? formatBytes(summary.totals.totalBytes) : '-' }}
</div>
</ElCard>
<ElCard shadow="never">
<div class="text-gray-500 text-sm">{{ $t('usage.totals.thresholds') }}</div>
<div class="mt-1 flex flex-wrap gap-2" data-test="usage-threshold-tags">
<ElTag size="small" :type="summary?.thresholds.storageBytes ? 'success' : 'info'">
{{ $t('usage.thresholds.storageShort') }}
{{
summary?.thresholds.storageBytes
? formatBytes(summary.thresholds.storageBytes)
: $t('usage.thresholds.off')
}}
</ElTag>
<ElTag size="small" :type="summary?.thresholds.perAlbumBytes ? 'success' : 'info'">
{{ $t('usage.thresholds.perAlbumShort') }}
{{
summary?.thresholds.perAlbumBytes
? formatBytes(summary.thresholds.perAlbumBytes)
: $t('usage.thresholds.off')
}}
</ElTag>
</div>
</ElCard>
</div>
<!-- ── 相册用量 + 告警 ── -->
<ElCard data-test="usage-albums-card">
<template #header>
<div class="flex items-center justify-between">
<span>{{ $t('usage.albums.title') }}</span>
<ElButton
type="primary"
plain
data-test="usage-thresholds-btn"
@click="thresholdsVisible = true"
>
{{ $t('usage.thresholds.entry') }}
</ElButton>
</div>
</template>
<ElAlert v-if="error && !summary" type="error" :closable="false" data-test="usage-error">
<div class="flex items-center gap-2">
<span>{{ $t('usage.loadFailed') }}:{{ error.message }}</span>
<ElButton size="small" data-test="usage-retry" @click="load">
{{ $t('usage.retry') }}
</ElButton>
</div>
</ElAlert>
<ElTable
v-else
v-loading="loading"
:data="summary?.albums ?? []"
data-test="usage-albums-table"
>
<ElTableColumn :label="$t('usage.albums.colTitle')" min-width="180">
<template #default="{ row }">
<span>{{ row.title || row.albumId }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('usage.albums.colPhotoCount')" width="110">
<template #default="{ row }">
<span>{{ row.photoCount }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('usage.albums.colOriginal')" min-width="120">
<template #default="{ row }">
<span class="text-gray-500">{{ formatBytes(row.originalBytes) }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('usage.albums.colVariant')" min-width="120">
<template #default="{ row }">
<span class="text-gray-500">{{ formatBytes(row.variantBytes) }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('usage.albums.colTotal')" min-width="120">
<template #default="{ row }">
<span class="font-medium">{{ formatBytes(row.totalBytes) }}</span>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty v-if="!loading" :description="$t('usage.albums.empty')" />
</template>
</ElTable>
</ElCard>
<!-- ── 告警列表 ── -->
<ElCard class="mt-4" data-test="usage-alerts-card">
<template #header>
<span>{{ $t('usage.alerts.title') }}</span>
</template>
<ElTable
:data="summary?.alerts ?? []"
data-test="usage-alerts-table"
:row-class-name="alertRowClass"
>
<ElTableColumn :label="$t('usage.alerts.colState')" width="110">
<template #default="{ row }">
<ElTag
:type="alertTagType(row)"
:effect="isAlertActive(row) ? 'light' : 'plain'"
data-test="alert-state-tag"
>
{{ isAlertActive(row) ? $t('usage.alerts.active') : $t('usage.alerts.resolved') }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('usage.alerts.colKind')" width="110">
<template #default="{ row }">
<ElTag :type="row.kind === 'storage' ? 'warning' : 'info'" effect="plain">
{{ $t(`usage.alerts.kind_${row.kind}`) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('usage.alerts.colMessage')" min-width="220">
<template #default="{ row }">
<span class="text-sm break-all" data-test="alert-message">{{ row.message }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('usage.alerts.colCreatedAt')" width="160">
<template #default="{ row }">
<span>{{ formatDateTime(row.createdAt) }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('usage.alerts.colResolvedAt')" width="160">
<template #default="{ row }">
<span class="text-gray-500">{{ formatDateTime(row.resolvedAt) }}</span>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty v-if="!loading" :description="$t('usage.alerts.empty')" />
</template>
</ElTable>
</ElCard>
<ThresholdsDialog
v-if="summary"
v-model="thresholdsVisible"
:thresholds="summary.thresholds"
@saved="onThresholdsSaved"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchUsageSummary } from '@/api/usage'
import { formatBytes, formatDateTime } from '@/utils/albums/format'
import { alertTagType, isAlertActive } from '@/utils/albums/usageStorage'
import ThresholdsDialog from './ThresholdsDialog.vue'
defineOptions({ name: 'SystemUsage' })
const summary = ref<Api.Usage.SummaryResponse | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
const thresholdsVisible = ref(false)
async function load(): Promise<void> {
loading.value = true
error.value = null
try {
summary.value = await fetchUsageSummary()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
/** 阈值保存后刷新整页(服务端保存即评估——新告警/解除即时可见) */
function onThresholdsSaved(thresholds: Api.Usage.Thresholds): void {
if (summary.value) summary.value = { ...summary.value, thresholds }
void load()
}
/** 告警行 class(e2e 锚点:未解决/已解决行区分)。ElTable row-class-name
* 回调载荷是 {row, rowIndex}——解构取行,直接收行会把整个载荷当行判态。 */
function alertRowClass({ row }: { row: Api.Usage.AlertItem }): string {
return isAlertActive(row) ? 'alert-row-active' : 'alert-row-resolved'
}
onMounted(() => {
void load()
})
</script>