feat: add superadmin assets and notifications

This commit is contained in:
2026-01-15 15:28:41 +08:00
parent c683fa5cf3
commit b896d0fa00
22 changed files with 4852 additions and 260 deletions

View File

@@ -0,0 +1,57 @@
import { requestJson } from './apiClient';
function normalizeItems(items) {
if (Array.isArray(items)) return items;
if (items && typeof items === 'object') return [items];
return [];
}
export const AssetService = {
async listAssets({ page, limit, id, tenant_id, tenant_code, tenant_name, user_id, username, type, status, provider, object_key, created_at_from, created_at_to, size_min, size_max, sortField, sortOrder } = {}) {
const iso = (d) => {
if (!d) return undefined;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString();
};
const query = {
page,
limit,
id,
tenant_id,
tenant_code,
tenant_name,
user_id,
username,
type,
status,
provider,
object_key,
created_at_from: iso(created_at_from),
created_at_to: iso(created_at_to),
size_min,
size_max
};
if (sortField && sortOrder) {
if (sortOrder === 1) query.asc = sortField;
if (sortOrder === -1) query.desc = sortField;
}
const data = await requestJson('/super/v1/assets', { query });
return {
page: data?.page ?? page ?? 1,
limit: data?.limit ?? limit ?? 10,
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
},
async getUsage({ tenant_id } = {}) {
const query = { tenant_id };
return requestJson('/super/v1/assets/usage', { query });
},
async deleteAsset(assetID, { force } = {}) {
if (!assetID) throw new Error('assetID is required');
return requestJson(`/super/v1/assets/${assetID}`, { method: 'DELETE', query: { force } });
}
};

View File

@@ -0,0 +1,102 @@
import { requestJson } from './apiClient';
function normalizeItems(items) {
if (Array.isArray(items)) return items;
if (items && typeof items === 'object') return [items];
return [];
}
export const NotificationService = {
async listNotifications({ page, limit, id, tenant_id, tenant_code, tenant_name, user_id, username, type, is_read, keyword, created_at_from, created_at_to, sortField, sortOrder } = {}) {
const iso = (d) => {
if (!d) return undefined;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString();
};
const query = {
page,
limit,
id,
tenant_id,
tenant_code,
tenant_name,
user_id,
username,
type,
is_read,
keyword,
created_at_from: iso(created_at_from),
created_at_to: iso(created_at_to)
};
if (sortField && sortOrder) {
if (sortOrder === 1) query.asc = sortField;
if (sortOrder === -1) query.desc = sortField;
}
const data = await requestJson('/super/v1/notifications', { query });
return {
page: data?.page ?? page ?? 1,
limit: data?.limit ?? limit ?? 10,
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
},
async broadcast({ tenant_id, user_ids, type, title, content } = {}) {
return requestJson('/super/v1/notifications/broadcast', {
method: 'POST',
body: {
tenant_id,
user_ids,
type,
title,
content
}
});
},
async listTemplates({ page, limit, tenant_id, keyword, type, is_active, created_at_from, created_at_to, sortField, sortOrder } = {}) {
const iso = (d) => {
if (!d) return undefined;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString();
};
const query = {
page,
limit,
tenant_id,
keyword,
type,
is_active,
created_at_from: iso(created_at_from),
created_at_to: iso(created_at_to)
};
if (sortField && sortOrder) {
if (sortOrder === 1) query.asc = sortField;
if (sortOrder === -1) query.desc = sortField;
}
const data = await requestJson('/super/v1/notifications/templates', { query });
return {
page: data?.page ?? page ?? 1,
limit: data?.limit ?? limit ?? 10,
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
},
async createTemplate({ tenant_id, name, type, title, content, is_active } = {}) {
return requestJson('/super/v1/notifications/templates', {
method: 'POST',
body: {
tenant_id,
name,
type,
title,
content,
is_active
}
});
}
};

View File

@@ -1,11 +1,371 @@
<script setup>
import PendingPanel from '@/components/PendingPanel.vue';
import SearchField from '@/components/SearchField.vue';
import SearchPanel from '@/components/SearchPanel.vue';
import StatisticsStrip from '@/components/StatisticsStrip.vue';
import { AssetService } from '@/service/AssetService';
import { useToast } from 'primevue/usetoast';
import { computed, ref } from 'vue';
const endpoints = ['GET /super/v1/assets', 'DELETE /super/v1/assets/:id', 'GET /super/v1/assets/usage'];
const toast = useToast();
const notes = ['Upload and storage endpoints are tenant-scoped today.', 'Add asset inventory before enabling cleanup actions.'];
const assets = ref([]);
const loading = ref(false);
const usage = ref(null);
const usageLoading = ref(false);
const totalRecords = ref(0);
const page = ref(1);
const rows = ref(10);
const sortField = ref('created_at');
const sortOrder = ref(-1);
const assetID = ref(null);
const tenantID = ref(null);
const tenantCode = ref('');
const tenantName = ref('');
const userID = ref(null);
const username = ref('');
const type = ref('');
const status = ref('');
const provider = ref('');
const objectKey = ref('');
const createdAtFrom = ref(null);
const createdAtTo = ref(null);
const sizeMin = ref(null);
const sizeMax = ref(null);
const typeOptions = [
{ label: '全部', value: '' },
{ label: 'video', value: 'video' },
{ label: 'audio', value: 'audio' },
{ label: 'image', value: 'image' }
];
const statusOptions = [
{ label: '全部', value: '' },
{ label: 'uploaded', value: 'uploaded' },
{ label: 'processing', value: 'processing' },
{ label: 'ready', value: 'ready' },
{ label: 'failed', value: 'failed' },
{ label: 'deleted', value: 'deleted' }
];
const deleteDialogVisible = ref(false);
const deleteSubmitting = ref(false);
const deleteTarget = ref(null);
const deleteForce = ref(false);
function formatDate(value) {
if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
function formatBytes(bytes) {
const value = Number(bytes);
if (!Number.isFinite(value)) return '-';
if (value < 1024) return `${value} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let size = value;
let idx = -1;
while (size >= 1024 && idx < units.length - 1) {
size /= 1024;
idx += 1;
}
return `${size.toFixed(1)} ${units[idx]}`;
}
function getStatusSeverity(value) {
switch (value) {
case 'ready':
return 'success';
case 'uploaded':
case 'processing':
return 'warn';
case 'failed':
return 'danger';
default:
return 'secondary';
}
}
const usageItems = computed(() => {
const summary = usage.value;
const byType = new Map((summary?.by_type || []).map((item) => [item?.type, item]));
return [
{
key: 'assets-total',
label: '资产总量:',
value: usageLoading.value ? '-' : (summary?.total_count ?? 0),
icon: 'pi-images'
},
{
key: 'assets-size',
label: '总大小:',
value: usageLoading.value ? '-' : formatBytes(summary?.total_size ?? 0),
icon: 'pi-database'
},
{
key: 'assets-video',
label: '视频:',
value: usageLoading.value ? '-' : (byType.get('video')?.count ?? 0),
icon: 'pi-video'
},
{
key: 'assets-audio',
label: '音频:',
value: usageLoading.value ? '-' : (byType.get('audio')?.count ?? 0),
icon: 'pi-volume-up'
},
{
key: 'assets-image',
label: '图片:',
value: usageLoading.value ? '-' : (byType.get('image')?.count ?? 0),
icon: 'pi-image'
}
];
});
async function loadUsage() {
usageLoading.value = true;
try {
usage.value = await AssetService.getUsage({ tenant_id: tenantID.value || undefined });
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载资产统计', life: 4000 });
} finally {
usageLoading.value = false;
}
}
async function loadAssets() {
loading.value = true;
try {
const result = await AssetService.listAssets({
page: page.value,
limit: rows.value,
id: assetID.value || undefined,
tenant_id: tenantID.value || undefined,
tenant_code: tenantCode.value,
tenant_name: tenantName.value,
user_id: userID.value || undefined,
username: username.value,
type: type.value,
status: status.value,
provider: provider.value,
object_key: objectKey.value,
created_at_from: createdAtFrom.value || undefined,
created_at_to: createdAtTo.value || undefined,
size_min: sizeMin.value || undefined,
size_max: sizeMax.value || undefined,
sortField: sortField.value,
sortOrder: sortOrder.value
});
assets.value = result.items;
totalRecords.value = result.total;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载资产列表', life: 4000 });
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
loadAssets();
loadUsage();
}
function onReset() {
assetID.value = null;
tenantID.value = null;
tenantCode.value = '';
tenantName.value = '';
userID.value = null;
username.value = '';
type.value = '';
status.value = '';
provider.value = '';
objectKey.value = '';
createdAtFrom.value = null;
createdAtTo.value = null;
sizeMin.value = null;
sizeMax.value = null;
sortField.value = 'created_at';
sortOrder.value = -1;
page.value = 1;
rows.value = 10;
loadAssets();
loadUsage();
}
function onPage(event) {
page.value = (event.page ?? 0) + 1;
rows.value = event.rows ?? rows.value;
loadAssets();
}
function onSort(event) {
sortField.value = event.sortField ?? sortField.value;
sortOrder.value = event.sortOrder ?? sortOrder.value;
loadAssets();
}
function openDeleteDialog(asset) {
deleteTarget.value = asset;
deleteForce.value = false;
deleteDialogVisible.value = true;
}
async function confirmDelete() {
const asset = deleteTarget.value;
if (!asset?.id) return;
deleteSubmitting.value = true;
try {
await AssetService.deleteAsset(asset.id, { force: deleteForce.value });
toast.add({ severity: 'success', summary: '已删除', detail: `资产ID: ${asset.id}`, life: 3000 });
deleteDialogVisible.value = false;
await loadAssets();
await loadUsage();
} catch (error) {
toast.add({ severity: 'error', summary: '删除失败', detail: error?.message || '无法删除资产', life: 4000 });
} finally {
deleteSubmitting.value = false;
}
}
loadUsage();
loadAssets();
</script>
<template>
<PendingPanel title="Assets" description="Asset governance requires a super admin inventory API." :endpoints="endpoints" :notes="notes" />
<div>
<StatisticsStrip :items="usageItems" containerClass="card mb-4" />
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">资产列表</h4>
</div>
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
<SearchField label="资产ID">
<InputNumber v-model="assetID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="TenantID">
<InputNumber v-model="tenantID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="Tenant Code">
<InputText v-model="tenantCode" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</SearchField>
<SearchField label="Tenant Name">
<InputText v-model="tenantName" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</SearchField>
<SearchField label="UserID">
<InputNumber v-model="userID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="用户名">
<InputText v-model="username" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</SearchField>
<SearchField label="类型">
<Select v-model="type" :options="typeOptions" optionLabel="label" optionValue="value" placeholder="请选择" class="w-full" />
</SearchField>
<SearchField label="状态">
<Select v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="请选择" class="w-full" />
</SearchField>
<SearchField label="Provider">
<InputText v-model="provider" placeholder="精确匹配" class="w-full" @keyup.enter="onSearch" />
</SearchField>
<SearchField label="Object Key">
<InputText v-model="objectKey" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</SearchField>
<SearchField label="创建时间 From">
<DatePicker v-model="createdAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="创建时间 To">
<DatePicker v-model="createdAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
<SearchField label="大小 Min">
<InputNumber v-model="sizeMin" :min="0" placeholder="字节" class="w-full" />
</SearchField>
<SearchField label="大小 Max">
<InputNumber v-model="sizeMax" :min="0" placeholder="字节" class="w-full" />
</SearchField>
</SearchPanel>
<DataTable :value="assets" :loading="loading" :rows="rows" :totalRecords="totalRecords" :first="(page - 1) * rows" paginator lazy dataKey="id" @page="onPage" @sort="onSort" :sortField="sortField" :sortOrder="sortOrder">
<Column field="id" header="资产ID" style="min-width: 8rem" sortable />
<Column header="租户" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.tenant_name || '-' }}</span>
<span class="text-sm text-muted-color">{{ data.tenant_code || '-' }}</span>
</div>
</template>
</Column>
<Column header="用户" style="min-width: 12rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.username || '-' }}</span>
<span class="text-sm text-muted-color">ID: {{ data.user_id ?? '-' }}</span>
</div>
</template>
</Column>
<Column field="type" header="类型" style="min-width: 8rem">
<template #body="{ data }">
<Tag :value="data.type || '-'" severity="info" />
</template>
</Column>
<Column field="status" header="状态" style="min-width: 8rem">
<template #body="{ data }">
<Tag :value="data.status || '-'" :severity="getStatusSeverity(data.status)" />
</template>
</Column>
<Column header="对象Key" style="min-width: 16rem">
<template #body="{ data }">
<span class="block max-w-[280px] truncate" :title="data.object_key">{{ data.object_key || '-' }}</span>
</template>
</Column>
<Column field="size" header="大小" style="min-width: 8rem">
<template #body="{ data }">
{{ formatBytes(data.size) }}
</template>
</Column>
<Column field="used_count" header="引用数" style="min-width: 6rem" />
<Column field="created_at" header="创建时间" style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
<Column header="操作" style="min-width: 6rem">
<template #body="{ data }">
<Button icon="pi pi-trash" text severity="danger" size="small" @click="openDeleteDialog(data)" />
</template>
</Column>
</DataTable>
</div>
<Dialog v-model:visible="deleteDialogVisible" modal :style="{ width: '520px' }">
<template #header>
<span class="font-medium">删除资产</span>
</template>
<div class="space-y-3">
<div class="text-sm text-muted-color">确认删除资产后将无法恢复请谨慎操作</div>
<div class="grid gap-2 text-sm">
<div>资产ID{{ deleteTarget?.id ?? '-' }}</div>
<div>对象Key{{ deleteTarget?.object_key ?? '-' }}</div>
<div>引用数{{ deleteTarget?.used_count ?? 0 }}</div>
</div>
<div v-if="(deleteTarget?.used_count ?? 0) > 0" class="flex items-center gap-2 text-sm text-orange-600">
<Checkbox v-model="deleteForce" binary inputId="forceDelete" />
<label for="forceDelete">强制删除清理内容引用</label>
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="deleteDialogVisible = false" />
<Button label="确认删除" icon="pi pi-trash" severity="danger" :loading="deleteSubmitting" :disabled="deleteSubmitting || ((deleteTarget?.used_count ?? 0) > 0 && !deleteForce)" @click="confirmDelete" />
</template>
</Dialog>
</div>
</template>

View File

@@ -1,11 +1,565 @@
<script setup>
import PendingPanel from '@/components/PendingPanel.vue';
import SearchField from '@/components/SearchField.vue';
import SearchPanel from '@/components/SearchPanel.vue';
import { NotificationService } from '@/service/NotificationService';
import { useToast } from 'primevue/usetoast';
import { ref } from 'vue';
const endpoints = ['GET /super/v1/notifications', 'POST /super/v1/notifications/broadcast', 'POST /super/v1/notifications/templates', 'GET /super/v1/notifications/templates'];
const toast = useToast();
const notes = ['The current notification API is user-scoped only.', 'Add super admin send and template endpoints before enabling operations.'];
const tabValue = ref('list');
const notifications = ref([]);
const loading = ref(false);
const totalRecords = ref(0);
const page = ref(1);
const rows = ref(10);
const sortField = ref('created_at');
const sortOrder = ref(-1);
const noticeID = ref(null);
const tenantID = ref(null);
const tenantCode = ref('');
const tenantName = ref('');
const userID = ref(null);
const username = ref('');
const type = ref('');
const isRead = ref(null);
const keyword = ref('');
const createdAtFrom = ref(null);
const createdAtTo = ref(null);
const typeOptions = [
{ label: '全部', value: '' },
{ label: 'system', value: 'system' },
{ label: 'order', value: 'order' },
{ label: 'audit', value: 'audit' },
{ label: 'interaction', value: 'interaction' }
];
const sendTypeOptions = typeOptions.filter((item) => item.value);
const readOptions = [
{ label: '全部', value: null },
{ label: '已读', value: true },
{ label: '未读', value: false }
];
const broadcastDialogVisible = ref(false);
const broadcastSubmitting = ref(false);
const broadcastTenantID = ref(null);
const broadcastUserIDs = ref('');
const broadcastType = ref('system');
const broadcastTitle = ref('');
const broadcastContent = ref('');
const templates = ref([]);
const templateLoading = ref(false);
const templateTotalRecords = ref(0);
const templatePage = ref(1);
const templateRows = ref(10);
const templateSortField = ref('created_at');
const templateSortOrder = ref(-1);
const templateTenantID = ref(null);
const templateKeyword = ref('');
const templateType = ref('');
const templateIsActive = ref(null);
const templateCreatedAtFrom = ref(null);
const templateCreatedAtTo = ref(null);
const templateDialogVisible = ref(false);
const templateSubmitting = ref(false);
const createTenantID = ref(null);
const createName = ref('');
const createType = ref('system');
const createTitle = ref('');
const createContent = ref('');
const createIsActive = ref(true);
const activeOptions = [
{ label: '全部', value: null },
{ label: '启用', value: true },
{ label: '停用', value: false }
];
function formatDate(value) {
if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
function getReadSeverity(value) {
if (value === true) return 'success';
if (value === false) return 'warn';
return 'secondary';
}
function getTypeSeverity(value) {
switch (value) {
case 'system':
return 'info';
case 'order':
return 'success';
case 'audit':
return 'warn';
case 'interaction':
return 'secondary';
default:
return 'secondary';
}
}
async function loadNotifications() {
loading.value = true;
try {
const result = await NotificationService.listNotifications({
page: page.value,
limit: rows.value,
id: noticeID.value || undefined,
tenant_id: tenantID.value || undefined,
tenant_code: tenantCode.value,
tenant_name: tenantName.value,
user_id: userID.value || undefined,
username: username.value,
type: type.value,
is_read: isRead.value ?? undefined,
keyword: keyword.value,
created_at_from: createdAtFrom.value || undefined,
created_at_to: createdAtTo.value || undefined,
sortField: sortField.value,
sortOrder: sortOrder.value
});
notifications.value = result.items;
totalRecords.value = result.total;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载通知列表', life: 4000 });
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
loadNotifications();
}
function onReset() {
noticeID.value = null;
tenantID.value = null;
tenantCode.value = '';
tenantName.value = '';
userID.value = null;
username.value = '';
type.value = '';
isRead.value = null;
keyword.value = '';
createdAtFrom.value = null;
createdAtTo.value = null;
sortField.value = 'created_at';
sortOrder.value = -1;
page.value = 1;
rows.value = 10;
loadNotifications();
}
function onPage(event) {
page.value = (event.page ?? 0) + 1;
rows.value = event.rows ?? rows.value;
loadNotifications();
}
function onSort(event) {
sortField.value = event.sortField ?? sortField.value;
sortOrder.value = event.sortOrder ?? sortOrder.value;
loadNotifications();
}
function openBroadcastDialog() {
broadcastTenantID.value = null;
broadcastUserIDs.value = '';
broadcastType.value = 'system';
broadcastTitle.value = '';
broadcastContent.value = '';
broadcastDialogVisible.value = true;
}
async function confirmBroadcast() {
const title = broadcastTitle.value.trim();
const content = broadcastContent.value.trim();
if (!title || !content) return;
const userIDs = broadcastUserIDs.value
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0)
.map((item) => Number(item))
.filter((item) => Number.isFinite(item) && item > 0);
if (!broadcastTenantID.value && userIDs.length === 0) {
toast.add({ severity: 'warn', summary: '提示', detail: '请填写租户ID或用户ID', life: 3000 });
return;
}
broadcastSubmitting.value = true;
try {
await NotificationService.broadcast({
tenant_id: broadcastTenantID.value || undefined,
user_ids: userIDs,
type: broadcastType.value,
title,
content
});
toast.add({ severity: 'success', summary: '已发送', detail: '通知已提交', life: 3000 });
broadcastDialogVisible.value = false;
loadNotifications();
} catch (error) {
toast.add({ severity: 'error', summary: '发送失败', detail: error?.message || '无法发送通知', life: 4000 });
} finally {
broadcastSubmitting.value = false;
}
}
async function loadTemplates() {
templateLoading.value = true;
try {
const result = await NotificationService.listTemplates({
page: templatePage.value,
limit: templateRows.value,
tenant_id: templateTenantID.value || undefined,
keyword: templateKeyword.value,
type: templateType.value,
is_active: templateIsActive.value ?? undefined,
created_at_from: templateCreatedAtFrom.value || undefined,
created_at_to: templateCreatedAtTo.value || undefined,
sortField: templateSortField.value,
sortOrder: templateSortOrder.value
});
templates.value = result.items;
templateTotalRecords.value = result.total;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载模板列表', life: 4000 });
} finally {
templateLoading.value = false;
}
}
function onTemplateSearch() {
templatePage.value = 1;
loadTemplates();
}
function onTemplateReset() {
templateTenantID.value = null;
templateKeyword.value = '';
templateType.value = '';
templateIsActive.value = null;
templateCreatedAtFrom.value = null;
templateCreatedAtTo.value = null;
templateSortField.value = 'created_at';
templateSortOrder.value = -1;
templatePage.value = 1;
templateRows.value = 10;
loadTemplates();
}
function onTemplatePage(event) {
templatePage.value = (event.page ?? 0) + 1;
templateRows.value = event.rows ?? templateRows.value;
loadTemplates();
}
function onTemplateSort(event) {
templateSortField.value = event.sortField ?? templateSortField.value;
templateSortOrder.value = event.sortOrder ?? templateSortOrder.value;
loadTemplates();
}
function openTemplateDialog() {
createTenantID.value = null;
createName.value = '';
createType.value = 'system';
createTitle.value = '';
createContent.value = '';
createIsActive.value = true;
templateDialogVisible.value = true;
}
async function confirmCreateTemplate() {
const name = createName.value.trim();
const title = createTitle.value.trim();
const content = createContent.value.trim();
if (!name || !title || !content) return;
templateSubmitting.value = true;
try {
await NotificationService.createTemplate({
tenant_id: createTenantID.value || undefined,
name,
type: createType.value,
title,
content,
is_active: createIsActive.value
});
toast.add({ severity: 'success', summary: '已创建', detail: '模板已创建', life: 3000 });
templateDialogVisible.value = false;
loadTemplates();
} catch (error) {
toast.add({ severity: 'error', summary: '创建失败', detail: error?.message || '无法创建模板', life: 4000 });
} finally {
templateSubmitting.value = false;
}
}
loadNotifications();
loadTemplates();
</script>
<template>
<PendingPanel title="Notifications" description="Notification management is pending super admin APIs." :endpoints="endpoints" :notes="notes" />
<div>
<Tabs v-model:value="tabValue" value="list">
<TabList>
<Tab value="list">通知列表</Tab>
<Tab value="templates">模板管理</Tab>
</TabList>
<TabPanels>
<TabPanel value="list">
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">通知列表</h4>
<Button label="群发通知" icon="pi pi-send" @click="openBroadcastDialog" />
</div>
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
<SearchField label="通知ID">
<InputNumber v-model="noticeID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="TenantID">
<InputNumber v-model="tenantID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="Tenant Code">
<InputText v-model="tenantCode" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</SearchField>
<SearchField label="Tenant Name">
<InputText v-model="tenantName" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</SearchField>
<SearchField label="UserID">
<InputNumber v-model="userID" :min="1" placeholder="精确匹配" class="w-full" />
</SearchField>
<SearchField label="用户名">
<InputText v-model="username" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
</SearchField>
<SearchField label="类型">
<Select v-model="type" :options="typeOptions" optionLabel="label" optionValue="value" placeholder="请选择" class="w-full" />
</SearchField>
<SearchField label="已读">
<Select v-model="isRead" :options="readOptions" optionLabel="label" optionValue="value" placeholder="全部" class="w-full" />
</SearchField>
<SearchField label="关键字">
<InputText v-model="keyword" placeholder="标题/内容" class="w-full" @keyup.enter="onSearch" />
</SearchField>
<SearchField label="创建时间 From">
<DatePicker v-model="createdAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="创建时间 To">
<DatePicker v-model="createdAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
</SearchPanel>
<DataTable :value="notifications" :loading="loading" :rows="rows" :totalRecords="totalRecords" :first="(page - 1) * rows" paginator lazy dataKey="id" @page="onPage" @sort="onSort" :sortField="sortField" :sortOrder="sortOrder">
<Column field="id" header="通知ID" style="min-width: 8rem" sortable />
<Column header="租户" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.tenant_name || '-' }}</span>
<span class="text-sm text-muted-color">{{ data.tenant_code || '-' }}</span>
</div>
</template>
</Column>
<Column header="用户" style="min-width: 12rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.username || '-' }}</span>
<span class="text-sm text-muted-color">ID: {{ data.user_id ?? '-' }}</span>
</div>
</template>
</Column>
<Column field="type" header="类型" style="min-width: 8rem">
<template #body="{ data }">
<Tag :value="data.type || '-'" :severity="getTypeSeverity(data.type)" />
</template>
</Column>
<Column field="title" header="标题" style="min-width: 14rem" />
<Column header="内容" style="min-width: 16rem">
<template #body="{ data }">
<span class="block max-w-[280px] truncate" :title="data.content">{{ data.content || '-' }}</span>
</template>
</Column>
<Column field="is_read" header="已读" style="min-width: 6rem">
<template #body="{ data }">
<Tag :value="data.is_read ? '已读' : '未读'" :severity="getReadSeverity(data.is_read)" />
</template>
</Column>
<Column field="created_at" header="创建时间" style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
</DataTable>
</div>
</TabPanel>
<TabPanel value="templates">
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">模板管理</h4>
<Button label="创建模板" icon="pi pi-plus" @click="openTemplateDialog" />
</div>
<SearchPanel :loading="templateLoading" @search="onTemplateSearch" @reset="onTemplateReset">
<SearchField label="TenantID">
<InputNumber v-model="templateTenantID" :min="1" placeholder="不填则平台模板" class="w-full" />
</SearchField>
<SearchField label="关键字">
<InputText v-model="templateKeyword" placeholder="名称/标题" class="w-full" @keyup.enter="onTemplateSearch" />
</SearchField>
<SearchField label="类型">
<Select v-model="templateType" :options="typeOptions" optionLabel="label" optionValue="value" placeholder="请选择" class="w-full" />
</SearchField>
<SearchField label="启用状态">
<Select v-model="templateIsActive" :options="activeOptions" optionLabel="label" optionValue="value" placeholder="全部" class="w-full" />
</SearchField>
<SearchField label="创建时间 From">
<DatePicker v-model="templateCreatedAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
</SearchField>
<SearchField label="创建时间 To">
<DatePicker v-model="templateCreatedAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
</SearchField>
</SearchPanel>
<DataTable
:value="templates"
:loading="templateLoading"
:rows="templateRows"
:totalRecords="templateTotalRecords"
:first="(templatePage - 1) * templateRows"
paginator
lazy
dataKey="id"
@page="onTemplatePage"
@sort="onTemplateSort"
:sortField="templateSortField"
:sortOrder="templateSortOrder"
>
<Column field="id" header="模板ID" style="min-width: 8rem" sortable />
<Column header="租户" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.tenant_name || '平台模板' }}</span>
<span class="text-sm text-muted-color">{{ data.tenant_code || '-' }}</span>
</div>
</template>
</Column>
<Column field="name" header="模板名称" style="min-width: 14rem" />
<Column field="type" header="类型" style="min-width: 8rem">
<template #body="{ data }">
<Tag :value="data.type || '-'" :severity="getTypeSeverity(data.type)" />
</template>
</Column>
<Column field="title" header="标题" style="min-width: 14rem" />
<Column header="启用" style="min-width: 6rem">
<template #body="{ data }">
<Tag :value="data.is_active ? '启用' : '停用'" :severity="data.is_active ? 'success' : 'secondary'" />
</template>
</Column>
<Column field="created_at" header="创建时间" style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
<Column field="updated_at" header="更新时间" style="min-width: 14rem">
<template #body="{ data }">
{{ formatDate(data.updated_at) }}
</template>
</Column>
</DataTable>
</div>
</TabPanel>
</TabPanels>
</Tabs>
<Dialog v-model:visible="broadcastDialogVisible" modal :style="{ width: '560px' }">
<template #header>
<span class="font-medium">群发通知</span>
</template>
<div class="grid gap-4">
<div class="text-sm text-muted-color">优先向指定用户发送未指定用户则按租户成员广播</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="text-sm text-muted-color mb-2 block">TenantID</label>
<InputNumber v-model="broadcastTenantID" :min="1" placeholder="选填" class="w-full" />
</div>
<div>
<label class="text-sm text-muted-color mb-2 block">UserIDs</label>
<InputText v-model="broadcastUserIDs" placeholder="逗号分隔" class="w-full" />
</div>
</div>
<div>
<label class="text-sm text-muted-color mb-2 block">类型</label>
<Select v-model="broadcastType" :options="sendTypeOptions" optionLabel="label" optionValue="value" class="w-full" />
</div>
<div>
<label class="text-sm text-muted-color mb-2 block">标题</label>
<InputText v-model="broadcastTitle" placeholder="请输入标题" class="w-full" />
</div>
<div>
<label class="text-sm text-muted-color mb-2 block">内容</label>
<Textarea v-model="broadcastContent" rows="4" placeholder="请输入通知内容" class="w-full" />
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="broadcastDialogVisible = false" />
<Button label="发送" icon="pi pi-send" :loading="broadcastSubmitting" @click="confirmBroadcast" :disabled="broadcastSubmitting || !broadcastTitle.trim() || !broadcastContent.trim()" />
</template>
</Dialog>
<Dialog v-model:visible="templateDialogVisible" modal :style="{ width: '560px' }">
<template #header>
<span class="font-medium">创建模板</span>
</template>
<div class="grid gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="text-sm text-muted-color mb-2 block">TenantID</label>
<InputNumber v-model="createTenantID" :min="1" placeholder="选填" class="w-full" />
</div>
<div>
<label class="text-sm text-muted-color mb-2 block">类型</label>
<Select v-model="createType" :options="sendTypeOptions" optionLabel="label" optionValue="value" class="w-full" />
</div>
</div>
<div>
<label class="text-sm text-muted-color mb-2 block">模板名称</label>
<InputText v-model="createName" placeholder="用于内部识别" class="w-full" />
</div>
<div>
<label class="text-sm text-muted-color mb-2 block">标题</label>
<InputText v-model="createTitle" placeholder="通知标题" class="w-full" />
</div>
<div>
<label class="text-sm text-muted-color mb-2 block">内容</label>
<Textarea v-model="createContent" rows="4" placeholder="通知内容" class="w-full" />
</div>
<div class="flex items-center gap-2 text-sm">
<Checkbox v-model="createIsActive" binary inputId="templateActive" />
<label for="templateActive">启用模板</label>
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text @click="templateDialogVisible = false" />
<Button label="创建" icon="pi pi-check" :loading="templateSubmitting" :disabled="templateSubmitting || !createName.trim() || !createTitle.trim() || !createContent.trim()" @click="confirmCreateTemplate" />
</template>
</Dialog>
</div>
</template>