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