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
@@ -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>