feat(admin): batch ops, transfer, watermark, usage and qr share pages
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user