feat: add tenant admin invite management, ledger overview, order details, and order management features
- Implemented Invite management with creation, searching, and disabling functionalities. - Added Ledger overview for financial transactions with filtering options. - Developed Order Detail view for individual order insights and refund capabilities. - Created Orders management page with search, reset, and pagination features. - Enhanced user experience with toast notifications for actions and error handling.
This commit is contained in:
@@ -24,7 +24,11 @@ const model = computed(() => {
|
||||
items: [
|
||||
{ label: '概览', icon: 'pi pi-fw pi-home', to: { name: 'tenantadmin-dashboard', params: { tenantCode: code } } },
|
||||
{ label: '入驻申请', icon: 'pi pi-fw pi-inbox', to: { name: 'tenantadmin-join-requests', params: { tenantCode: code } } },
|
||||
{ label: '成员管理', icon: 'pi pi-fw pi-users', to: { name: 'tenantadmin-members', params: { tenantCode: code } } }
|
||||
{ label: '成员管理', icon: 'pi pi-fw pi-users', to: { name: 'tenantadmin-members', params: { tenantCode: code } } },
|
||||
{ label: '内容管理', icon: 'pi pi-fw pi-file', to: { name: 'tenantadmin-contents', params: { tenantCode: code } } },
|
||||
{ label: '订单与退款', icon: 'pi pi-fw pi-shopping-cart', to: { name: 'tenantadmin-orders', params: { tenantCode: code } } },
|
||||
{ label: '邀请码', icon: 'pi pi-fw pi-ticket', to: { name: 'tenantadmin-invites', params: { tenantCode: code } } },
|
||||
{ label: '财务流水', icon: 'pi pi-fw pi-wallet', to: { name: 'tenantadmin-ledgers', params: { tenantCode: code } } }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -28,6 +28,31 @@ const router = createRouter({
|
||||
path: 'members',
|
||||
name: 'tenantadmin-members',
|
||||
component: () => import('@/views/tenantadmin/Members.vue')
|
||||
},
|
||||
{
|
||||
path: 'contents',
|
||||
name: 'tenantadmin-contents',
|
||||
component: () => import('@/views/tenantadmin/Contents.vue')
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'tenantadmin-orders',
|
||||
component: () => import('@/views/tenantadmin/Orders.vue')
|
||||
},
|
||||
{
|
||||
path: 'orders/:orderID',
|
||||
name: 'tenantadmin-order-detail',
|
||||
component: () => import('@/views/tenantadmin/OrderDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'invites',
|
||||
name: 'tenantadmin-invites',
|
||||
component: () => import('@/views/tenantadmin/Invites.vue')
|
||||
},
|
||||
{
|
||||
path: 'ledgers',
|
||||
name: 'tenantadmin-ledgers',
|
||||
component: () => import('@/views/tenantadmin/Ledgers.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -40,6 +65,8 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta?.requiresAuth !== true) return true;
|
||||
const token = String(localStorage.getItem('token') || '').trim();
|
||||
if (!token) return { name: 'tenantadmin-enter' };
|
||||
const tenantCode = String(to.params?.tenantCode ?? '').trim();
|
||||
if (!tenantCode) return { name: 'tenantadmin-enter' };
|
||||
return true;
|
||||
|
||||
@@ -7,6 +7,25 @@ function normalizeItems(items) {
|
||||
}
|
||||
|
||||
export const TenantAdminService = {
|
||||
async login({ username, password } = {}) {
|
||||
const data = await requestJson('/v1/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username, password }
|
||||
});
|
||||
return { token: data?.token || '' };
|
||||
},
|
||||
async refreshToken() {
|
||||
const data = await requestJson('/v1/auth/token');
|
||||
return { token: data?.token || '' };
|
||||
},
|
||||
async getMeGlobal() {
|
||||
return requestJson('/v1/me');
|
||||
},
|
||||
async listMyTenants() {
|
||||
const data = await requestJson('/v1/me/tenants');
|
||||
if (Array.isArray(data)) return data;
|
||||
return normalizeItems(data);
|
||||
},
|
||||
async getMe(tenantCode) {
|
||||
return requestJson(tenantApiPath(tenantCode, '/me'));
|
||||
},
|
||||
@@ -61,6 +80,207 @@ export const TenantAdminService = {
|
||||
return requestJson(tenantApiPath(tenantCode, `/admin/users/${userID}/join`), {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
|
||||
async listOrders(
|
||||
tenantCode,
|
||||
{
|
||||
page,
|
||||
limit,
|
||||
user_id,
|
||||
username,
|
||||
content_id,
|
||||
content_title,
|
||||
type,
|
||||
status,
|
||||
created_at_from,
|
||||
created_at_to,
|
||||
paid_at_from,
|
||||
paid_at_to,
|
||||
amount_paid_min,
|
||||
amount_paid_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,
|
||||
user_id,
|
||||
username,
|
||||
content_id,
|
||||
content_title,
|
||||
type,
|
||||
status,
|
||||
created_at_from: iso(created_at_from),
|
||||
created_at_to: iso(created_at_to),
|
||||
paid_at_from: iso(paid_at_from),
|
||||
paid_at_to: iso(paid_at_to),
|
||||
amount_paid_min,
|
||||
amount_paid_max
|
||||
};
|
||||
if (sortField && sortOrder) {
|
||||
if (sortOrder === 1) query.asc = sortField;
|
||||
if (sortOrder === -1) query.desc = sortField;
|
||||
}
|
||||
|
||||
const data = await requestJson(tenantApiPath(tenantCode, '/admin/orders'), { query });
|
||||
return {
|
||||
page: data?.page ?? page ?? 1,
|
||||
limit: data?.limit ?? limit ?? 10,
|
||||
total: data?.total ?? 0,
|
||||
items: normalizeItems(data?.items)
|
||||
};
|
||||
},
|
||||
async getOrderDetail(tenantCode, orderID) {
|
||||
if (!orderID) throw new Error('orderID is required');
|
||||
return requestJson(tenantApiPath(tenantCode, `/admin/orders/${orderID}`));
|
||||
},
|
||||
async refundOrder(tenantCode, orderID, { force, reason, idempotency_key } = {}) {
|
||||
if (!orderID) throw new Error('orderID is required');
|
||||
return requestJson(tenantApiPath(tenantCode, `/admin/orders/${orderID}/refund`), {
|
||||
method: 'POST',
|
||||
body: { force: Boolean(force), reason, idempotency_key }
|
||||
});
|
||||
},
|
||||
|
||||
async listInvites(tenantCode, { page, limit, status, code } = {}) {
|
||||
const data = await requestJson(tenantApiPath(tenantCode, '/admin/invites'), {
|
||||
query: { page, limit, status, code }
|
||||
});
|
||||
return {
|
||||
page: data?.page ?? page ?? 1,
|
||||
limit: data?.limit ?? limit ?? 10,
|
||||
total: data?.total ?? 0,
|
||||
items: normalizeItems(data?.items)
|
||||
};
|
||||
},
|
||||
async createInvite(tenantCode, { code, max_uses, expires_at, remark } = {}) {
|
||||
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();
|
||||
};
|
||||
return requestJson(tenantApiPath(tenantCode, '/admin/invites'), {
|
||||
method: 'POST',
|
||||
body: {
|
||||
code,
|
||||
max_uses: max_uses ?? undefined,
|
||||
expires_at: iso(expires_at),
|
||||
remark
|
||||
}
|
||||
});
|
||||
},
|
||||
async disableInvite(tenantCode, inviteID, { reason } = {}) {
|
||||
if (!inviteID) throw new Error('inviteID is required');
|
||||
return requestJson(tenantApiPath(tenantCode, `/admin/invites/${inviteID}/disable`), {
|
||||
method: 'PATCH',
|
||||
body: { reason }
|
||||
});
|
||||
},
|
||||
|
||||
async listLedgers(
|
||||
tenantCode,
|
||||
{ page, limit, operator_user_id, user_id, type, order_id, biz_ref_type, biz_ref_id, created_at_from, created_at_to } = {}
|
||||
) {
|
||||
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 data = await requestJson(tenantApiPath(tenantCode, '/admin/ledgers'), {
|
||||
query: {
|
||||
page,
|
||||
limit,
|
||||
operator_user_id,
|
||||
user_id,
|
||||
type,
|
||||
order_id,
|
||||
biz_ref_type,
|
||||
biz_ref_id,
|
||||
created_at_from: iso(created_at_from),
|
||||
created_at_to: iso(created_at_to)
|
||||
}
|
||||
});
|
||||
return {
|
||||
page: data?.page ?? page ?? 1,
|
||||
limit: data?.limit ?? limit ?? 10,
|
||||
total: data?.total ?? 0,
|
||||
items: normalizeItems(data?.items)
|
||||
};
|
||||
},
|
||||
|
||||
async listContents(
|
||||
tenantCode,
|
||||
{
|
||||
page,
|
||||
limit,
|
||||
id,
|
||||
user_id,
|
||||
keyword,
|
||||
status,
|
||||
visibility,
|
||||
published_at_from,
|
||||
published_at_to,
|
||||
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,
|
||||
user_id,
|
||||
keyword,
|
||||
status,
|
||||
visibility,
|
||||
published_at_from: iso(published_at_from),
|
||||
published_at_to: iso(published_at_to),
|
||||
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(tenantApiPath(tenantCode, '/admin/contents'), { query });
|
||||
return {
|
||||
page: data?.page ?? page ?? 1,
|
||||
limit: data?.limit ?? limit ?? 10,
|
||||
total: data?.total ?? 0,
|
||||
items: normalizeItems(data?.items)
|
||||
};
|
||||
},
|
||||
async updateContent(tenantCode, contentID, { title, description, visibility, status, preview_seconds } = {}) {
|
||||
if (!contentID) throw new Error('contentID is required');
|
||||
return requestJson(tenantApiPath(tenantCode, `/admin/contents/${contentID}`), {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
title: title ?? undefined,
|
||||
description: description ?? undefined,
|
||||
visibility: visibility ?? undefined,
|
||||
status: status ?? undefined,
|
||||
preview_seconds: preview_seconds ?? undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
355
frontend/tenant_admin/src/views/tenantadmin/Contents.vue
Normal file
355
frontend/tenant_admin/src/views/tenantadmin/Contents.vue
Normal file
@@ -0,0 +1,355 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { TenantAdminService } from '@/service/TenantAdminService';
|
||||
|
||||
const toast = useToast();
|
||||
const route = useRoute();
|
||||
const tenantCode = computed(() => String(route.params.tenantCode || ''));
|
||||
|
||||
const loading = ref(false);
|
||||
const items = ref([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const rows = ref(10);
|
||||
const sortField = ref('id');
|
||||
const sortOrder = ref(-1);
|
||||
|
||||
const contentID = ref(null);
|
||||
const ownerUserID = ref(null);
|
||||
const keyword = ref('');
|
||||
const status = ref('');
|
||||
const visibility = ref('');
|
||||
const publishedAtFrom = ref(null);
|
||||
const publishedAtTo = ref(null);
|
||||
const createdAtFrom = ref(null);
|
||||
const createdAtTo = ref(null);
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: 'draft', value: 'draft' },
|
||||
{ label: 'reviewing', value: 'reviewing' },
|
||||
{ label: 'published', value: 'published' },
|
||||
{ label: 'unpublished', value: 'unpublished' },
|
||||
{ label: 'blocked', value: 'blocked' }
|
||||
];
|
||||
|
||||
const visibilityOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: 'public', value: 'public' },
|
||||
{ label: 'tenant_only', value: 'tenant_only' },
|
||||
{ label: 'private', value: 'private' }
|
||||
];
|
||||
|
||||
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 formatCny(amountInCents) {
|
||||
const amount = Number(amountInCents) / 100;
|
||||
if (!Number.isFinite(amount)) return '-';
|
||||
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
|
||||
}
|
||||
|
||||
function getStatusSeverity(value) {
|
||||
switch (value) {
|
||||
case 'published':
|
||||
return 'success';
|
||||
case 'reviewing':
|
||||
return 'warn';
|
||||
case 'blocked':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function getVisibilitySeverity(value) {
|
||||
switch (value) {
|
||||
case 'public':
|
||||
return 'info';
|
||||
case 'tenant_only':
|
||||
return 'warn';
|
||||
case 'private':
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await TenantAdminService.listContents(tenantCode.value, {
|
||||
page: page.value,
|
||||
limit: rows.value,
|
||||
id: contentID.value || undefined,
|
||||
user_id: ownerUserID.value || undefined,
|
||||
keyword: keyword.value,
|
||||
status: status.value || undefined,
|
||||
visibility: visibility.value || undefined,
|
||||
published_at_from: publishedAtFrom.value || undefined,
|
||||
published_at_to: publishedAtTo.value || undefined,
|
||||
created_at_from: createdAtFrom.value || undefined,
|
||||
created_at_to: createdAtTo.value || undefined,
|
||||
sortField: sortField.value,
|
||||
sortOrder: sortOrder.value
|
||||
});
|
||||
items.value = result.items || [];
|
||||
total.value = result.total ?? 0;
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载内容列表', life: 5000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1;
|
||||
load();
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
contentID.value = null;
|
||||
ownerUserID.value = null;
|
||||
keyword.value = '';
|
||||
status.value = '';
|
||||
visibility.value = '';
|
||||
publishedAtFrom.value = null;
|
||||
publishedAtTo.value = null;
|
||||
createdAtFrom.value = null;
|
||||
createdAtTo.value = null;
|
||||
sortField.value = 'id';
|
||||
sortOrder.value = -1;
|
||||
page.value = 1;
|
||||
rows.value = 10;
|
||||
load();
|
||||
}
|
||||
|
||||
function onPage(event) {
|
||||
page.value = (event.page ?? 0) + 1;
|
||||
rows.value = event.rows ?? rows.value;
|
||||
load();
|
||||
}
|
||||
|
||||
function onSort(event) {
|
||||
sortField.value = event.sortField ?? sortField.value;
|
||||
sortOrder.value = event.sortOrder ?? sortOrder.value;
|
||||
load();
|
||||
}
|
||||
|
||||
const statusDialogVisible = ref(false);
|
||||
const statusLoading = ref(false);
|
||||
const statusTarget = ref(null);
|
||||
const statusNext = ref('');
|
||||
|
||||
function openPublish(row) {
|
||||
statusTarget.value = row;
|
||||
statusNext.value = 'published';
|
||||
statusDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openUnpublish(row) {
|
||||
statusTarget.value = row;
|
||||
statusNext.value = 'unpublished';
|
||||
statusDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmStatusChange() {
|
||||
const contentIDValue = statusTarget.value?.content?.id;
|
||||
if (!contentIDValue) return;
|
||||
statusLoading.value = true;
|
||||
try {
|
||||
await TenantAdminService.updateContent(tenantCode.value, contentIDValue, { status: statusNext.value });
|
||||
toast.add({ severity: 'success', summary: '已更新状态', detail: `ContentID: ${contentIDValue}`, life: 3000 });
|
||||
statusDialogVisible.value = false;
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '操作失败', detail: error?.message || '无法更新内容状态', life: 5000 });
|
||||
} finally {
|
||||
statusLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex flex-col">
|
||||
<h3 class="m-0 text-2xl">内容管理</h3>
|
||||
<div class="text-lg text-muted-color">查看、筛选并上架/下架内容</div>
|
||||
</div>
|
||||
<Button label="刷新" icon="pi pi-refresh" severity="secondary" class="text-lg" @click="load" :loading="loading" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-4 text-lg mb-4">
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<label class="block font-medium mb-2">ContentID</label>
|
||||
<InputNumber v-model="contentID" :min="1" class="w-full text-lg" placeholder="精确匹配" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<label class="block font-medium mb-2">Owner UserID</label>
|
||||
<InputNumber v-model="ownerUserID" :min="1" class="w-full text-lg" placeholder="精确匹配" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<label class="block font-medium mb-2">关键词(标题)</label>
|
||||
<InputText v-model="keyword" class="w-full text-lg" placeholder="模糊匹配" @keyup.enter="onSearch" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<label class="block font-medium mb-2">状态</label>
|
||||
<Select v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" class="w-full text-lg" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<label class="block font-medium mb-2">可见性</label>
|
||||
<Select v-model="visibility" :options="visibilityOptions" optionLabel="label" optionValue="value" class="w-full text-lg" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6 flex items-end gap-3">
|
||||
<Button label="查询" icon="pi pi-search" class="text-lg px-6" @click="onSearch" :disabled="loading" />
|
||||
<Button label="重置" icon="pi pi-refresh" severity="secondary" class="text-lg px-6" @click="onReset" :disabled="loading" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<label class="block font-medium mb-2">发布时间 From</label>
|
||||
<DatePicker v-model="publishedAtFrom" showIcon showButtonBar class="w-full text-lg" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<label class="block font-medium mb-2">发布时间 To</label>
|
||||
<DatePicker v-model="publishedAtTo" showIcon showButtonBar class="w-full text-lg" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<label class="block font-medium mb-2">创建时间 From</label>
|
||||
<DatePicker v-model="createdAtFrom" showIcon showButtonBar class="w-full text-lg" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<label class="block font-medium mb-2">创建时间 To</label>
|
||||
<DatePicker v-model="createdAtTo" showIcon showButtonBar class="w-full text-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="items"
|
||||
dataKey="content.id"
|
||||
:loading="loading"
|
||||
lazy
|
||||
:paginator="true"
|
||||
:rows="rows"
|
||||
:totalRecords="total"
|
||||
:first="(page - 1) * rows"
|
||||
:rowsPerPageOptions="[10, 20, 50]"
|
||||
sortMode="single"
|
||||
:sortField="sortField"
|
||||
:sortOrder="sortOrder"
|
||||
@page="onPage"
|
||||
@sort="onSort"
|
||||
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column header="内容" sortField="id" sortable style="min-width: 22rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-lg font-semibold">#{{ data?.content?.id ?? '-' }} {{ data?.content?.title ?? '-' }}</div>
|
||||
<div class="text-muted-color">创建:{{ formatDate(data?.content?.created_at) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="作者" sortField="user_id" sortable style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-lg font-semibold">{{ data?.owner?.username ?? '-' }}</div>
|
||||
<div class="text-muted-color">ID: {{ data?.owner?.id ?? data?.content?.user_id ?? '-' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="价格" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<div class="text-lg font-semibold">{{ formatCny(data?.price?.price_amount) }}</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="状态" sortField="status" sortable style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<Tag
|
||||
:value="data?.status_description ?? data?.content?.status ?? '-'"
|
||||
:severity="getStatusSeverity(data?.content?.status)"
|
||||
class="text-base"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="可见性" sortField="visibility" sortable style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<Tag
|
||||
:value="data?.visibility_description ?? data?.content?.visibility ?? '-'"
|
||||
:severity="getVisibilitySeverity(data?.content?.visibility)"
|
||||
class="text-base"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="发布时间" sortField="published_at" sortable style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-lg">{{ formatDate(data?.content?.published_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="操作" style="min-width: 20rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
v-if="data?.content?.status !== 'published'"
|
||||
label="上架"
|
||||
icon="pi pi-upload"
|
||||
class="text-lg px-5"
|
||||
@click="openPublish(data)"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
label="下架"
|
||||
icon="pi pi-download"
|
||||
severity="danger"
|
||||
class="text-lg px-5"
|
||||
@click="openUnpublish(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="statusDialogVisible" :modal="true" :style="{ width: '560px' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-xl">{{ statusNext === 'published' ? '确认上架' : '确认下架' }}</span>
|
||||
<span class="text-muted-color truncate max-w-[260px]">#{{ statusTarget?.content?.id ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-3 text-lg">
|
||||
<div class="text-muted-color">
|
||||
内容:{{ statusTarget?.content?.title ?? '-' }}
|
||||
<span v-if="statusTarget?.owner?.username">(作者:{{ statusTarget.owner.username }})</span>
|
||||
</div>
|
||||
<div>
|
||||
当前状态:
|
||||
<Tag :value="statusTarget?.content?.status ?? '-'" :severity="getStatusSeverity(statusTarget?.content?.status)" class="text-base" />
|
||||
<span class="mx-2">→</span>
|
||||
<Tag :value="statusNext" :severity="getStatusSeverity(statusNext)" class="text-base" />
|
||||
</div>
|
||||
<div v-if="statusNext === 'unpublished'" class="text-muted-color">下架后用户将无法继续购买/访问(取决于可见性与权限)。</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="取消" icon="pi pi-times" text class="text-lg" @click="statusDialogVisible = false" :disabled="statusLoading" />
|
||||
<Button
|
||||
:label="statusNext === 'published' ? '确认上架' : '确认下架'"
|
||||
icon="pi pi-check"
|
||||
class="text-lg"
|
||||
@click="confirmStatusChange"
|
||||
:loading="statusLoading"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -1,37 +1,173 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { TenantAdminService } from '@/service/TenantAdminService';
|
||||
|
||||
const toast = useToast();
|
||||
const router = useRouter();
|
||||
const tenantCode = ref('');
|
||||
|
||||
function go() {
|
||||
const code = tenantCode.value.trim();
|
||||
const token = ref(String(localStorage.getItem('token') || '').trim());
|
||||
const isLoggedIn = computed(() => token.value.length > 0);
|
||||
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const loginLoading = ref(false);
|
||||
|
||||
const me = ref(null);
|
||||
const tenantsLoading = ref(false);
|
||||
const tenants = ref([]);
|
||||
const keyword = ref('');
|
||||
|
||||
function setToken(value) {
|
||||
const v = String(value || '').trim();
|
||||
token.value = v;
|
||||
if (!v) {
|
||||
localStorage.removeItem('token');
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('token', v.startsWith('Bearer ') ? v.slice('Bearer '.length) : v);
|
||||
}
|
||||
|
||||
const filteredTenants = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase();
|
||||
if (!kw) return tenants.value || [];
|
||||
return (tenants.value || []).filter((t) => {
|
||||
const code = String(t?.tenant_code || '').toLowerCase();
|
||||
const name = String(t?.tenant_name || '').toLowerCase();
|
||||
return code.includes(kw) || name.includes(kw);
|
||||
});
|
||||
});
|
||||
|
||||
async function loadTenants() {
|
||||
tenantsLoading.value = true;
|
||||
try {
|
||||
me.value = await TenantAdminService.getMeGlobal();
|
||||
tenants.value = await TenantAdminService.listMyTenants();
|
||||
} catch (error) {
|
||||
const status = error?.status;
|
||||
if (status === 401 || status === 403) {
|
||||
setToken('');
|
||||
}
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载租户列表', life: 5000 });
|
||||
} finally {
|
||||
tenantsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
loginLoading.value = true;
|
||||
try {
|
||||
const resp = await TenantAdminService.login({ username: username.value.trim(), password: password.value });
|
||||
if (!resp?.token) throw new Error('登录失败:未返回 token');
|
||||
setToken(resp.token);
|
||||
toast.add({ severity: 'success', summary: '登录成功', detail: '正在加载租户列表', life: 2500 });
|
||||
await loadTenants();
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '登录失败', detail: error?.message || '用户名或密码错误', life: 5000 });
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
setToken('');
|
||||
me.value = null;
|
||||
tenants.value = [];
|
||||
username.value = '';
|
||||
password.value = '';
|
||||
}
|
||||
|
||||
function enterTenant(item) {
|
||||
const code = String(item?.tenant_code || '').trim();
|
||||
if (!code) return;
|
||||
localStorage.setItem('last_tenant_code', code);
|
||||
router.push({ name: 'tenantadmin-dashboard', params: { tenantCode: code } });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isLoggedIn.value) {
|
||||
loadTenants();
|
||||
} else {
|
||||
const last = String(localStorage.getItem('last_username') || '').trim();
|
||||
if (last) username.value = last;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-surface-50 dark:bg-surface-950 flex items-center justify-center min-h-screen min-w-[100vw] overflow-hidden">
|
||||
<div class="w-full max-w-[560px] px-6">
|
||||
<div class="card">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-2xl font-semibold">进入租户管理后台</div>
|
||||
<div class="text-lg text-muted-color">请输入租户编码(Tenant Code)</div>
|
||||
<div class="flex flex-col gap-3" v-if="!isLoggedIn">
|
||||
<div class="text-2xl font-semibold">登录后进入租户后台</div>
|
||||
<div class="text-lg text-muted-color">请先登录系统,再选择要管理的租户</div>
|
||||
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<label class="text-lg font-medium">租户编码</label>
|
||||
<InputText v-model="tenantCode" class="w-full text-lg py-3" placeholder="例如:abc" @keyup.enter="go" />
|
||||
<label class="text-lg font-medium">用户名</label>
|
||||
<InputText
|
||||
v-model="username"
|
||||
class="w-full text-lg py-3"
|
||||
placeholder="请输入用户名"
|
||||
@keyup.enter="doLogin"
|
||||
@blur="localStorage.setItem('last_username', username.trim())"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button label="进入" class="w-full text-lg py-3 mt-4" @click="go" :disabled="tenantCode.trim().length === 0" />
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<label class="text-lg font-medium">密码</label>
|
||||
<InputText v-model="password" type="password" class="w-full text-lg py-3" placeholder="请输入密码" @keyup.enter="doLogin" />
|
||||
</div>
|
||||
|
||||
<Button label="登录" icon="pi pi-sign-in" class="w-full text-lg py-3 mt-3" @click="doLogin" :loading="loginLoading" :disabled="!username.trim() || !password" />
|
||||
<div class="text-sm text-muted-color mt-2">
|
||||
提示:若提示无权限,请先使用用户端登录,并确保你是该租户的管理员。
|
||||
说明:仅登录成功且拥有对应租户权限,才可进入租户管理后台。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3" v-else>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-2xl font-semibold">选择租户进入后台</div>
|
||||
<div class="text-lg text-muted-color">
|
||||
当前用户:<span class="font-medium">{{ me?.username ?? '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button label="退出登录" icon="pi pi-sign-out" severity="secondary" class="text-lg" @click="logout" />
|
||||
<Button label="刷新" icon="pi pi-refresh" severity="secondary" class="text-lg" @click="loadTenants" :loading="tenantsLoading" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<label class="text-lg font-medium">搜索租户</label>
|
||||
<InputText v-model="keyword" class="w-full text-lg py-3" placeholder="输入租户编码或名称" />
|
||||
</div>
|
||||
|
||||
<div v-if="tenantsLoading" class="text-lg text-muted-color mt-2">正在加载租户列表...</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="filteredTenants.length === 0" class="text-lg text-muted-color mt-2">暂无可进入的租户。</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3 mt-3">
|
||||
<div v-for="t in filteredTenants" :key="t.tenant_id" class="p-4 border rounded-lg bg-surface-0 dark:bg-surface-900">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-xl font-semibold">{{ t?.tenant_name || '-' }}</div>
|
||||
<div class="text-lg text-muted-color">Code: {{ t?.tenant_code || '-' }} · ID: {{ t?.tenant_id || '-' }}</div>
|
||||
<div class="text-lg text-muted-color">状态:{{ t?.tenant_status_description || t?.tenant_status || '-' }}</div>
|
||||
</div>
|
||||
<Button label="进入管理" icon="pi pi-arrow-right" class="text-lg px-6" @click="enterTenant(t)" />
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<Tag v-if="t?.is_owner" value="Owner" severity="info" class="text-base" />
|
||||
<Tag v-for="r in t?.member_roles || []" :key="r" :value="r" severity="secondary" class="text-base" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
300
frontend/tenant_admin/src/views/tenantadmin/Invites.vue
Normal file
300
frontend/tenant_admin/src/views/tenantadmin/Invites.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { TenantAdminService } from '@/service/TenantAdminService';
|
||||
|
||||
const toast = useToast();
|
||||
const route = useRoute();
|
||||
const tenantCode = computed(() => String(route.params.tenantCode || ''));
|
||||
|
||||
const loading = ref(false);
|
||||
const items = ref([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const rows = ref(10);
|
||||
|
||||
const status = ref('active');
|
||||
const code = ref('');
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '启用中', value: 'active' },
|
||||
{ label: '已禁用', value: 'disabled' },
|
||||
{ label: '已过期', value: 'expired' },
|
||||
{ label: '全部', value: '' }
|
||||
];
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await TenantAdminService.listInvites(tenantCode.value, {
|
||||
page: page.value,
|
||||
limit: rows.value,
|
||||
status: status.value || undefined,
|
||||
code: code.value
|
||||
});
|
||||
items.value = result.items || [];
|
||||
total.value = result.total ?? 0;
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载邀请码列表', life: 5000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1;
|
||||
load();
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
status.value = 'active';
|
||||
code.value = '';
|
||||
page.value = 1;
|
||||
rows.value = 10;
|
||||
load();
|
||||
}
|
||||
|
||||
function onPage(event) {
|
||||
page.value = (event.page ?? 0) + 1;
|
||||
rows.value = event.rows ?? rows.value;
|
||||
load();
|
||||
}
|
||||
|
||||
const createDialogVisible = ref(false);
|
||||
const createLoading = ref(false);
|
||||
const createCode = ref('');
|
||||
const createMaxUses = ref(null);
|
||||
const createExpiresAt = ref(null);
|
||||
const createRemark = ref('');
|
||||
const createdInvite = ref(null);
|
||||
|
||||
function openCreateDialog() {
|
||||
createCode.value = '';
|
||||
createMaxUses.value = null;
|
||||
createExpiresAt.value = null;
|
||||
createRemark.value = '';
|
||||
createdInvite.value = null;
|
||||
createDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmCreate() {
|
||||
createLoading.value = true;
|
||||
try {
|
||||
const inv = await TenantAdminService.createInvite(tenantCode.value, {
|
||||
code: createCode.value.trim() || '',
|
||||
max_uses: createMaxUses.value ?? undefined,
|
||||
expires_at: createExpiresAt.value ?? undefined,
|
||||
remark: createRemark.value.trim() || ''
|
||||
});
|
||||
createdInvite.value = inv;
|
||||
toast.add({ severity: 'success', summary: '创建成功', detail: `邀请码ID: ${inv?.id ?? '-'}`, life: 3000 });
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '创建失败', detail: error?.message || '无法创建邀请码', life: 5000 });
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
const value = String(text ?? '');
|
||||
if (!value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.add({ severity: 'success', summary: '已复制', detail: value, life: 1500 });
|
||||
} catch {
|
||||
toast.add({ severity: 'warn', summary: '复制失败', detail: '请手动复制', life: 3000 });
|
||||
}
|
||||
}
|
||||
|
||||
const disableDialogVisible = ref(false);
|
||||
const disableLoading = ref(false);
|
||||
const disableTarget = ref(null);
|
||||
const disableReason = ref('');
|
||||
|
||||
function openDisableDialog(row) {
|
||||
disableTarget.value = row;
|
||||
disableReason.value = '';
|
||||
disableDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmDisable() {
|
||||
const id = disableTarget.value?.id;
|
||||
if (!id) return;
|
||||
disableLoading.value = true;
|
||||
try {
|
||||
await TenantAdminService.disableInvite(tenantCode.value, id, { reason: disableReason.value });
|
||||
toast.add({ severity: 'success', summary: '已禁用', detail: `邀请码ID: ${id}`, life: 3000 });
|
||||
disableDialogVisible.value = false;
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '禁用失败', detail: error?.message || '无法禁用邀请码', life: 5000 });
|
||||
} finally {
|
||||
disableLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex flex-col">
|
||||
<h3 class="m-0 text-2xl">邀请码管理</h3>
|
||||
<div class="text-lg text-muted-color">创建邀请码并复制给对方加入租户</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<Button label="创建邀请码" icon="pi pi-plus" class="text-lg" @click="openCreateDialog" />
|
||||
<Button label="刷新" icon="pi pi-refresh" severity="secondary" class="text-lg" @click="load" :loading="loading" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-4 text-lg mb-4">
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<label class="block font-medium mb-2">状态</label>
|
||||
<Select v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" class="w-full text-lg" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<label class="block font-medium mb-2">邀请码</label>
|
||||
<InputText v-model="code" class="w-full text-lg" placeholder="包含匹配" @keyup.enter="onSearch" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4 flex items-end gap-3">
|
||||
<Button label="查询" icon="pi pi-search" class="text-lg px-6" @click="onSearch" :disabled="loading" />
|
||||
<Button label="重置" icon="pi pi-refresh" severity="secondary" class="text-lg px-6" @click="onReset" :disabled="loading" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="items"
|
||||
dataKey="id"
|
||||
:loading="loading"
|
||||
lazy
|
||||
:paginator="true"
|
||||
:rows="rows"
|
||||
:totalRecords="total"
|
||||
:first="(page - 1) * rows"
|
||||
:rowsPerPageOptions="[10, 20, 50]"
|
||||
@page="onPage"
|
||||
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="id" header="ID" style="min-width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-lg">{{ data?.id ?? '-' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="code" header="邀请码" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg font-semibold">{{ data?.code ?? '-' }}</span>
|
||||
<Button label="复制" icon="pi pi-copy" severity="secondary" class="text-lg px-4" @click="copyText(data?.code)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="状态" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data?.status ?? '-'" severity="secondary" class="text-base" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="max_uses" header="次数" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-lg">{{ data?.max_uses ?? 0 }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="expires_at" header="过期时间" style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-lg">{{ formatDate(data?.expires_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="操作" style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
v-if="data?.status === 'active'"
|
||||
label="禁用"
|
||||
icon="pi pi-ban"
|
||||
severity="danger"
|
||||
class="text-lg px-6"
|
||||
@click="openDisableDialog(data)"
|
||||
/>
|
||||
<span v-else class="text-muted-color">-</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="createDialogVisible" :modal="true" :style="{ width: '640px' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-xl">创建邀请码</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4 text-lg">
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<label class="block font-medium mb-2">邀请码(可不填)</label>
|
||||
<InputText v-model="createCode" class="w-full text-lg" placeholder="不填则自动生成" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<label class="block font-medium mb-2">最大使用次数(0=不限)</label>
|
||||
<InputNumber v-model="createMaxUses" :min="0" class="w-full text-lg" placeholder="例如:1" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<label class="block font-medium mb-2">过期时间(可不填)</label>
|
||||
<DatePicker v-model="createExpiresAt" showIcon showButtonBar class="w-full text-lg" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<label class="block font-medium mb-2">备注(可不填)</label>
|
||||
<InputText v-model="createRemark" class="w-full text-lg" placeholder="例如:线下活动" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="createdInvite" class="p-4 rounded-lg bg-surface-50 dark:bg-surface-800">
|
||||
<div class="text-lg font-semibold mb-2">创建结果</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg">邀请码:</span>
|
||||
<span class="text-lg font-semibold">{{ createdInvite?.code ?? '-' }}</span>
|
||||
<Button label="复制" icon="pi pi-copy" severity="secondary" class="text-lg px-4" @click="copyText(createdInvite?.code)" />
|
||||
</div>
|
||||
<div class="text-muted-color mt-2">提示:把邀请码发给对方,对方使用“邀请码加入租户”流程即可。</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="关闭" icon="pi pi-times" text class="text-lg" @click="createDialogVisible = false" :disabled="createLoading" />
|
||||
<Button label="确认创建" icon="pi pi-check" class="text-lg" @click="confirmCreate" :loading="createLoading" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="disableDialogVisible" :modal="true" :style="{ width: '560px' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-xl">禁用邀请码</span>
|
||||
<span class="text-muted-color">ID: {{ disableTarget?.id ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-3 text-lg">
|
||||
<div class="text-muted-color">禁用后该邀请码不可再使用。</div>
|
||||
<div>
|
||||
<label class="block font-medium mb-2">原因(可不填)</label>
|
||||
<Textarea v-model="disableReason" rows="3" autoResize class="w-full text-lg" placeholder="例如:误发" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="取消" icon="pi pi-times" text class="text-lg" @click="disableDialogVisible = false" :disabled="disableLoading" />
|
||||
<Button label="确认禁用" icon="pi pi-ban" severity="danger" class="text-lg" @click="confirmDisable" :loading="disableLoading" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
200
frontend/tenant_admin/src/views/tenantadmin/Ledgers.vue
Normal file
200
frontend/tenant_admin/src/views/tenantadmin/Ledgers.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { TenantAdminService } from '@/service/TenantAdminService';
|
||||
|
||||
const toast = useToast();
|
||||
const route = useRoute();
|
||||
const tenantCode = computed(() => String(route.params.tenantCode || ''));
|
||||
|
||||
const loading = ref(false);
|
||||
const items = ref([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const rows = ref(10);
|
||||
|
||||
const operatorUserID = ref(null);
|
||||
const userID = ref(null);
|
||||
const orderID = ref(null);
|
||||
const type = ref('');
|
||||
const createdAtFrom = ref(null);
|
||||
const createdAtTo = ref(null);
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: 'debit_purchase', value: 'debit_purchase' },
|
||||
{ label: 'credit_refund', value: 'credit_refund' },
|
||||
{ label: 'freeze', value: 'freeze' },
|
||||
{ label: 'unfreeze', value: 'unfreeze' },
|
||||
{ label: 'adjustment', value: 'adjustment' }
|
||||
];
|
||||
|
||||
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 formatCny(amountInCents) {
|
||||
const amount = Number(amountInCents) / 100;
|
||||
if (!Number.isFinite(amount)) return '-';
|
||||
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await TenantAdminService.listLedgers(tenantCode.value, {
|
||||
page: page.value,
|
||||
limit: rows.value,
|
||||
operator_user_id: operatorUserID.value || undefined,
|
||||
user_id: userID.value || undefined,
|
||||
order_id: orderID.value || undefined,
|
||||
type: type.value || undefined,
|
||||
created_at_from: createdAtFrom.value || undefined,
|
||||
created_at_to: createdAtTo.value || undefined
|
||||
});
|
||||
items.value = result.items || [];
|
||||
total.value = result.total ?? 0;
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载流水', life: 5000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1;
|
||||
load();
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
operatorUserID.value = null;
|
||||
userID.value = null;
|
||||
orderID.value = null;
|
||||
type.value = '';
|
||||
createdAtFrom.value = null;
|
||||
createdAtTo.value = null;
|
||||
page.value = 1;
|
||||
rows.value = 10;
|
||||
load();
|
||||
}
|
||||
|
||||
function onPage(event) {
|
||||
page.value = (event.page ?? 0) + 1;
|
||||
rows.value = event.rows ?? rows.value;
|
||||
load();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex flex-col">
|
||||
<h3 class="m-0 text-2xl">财务流水</h3>
|
||||
<div class="text-lg text-muted-color">用于对账与审计</div>
|
||||
</div>
|
||||
<Button label="刷新" icon="pi pi-refresh" severity="secondary" class="text-lg" @click="load" :loading="loading" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-4 text-lg mb-4">
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<label class="block font-medium mb-2">操作者UserID</label>
|
||||
<InputNumber v-model="operatorUserID" :min="1" class="w-full text-lg" placeholder="可不填" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<label class="block font-medium mb-2">账户UserID</label>
|
||||
<InputNumber v-model="userID" :min="1" class="w-full text-lg" placeholder="可不填" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<label class="block font-medium mb-2">订单ID</label>
|
||||
<InputNumber v-model="orderID" :min="1" class="w-full text-lg" placeholder="可不填" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<label class="block font-medium mb-2">类型</label>
|
||||
<Select v-model="type" :options="typeOptions" optionLabel="label" optionValue="value" class="w-full text-lg" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<label class="block font-medium mb-2">创建时间 From</label>
|
||||
<DatePicker v-model="createdAtFrom" showIcon showButtonBar class="w-full text-lg" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<label class="block font-medium mb-2">创建时间 To</label>
|
||||
<DatePicker v-model="createdAtTo" showIcon showButtonBar class="w-full text-lg" />
|
||||
</div>
|
||||
<div class="col-span-12 flex items-center gap-3">
|
||||
<Button label="查询" icon="pi pi-search" class="text-lg px-6" @click="onSearch" :disabled="loading" />
|
||||
<Button label="重置" icon="pi pi-refresh" severity="secondary" class="text-lg px-6" @click="onReset" :disabled="loading" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="items"
|
||||
dataKey="ledger.id"
|
||||
:loading="loading"
|
||||
lazy
|
||||
:paginator="true"
|
||||
:rows="rows"
|
||||
:totalRecords="total"
|
||||
:first="(page - 1) * rows"
|
||||
:rowsPerPageOptions="[10, 20, 50]"
|
||||
@page="onPage"
|
||||
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column header="ID" style="min-width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-lg">{{ data?.ledger?.id ?? '-' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="类型" style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-lg font-semibold">{{ data?.type_description ?? data?.ledger?.type ?? '-' }}</span>
|
||||
<span class="text-muted-color">{{ data?.ledger?.type ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="金额" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-lg font-semibold">{{ formatCny(data?.ledger?.amount) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="余额变化" style="min-width: 18rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-lg">可用:{{ formatCny(data?.ledger?.balance_before) }} → {{ formatCny(data?.ledger?.balance_after) }}</span>
|
||||
<span class="text-muted-color">冻结:{{ formatCny(data?.ledger?.frozen_before) }} → {{ formatCny(data?.ledger?.frozen_after) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="用户/操作者" style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-lg">用户ID: {{ data?.ledger?.user_id ?? '-' }}</span>
|
||||
<span class="text-muted-color">操作者ID: {{ data?.ledger?.operator_user_id ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="订单ID" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-lg">{{ data?.ledger?.order_id ?? '-' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="创建时间" style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-lg">{{ formatDate(data?.ledger?.created_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
166
frontend/tenant_admin/src/views/tenantadmin/OrderDetail.vue
Normal file
166
frontend/tenant_admin/src/views/tenantadmin/OrderDetail.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { TenantAdminService } from '@/service/TenantAdminService';
|
||||
|
||||
const toast = useToast();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const tenantCode = computed(() => String(route.params.tenantCode || ''));
|
||||
const orderID = computed(() => Number(route.params.orderID || 0));
|
||||
|
||||
const loading = ref(false);
|
||||
const detail = ref(null);
|
||||
|
||||
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 formatCny(amountInCents) {
|
||||
const amount = Number(amountInCents) / 100;
|
||||
if (!Number.isFinite(amount)) return '-';
|
||||
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!orderID.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
detail.value = await TenantAdminService.getOrderDetail(tenantCode.value, orderID.value);
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载订单详情', life: 5000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const refundDialogVisible = ref(false);
|
||||
const refundLoading = ref(false);
|
||||
const refundReason = ref('');
|
||||
const refundForce = ref(false);
|
||||
|
||||
function openRefund() {
|
||||
refundReason.value = '';
|
||||
refundForce.value = false;
|
||||
refundDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmRefund() {
|
||||
const id = orderID.value;
|
||||
if (!id) return;
|
||||
refundLoading.value = true;
|
||||
try {
|
||||
await TenantAdminService.refundOrder(tenantCode.value, id, {
|
||||
force: refundForce.value,
|
||||
reason: refundReason.value,
|
||||
idempotency_key: `tenant_admin_refund_${id}`
|
||||
});
|
||||
toast.add({ severity: 'success', summary: '已提交退款', detail: `订单ID: ${id}`, life: 3000 });
|
||||
refundDialogVisible.value = false;
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '退款失败', detail: error?.message || '无法发起退款', life: 5000 });
|
||||
} finally {
|
||||
refundLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex flex-col">
|
||||
<h3 class="m-0 text-2xl">订单详情</h3>
|
||||
<div class="text-lg text-muted-color">订单ID:{{ orderID || '-' }}</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<Button label="返回" icon="pi pi-arrow-left" severity="secondary" class="text-lg" @click="router.back()" />
|
||||
<Button label="刷新" icon="pi pi-refresh" severity="secondary" class="text-lg" @click="load" :loading="loading" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-10">
|
||||
<ProgressSpinner style="width: 40px; height: 40px" strokeWidth="6" />
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-12 gap-4 text-lg">
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-muted-color">买家UserID</div>
|
||||
<div class="font-semibold">{{ detail?.order?.user_id ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-muted-color">状态</div>
|
||||
<div class="font-semibold">{{ detail?.order?.status ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<div class="text-muted-color">实付金额</div>
|
||||
<div class="font-semibold">{{ formatCny(detail?.order?.amount_paid) }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="text-muted-color">创建时间</div>
|
||||
<div class="font-semibold">{{ formatDate(detail?.order?.created_at) }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="text-muted-color">支付时间</div>
|
||||
<div class="font-semibold">{{ formatDate(detail?.order?.paid_at) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12">
|
||||
<div class="flex items-center justify-between mt-2 mb-2">
|
||||
<div class="text-xl font-medium">订单明细</div>
|
||||
<Button
|
||||
v-if="detail?.order?.status === 'paid'"
|
||||
label="退款"
|
||||
icon="pi pi-undo"
|
||||
severity="danger"
|
||||
class="text-lg"
|
||||
@click="openRefund"
|
||||
/>
|
||||
</div>
|
||||
<DataTable :value="detail?.order?.items || []" dataKey="id" responsiveLayout="scroll">
|
||||
<Column field="id" header="ItemID" style="min-width: 8rem" />
|
||||
<Column field="content_id" header="ContentID" style="min-width: 10rem" />
|
||||
<Column field="quantity" header="数量" style="min-width: 8rem" />
|
||||
<Column header="成交价" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-lg">{{ formatCny(data?.amount_paid) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="refundDialogVisible" :modal="true" :style="{ width: '560px' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-xl">发起退款</span>
|
||||
<span class="text-muted-color">订单ID: {{ orderID || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4 text-lg">
|
||||
<div>
|
||||
<label class="block font-medium mb-2">退款原因(建议填写)</label>
|
||||
<Textarea v-model="refundReason" rows="3" autoResize class="w-full text-lg" placeholder="例如:用户申请退款" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Checkbox v-model="refundForce" binary />
|
||||
<span>强制退款(绕过默认时间限制,谨慎使用)</span>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="取消" icon="pi pi-times" text class="text-lg" @click="refundDialogVisible = false" :disabled="refundLoading" />
|
||||
<Button label="确认退款" icon="pi pi-undo" severity="danger" class="text-lg" @click="confirmRefund" :loading="refundLoading" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
307
frontend/tenant_admin/src/views/tenantadmin/Orders.vue
Normal file
307
frontend/tenant_admin/src/views/tenantadmin/Orders.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { TenantAdminService } from '@/service/TenantAdminService';
|
||||
|
||||
const toast = useToast();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const tenantCode = computed(() => String(route.params.tenantCode || ''));
|
||||
|
||||
const loading = ref(false);
|
||||
const orders = ref([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const rows = ref(10);
|
||||
const sortField = ref('id');
|
||||
const sortOrder = ref(-1);
|
||||
|
||||
const buyerUserID = ref(null);
|
||||
const buyerUsername = ref('');
|
||||
const status = ref('');
|
||||
const createdAtFrom = ref(null);
|
||||
const createdAtTo = ref(null);
|
||||
const paidAtFrom = ref(null);
|
||||
const paidAtTo = ref(null);
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: 'paid', value: 'paid' },
|
||||
{ label: 'refunding', value: 'refunding' },
|
||||
{ label: 'refunded', value: 'refunded' },
|
||||
{ label: 'created', value: 'created' },
|
||||
{ label: 'canceled', value: 'canceled' },
|
||||
{ label: 'failed', value: 'failed' }
|
||||
];
|
||||
|
||||
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 formatCny(amountInCents) {
|
||||
const amount = Number(amountInCents) / 100;
|
||||
if (!Number.isFinite(amount)) return '-';
|
||||
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
|
||||
}
|
||||
|
||||
function getStatusSeverity(value) {
|
||||
switch (value) {
|
||||
case 'paid':
|
||||
return 'success';
|
||||
case 'created':
|
||||
case 'refunding':
|
||||
return 'warn';
|
||||
case 'failed':
|
||||
case 'canceled':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await TenantAdminService.listOrders(tenantCode.value, {
|
||||
page: page.value,
|
||||
limit: rows.value,
|
||||
user_id: buyerUserID.value || undefined,
|
||||
username: buyerUsername.value,
|
||||
status: status.value || undefined,
|
||||
created_at_from: createdAtFrom.value || undefined,
|
||||
created_at_to: createdAtTo.value || undefined,
|
||||
paid_at_from: paidAtFrom.value || undefined,
|
||||
paid_at_to: paidAtTo.value || undefined,
|
||||
sortField: sortField.value,
|
||||
sortOrder: sortOrder.value
|
||||
});
|
||||
orders.value = result.items || [];
|
||||
total.value = result.total ?? 0;
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载订单列表', life: 5000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1;
|
||||
load();
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
buyerUserID.value = null;
|
||||
buyerUsername.value = '';
|
||||
status.value = '';
|
||||
createdAtFrom.value = null;
|
||||
createdAtTo.value = null;
|
||||
paidAtFrom.value = null;
|
||||
paidAtTo.value = null;
|
||||
sortField.value = 'id';
|
||||
sortOrder.value = -1;
|
||||
page.value = 1;
|
||||
rows.value = 10;
|
||||
load();
|
||||
}
|
||||
|
||||
function onPage(event) {
|
||||
page.value = (event.page ?? 0) + 1;
|
||||
rows.value = event.rows ?? rows.value;
|
||||
load();
|
||||
}
|
||||
|
||||
function onSort(event) {
|
||||
sortField.value = event.sortField ?? sortField.value;
|
||||
sortOrder.value = event.sortOrder ?? sortOrder.value;
|
||||
load();
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
const orderID = row?.id;
|
||||
if (!orderID) return;
|
||||
router.push({ name: 'tenantadmin-order-detail', params: { tenantCode: tenantCode.value, orderID } });
|
||||
}
|
||||
|
||||
const refundDialogVisible = ref(false);
|
||||
const refundLoading = ref(false);
|
||||
const refundTarget = ref(null);
|
||||
const refundReason = ref('');
|
||||
const refundForce = ref(false);
|
||||
|
||||
function openRefund(row) {
|
||||
refundTarget.value = row;
|
||||
refundReason.value = '';
|
||||
refundForce.value = false;
|
||||
refundDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmRefund() {
|
||||
const orderID = refundTarget.value?.id;
|
||||
if (!orderID) return;
|
||||
refundLoading.value = true;
|
||||
try {
|
||||
await TenantAdminService.refundOrder(tenantCode.value, orderID, {
|
||||
force: refundForce.value,
|
||||
reason: refundReason.value,
|
||||
idempotency_key: `tenant_admin_refund_${orderID}`
|
||||
});
|
||||
toast.add({ severity: 'success', summary: '已提交退款', detail: `订单ID: ${orderID}`, life: 3000 });
|
||||
refundDialogVisible.value = false;
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '退款失败', detail: error?.message || '无法发起退款', life: 5000 });
|
||||
} finally {
|
||||
refundLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex flex-col">
|
||||
<h3 class="m-0 text-2xl">订单与退款</h3>
|
||||
<div class="text-lg text-muted-color">查询订单并处理退款</div>
|
||||
</div>
|
||||
<Button label="刷新" icon="pi pi-refresh" severity="secondary" class="text-lg" @click="load" :loading="loading" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-4 text-lg mb-4">
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<label class="block font-medium mb-2">买家UserID</label>
|
||||
<InputNumber v-model="buyerUserID" :min="1" class="w-full text-lg" placeholder="可不填" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<label class="block font-medium mb-2">买家用户名</label>
|
||||
<InputText v-model="buyerUsername" class="w-full text-lg" placeholder="包含匹配" @keyup.enter="onSearch" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<label class="block font-medium mb-2">状态</label>
|
||||
<Select v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" class="w-full text-lg" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-3 flex items-end gap-3">
|
||||
<Button label="查询" icon="pi pi-search" class="text-lg px-6" @click="onSearch" :disabled="loading" />
|
||||
<Button label="重置" icon="pi pi-refresh" severity="secondary" class="text-lg px-6" @click="onReset" :disabled="loading" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<label class="block font-medium mb-2">创建时间 From</label>
|
||||
<DatePicker v-model="createdAtFrom" showIcon showButtonBar class="w-full text-lg" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<label class="block font-medium mb-2">创建时间 To</label>
|
||||
<DatePicker v-model="createdAtTo" showIcon showButtonBar class="w-full text-lg" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<label class="block font-medium mb-2">支付时间 From</label>
|
||||
<DatePicker v-model="paidAtFrom" showIcon showButtonBar class="w-full text-lg" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<label class="block font-medium mb-2">支付时间 To</label>
|
||||
<DatePicker v-model="paidAtTo" showIcon showButtonBar class="w-full text-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="orders"
|
||||
dataKey="id"
|
||||
:loading="loading"
|
||||
lazy
|
||||
:paginator="true"
|
||||
:rows="rows"
|
||||
:totalRecords="total"
|
||||
:first="(page - 1) * rows"
|
||||
:rowsPerPageOptions="[10, 20, 50]"
|
||||
sortMode="single"
|
||||
:sortField="sortField"
|
||||
:sortOrder="sortOrder"
|
||||
@page="onPage"
|
||||
@sort="onSort"
|
||||
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="id" header="订单ID" sortable style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Button label="详情" icon="pi pi-search" text class="p-0 mr-3 text-lg" @click="openDetail(data)" />
|
||||
<span class="text-lg">{{ data?.id ?? '-' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="user_id" header="买家" sortable style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-lg font-semibold">ID: {{ data?.user_id ?? '-' }}</span>
|
||||
<span class="text-muted-color">类型:{{ data?.type ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="状态" sortable style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data?.status ?? '-'" :severity="getStatusSeverity(data?.status)" class="text-base" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="amount_paid" header="实付" sortable style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-lg font-semibold">{{ formatCny(data?.amount_paid) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="created_at" header="创建时间" sortable style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-lg">{{ formatDate(data?.created_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="paid_at" header="支付时间" sortable style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-lg">{{ formatDate(data?.paid_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="操作" style="min-width: 18rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-3">
|
||||
<Button
|
||||
v-if="data?.status === 'paid'"
|
||||
label="退款"
|
||||
icon="pi pi-undo"
|
||||
severity="danger"
|
||||
class="text-lg px-6"
|
||||
@click="openRefund(data)"
|
||||
/>
|
||||
<span v-else class="text-muted-color">-</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="refundDialogVisible" :modal="true" :style="{ width: '560px' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-xl">发起退款</span>
|
||||
<span class="text-muted-color">订单ID: {{ refundTarget?.id ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4 text-lg">
|
||||
<div>
|
||||
<label class="block font-medium mb-2">退款原因(建议填写)</label>
|
||||
<Textarea v-model="refundReason" rows="3" autoResize class="w-full text-lg" placeholder="例如:用户申请退款" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Checkbox v-model="refundForce" binary />
|
||||
<span>强制退款(绕过默认时间限制,谨慎使用)</span>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="取消" icon="pi pi-times" text class="text-lg" @click="refundDialogVisible = false" :disabled="refundLoading" />
|
||||
<Button label="确认退款" icon="pi pi-undo" severity="danger" class="text-lg" @click="confirmRefund" :loading="refundLoading" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,21 @@ export default defineConfig({
|
||||
optimizeDeps: {
|
||||
noDiscovery: true
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/v1': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/t': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
|
||||
Reference in New Issue
Block a user