mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-01 16:21:20 +00:00
refactor: 大规模代码整理与功能优化
1. 重构后端API路由、CRUD与模块结构,整合日志管理,移除废弃demo代码 2. 优化前端组件类型定义、样式与路由配置,修复权限判断逻辑 3. 调整默认排序规则、滚动条样式与工具类函数,更新依赖与配置文件 4. 修复多处类型不匹配与默认值问题,完善表单与菜单验证逻辑
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,466 @@
|
||||
<template>
|
||||
<div class="fa-full-height">
|
||||
<ElTabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<!-- 平台端:全部发票 -->
|
||||
<ElTabPane v-if="isSuperAdmin" label="发票管理" name="platform">
|
||||
<FaSearchBar
|
||||
v-show="platformShowSearchBar"
|
||||
ref="platformSearchBarRef"
|
||||
v-model="platformSearchForm"
|
||||
:items="platformSearchItems"
|
||||
:rules="{}"
|
||||
:is-expand="false"
|
||||
:show-expand="true"
|
||||
:show-reset="true"
|
||||
:show-search="true"
|
||||
:disabled-search="false"
|
||||
:default-expanded="false"
|
||||
:button-left-limit="0"
|
||||
@search="handlePlatformSearch"
|
||||
@reset="handlePlatformReset"
|
||||
/>
|
||||
|
||||
<ElCard
|
||||
shadow="hover"
|
||||
class="fa-table-card"
|
||||
:style="{ 'margin-top': platformShowSearchBar ? '12px' : '0' }"
|
||||
>
|
||||
<FaTableHeader
|
||||
v-model:columns="platformColumnChecks"
|
||||
v-model:showSearchBar="platformShowSearchBar"
|
||||
:loading="platformLoading"
|
||||
@refresh="getPlatformData"
|
||||
/>
|
||||
|
||||
<FaTable
|
||||
ref="platformTableRef"
|
||||
:loading="platformLoading"
|
||||
:data="platformData"
|
||||
:columns="platformColumns"
|
||||
:pagination="platformPagination"
|
||||
@pagination:size-change="handlePlatformSizeChange"
|
||||
@pagination:current-change="handlePlatformCurrentChange"
|
||||
/>
|
||||
</ElCard>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- 租户端:我的发票 -->
|
||||
<ElTabPane label="我的发票" name="my">
|
||||
<ElCard shadow="hover" class="fa-table-card" :style="{ 'margin-top': '0' }">
|
||||
<FaTableHeader :loading="myLoading" @refresh="getMyData">
|
||||
<template #left>
|
||||
<FaTableHeaderLeft perm-create="tenant:admin" @add="openApplyDialog" />
|
||||
</template>
|
||||
</FaTableHeader>
|
||||
|
||||
<FaTable
|
||||
ref="myTableRef"
|
||||
:loading="myLoading"
|
||||
:data="myData"
|
||||
:columns="myColumns"
|
||||
:pagination="myPagination"
|
||||
@pagination:size-change="handleMySizeChange"
|
||||
@pagination:current-change="handleMyCurrentChange"
|
||||
/>
|
||||
</ElCard>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
|
||||
<!-- 申请开票弹窗 -->
|
||||
<FaDialog v-model="applyDialogVisible" title="申请开票" width="520px">
|
||||
<FaForm
|
||||
ref="applyFormRef"
|
||||
v-model="applyFormData"
|
||||
:items="applyFormItems"
|
||||
:rules="applyRules"
|
||||
:show-footer="false"
|
||||
/>
|
||||
<template #footer>
|
||||
<ElButton @click="applyDialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="applySubmitting" @click="submitApply">提交申请</ElButton>
|
||||
</template>
|
||||
</FaDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, h } from "vue";
|
||||
import { ElMessageBox, ElButton, ElTabs, ElTabPane, ElTag } from "element-plus";
|
||||
import { useTable } from "@/hooks/core/useTable";
|
||||
import { useAuth } from "@/hooks/core/useAuth";
|
||||
import InvoiceAPI from "@/api/module_platform/invoice";
|
||||
import type { InvoiceTable } from "@/api/module_platform/invoice";
|
||||
import type { ColumnOption } from "@/types/component";
|
||||
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type { FormItem } from "@/components/forms/fa-form/index.vue";
|
||||
import { renderTableOperationCell, type TableOperationAction } from "@utils";
|
||||
|
||||
defineOptions({ name: "Invoice" });
|
||||
|
||||
const { hasAuth } = useAuth();
|
||||
|
||||
const activeTab = ref("my");
|
||||
const isSuperAdmin = ref(true);
|
||||
const platformShowSearchBar = ref(true);
|
||||
|
||||
// ══════════════════ 标签函数 ════════════════════
|
||||
|
||||
function invoiceStatusLabel(status: number): string {
|
||||
const map: Record<number, string> = { 0: "待开具", 1: "已开具", 2: "开票失败", 3: "已作废" };
|
||||
return map[status] || String(status);
|
||||
}
|
||||
|
||||
function invoiceStatusType(status: number): string {
|
||||
const map: Record<number, string> = { 0: "warning", 1: "success", 2: "danger", 3: "info" };
|
||||
return map[status] || "info";
|
||||
}
|
||||
|
||||
// ══════════════════ 平台端:全部发票 ════════════════════
|
||||
|
||||
function buildPlatformRowActions(row: InvoiceTable): TableOperationAction[] {
|
||||
const actions: TableOperationAction[] = [];
|
||||
if (row.status === 0) {
|
||||
actions.push({
|
||||
key: "issue",
|
||||
label: "开具",
|
||||
artType: "edit",
|
||||
perm: "module_platform:invoice:update",
|
||||
run: () => issueInvoice(row),
|
||||
});
|
||||
}
|
||||
if (row.status === 1) {
|
||||
actions.push({
|
||||
key: "void",
|
||||
label: "作废",
|
||||
artType: "delete",
|
||||
perm: "module_platform:invoice:update",
|
||||
run: () => voidInvoice(row),
|
||||
});
|
||||
}
|
||||
return actions.filter((a) => a.perm != null && hasAuth(String(a.perm)));
|
||||
}
|
||||
|
||||
function formatPlatformOpCell(row: InvoiceTable) {
|
||||
const actions = buildPlatformRowActions(row);
|
||||
if (actions.length === 0) return "—";
|
||||
return renderTableOperationCell(actions, {
|
||||
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1",
|
||||
});
|
||||
}
|
||||
|
||||
const platformSearchForm = ref<Record<string, unknown>>({ tenant_id: "", status: undefined });
|
||||
|
||||
const platformSearchItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: "租户ID",
|
||||
key: "tenant_id",
|
||||
type: "input",
|
||||
placeholder: "租户ID",
|
||||
span: 6,
|
||||
props: { clearable: true },
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
key: "status",
|
||||
type: "select",
|
||||
placeholder: "状态",
|
||||
span: 6,
|
||||
props: {
|
||||
clearable: true,
|
||||
options: [
|
||||
{ label: "待开具", value: 0 },
|
||||
{ label: "已开具", value: 1 },
|
||||
{ label: "已作废", value: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const platformSearchBarRef = ref();
|
||||
|
||||
const {
|
||||
columns: platformColumns,
|
||||
columnChecks: platformColumnChecks,
|
||||
data: platformData,
|
||||
loading: platformLoading,
|
||||
pagination: platformPagination,
|
||||
getData: getPlatformData,
|
||||
replaceSearchParams: replacePlatformSearchParams,
|
||||
resetSearchParams: resetPlatformSearchParams,
|
||||
handleSizeChange: handlePlatformSizeChange,
|
||||
handleCurrentChange: handlePlatformCurrentChange,
|
||||
} = useTable({
|
||||
core: {
|
||||
apiFn: InvoiceAPI.listInvoices,
|
||||
apiParams: {
|
||||
page_no: 1,
|
||||
page_size: 50,
|
||||
},
|
||||
columnsFactory: (): ColumnOption<InvoiceTable>[] => [
|
||||
{ prop: "id", label: "ID", width: 60 },
|
||||
{ prop: "tenant_id", label: "租户ID", width: 80 },
|
||||
{ prop: "invoice_no", label: "发票号", width: 180, showOverflowTooltip: true },
|
||||
{ prop: "title", label: "抬头", minWidth: 180, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "invoice_type",
|
||||
label: "类型",
|
||||
width: 110,
|
||||
formatter: (row) => row.invoice_type || "—",
|
||||
},
|
||||
{
|
||||
prop: "amount",
|
||||
label: "金额",
|
||||
width: 120,
|
||||
formatter: (row) => `¥${((row.amount || 0) / 100).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
prop: "status",
|
||||
label: "状态",
|
||||
width: 100,
|
||||
formatter: (row) => {
|
||||
const t = invoiceStatusType(row.status) as "warning" | "success" | "info" | "danger";
|
||||
return h(ElTag, { type: t, size: "small" as const }, () =>
|
||||
invoiceStatusLabel(row.status)
|
||||
);
|
||||
},
|
||||
},
|
||||
{ prop: "address_info", label: "地址电话", width: 180, showOverflowTooltip: true },
|
||||
{ prop: "created_time", label: "申请时间", width: 160, showOverflowTooltip: true },
|
||||
{
|
||||
label: "操作",
|
||||
width: 150,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
formatter: (row) => formatPlatformOpCell(row),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
function handlePlatformSearch() {
|
||||
const params: Record<string, unknown> = {};
|
||||
if (platformSearchForm.value.status !== undefined && platformSearchForm.value.status !== "") {
|
||||
params.status = platformSearchForm.value.status;
|
||||
}
|
||||
const tid = parseInt(String(platformSearchForm.value.tenant_id || ""));
|
||||
if (!isNaN(tid)) params.tenant_id = tid;
|
||||
replacePlatformSearchParams(params);
|
||||
}
|
||||
|
||||
function handlePlatformReset() {
|
||||
platformSearchForm.value = { tenant_id: "", status: undefined };
|
||||
resetPlatformSearchParams();
|
||||
}
|
||||
|
||||
async function issueInvoice(row: InvoiceTable) {
|
||||
try {
|
||||
await InvoiceAPI.issueInvoice(row.id, {});
|
||||
getPlatformData();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function voidInvoice(row: InvoiceTable) {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt("请输入作废原因", "作废发票", { type: "warning" });
|
||||
await InvoiceAPI.voidInvoice(row.id, value);
|
||||
getPlatformData();
|
||||
} catch {
|
||||
/* cancelled */
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════ 租户端:我的发票 ════════════════════
|
||||
|
||||
const {
|
||||
columns: myColumns,
|
||||
data: myData,
|
||||
loading: myLoading,
|
||||
pagination: myPagination,
|
||||
getData: getMyData,
|
||||
handleSizeChange: handleMySizeChange,
|
||||
handleCurrentChange: handleMyCurrentChange,
|
||||
} = useTable({
|
||||
core: {
|
||||
apiFn: InvoiceAPI.tenantListInvoices,
|
||||
apiParams: {
|
||||
page_no: 1,
|
||||
page_size: 50,
|
||||
},
|
||||
columnsFactory: (): ColumnOption<InvoiceTable>[] => [
|
||||
{ prop: "id", label: "ID", width: 60 },
|
||||
{ prop: "invoice_no", label: "发票号", width: 180, showOverflowTooltip: true },
|
||||
{ prop: "title", label: "抬头", minWidth: 180, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "invoice_type",
|
||||
label: "类型",
|
||||
width: 110,
|
||||
formatter: (row) => row.invoice_type || "—",
|
||||
},
|
||||
{
|
||||
prop: "amount",
|
||||
label: "金额",
|
||||
width: 120,
|
||||
formatter: (row) => `¥${((row.amount || 0) / 100).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
prop: "status",
|
||||
label: "状态",
|
||||
width: 100,
|
||||
formatter: (row) => {
|
||||
const t = invoiceStatusType(row.status) as "warning" | "success" | "info" | "danger";
|
||||
return h(ElTag, { type: t, size: "small" as const }, () =>
|
||||
invoiceStatusLabel(row.status)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: "created_time",
|
||||
label: "申请时间",
|
||||
width: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row) => row.created_time || "—",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// ══════════════════ 申请开票弹窗 ════════════════════
|
||||
|
||||
const applyDialogVisible = ref(false);
|
||||
const applySubmitting = ref(false);
|
||||
const applyFormRef = ref();
|
||||
let applyFormData = reactive({
|
||||
order_id: null as number | null,
|
||||
title: "",
|
||||
invoice_type: "vat_normal",
|
||||
tax_no: "",
|
||||
address_info: "",
|
||||
bank_info: "",
|
||||
});
|
||||
|
||||
const applyFormItems: FormItem[] = [
|
||||
{
|
||||
label: "关联订单ID",
|
||||
key: "order_id",
|
||||
type: "inputNumber",
|
||||
span: 24,
|
||||
props: { min: 1, placeholder: "输入已支付订单ID" },
|
||||
},
|
||||
{
|
||||
label: "发票抬头",
|
||||
key: "title",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: { placeholder: "公司全称或个人姓名", maxlength: 100 },
|
||||
},
|
||||
{
|
||||
label: "发票类型",
|
||||
key: "invoice_type",
|
||||
type: "select",
|
||||
span: 24,
|
||||
props: {
|
||||
options: [
|
||||
{ label: "普通发票", value: "vat_normal" },
|
||||
{ label: "增值税专用发票", value: "vat_special" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "税号",
|
||||
key: "tax_no",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: { placeholder: "统一社会信用代码(可选)", maxlength: 30 },
|
||||
},
|
||||
{
|
||||
label: "银行信息",
|
||||
key: "bank_info",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: { placeholder: "开户行及账号(可选)", maxlength: 100 },
|
||||
},
|
||||
{
|
||||
label: "地址电话",
|
||||
key: "address_info",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: { placeholder: "注册地址及电话(专票必填)", maxlength: 200 },
|
||||
},
|
||||
];
|
||||
|
||||
const applyRules = {
|
||||
order_id: [{ required: true, message: "请输入订单ID", trigger: "blur" }],
|
||||
title: [{ required: true, message: "请输入发票抬头", trigger: "blur" }],
|
||||
};
|
||||
|
||||
function openApplyDialog() {
|
||||
Object.assign(applyFormData, {
|
||||
order_id: null,
|
||||
title: "",
|
||||
invoice_type: "vat_normal",
|
||||
tax_no: "",
|
||||
address_info: "",
|
||||
bank_info: "",
|
||||
});
|
||||
applyFormRef.value?.resetFields?.();
|
||||
applyDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitApply() {
|
||||
const form: any = applyFormRef.value;
|
||||
if (!form) return;
|
||||
const valid = await form.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
applySubmitting.value = true;
|
||||
try {
|
||||
await InvoiceAPI.applyInvoice({
|
||||
order_id: applyFormData.order_id!,
|
||||
title: applyFormData.title,
|
||||
invoice_type: applyFormData.invoice_type,
|
||||
tax_no: applyFormData.tax_no || undefined,
|
||||
address_info: applyFormData.address_info || undefined,
|
||||
bank_info: applyFormData.bank_info || undefined,
|
||||
});
|
||||
applyDialogVisible.value = false;
|
||||
getMyData();
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
applySubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════ Tab 切换 ════════════════════
|
||||
|
||||
function onTabChange(tab: string | number) {
|
||||
if (tab === "platform") getPlatformData();
|
||||
else if (tab === "my") getMyData();
|
||||
}
|
||||
|
||||
// ══════════════════ 初始化 ════════════════════
|
||||
|
||||
getMyData();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.el-tabs) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:deep(.el-tab-pane) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<div class="payment-page">
|
||||
<div v-if="loading" class="payment-loading">
|
||||
<ElSkeleton :rows="6" animated />
|
||||
</div>
|
||||
|
||||
<div v-else-if="paid" class="payment-success">
|
||||
<div class="success-icon">✅</div>
|
||||
<h2>支付成功</h2>
|
||||
<p>订单 {{ orderNo }} 已完成支付</p>
|
||||
<p class="amount">¥{{ (amount / 100).toFixed(2) }}</p>
|
||||
<ElButton type="primary" @click="goOrders">查看订单</ElButton>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="payment-error">
|
||||
<h2>订单异常</h2>
|
||||
<p>{{ error }}</p>
|
||||
<ElButton @click="goOrders">返回</ElButton>
|
||||
</div>
|
||||
|
||||
<div v-else class="payment-content">
|
||||
<div class="order-info">
|
||||
<h2>订单支付</h2>
|
||||
<div class="info-row">
|
||||
<span class="label">订单号</span>
|
||||
<span class="value">{{ orderNo }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">金额</span>
|
||||
<span class="value price">¥{{ (amount / 100).toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pay-methods">
|
||||
<h3>选择支付方式</h3>
|
||||
<div class="method-list">
|
||||
<div
|
||||
class="method-item"
|
||||
:class="{ active: selectedMethod === 'alipay' }"
|
||||
@click="selectedMethod = 'alipay'"
|
||||
>
|
||||
<span class="method-icon">💳</span>
|
||||
<span>支付宝</span>
|
||||
</div>
|
||||
<div
|
||||
class="method-item"
|
||||
:class="{ active: selectedMethod === 'wxpay' }"
|
||||
@click="selectedMethod = 'wxpay'"
|
||||
>
|
||||
<span class="method-icon">💚</span>
|
||||
<span>微信支付</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="qrCodeUrl || payUrl" class="pay-result">
|
||||
<div v-if="qrCodeUrl" class="qr-section">
|
||||
<p class="hint">请使用{{ methodLabel }}扫码支付</p>
|
||||
<img :src="qrCodeUrl" class="qr-code" alt="支付二维码" />
|
||||
</div>
|
||||
<div v-else-if="payUrl" class="redirect-section">
|
||||
<p class="hint">即将跳转{{ methodLabel }}支付...</p>
|
||||
<ElButton type="primary" @click="openPayUrl">立即支付</ElButton>
|
||||
</div>
|
||||
<div class="poll-status">
|
||||
<ElTag :type="polling ? 'warning' : 'info'">
|
||||
{{ polling ? "等待支付中..." : "未发起支付" }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions" v-if="!paying">
|
||||
<ElButton type="primary" size="large" @click="startPay" :loading="paying">
|
||||
立即支付
|
||||
</ElButton>
|
||||
<ElButton size="large" @click="goOrders">稍后支付</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="actions" v-if="payUrl && !paying">
|
||||
<ElButton type="success" size="large" @click="mockPaySuccess"> 模拟支付成功 </ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import OrderAPI from "@/api/module_platform/order";
|
||||
|
||||
defineOptions({ name: "PaymentPage" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const orderId = computed(() => {
|
||||
const n = Number(route.params.orderId);
|
||||
return Number.isNaN(n) ? 0 : n;
|
||||
});
|
||||
|
||||
// 订单号无效时直接重定向
|
||||
if (orderId.value === 0) {
|
||||
router.replace("/module_platform/order");
|
||||
}
|
||||
const loading = ref(true);
|
||||
const paid = ref(false);
|
||||
const paying = ref(false);
|
||||
const polling = ref(false);
|
||||
const error = ref("");
|
||||
const orderNo = ref("");
|
||||
const amount = ref(0);
|
||||
const qrCodeUrl = ref("");
|
||||
const payUrl = ref("");
|
||||
const selectedMethod = ref("alipay");
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const methodLabel = computed(() => (selectedMethod.value === "alipay" ? "支付宝" : "微信"));
|
||||
|
||||
async function loadOrderStatus() {
|
||||
try {
|
||||
const res = await OrderAPI.queryPaymentStatus(orderId.value);
|
||||
const data = (res.data as any)?.data;
|
||||
if (data?.paid) {
|
||||
paid.value = true;
|
||||
orderNo.value = data.order_no || "";
|
||||
amount.value = data.amount || 0;
|
||||
stopPolling();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function startPay() {
|
||||
paying.value = true;
|
||||
try {
|
||||
const res = await OrderAPI.payOrder(orderId.value, { pay_method: selectedMethod.value });
|
||||
const data = (res.data as any)?.data;
|
||||
orderNo.value = data.order_no;
|
||||
amount.value = data.amount;
|
||||
qrCodeUrl.value = data.qr_code_url || "";
|
||||
payUrl.value = data.pay_url || "";
|
||||
startPolling();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || "创建支付失败");
|
||||
} finally {
|
||||
paying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openPayUrl() {
|
||||
if (payUrl.value) {
|
||||
window.open(payUrl.value, "_blank");
|
||||
}
|
||||
}
|
||||
|
||||
async function mockPaySuccess() {
|
||||
try {
|
||||
await OrderAPI.mockPaymentCallback(orderId.value);
|
||||
ElMessage.success("支付成功(模拟)");
|
||||
paid.value = true;
|
||||
stopPolling();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || "模拟支付失败");
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
polling.value = true;
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const res = await OrderAPI.queryPaymentStatus(orderId.value);
|
||||
const data = (res.data as any)?.data;
|
||||
if (data?.paid) {
|
||||
paid.value = true;
|
||||
orderNo.value = data.order_no || orderNo.value;
|
||||
amount.value = data.amount || amount.value;
|
||||
stopPolling();
|
||||
ElMessage.success("支付成功");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
polling.value = false;
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function goOrders() {
|
||||
router.push("/self-service?tab=orders");
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
await loadOrderStatus();
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.payment-page {
|
||||
max-width: 520px;
|
||||
padding: 24px;
|
||||
margin: 40px auto;
|
||||
}
|
||||
|
||||
.payment-loading {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.payment-content {
|
||||
.order-info {
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
|
||||
.label {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.value.price {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pay-methods {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.method-list {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.method-item {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #ecf5ff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.method-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.pay-result {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
|
||||
.hint {
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.poll-status {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.payment-success {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
|
||||
.success-icon {
|
||||
margin-bottom: 16px;
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 22px;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 4px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.amount {
|
||||
margin: 16px 0 24px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-error {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,662 @@
|
||||
<template>
|
||||
<div class="fa-full-height">
|
||||
<ElTabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<!-- 订单列表 -->
|
||||
<ElTabPane label="订单列表" name="orders">
|
||||
<FaSearchBar
|
||||
v-show="orderShowSearchBar"
|
||||
ref="orderSearchBarRef"
|
||||
v-model="orderSearchForm"
|
||||
:items="orderSearchItems"
|
||||
:rules="{}"
|
||||
:is-expand="false"
|
||||
:show-expand="true"
|
||||
:show-reset="true"
|
||||
:show-search="true"
|
||||
:disabled-search="false"
|
||||
:default-expanded="false"
|
||||
:button-left-limit="0"
|
||||
@search="handleOrderSearch"
|
||||
@reset="handleOrderReset"
|
||||
/>
|
||||
|
||||
<ElCard
|
||||
shadow="hover"
|
||||
class="fa-table-card"
|
||||
:style="{ 'margin-top': orderShowSearchBar ? '12px' : '0' }"
|
||||
>
|
||||
<FaTableHeader
|
||||
v-model:columns="orderColumnChecks"
|
||||
v-model:showSearchBar="orderShowSearchBar"
|
||||
:loading="orderLoading"
|
||||
@refresh="getOrderData"
|
||||
/>
|
||||
|
||||
<FaTable
|
||||
ref="orderTableRef"
|
||||
:loading="orderLoading"
|
||||
:data="orderData"
|
||||
:columns="orderColumns"
|
||||
:pagination="orderPagination"
|
||||
@pagination:size-change="handleOrderSizeChange"
|
||||
@pagination:current-change="handleOrderCurrentChange"
|
||||
/>
|
||||
</ElCard>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- 支付记录 -->
|
||||
<ElTabPane label="支付记录" name="payments">
|
||||
<ElCard shadow="hover" class="fa-table-card" :style="{ 'margin-top': '0' }">
|
||||
<FaTableHeader :loading="paymentLoading" @refresh="getPaymentData" />
|
||||
|
||||
<FaTable
|
||||
ref="paymentTableRef"
|
||||
:loading="paymentLoading"
|
||||
:data="paymentData"
|
||||
:columns="paymentColumns"
|
||||
:pagination="paymentPagination"
|
||||
@pagination:size-change="handlePaymentSizeChange"
|
||||
@pagination:current-change="handlePaymentCurrentChange"
|
||||
/>
|
||||
</ElCard>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- 退款审核 -->
|
||||
<ElTabPane label="退款审核" name="refunds">
|
||||
<FaSearchBar
|
||||
v-show="refundShowSearchBar"
|
||||
ref="refundSearchBarRef"
|
||||
v-model="refundSearchForm"
|
||||
:items="refundSearchItems"
|
||||
:rules="{}"
|
||||
:is-expand="false"
|
||||
:show-expand="true"
|
||||
:show-reset="true"
|
||||
:show-search="true"
|
||||
:disabled-search="false"
|
||||
:default-expanded="false"
|
||||
:button-left-limit="0"
|
||||
@search="handleRefundSearch"
|
||||
@reset="handleRefundReset"
|
||||
/>
|
||||
|
||||
<ElCard
|
||||
shadow="hover"
|
||||
class="fa-table-card"
|
||||
:style="{ 'margin-top': refundShowSearchBar ? '12px' : '0' }"
|
||||
>
|
||||
<FaTableHeader
|
||||
v-model:columns="refundColumnChecks"
|
||||
v-model:showSearchBar="refundShowSearchBar"
|
||||
:loading="refundLoading"
|
||||
@refresh="getRefundData"
|
||||
/>
|
||||
|
||||
<FaTable
|
||||
ref="refundTableRef"
|
||||
:loading="refundLoading"
|
||||
:data="refundData"
|
||||
:columns="refundColumns"
|
||||
:pagination="refundPagination"
|
||||
@pagination:size-change="handleRefundSizeChange"
|
||||
@pagination:current-change="handleRefundCurrentChange"
|
||||
/>
|
||||
</ElCard>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
|
||||
<!-- 订单详情弹窗 -->
|
||||
<FaDialog v-model="detailVisible" title="订单详情" width="560px" :show-footer="false">
|
||||
<FaDescriptions v-if="orderDetail" :column="2" border :items="detailItems" />
|
||||
</FaDialog>
|
||||
|
||||
<!-- 驳回退款弹窗 -->
|
||||
<FaDialog v-model="rejectVisible" title="驳回退款" width="420px">
|
||||
<FaForm
|
||||
ref="rejectFormRef"
|
||||
v-model="rejectFormData"
|
||||
:items="rejectFormItems"
|
||||
:rules="rejectRules"
|
||||
:show-footer="false"
|
||||
/>
|
||||
<template #footer>
|
||||
<ElButton @click="rejectVisible = false">取消</ElButton>
|
||||
<ElButton type="danger" :loading="rejectSubmitting" @click="submitReject">驳回</ElButton>
|
||||
</template>
|
||||
</FaDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, h } from "vue";
|
||||
import { ElMessageBox, ElButton, ElTabs, ElTabPane, ElTag } from "element-plus";
|
||||
import { useTable } from "@/hooks/core/useTable";
|
||||
import { useAuth } from "@/hooks/core/useAuth";
|
||||
import OrderAPI from "@/api/module_platform/order";
|
||||
import type { OrderTable, PaymentRecordTable, RefundTable } from "@/api/module_platform/order";
|
||||
import type { ColumnOption } from "@/types/component";
|
||||
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type { FormItem } from "@/components/forms/fa-form/index.vue";
|
||||
import { renderTableOperationCell, type TableOperationAction } from "@utils";
|
||||
|
||||
defineOptions({ name: "Order" });
|
||||
|
||||
const { hasAuth } = useAuth();
|
||||
|
||||
// ══════════════════ Tab ════════════════════
|
||||
const activeTab = ref("orders");
|
||||
const orderShowSearchBar = ref(true);
|
||||
const refundShowSearchBar = ref(true);
|
||||
|
||||
// ══════════════════ 标签函数 ════════════════════
|
||||
function orderTypeLabel(t: string): string {
|
||||
const m: Record<string, string> = {
|
||||
new: "新购",
|
||||
renew: "续费",
|
||||
upgrade: "升级",
|
||||
downgrade: "降级",
|
||||
};
|
||||
return m[t] || t;
|
||||
}
|
||||
|
||||
function orderStatusLabel(s: number): string {
|
||||
const m: Record<number, string> = {
|
||||
0: "待支付",
|
||||
1: "已支付",
|
||||
2: "已取消",
|
||||
3: "已退款",
|
||||
4: "已过期",
|
||||
};
|
||||
return m[s] ?? String(s);
|
||||
}
|
||||
|
||||
function payMethodLabel(m: string): string {
|
||||
const mp: Record<string, string> = { alipay: "支付宝", wxpay: "微信支付" };
|
||||
return mp[m] || m;
|
||||
}
|
||||
|
||||
function refundStatusLabel(s: number): string {
|
||||
const m: Record<number, string> = { 0: "待审核", 1: "已批准", 2: "已驳回", 3: "已完成" };
|
||||
return m[s] ?? String(s);
|
||||
}
|
||||
|
||||
// ══════════════════ 订单列表 ════════════════════
|
||||
|
||||
function buildOrderRowActions(row: OrderTable): TableOperationAction[] {
|
||||
const actions: TableOperationAction[] = [
|
||||
{
|
||||
key: "detail",
|
||||
label: "详情",
|
||||
artType: "view",
|
||||
perm: "module_platform:order:query",
|
||||
run: () => showOrderDetail(row),
|
||||
},
|
||||
];
|
||||
if (row.status === 0) {
|
||||
actions.push({
|
||||
key: "cancel",
|
||||
label: "取消",
|
||||
artType: "delete",
|
||||
perm: "module_platform:order:create",
|
||||
run: () => cancelOrder(row.id),
|
||||
});
|
||||
}
|
||||
return actions.filter((a) => a.perm != null && hasAuth(a.perm));
|
||||
}
|
||||
|
||||
function formatOrderOpCell(row: OrderTable) {
|
||||
return renderTableOperationCell(buildOrderRowActions(row), {
|
||||
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1",
|
||||
});
|
||||
}
|
||||
|
||||
const orderSearchForm = ref<Record<string, unknown>>({ status: undefined, order_type: undefined });
|
||||
|
||||
const orderSearchItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: "订单状态",
|
||||
key: "status",
|
||||
type: "select",
|
||||
placeholder: "订单状态",
|
||||
span: 6,
|
||||
props: {
|
||||
clearable: true,
|
||||
options: [
|
||||
{ label: "待支付", value: 0 },
|
||||
{ label: "已支付", value: 1 },
|
||||
{ label: "已取消", value: 2 },
|
||||
{ label: "已退款", value: 3 },
|
||||
{ label: "已过期", value: 4 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "订单类型",
|
||||
key: "order_type",
|
||||
type: "select",
|
||||
placeholder: "订单类型",
|
||||
span: 6,
|
||||
props: {
|
||||
clearable: true,
|
||||
options: [
|
||||
{ label: "新购", value: "new" },
|
||||
{ label: "续费", value: "renew" },
|
||||
{ label: "升级", value: "upgrade" },
|
||||
{ label: "降级", value: "downgrade" },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const orderSearchBarRef = ref();
|
||||
|
||||
const {
|
||||
columns: orderColumns,
|
||||
columnChecks: orderColumnChecks,
|
||||
data: orderData,
|
||||
loading: orderLoading,
|
||||
pagination: orderPagination,
|
||||
getData: getOrderData,
|
||||
replaceSearchParams: replaceOrderSearchParams,
|
||||
resetSearchParams: resetOrderSearchParams,
|
||||
handleSizeChange: handleOrderSizeChange,
|
||||
handleCurrentChange: handleOrderCurrentChange,
|
||||
} = useTable({
|
||||
core: {
|
||||
apiFn: OrderAPI.listOrders,
|
||||
apiParams: {
|
||||
page_no: 1,
|
||||
page_size: 20,
|
||||
},
|
||||
columnsFactory: (): ColumnOption<OrderTable>[] => [
|
||||
{ prop: "id", label: "ID", width: 60 },
|
||||
{ prop: "order_no", label: "订单号", minWidth: 180, showOverflowTooltip: true },
|
||||
{ prop: "tenant_id", label: "租户ID", width: 80 },
|
||||
{ prop: "package_id", label: "套餐ID", width: 80 },
|
||||
{
|
||||
prop: "order_type",
|
||||
label: "类型",
|
||||
width: 90,
|
||||
formatter: (row: OrderTable) =>
|
||||
h(ElTag, { size: "small" as const }, () => orderTypeLabel(row.order_type)),
|
||||
},
|
||||
{
|
||||
prop: "amount",
|
||||
label: "金额",
|
||||
width: 120,
|
||||
formatter: (row: OrderTable) => `¥${(row.amount / 100).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
prop: "status",
|
||||
label: "状态",
|
||||
width: 90,
|
||||
formatter: (row: OrderTable) => {
|
||||
const typeMap = ["warning", "success", "info", "warning", "danger"] as const;
|
||||
const t = typeMap[row.status] || "info";
|
||||
return h(
|
||||
ElTag,
|
||||
{ type: t as "warning" | "success" | "info" | "danger", size: "small" as const },
|
||||
() => orderStatusLabel(row.status)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: "pay_method",
|
||||
label: "支付方式",
|
||||
width: 90,
|
||||
formatter: (row: OrderTable) => (row.pay_method ? payMethodLabel(row.pay_method) : "—"),
|
||||
},
|
||||
{
|
||||
prop: "pay_time",
|
||||
label: "支付时间",
|
||||
width: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: OrderTable) => row.pay_time || "—",
|
||||
},
|
||||
{ prop: "expire_time", label: "过期时间", width: 160, showOverflowTooltip: true },
|
||||
{
|
||||
label: "操作",
|
||||
width: 140,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
formatter: (row: OrderTable) => formatOrderOpCell(row),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
function handleOrderSearch() {
|
||||
const params: Record<string, unknown> = {};
|
||||
if (orderSearchForm.value.status !== undefined && orderSearchForm.value.status !== "") {
|
||||
params.status = orderSearchForm.value.status;
|
||||
}
|
||||
if (orderSearchForm.value.order_type) params.order_type = orderSearchForm.value.order_type;
|
||||
replaceOrderSearchParams(params);
|
||||
}
|
||||
|
||||
function handleOrderReset() {
|
||||
orderSearchForm.value = { status: undefined, order_type: undefined };
|
||||
resetOrderSearchParams();
|
||||
}
|
||||
|
||||
const detailVisible = ref(false);
|
||||
const orderDetail = ref<OrderTable | null>(null);
|
||||
|
||||
async function showOrderDetail(row: OrderTable) {
|
||||
try {
|
||||
const res = await OrderAPI.detailOrder(row.id);
|
||||
orderDetail.value = res.data.data as OrderTable;
|
||||
detailVisible.value = true;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelOrder(orderId: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定取消该订单吗?", "确认取消", { type: "warning" });
|
||||
await OrderAPI.cancelOrder(orderId);
|
||||
getOrderData();
|
||||
} catch {
|
||||
/* cancelled */
|
||||
}
|
||||
}
|
||||
|
||||
const detailItems = computed(() => {
|
||||
if (!orderDetail.value) return [];
|
||||
return [
|
||||
{ label: "订单ID", prop: "id" },
|
||||
{ label: "订单号", prop: "order_no" },
|
||||
{ label: "租户ID", prop: "tenant_id" },
|
||||
{ label: "套餐ID", prop: "package_id" },
|
||||
{ label: "订单类型", prop: "order_type", slot: "order_type" },
|
||||
{ label: "金额", prop: "amount", slot: "amount" },
|
||||
{ label: "状态", prop: "status", slot: "status" },
|
||||
{ label: "支付方式", prop: "pay_method" },
|
||||
{ label: "支付时间", prop: "pay_time" },
|
||||
{ label: "过期时间", prop: "expire_time" },
|
||||
{ label: "创建时间", prop: "created_time" },
|
||||
];
|
||||
});
|
||||
|
||||
// ══════════════════ 支付记录 ════════════════════
|
||||
|
||||
const {
|
||||
columns: paymentColumns,
|
||||
data: paymentData,
|
||||
loading: paymentLoading,
|
||||
pagination: paymentPagination,
|
||||
getData: getPaymentData,
|
||||
handleSizeChange: handlePaymentSizeChange,
|
||||
handleCurrentChange: handlePaymentCurrentChange,
|
||||
} = useTable({
|
||||
core: {
|
||||
apiFn: OrderAPI.listPaymentRecords,
|
||||
apiParams: {
|
||||
page_no: 1,
|
||||
page_size: 20,
|
||||
},
|
||||
columnsFactory: (): ColumnOption<PaymentRecordTable>[] => [
|
||||
{ prop: "id", label: "ID", width: 60 },
|
||||
{ prop: "order_id", label: "订单ID", width: 80 },
|
||||
{
|
||||
prop: "transaction_id",
|
||||
label: "交易流水号",
|
||||
minWidth: 220,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PaymentRecordTable) => row.transaction_id || "—",
|
||||
},
|
||||
{
|
||||
prop: "pay_method",
|
||||
label: "支付方式",
|
||||
width: 100,
|
||||
formatter: (row: PaymentRecordTable) => payMethodLabel(row.pay_method),
|
||||
},
|
||||
{
|
||||
prop: "amount",
|
||||
label: "金额",
|
||||
width: 120,
|
||||
formatter: (row: PaymentRecordTable) => `¥${(row.amount / 100).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
prop: "status",
|
||||
label: "状态",
|
||||
width: 90,
|
||||
formatter: (row: PaymentRecordTable) =>
|
||||
h(
|
||||
ElTag,
|
||||
{
|
||||
type: (row.status === 1 ? "success" : "danger") as "success" | "danger",
|
||||
size: "small" as const,
|
||||
},
|
||||
() => (row.status === 1 ? "成功" : "失败")
|
||||
),
|
||||
},
|
||||
{ prop: "pay_time", label: "支付时间", width: 160, showOverflowTooltip: true },
|
||||
{ prop: "created_time", label: "创建时间", width: 160, showOverflowTooltip: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// ══════════════════ 退款审核 ════════════════════
|
||||
|
||||
function buildRefundRowActions(row: RefundTable): TableOperationAction[] {
|
||||
if (row.status !== 0) return [];
|
||||
const actions: TableOperationAction[] = [
|
||||
{
|
||||
key: "approve",
|
||||
label: "批准",
|
||||
artType: "edit",
|
||||
perm: "module_platform:order:update",
|
||||
run: () => approveRefund(row.id),
|
||||
},
|
||||
{
|
||||
key: "reject",
|
||||
label: "驳回",
|
||||
artType: "delete",
|
||||
perm: "module_platform:order:update",
|
||||
run: () => showRejectDialog(row.id),
|
||||
},
|
||||
];
|
||||
return actions.filter((a) => a.perm != null && hasAuth(a.perm));
|
||||
}
|
||||
|
||||
function formatRefundOpCell(row: RefundTable) {
|
||||
const actions = buildRefundRowActions(row);
|
||||
if (actions.length === 0) return "—";
|
||||
return renderTableOperationCell(actions, {
|
||||
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1",
|
||||
});
|
||||
}
|
||||
|
||||
const refundSearchForm = ref<Record<string, unknown>>({ status: undefined });
|
||||
|
||||
const refundSearchItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: "退款状态",
|
||||
key: "status",
|
||||
type: "select",
|
||||
placeholder: "退款状态",
|
||||
span: 6,
|
||||
props: {
|
||||
clearable: true,
|
||||
options: [
|
||||
{ label: "待审核", value: 0 },
|
||||
{ label: "已批准", value: 1 },
|
||||
{ label: "已驳回", value: 2 },
|
||||
{ label: "已完成", value: 3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const refundSearchBarRef = ref();
|
||||
|
||||
const {
|
||||
columns: refundColumns,
|
||||
columnChecks: refundColumnChecks,
|
||||
data: refundData,
|
||||
loading: refundLoading,
|
||||
pagination: refundPagination,
|
||||
getData: getRefundData,
|
||||
replaceSearchParams: replaceRefundSearchParams,
|
||||
resetSearchParams: resetRefundSearchParams,
|
||||
handleSizeChange: handleRefundSizeChange,
|
||||
handleCurrentChange: handleRefundCurrentChange,
|
||||
} = useTable({
|
||||
core: {
|
||||
apiFn: OrderAPI.listRefunds,
|
||||
apiParams: {
|
||||
page_no: 1,
|
||||
page_size: 20,
|
||||
},
|
||||
columnsFactory: (): ColumnOption<RefundTable>[] => [
|
||||
{ prop: "id", label: "ID", width: 60 },
|
||||
{ prop: "refund_no", label: "退款单号", minWidth: 180, showOverflowTooltip: true },
|
||||
{ prop: "order_id", label: "订单ID", width: 80 },
|
||||
{
|
||||
prop: "amount",
|
||||
label: "退款金额",
|
||||
width: 120,
|
||||
formatter: (row: RefundTable) => `¥${(row.amount / 100).toFixed(2)}`,
|
||||
},
|
||||
{ prop: "reason", label: "退款原因", minWidth: 160, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "status",
|
||||
label: "状态",
|
||||
width: 100,
|
||||
formatter: (row: RefundTable) => {
|
||||
const typeMap = ["warning", "success", "danger", "info"] as const;
|
||||
const t = typeMap[row.status] || "info";
|
||||
return h(
|
||||
ElTag,
|
||||
{ type: t as "warning" | "success" | "info" | "danger", size: "small" as const },
|
||||
() => refundStatusLabel(row.status)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: "reject_reason",
|
||||
label: "驳回原因",
|
||||
minWidth: 140,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: RefundTable) =>
|
||||
row.reject_reason ? h("span", { style: "color: #f56c6c" }, row.reject_reason) : "—",
|
||||
},
|
||||
{ prop: "review_time", label: "审核时间", width: 160, showOverflowTooltip: true },
|
||||
{ prop: "created_time", label: "申请时间", width: 160, showOverflowTooltip: true },
|
||||
{
|
||||
label: "操作",
|
||||
width: 160,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
formatter: (row: RefundTable) => formatRefundOpCell(row),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
function handleRefundSearch() {
|
||||
const params: Record<string, unknown> = {};
|
||||
if (refundSearchForm.value.status !== undefined && refundSearchForm.value.status !== "") {
|
||||
params.status = refundSearchForm.value.status;
|
||||
}
|
||||
replaceRefundSearchParams(params);
|
||||
}
|
||||
|
||||
function handleRefundReset() {
|
||||
refundSearchForm.value = { status: undefined };
|
||||
resetRefundSearchParams();
|
||||
}
|
||||
|
||||
async function approveRefund(refundId: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定批准该退款申请吗?款项将原路退回。", "确认批准", {
|
||||
type: "warning",
|
||||
});
|
||||
await OrderAPI.approveRefund(refundId);
|
||||
getRefundData();
|
||||
} catch {
|
||||
/* cancelled */
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════ 驳回弹窗 ════════════════════
|
||||
const rejectVisible = ref(false);
|
||||
const rejectSubmitting = ref(false);
|
||||
const rejectFormRef = ref();
|
||||
let rejectFormData = reactive({ reject_reason: "" });
|
||||
const currentRefundId = ref<number | null>(null);
|
||||
|
||||
const rejectFormItems: FormItem[] = [
|
||||
{
|
||||
label: "驳回原因",
|
||||
key: "reject_reason",
|
||||
type: "textarea",
|
||||
span: 24,
|
||||
props: { rows: 3, maxlength: 500, placeholder: "请输入驳回原因" },
|
||||
},
|
||||
];
|
||||
|
||||
const rejectRules = {
|
||||
reject_reason: [{ required: true, message: "请输入驳回原因", trigger: "blur" }],
|
||||
};
|
||||
|
||||
function showRejectDialog(refundId: number) {
|
||||
currentRefundId.value = refundId;
|
||||
rejectFormData.reject_reason = "";
|
||||
rejectVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitReject() {
|
||||
const form: any = rejectFormRef.value;
|
||||
if (!form) return;
|
||||
const valid = await form.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
rejectSubmitting.value = true;
|
||||
try {
|
||||
await OrderAPI.rejectRefund(currentRefundId.value!, {
|
||||
reject_reason: rejectFormData.reject_reason,
|
||||
});
|
||||
rejectVisible.value = false;
|
||||
getRefundData();
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
rejectSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════ Tab 切换 ════════════════════
|
||||
|
||||
function onTabChange(tab: string | number) {
|
||||
if (tab === "orders") getOrderData();
|
||||
else if (tab === "payments") getPaymentData();
|
||||
else if (tab === "refunds") getRefundData();
|
||||
}
|
||||
|
||||
// ══════════════════ 初始化 ════════════════════
|
||||
|
||||
getOrderData();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.el-tabs) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:deep(.el-tab-pane) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,695 @@
|
||||
<!-- 套餐管理:FA + useTable;含菜单权限管理 -->
|
||||
<template>
|
||||
<div class="fa-full-height">
|
||||
<FaSearchBar
|
||||
v-show="showSearchBar"
|
||||
ref="searchBarRef"
|
||||
v-model="searchForm"
|
||||
:items="pkgSearchItems"
|
||||
:rules="searchBarRules"
|
||||
:is-expand="false"
|
||||
:show-expand="true"
|
||||
:show-reset="true"
|
||||
:show-search="true"
|
||||
:disabled-search="false"
|
||||
:default-expanded="false"
|
||||
@search="handleSearchBarSearch"
|
||||
@reset="onResetSearch"
|
||||
/>
|
||||
|
||||
<ElCard
|
||||
shadow="hover"
|
||||
class="fa-table-card"
|
||||
:style="{ 'margin-top': showSearchBar ? '12px' : '0' }"
|
||||
>
|
||||
<FaTableHeader
|
||||
v-model:columns="columnChecks"
|
||||
v-model:showSearchBar="showSearchBar"
|
||||
:loading="loading"
|
||||
@refresh="refreshData"
|
||||
>
|
||||
<template #left>
|
||||
<FaTableHeaderLeft
|
||||
:remove-ids="selectedIds"
|
||||
:perm-create="['module_package:package:create']"
|
||||
:perm-delete="['module_package:package:delete']"
|
||||
:perm-patch="['module_package:package:update']"
|
||||
:delete-loading="batchDeleting"
|
||||
@add="handleOpenDialog('create')"
|
||||
@delete="handleBatchDelete"
|
||||
@more="handleMoreClick"
|
||||
/>
|
||||
</template>
|
||||
</FaTableHeader>
|
||||
|
||||
<FaTable
|
||||
ref="faTableRef"
|
||||
:loading="loading"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:pagination="pagination"
|
||||
@selection-change="onTableSelectionChange"
|
||||
@pagination:size-change="handleSizeChange"
|
||||
@pagination:current-change="handleCurrentChange"
|
||||
/>
|
||||
</ElCard>
|
||||
|
||||
<FaDialog
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
width="700px"
|
||||
dialog-class="crud-embed-dialog"
|
||||
modal-class="crud-embed-dialog"
|
||||
:form-mode="dialogVisible.type"
|
||||
:confirm-loading="submitLoading"
|
||||
@cancel="handleCloseDialog"
|
||||
@confirm="dialogVisible.type === 'detail' ? handleCloseDialog() : handleSubmit()"
|
||||
>
|
||||
<template v-if="dialogVisible.type === 'detail'">
|
||||
<FaDescriptions
|
||||
:column="4"
|
||||
:label-width="'110px'"
|
||||
:data="detailFormData"
|
||||
:items="pkgDetailItems"
|
||||
max-height="75vh"
|
||||
>
|
||||
<template #price="{ row }"> ¥{{ (Number(row?.price ?? 0) / 100).toFixed(2) }} </template>
|
||||
<template #period="{ row }">
|
||||
{{ row?.period === "month" ? "按月" : "按年" }}
|
||||
</template>
|
||||
</FaDescriptions>
|
||||
</template>
|
||||
<template v-else>
|
||||
<FaForm
|
||||
scrollbar
|
||||
max-height="75vh"
|
||||
:key="pkgFormRenderKey"
|
||||
ref="dataFormRef"
|
||||
v-model="formData"
|
||||
:items="pkgDialogFormItems"
|
||||
:rules="rules"
|
||||
label-suffix=":"
|
||||
:label-width="120"
|
||||
label-position="right"
|
||||
:span="12"
|
||||
:gutter="20"
|
||||
:show-reset="false"
|
||||
:show-submit="false"
|
||||
class="crud-dialog-art-form"
|
||||
>
|
||||
<template #status>
|
||||
<ElRadioGroup v-model="formData.status">
|
||||
<ElRadio :value="0">正常</ElRadio>
|
||||
<ElRadio :value="1">禁用</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</template>
|
||||
<template #period>
|
||||
<ElRadioGroup v-model="formData.period">
|
||||
<ElRadio value="month">按月</ElRadio>
|
||||
<ElRadio value="year">按年</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</template>
|
||||
</FaForm>
|
||||
</template>
|
||||
</FaDialog>
|
||||
|
||||
<!-- 套餐菜单权限 -->
|
||||
<FaDrawer
|
||||
v-model="menuDialogVisible"
|
||||
title="套餐菜单权限"
|
||||
size="1200px"
|
||||
destroy-on-close
|
||||
@close="menuDialogVisible = false"
|
||||
>
|
||||
<FaMenuTreeTable
|
||||
ref="menuTreeTableRef"
|
||||
:menu-tree="menuTreeData"
|
||||
:checked-ids="menuCheckedIds"
|
||||
:loading="menuTreeLoading"
|
||||
/>
|
||||
<template #footer>
|
||||
<ElButton @click="menuDialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="menuSaveLoading" @click="handleSaveMenus">保存</ElButton>
|
||||
</template>
|
||||
</FaDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTable } from "@/hooks/core/useTable";
|
||||
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
|
||||
import { useTableSelection } from "@/hooks/core/useTableSelection";
|
||||
import { useCrudForm } from "@/hooks/core/useCrudForm";
|
||||
import { confirmDelete, confirmBatchDelete, confirmToggleStatus } from "@/hooks/core/useConfirm";
|
||||
import type { ColumnOption } from "@/types/component";
|
||||
import PackageAPI, { type PackageForm, type PackageTable } from "@/api/module_platform/package";
|
||||
import MenuAPI from "@/api/module_platform/menu";
|
||||
import { useAuth } from "@/hooks/core/useAuth";
|
||||
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type { FormItem } from "@/components/forms/fa-form/index.vue";
|
||||
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
|
||||
import FaForm from "@/components/forms/fa-form/index.vue";
|
||||
import { renderTableOperationCell, type TableOperationAction } from "@utils";
|
||||
import { ElTag, ElMessage } from "element-plus";
|
||||
import FaDrawer from "@/components/modal/fa-drawer/index.vue";
|
||||
import FaMenuTreeTable from "@/components/others/fa-menu-tree-drawer/index.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "Package",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const { hasAuth } = useAuth();
|
||||
|
||||
type PackageSearchForm = {
|
||||
name?: string;
|
||||
code?: string;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
function buildPkgReplaceParams(p: PackageSearchForm): Record<string, unknown> {
|
||||
return {
|
||||
name: p.name || undefined,
|
||||
code: p.code || undefined,
|
||||
status: p.status || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPkgRowActions(
|
||||
row: PackageTable,
|
||||
ctx: {
|
||||
onDetail: (id: number) => void;
|
||||
onEdit: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onToggleStatus: (row: PackageTable) => void;
|
||||
onManageMenus: (id: number) => void;
|
||||
}
|
||||
): TableOperationAction[] {
|
||||
const all: TableOperationAction[] = [
|
||||
{
|
||||
key: "detail",
|
||||
label: "详情",
|
||||
artType: "view",
|
||||
perm: "module_package:package:query",
|
||||
run: () => ctx.onDetail(row.id!),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "编辑",
|
||||
artType: "edit",
|
||||
perm: "module_package:package:update",
|
||||
run: () => ctx.onEdit(row.id!),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
artType: "delete",
|
||||
perm: "module_package:package:delete",
|
||||
run: () => ctx.onDelete(row.id!),
|
||||
},
|
||||
{
|
||||
key: "toggle-status",
|
||||
label: row.status === 0 ? "禁用" : "启用",
|
||||
artType: "more",
|
||||
icon: "ri:toggle-line",
|
||||
perm: "module_package:package:update",
|
||||
run: () => ctx.onToggleStatus(row),
|
||||
},
|
||||
{
|
||||
key: "menus",
|
||||
label: "菜单权限",
|
||||
artType: "more",
|
||||
icon: "ri:menu-line",
|
||||
perm: "module_package:package:update",
|
||||
run: () => ctx.onManageMenus(row.id!),
|
||||
},
|
||||
];
|
||||
return all.filter((a) => a.perm != null && hasAuth(a.perm));
|
||||
}
|
||||
|
||||
function formatPkgOperationCell(row: PackageTable, ctx: Parameters<typeof buildPkgRowActions>[1]) {
|
||||
const actions = buildPkgRowActions(row, ctx);
|
||||
return renderTableOperationCell(actions, {
|
||||
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 pkg-table-actions",
|
||||
});
|
||||
}
|
||||
|
||||
const searchForm = ref<PackageSearchForm>({
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
status: undefined,
|
||||
});
|
||||
|
||||
const showSearchBar = ref(true);
|
||||
const searchBarRef = ref<InstanceType<typeof FaSearchBar> | null>(null);
|
||||
const searchBarRules: Record<string, unknown> = {};
|
||||
|
||||
const statusOptions = ref([
|
||||
{ label: "正常", value: "0" },
|
||||
{ label: "禁用", value: "1" },
|
||||
]);
|
||||
|
||||
const pkgSearchItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: "套餐名称",
|
||||
key: "name",
|
||||
type: "input",
|
||||
placeholder: "请输入套餐名称",
|
||||
clearable: true,
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "套餐编码",
|
||||
key: "code",
|
||||
type: "input",
|
||||
placeholder: "请输入套餐编码",
|
||||
clearable: true,
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
key: "status",
|
||||
type: "select",
|
||||
props: {
|
||||
placeholder: "请选择状态",
|
||||
options: statusOptions.value,
|
||||
clearable: true,
|
||||
},
|
||||
span: 6,
|
||||
},
|
||||
]);
|
||||
|
||||
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
|
||||
const { selectedIds, batchDeleting, onTableSelectionChange } = useTableSelection<PackageTable>();
|
||||
|
||||
const opCtx = {
|
||||
onDetail: (id: number) => void handleOpenDialog("detail", id),
|
||||
onEdit: (id: number) => void handleOpenDialog("update", id),
|
||||
onDelete: deletePkgRow,
|
||||
onToggleStatus: (row: PackageTable) => togglePkgStatus(row),
|
||||
onManageMenus: (id: number) => openMenuDialog(id),
|
||||
};
|
||||
|
||||
const {
|
||||
columns,
|
||||
columnChecks,
|
||||
data,
|
||||
loading,
|
||||
pagination,
|
||||
getData,
|
||||
replaceSearchParams,
|
||||
resetSearchParams,
|
||||
handleSizeChange,
|
||||
handleCurrentChange,
|
||||
refreshData,
|
||||
refreshCreate,
|
||||
refreshRemove,
|
||||
} = useTable({
|
||||
core: {
|
||||
apiFn: PackageAPI.listPackage,
|
||||
apiParams: {
|
||||
page_no: 1,
|
||||
page_size: 10,
|
||||
},
|
||||
columnsFactory: (): ColumnOption<PackageTable>[] => [
|
||||
{ type: "selection", width: 48, fixed: "left" },
|
||||
{ type: "globalIndex", width: 56, label: "序号" },
|
||||
{ prop: "name", label: "套餐名称", minWidth: 120, showOverflowTooltip: true },
|
||||
{ prop: "code", label: "套餐编码", minWidth: 100, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "status",
|
||||
label: "状态",
|
||||
width: 80,
|
||||
formatter: (row: PackageTable) =>
|
||||
h(ElTag, { type: row.status === 0 ? "success" : "danger" }, () =>
|
||||
row.status === 0 ? "正常" : "禁用"
|
||||
),
|
||||
},
|
||||
{ prop: "sort", label: "排序", width: 60, align: "center" },
|
||||
{
|
||||
prop: "price",
|
||||
label: "价格",
|
||||
width: 100,
|
||||
align: "right",
|
||||
formatter: (row: PackageTable) => `¥${(Number(row.price ?? 0) / 100).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
prop: "period",
|
||||
label: "计费周期",
|
||||
width: 90,
|
||||
formatter: (row: PackageTable) => (row.period === "month" ? "按月" : "按年"),
|
||||
},
|
||||
{ prop: "trial_days", label: "试用天数", width: 80, align: "center" },
|
||||
{ prop: "max_users", label: "最大用户数", width: 90, align: "center" },
|
||||
{ prop: "max_roles", label: "最大角色数", width: 90, align: "center" },
|
||||
{ prop: "max_depts", label: "最大部门数", width: 90, align: "center" },
|
||||
{ prop: "max_storage_mb", label: "存储(MB)", width: 90, align: "center" },
|
||||
{ prop: "rate_limit", label: "速率限制", width: 80, align: "center" },
|
||||
{
|
||||
prop: "operation",
|
||||
label: "操作",
|
||||
width: 200,
|
||||
fixed: "right",
|
||||
align: "right",
|
||||
formatter: (row: PackageTable) => formatPkgOperationCell(row, opCtx),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// 详情 & 表单
|
||||
const formData = ref<PackageForm>({});
|
||||
const detailFormData = ref<Partial<PackageTable>>({});
|
||||
const pkgFormRenderKey = ref(0);
|
||||
|
||||
const pkgDetailItems: import("@/components/others/fa-descriptions/index.vue").DescriptionsItem[] = [
|
||||
{ label: "套餐名称", prop: "name" },
|
||||
{ label: "套餐编码", prop: "code" },
|
||||
{
|
||||
label: "状态",
|
||||
prop: "status",
|
||||
tag: { map: { 0: { type: "success", text: "正常" }, 1: { type: "danger", text: "禁用" } } },
|
||||
},
|
||||
{ label: "排序", prop: "sort" },
|
||||
{ label: "价格", prop: "price", slot: "price" },
|
||||
{ label: "计费周期", prop: "period", slot: "period" },
|
||||
{ label: "试用天数", prop: "trial_days" },
|
||||
{ label: "最大用户数", prop: "max_users" },
|
||||
{ label: "最大角色数", prop: "max_roles" },
|
||||
{ label: "最大部门数", prop: "max_depts" },
|
||||
{ label: "最大存储(MB)", prop: "max_storage_mb" },
|
||||
{ label: "速率限制", prop: "rate_limit" },
|
||||
{ label: "描述", prop: "description", span: 4 },
|
||||
];
|
||||
|
||||
const pkgDialogFormItems = computed<FormItem[]>(() => [
|
||||
{
|
||||
label: "套餐名称",
|
||||
key: "name",
|
||||
type: "input",
|
||||
props: {
|
||||
placeholder: "请输入套餐名称",
|
||||
maxlength: 100,
|
||||
},
|
||||
rules: [
|
||||
{ required: true, message: "请输入套餐名称", trigger: "blur" },
|
||||
{ max: 100, message: "最长 100 个字符", trigger: "blur" },
|
||||
],
|
||||
span: 12,
|
||||
},
|
||||
{
|
||||
label: "套餐编码",
|
||||
key: "code",
|
||||
type: "input",
|
||||
props: {
|
||||
placeholder: "请输入套餐编码(仅字母数字)",
|
||||
maxlength: 100,
|
||||
},
|
||||
rules: [
|
||||
{ required: true, message: "请输入套餐编码", trigger: "blur" },
|
||||
{ pattern: /^[a-zA-Z0-9]+$/, message: "仅允许字母和数字", trigger: "blur" },
|
||||
{ min: 2, message: "至少 2 个字符", trigger: "blur" },
|
||||
],
|
||||
span: 12,
|
||||
},
|
||||
{
|
||||
label: "排序",
|
||||
key: "sort",
|
||||
type: "number",
|
||||
props: {
|
||||
placeholder: "排序",
|
||||
min: 0,
|
||||
},
|
||||
span: 12,
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
key: "status",
|
||||
type: "slot",
|
||||
slot: "status",
|
||||
span: 12,
|
||||
},
|
||||
{
|
||||
label: "价格(分)",
|
||||
key: "price",
|
||||
type: "number",
|
||||
props: {
|
||||
placeholder: "价格(单位:分)",
|
||||
min: 0,
|
||||
},
|
||||
rules: [
|
||||
{ required: true, message: "请输入价格", trigger: "blur" },
|
||||
{ type: "number", min: 0, message: "价格不能为负数", trigger: "blur" },
|
||||
],
|
||||
span: 12,
|
||||
},
|
||||
{
|
||||
label: "计费周期",
|
||||
key: "period",
|
||||
type: "slot",
|
||||
slot: "period",
|
||||
span: 12,
|
||||
},
|
||||
{
|
||||
label: "试用天数",
|
||||
key: "trial_days",
|
||||
type: "number",
|
||||
props: {
|
||||
placeholder: "免费试用天数",
|
||||
min: 0,
|
||||
},
|
||||
rules: [{ type: "number", min: 0, message: "不能为负数", trigger: "blur" }],
|
||||
span: 12,
|
||||
},
|
||||
{
|
||||
label: "最大用户数",
|
||||
key: "max_users",
|
||||
type: "number",
|
||||
props: {
|
||||
placeholder: "最大用户数",
|
||||
min: 0,
|
||||
},
|
||||
rules: [{ required: true, message: "请输入最大用户数", trigger: "blur" }],
|
||||
span: 12,
|
||||
},
|
||||
{
|
||||
label: "最大角色数",
|
||||
key: "max_roles",
|
||||
type: "number",
|
||||
props: {
|
||||
placeholder: "最大角色数",
|
||||
min: 0,
|
||||
},
|
||||
rules: [{ required: true, message: "请输入最大角色数", trigger: "blur" }],
|
||||
span: 12,
|
||||
},
|
||||
{
|
||||
label: "最大部门数",
|
||||
key: "max_depts",
|
||||
type: "number",
|
||||
props: {
|
||||
placeholder: "最大部门数",
|
||||
min: 0,
|
||||
},
|
||||
rules: [{ required: true, message: "请输入最大部门数", trigger: "blur" }],
|
||||
span: 12,
|
||||
},
|
||||
{
|
||||
label: "最大存储(MB)",
|
||||
key: "max_storage_mb",
|
||||
type: "number",
|
||||
props: {
|
||||
placeholder: "最大存储(MB)",
|
||||
min: 0,
|
||||
},
|
||||
rules: [{ required: true, message: "请输入最大存储", trigger: "blur" }],
|
||||
span: 12,
|
||||
},
|
||||
{
|
||||
label: "速率限制",
|
||||
key: "rate_limit",
|
||||
type: "number",
|
||||
props: {
|
||||
placeholder: "请求/10秒",
|
||||
min: 10,
|
||||
},
|
||||
rules: [
|
||||
{ required: true, message: "请输入速率限制", trigger: "blur" },
|
||||
{ type: "number", min: 10, message: "最低 10 请求/10秒", trigger: "blur" },
|
||||
],
|
||||
span: 12,
|
||||
},
|
||||
{
|
||||
label: "描述",
|
||||
key: "description",
|
||||
type: "input",
|
||||
props: {
|
||||
type: "textarea",
|
||||
placeholder: "套餐描述",
|
||||
maxlength: 255,
|
||||
rows: 2,
|
||||
},
|
||||
rules: [{ max: 255, message: "最长 255 个字符", trigger: "blur" }],
|
||||
span: 24,
|
||||
},
|
||||
]);
|
||||
|
||||
const rules = reactive({
|
||||
name: [
|
||||
{ required: true, message: "请输入套餐名称", trigger: "blur" },
|
||||
{ max: 100, message: "最长 100 个字符", trigger: "blur" },
|
||||
],
|
||||
code: [
|
||||
{ required: true, message: "请输入套餐编码", trigger: "blur" },
|
||||
{ pattern: /^[a-zA-Z0-9]+$/, message: "仅允许字母和数字", trigger: "blur" },
|
||||
{ min: 2, message: "至少 2 个字符", trigger: "blur" },
|
||||
],
|
||||
price: [
|
||||
{ required: true, message: "请输入价格", trigger: "blur" },
|
||||
{ type: "number", min: 0, message: "价格不能为负数", trigger: "blur" },
|
||||
],
|
||||
trial_days: [{ type: "number", min: 0, message: "不能为负数", trigger: "blur" }],
|
||||
max_users: [{ required: true, message: "请输入最大用户数", trigger: "blur" }],
|
||||
max_roles: [{ required: true, message: "请输入最大角色数", trigger: "blur" }],
|
||||
max_depts: [{ required: true, message: "请输入最大部门数", trigger: "blur" }],
|
||||
max_storage_mb: [{ required: true, message: "请输入最大存储", trigger: "blur" }],
|
||||
rate_limit: [
|
||||
{ required: true, message: "请输入速率限制", trigger: "blur" },
|
||||
{ type: "number", min: 10, message: "最低 10 请求/10秒", trigger: "blur" },
|
||||
],
|
||||
description: [{ max: 255, message: "最长 255 个字符", trigger: "blur" }],
|
||||
});
|
||||
|
||||
const { dialogVisible } = useCrudDialog();
|
||||
const dataFormRef = ref<any>(null);
|
||||
const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } =
|
||||
useCrudForm<PackageForm>({
|
||||
formData,
|
||||
initialFormData: {
|
||||
status: 0,
|
||||
period: "month",
|
||||
sort: 0,
|
||||
price: 0,
|
||||
trial_days: 0,
|
||||
max_users: 10,
|
||||
max_roles: 5,
|
||||
max_depts: 10,
|
||||
max_storage_mb: 1024,
|
||||
rate_limit: 60,
|
||||
},
|
||||
dialogVisible,
|
||||
dataFormRef,
|
||||
formRenderKey: pkgFormRenderKey,
|
||||
detailApi: PackageAPI.detailPackage as any,
|
||||
createApi: PackageAPI.createPackage as any,
|
||||
updateApi: PackageAPI.updatePackage as any,
|
||||
detailFormData,
|
||||
titles: { create: "新增套餐", update: "修改套餐", detail: "套餐详情" },
|
||||
onCreateSuccess: refreshCreate,
|
||||
onUpdateSuccess: refreshCreate,
|
||||
});
|
||||
|
||||
async function deletePkgRow(id: number) {
|
||||
try {
|
||||
await confirmDelete();
|
||||
await PackageAPI.deletePackage([id]);
|
||||
faTableRef.value?.elTableRef?.clearSelection();
|
||||
await refreshRemove();
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePkgStatus(row: PackageTable) {
|
||||
const newStatus = row.status === 0 ? 1 : 0;
|
||||
const label = newStatus === 0 ? "启用" : "禁用";
|
||||
try {
|
||||
await confirmToggleStatus(label);
|
||||
await PackageAPI.batchPackageStatus({ ids: [row.id!], status: Number(newStatus) });
|
||||
ElMessage.success(`${label}成功`);
|
||||
await refreshData();
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedIds.value;
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
await confirmBatchDelete(ids.length);
|
||||
batchDeleting.value = true;
|
||||
await PackageAPI.deletePackage(ids);
|
||||
faTableRef.value?.elTableRef?.clearSelection();
|
||||
await refreshRemove();
|
||||
} catch {
|
||||
// 用户取消
|
||||
} finally {
|
||||
batchDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMoreClick() {
|
||||
// 预留
|
||||
}
|
||||
|
||||
// 菜单权限管理
|
||||
const menuDialogVisible = ref(false);
|
||||
const menuTreeLoading = ref(false);
|
||||
const menuSaveLoading = ref(false);
|
||||
const menuTreeData = ref<any[]>([]);
|
||||
const menuCheckedIds = ref<number[]>([]);
|
||||
const menuTreeTableRef = ref<InstanceType<typeof FaMenuTreeTable>>();
|
||||
const currentMenuPkgId = ref<number>(0);
|
||||
|
||||
async function openMenuDialog(packageId: number) {
|
||||
currentMenuPkgId.value = packageId;
|
||||
menuTreeData.value = [];
|
||||
menuCheckedIds.value = [];
|
||||
menuTreeLoading.value = true;
|
||||
menuDialogVisible.value = true;
|
||||
try {
|
||||
const menuRes = await MenuAPI.listMenu();
|
||||
menuTreeData.value = menuRes.data.data ?? [];
|
||||
const menuIdsRes = await PackageAPI.getPackageMenus(packageId);
|
||||
menuCheckedIds.value = menuIdsRes.data.data ?? [];
|
||||
} catch {
|
||||
menuTreeData.value = [];
|
||||
} finally {
|
||||
menuTreeLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveMenus() {
|
||||
const checkedIds = menuTreeTableRef.value?.getCheckedIds?.() ?? [];
|
||||
if (checkedIds.length === 0) {
|
||||
ElMessage.warning("请至少选择一个菜单或功能按钮");
|
||||
return;
|
||||
}
|
||||
menuSaveLoading.value = true;
|
||||
try {
|
||||
await PackageAPI.setPackageMenus(currentMenuPkgId.value, checkedIds);
|
||||
ElMessage.success("菜单权限保存成功");
|
||||
menuDialogVisible.value = false;
|
||||
} catch {
|
||||
// 错误由全局拦截处理
|
||||
} finally {
|
||||
menuSaveLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearchBarSearch(params: PackageSearchForm) {
|
||||
await searchBarRef.value?.validate?.();
|
||||
replaceSearchParams(buildPkgReplaceParams(params));
|
||||
getData();
|
||||
}
|
||||
|
||||
function onResetSearch() {
|
||||
searchForm.value = {
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
status: undefined,
|
||||
};
|
||||
void resetSearchParams();
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,781 @@
|
||||
<!-- 插件市场:卡片展示 + 搜索筛选 -->
|
||||
<template>
|
||||
<div class="fa-full-height">
|
||||
<FaSearchBar
|
||||
v-show="showSearchBar"
|
||||
ref="searchBarRef"
|
||||
v-model="searchForm"
|
||||
:items="pluginSearchItems"
|
||||
:is-expand="false"
|
||||
:show-expand="true"
|
||||
:show-reset="true"
|
||||
:show-search="true"
|
||||
:disabled-search="false"
|
||||
:default-expanded="false"
|
||||
@search="handleSearchBarSearch"
|
||||
@reset="onResetSearch"
|
||||
/>
|
||||
|
||||
<ElCard
|
||||
shadow="hover"
|
||||
class="fa-table-card"
|
||||
:style="{ 'margin-top': showSearchBar ? '12px' : '0' }"
|
||||
>
|
||||
<FaTableHeader
|
||||
v-model:columns="columnChecks"
|
||||
v-model:showSearchBar="showSearchBar"
|
||||
:loading="loading"
|
||||
layout="search,refresh"
|
||||
@refresh="fetchData"
|
||||
>
|
||||
<template #left>
|
||||
<ElButton
|
||||
v-if="hasAuth('module_platform:plugin:create')"
|
||||
type="primary"
|
||||
@click="handleOpenDialog('create')"
|
||||
>
|
||||
<ElIcon><Plus /></ElIcon>
|
||||
新增插件
|
||||
</ElButton>
|
||||
</template>
|
||||
</FaTableHeader>
|
||||
|
||||
<!-- 加载 -->
|
||||
<ElSkeleton v-if="loading && !data.length" :rows="4" animated style="margin-top: 16px">
|
||||
<template #template>
|
||||
<div
|
||||
style="
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
"
|
||||
>
|
||||
<div v-for="i in 6" :key="i" style="height: 260px">
|
||||
<ElSkeletonItem
|
||||
variant="rect"
|
||||
style="width: 100%; height: 100%; border-radius: var(--custom-radius)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElSkeleton>
|
||||
|
||||
<!-- 卡片网格 -->
|
||||
<ElScrollbar v-else-if="data.length" class="plugin-scroll">
|
||||
<div class="plugin-grid">
|
||||
<div
|
||||
v-for="item in data"
|
||||
:key="item.id"
|
||||
class="plugin-card fa-card"
|
||||
:class="{ 'is-installed': item.installed }"
|
||||
>
|
||||
<!-- 头部:图标 + 名称 + 状态徽章 -->
|
||||
<div class="card-header">
|
||||
<span class="card-icon" :class="categoryIconBg(item.category)">
|
||||
<FaSvgIcon :icon="item.icon || 'ri:plug-2-line'" />
|
||||
</span>
|
||||
<div class="card-title-group">
|
||||
<span class="card-title">{{ item.name }}</span>
|
||||
<span class="card-badge" :class="item.status === 0 ? 'badge-on' : 'badge-off'">
|
||||
{{ item.status === 0 ? "启用" : "停用" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<p class="card-desc">{{ item.description || "暂无描述" }}</p>
|
||||
|
||||
<!-- 标签行 -->
|
||||
<div class="card-tags">
|
||||
<ElTag size="small" effect="plain" :type="categoryTagType(item.category)">
|
||||
{{ categoryLabel(item.category) }}
|
||||
</ElTag>
|
||||
<span class="tag-version">v{{ item.version || "1.0.0" }}</span>
|
||||
<ElTag v-if="item.price === 0" size="small" effect="plain" type="success">免费</ElTag>
|
||||
<ElTag v-else size="small" effect="plain" type="warning">
|
||||
¥{{ ((item.price ?? 0) / 100).toFixed(2) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
|
||||
<!-- 底部 -->
|
||||
<div class="card-footer">
|
||||
<span class="footer-author">
|
||||
<FaSvgIcon icon="ri:user-3-line" />{{ item.author || "—" }}
|
||||
</span>
|
||||
<div class="footer-actions">
|
||||
<ElButton
|
||||
size="small"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleOpenDialog('detail', item.id!)"
|
||||
>
|
||||
详情
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="hasAuth('module_platform:plugin:install') && !item.installed"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="doInstall(item)"
|
||||
>
|
||||
安装
|
||||
</ElButton>
|
||||
<ElDropdown v-if="showMore(item)" trigger="click">
|
||||
<ElButton size="small" link type="primary" class="more-btn">
|
||||
<ElIcon><MoreFilled /></ElIcon>
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-if="hasAuth('module_platform:plugin:update')"
|
||||
@click="handleOpenDialog('update', item.id!)"
|
||||
>
|
||||
<ElIcon><Edit /></ElIcon>编辑
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-if="hasAuth('module_platform:plugin:install') && item.installed"
|
||||
@click="doToggle(item)"
|
||||
>
|
||||
<ElIcon>
|
||||
<FaSvgIcon
|
||||
:icon="
|
||||
item.status === 0 ? 'ri:forbid-2-line' : 'ri:checkbox-circle-line'
|
||||
"
|
||||
/>
|
||||
</ElIcon>
|
||||
{{ item.status === 0 ? "禁用" : "启用" }}
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-if="hasAuth('module_platform:plugin:install') && item.installed"
|
||||
@click="doUninstall(item)"
|
||||
divided
|
||||
>
|
||||
<ElIcon><Delete /></ElIcon>卸载
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-if="hasAuth('module_platform:plugin:delete')"
|
||||
divided
|
||||
@click="deletePluginRow(item.id!)"
|
||||
>
|
||||
<ElIcon><Delete /></ElIcon>删除
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
|
||||
<ElEmpty v-else-if="!loading" description="暂无插件" style="margin-top: 40px" />
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="total > 0" class="plugin-pagination">
|
||||
<FaPagination
|
||||
:page="pageNo"
|
||||
:limit="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[12, 24, 48]"
|
||||
:disabled="loading"
|
||||
@pagination="onPaginationChange"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<!-- ─── 对话框 ─── -->
|
||||
<FaDialog
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
width="720px"
|
||||
dialog-class="crud-embed-dialog"
|
||||
modal-class="crud-embed-dialog"
|
||||
:form-mode="dialogVisible.type"
|
||||
:confirm-loading="submitLoading"
|
||||
@cancel="handleCloseDialog"
|
||||
@confirm="dialogVisible.type === 'detail' ? handleCloseDialog() : handleSubmit()"
|
||||
>
|
||||
<template v-if="dialogVisible.type === 'detail'">
|
||||
<FaDescriptions
|
||||
:column="2"
|
||||
:data="detailFormData"
|
||||
:items="pluginDetailItems"
|
||||
max-height="75vh"
|
||||
>
|
||||
<template #status>
|
||||
<ElTag :type="detailFormData.status === 0 ? 'success' : 'danger'">
|
||||
{{ detailFormData.status === 0 ? "启用" : "停用" }}
|
||||
</ElTag>
|
||||
</template>
|
||||
<template #price>
|
||||
{{
|
||||
detailFormData.price === 0
|
||||
? "免费"
|
||||
: "¥" + ((detailFormData.price ?? 0) / 100).toFixed(2)
|
||||
}}
|
||||
</template>
|
||||
</FaDescriptions>
|
||||
</template>
|
||||
<template v-else>
|
||||
<FaForm
|
||||
scrollbar
|
||||
max-height="75vh"
|
||||
:key="pluginFormRenderKey"
|
||||
ref="dataFormRef"
|
||||
v-model="formData"
|
||||
:items="pluginDialogFormItems"
|
||||
:rules="rules"
|
||||
label-suffix=":"
|
||||
:label-width="100"
|
||||
label-position="right"
|
||||
:span="24"
|
||||
:gutter="16"
|
||||
:show-reset="false"
|
||||
:show-submit="false"
|
||||
class="crud-dialog-art-form"
|
||||
>
|
||||
<template #status>
|
||||
<ElRadioGroup v-model="formData.status">
|
||||
<ElRadio :value="0">启用</ElRadio>
|
||||
<ElRadio :value="1">停用</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</template>
|
||||
</FaForm>
|
||||
</template>
|
||||
</FaDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
|
||||
import { useCrudForm } from "@/hooks/core/useCrudForm";
|
||||
import { confirmDelete } from "@/hooks/core/useConfirm";
|
||||
import PluginAPI, { type PluginForm, type PluginTable } from "@/api/module_platform/plugin";
|
||||
import { useAuth } from "@/hooks/core/useAuth";
|
||||
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type { FormItem } from "@/components/forms/fa-form/index.vue";
|
||||
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
|
||||
import FaForm from "@/components/forms/fa-form/index.vue";
|
||||
import FaPagination from "@/components/others/fa-pagination/index.vue";
|
||||
import {
|
||||
ElTag,
|
||||
ElMessage,
|
||||
ElButton,
|
||||
ElIcon,
|
||||
ElDropdown,
|
||||
ElDropdownMenu,
|
||||
ElDropdownItem,
|
||||
} from "element-plus";
|
||||
import { Plus, Edit, Delete, MoreFilled } from "@element-plus/icons-vue";
|
||||
|
||||
defineOptions({
|
||||
name: "PluginMarketplace",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const { hasAuth } = useAuth();
|
||||
|
||||
// ─── 搜索表单 ───
|
||||
const searchForm = ref<{ name?: string; category?: string; status?: string }>({
|
||||
name: "",
|
||||
category: "",
|
||||
status: "",
|
||||
});
|
||||
const showSearchBar = ref(true);
|
||||
|
||||
const categoryOptions = ref([
|
||||
{ label: "工具", value: "tool" },
|
||||
{ label: "AI", value: "ai" },
|
||||
{ label: "监控", value: "monitor" },
|
||||
{ label: "业务", value: "business" },
|
||||
]);
|
||||
|
||||
const statusOptions = ref([
|
||||
{ label: "启用", value: "0" },
|
||||
{ label: "停用", value: "1" },
|
||||
]);
|
||||
|
||||
const pluginSearchItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: "插件名称",
|
||||
key: "name",
|
||||
type: "input",
|
||||
placeholder: "请输入插件名称",
|
||||
clearable: true,
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "分类",
|
||||
key: "category",
|
||||
type: "select",
|
||||
props: { placeholder: "请选择分类", options: categoryOptions.value, clearable: true },
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
key: "status",
|
||||
type: "select",
|
||||
props: { placeholder: "请选择状态", options: statusOptions.value, clearable: true },
|
||||
span: 6,
|
||||
},
|
||||
]);
|
||||
|
||||
// ─── 数据管理 ───
|
||||
const data = ref<PluginTable[]>([]);
|
||||
const loading = ref(false);
|
||||
const pageNo = ref(1);
|
||||
const pageSize = ref(12);
|
||||
const total = ref(0);
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await PluginAPI.list(
|
||||
{ page_no: pageNo.value, page_size: pageSize.value },
|
||||
searchForm.value as any
|
||||
);
|
||||
const result = res.data?.data;
|
||||
data.value = (result?.items as PluginTable[]) || [];
|
||||
total.value = result?.total || 0;
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onPaginationChange({ page, limit }: { page: number; limit: number }) {
|
||||
pageNo.value = page;
|
||||
pageSize.value = limit;
|
||||
fetchData();
|
||||
}
|
||||
|
||||
const columnChecks = ref([]);
|
||||
|
||||
async function handleSearchBarSearch(params: Record<string, unknown>) {
|
||||
searchForm.value = {
|
||||
name: (params.name as string) ?? "",
|
||||
category: (params.category as string) ?? "",
|
||||
status: (params.status as string) ?? "",
|
||||
};
|
||||
pageNo.value = 1;
|
||||
await fetchData();
|
||||
}
|
||||
|
||||
async function onResetSearch() {
|
||||
searchForm.value = { name: "", category: "", status: "" };
|
||||
pageNo.value = 1;
|
||||
await fetchData();
|
||||
}
|
||||
|
||||
// ─── 分类辅助函数 ───
|
||||
function categoryLabel(cat?: string): string {
|
||||
const map: Record<string, string> = { tool: "工具", ai: "AI", monitor: "监控", business: "业务" };
|
||||
return (cat && map[cat]) || cat || "—";
|
||||
}
|
||||
|
||||
function categoryIconBg(cat?: string): string {
|
||||
const map: Record<string, string> = {
|
||||
tool: "icon-bg-default",
|
||||
ai: "icon-bg-warning",
|
||||
monitor: "icon-bg-success",
|
||||
business: "icon-bg-info",
|
||||
};
|
||||
return (cat && map[cat]) || "icon-bg-default";
|
||||
}
|
||||
|
||||
function categoryTagType(cat?: string): "success" | "warning" | "info" | "danger" | undefined {
|
||||
const map: Record<string, "success" | "warning" | "info" | "danger" | undefined> = {
|
||||
tool: "info",
|
||||
ai: "warning",
|
||||
monitor: "success",
|
||||
business: undefined,
|
||||
};
|
||||
return cat ? map[cat] : "info";
|
||||
}
|
||||
|
||||
// ─── 显示更多按钮 ───
|
||||
function showMore(row: PluginTable): boolean {
|
||||
return (
|
||||
hasAuth("module_platform:plugin:update") ||
|
||||
hasAuth("module_platform:plugin:delete") ||
|
||||
(hasAuth("module_platform:plugin:install") && !!row.installed)
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 操作 ───
|
||||
async function deletePluginRow(id: number) {
|
||||
try {
|
||||
await confirmDelete("确认删除该插件?");
|
||||
await PluginAPI.delete([id]);
|
||||
await fetchData();
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
async function doInstall(row: PluginTable) {
|
||||
if (!row.id) return;
|
||||
try {
|
||||
await PluginAPI.install(row.id);
|
||||
ElMessage.success("安装成功");
|
||||
row.installed = true;
|
||||
} catch {
|
||||
ElMessage.error("安装失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function doUninstall(row: PluginTable) {
|
||||
if (!row.id) return;
|
||||
try {
|
||||
await PluginAPI.uninstall(row.id);
|
||||
ElMessage.success("卸载成功");
|
||||
row.installed = false;
|
||||
} catch {
|
||||
ElMessage.error("卸载失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function doToggle(row: PluginTable) {
|
||||
if (!row.id) return;
|
||||
try {
|
||||
await PluginAPI.toggle(row.id);
|
||||
ElMessage.success(row.status === 0 ? "已禁用" : "已启用");
|
||||
row.status = row.status === 0 ? 1 : 0;
|
||||
} catch {
|
||||
ElMessage.error("操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 对话框 ───
|
||||
const { dialogVisible } = useCrudDialog();
|
||||
const detailFormData = ref<PluginTable>({} as PluginTable);
|
||||
|
||||
const pluginDetailItems = [
|
||||
{ label: "插件名称", prop: "name" },
|
||||
{ label: "插件编码", prop: "code" },
|
||||
{ label: "版本", prop: "version" },
|
||||
{ label: "分类", prop: "category" },
|
||||
{ label: "作者", prop: "author" },
|
||||
{ label: "状态", prop: "status", slot: "status" },
|
||||
{ label: "价格", prop: "price", slot: "price" },
|
||||
{ label: "排序", prop: "sort" },
|
||||
{ label: "菜单路径", prop: "menu_path" },
|
||||
{ label: "权限前缀", prop: "permission_prefix" },
|
||||
{ label: "依赖插件", prop: "dependencies" },
|
||||
{ label: "描述", prop: "description", span: 4 },
|
||||
];
|
||||
|
||||
const formData = ref<PluginForm>({
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
category: "tool",
|
||||
version: "1.0.0",
|
||||
status: 0,
|
||||
price: 0,
|
||||
sort: 0,
|
||||
description: undefined,
|
||||
author: undefined,
|
||||
icon: undefined,
|
||||
menu_path: undefined,
|
||||
permission_prefix: undefined,
|
||||
dependencies: undefined,
|
||||
});
|
||||
|
||||
const initialFormData: PluginForm = { ...formData.value };
|
||||
const pluginFormRenderKey = ref(0);
|
||||
const dataFormRef = ref<any>(null);
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: "请输入插件名称", trigger: "blur" }],
|
||||
code: [{ required: true, message: "请输入插件编码", trigger: "blur" }],
|
||||
category: [{ required: true, message: "请选择分类", trigger: "change" }],
|
||||
};
|
||||
|
||||
const pluginDialogFormItems: FormItem[] = [
|
||||
{
|
||||
label: "插件名称",
|
||||
key: "name",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "请输入插件名称", clearable: true },
|
||||
},
|
||||
{
|
||||
label: "插件编码",
|
||||
key: "code",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "请输入插件编码", clearable: true },
|
||||
},
|
||||
{
|
||||
label: "分类",
|
||||
key: "category",
|
||||
type: "select",
|
||||
span: 12,
|
||||
props: { options: categoryOptions.value, placeholder: "请选择分类", clearable: true },
|
||||
},
|
||||
{
|
||||
label: "版本",
|
||||
key: "version",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "版本号", clearable: true },
|
||||
},
|
||||
{
|
||||
label: "作者",
|
||||
key: "author",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "请输入作者", clearable: true },
|
||||
},
|
||||
{
|
||||
label: "图标",
|
||||
key: "icon",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "iconify 图标名", clearable: true },
|
||||
},
|
||||
{
|
||||
label: "价格(分)",
|
||||
key: "price",
|
||||
type: "input-number",
|
||||
span: 12,
|
||||
props: { min: 0, placeholder: "0=免费" },
|
||||
},
|
||||
{ label: "排序", key: "sort", type: "input-number", span: 12, props: { min: 0 } },
|
||||
{
|
||||
label: "菜单路径",
|
||||
key: "menu_path",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "安装后的菜单路径", clearable: true },
|
||||
},
|
||||
{
|
||||
label: "权限前缀",
|
||||
key: "permission_prefix",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "权限前缀", clearable: true },
|
||||
},
|
||||
{
|
||||
label: "依赖插件",
|
||||
key: "dependencies",
|
||||
type: "textarea",
|
||||
span: 24,
|
||||
props: { placeholder: "依赖插件编码(JSON数组)", rows: 2 },
|
||||
},
|
||||
{
|
||||
label: "描述",
|
||||
key: "description",
|
||||
type: "textarea",
|
||||
span: 24,
|
||||
props: { placeholder: "请输入描述", rows: 3 },
|
||||
},
|
||||
];
|
||||
|
||||
const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } =
|
||||
useCrudForm<PluginForm>({
|
||||
formData,
|
||||
initialFormData,
|
||||
dialogVisible,
|
||||
dataFormRef,
|
||||
formRenderKey: pluginFormRenderKey,
|
||||
detailApi: PluginAPI.detail as any,
|
||||
createApi: PluginAPI.create,
|
||||
updateApi: PluginAPI.update,
|
||||
titles: { create: "新增插件", update: "编辑插件", detail: "插件详情" },
|
||||
detailFormData,
|
||||
onCreateSuccess: async () => {
|
||||
await fetchData();
|
||||
},
|
||||
onUpdateSuccess: async () => {
|
||||
await fetchData();
|
||||
},
|
||||
onSubmitSuccess: async () => {
|
||||
await fetchData();
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fa-full-height {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.plugin-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.plugin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.plugin-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
box-shadow 0.3s,
|
||||
transform 0.25s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 8%);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
&.is-installed {
|
||||
border-color: var(--el-color-success-light-5);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 20px;
|
||||
border-radius: 10px;
|
||||
|
||||
&.icon-bg-default {
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color);
|
||||
}
|
||||
|
||||
&.icon-bg-warning {
|
||||
color: var(--el-color-warning);
|
||||
background: var(--el-color-warning-light-9);
|
||||
}
|
||||
|
||||
&.icon-bg-success {
|
||||
color: var(--el-color-success);
|
||||
background: var(--el-color-success-light-9);
|
||||
}
|
||||
|
||||
&.icon-bg-info {
|
||||
color: var(--el-color-info);
|
||||
background: var(--el-color-info-light-9);
|
||||
}
|
||||
}
|
||||
|
||||
.card-title-group {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.6;
|
||||
border-radius: 10px;
|
||||
|
||||
&.badge-on {
|
||||
color: var(--el-color-success);
|
||||
background: var(--el-color-success-light-9);
|
||||
}
|
||||
|
||||
&.badge-off {
|
||||
color: var(--el-color-danger);
|
||||
background: var(--el-color-danger-light-9);
|
||||
}
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
display: -webkit-box;
|
||||
min-height: calc(13px * 1.6 * 2);
|
||||
margin: 0 0 12px;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-secondary);
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.tag-version {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 12px;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.footer-author {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
padding: 2px 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-pagination {
|
||||
padding-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,673 @@
|
||||
<template>
|
||||
<div class="fa-full-height">
|
||||
<ElTabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<!-- ─── 工作台 ─── -->
|
||||
<ElTabPane label="工作台" name="workspace">
|
||||
<ElScrollbar
|
||||
v-if="workspace"
|
||||
class="workspace-scroll"
|
||||
:view-style="{ paddingRight: '4px' }"
|
||||
>
|
||||
<!-- 欢迎横幅 -->
|
||||
<FaBasicBanner
|
||||
class="workspace-banner"
|
||||
:title="`${workspace.tenant.name}(${workspace.tenant.code})`"
|
||||
:subtitle="
|
||||
workspace.package
|
||||
? `${workspace.package.name} · ${workspace.package.period === 'year' ? '年付' : '月付'} · 剩余 ${workspace.tenant.days_remaining} 天 · ${workspace.tenant.status_label}`
|
||||
: `暂无套餐 · 剩余 ${workspace.tenant.days_remaining} 天 · ${workspace.tenant.status_label}`
|
||||
"
|
||||
boxStyle="bg-theme/5!"
|
||||
titleColor="var(--fa-gray-900)"
|
||||
subtitleColor="var(--fa-gray-500)"
|
||||
:decoration="false"
|
||||
/>
|
||||
|
||||
<!-- 统计卡片行 -->
|
||||
<ElRow :gutter="16" class="workspace-section">
|
||||
<ElCol :xs="24" :sm="12" :md="6">
|
||||
<FaStatsCard
|
||||
icon="ri:user-line"
|
||||
iconStyle="bg-theme"
|
||||
boxStyle="bg-theme/10!"
|
||||
title="用户"
|
||||
:description="`上限 ${workspace.quota.max_users || '—'}`"
|
||||
:count="workspace.quota.current_users"
|
||||
textColor="var(--theme-color)"
|
||||
:showArrow="false"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="6">
|
||||
<FaStatsCard
|
||||
icon="ri:shield-user-line"
|
||||
iconStyle="bg-success"
|
||||
boxStyle="bg-success/10!"
|
||||
title="角色"
|
||||
:description="`上限 ${workspace.quota.max_roles || '—'}`"
|
||||
:count="workspace.quota.current_roles"
|
||||
textColor="var(--el-color-success)"
|
||||
:showArrow="false"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="6">
|
||||
<FaStatsCard
|
||||
icon="ri:organization-chart"
|
||||
iconStyle="bg-info"
|
||||
boxStyle="bg-info/10!"
|
||||
title="部门"
|
||||
:description="`上限 ${workspace.quota.max_depts || '—'}`"
|
||||
:count="workspace.quota.current_depts"
|
||||
textColor="var(--el-color-info)"
|
||||
:showArrow="false"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="6">
|
||||
<FaStatsCard
|
||||
icon="ri:money-cny-box-line"
|
||||
iconStyle="bg-warning"
|
||||
boxStyle="bg-warning/10!"
|
||||
title="套餐价格"
|
||||
:description="
|
||||
workspace.package
|
||||
? `${workspace.package.period === 'year' ? '年付' : '月付'}`
|
||||
: '—'
|
||||
"
|
||||
:count="workspace.package ? +(workspace.package.price / 100).toFixed(0) : 0"
|
||||
separator=","
|
||||
textColor="var(--el-color-warning)"
|
||||
:showArrow="false"
|
||||
/>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<!-- 配额使用进度 -->
|
||||
<ElRow :gutter="16" class="workspace-section">
|
||||
<ElCol :xs="24" :sm="8" :md="8">
|
||||
<FaProgressCard
|
||||
:percentage="workspace.quota.usage_percent.users"
|
||||
title="用户用量"
|
||||
color="var(--theme-color)"
|
||||
icon="ri:user-line"
|
||||
iconStyle="bg-theme/12 text-theme"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="8" :md="8">
|
||||
<FaProgressCard
|
||||
:percentage="workspace.quota.usage_percent.roles"
|
||||
title="角色用量"
|
||||
color="#67c23a"
|
||||
icon="ri:shield-user-line"
|
||||
iconStyle="bg-success/12 text-success"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="8" :md="8">
|
||||
<FaProgressCard
|
||||
:percentage="workspace.quota.usage_percent.depts"
|
||||
title="部门用量"
|
||||
color="#409EFF"
|
||||
icon="ri:organization-chart"
|
||||
iconStyle="bg-info/12 text-info"
|
||||
/>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<!-- 近期订单时间轴 -->
|
||||
<ElRow :gutter="16" class="workspace-section">
|
||||
<ElCol :span="24">
|
||||
<FaTimelineListCard
|
||||
:list="recentOrderTimeline"
|
||||
title="近期订单"
|
||||
:subtitle="`共 ${recentOrderTimeline.length} 条记录`"
|
||||
:maxCount="5"
|
||||
/>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElScrollbar>
|
||||
<div v-else-if="workspaceLoading" class="workspace-loading">
|
||||
<ElSkeleton :rows="10" animated />
|
||||
</div>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- ─── 选购套餐 ─── -->
|
||||
<ElTabPane label="选购套餐" name="packages">
|
||||
<div v-if="!packagesLoading && packages.length" class="package-grid">
|
||||
<div
|
||||
v-for="pkg in packages"
|
||||
:key="pkg.id"
|
||||
class="package-card"
|
||||
:class="{ 'package-card--current': pkg.is_current }"
|
||||
>
|
||||
<div class="package-card__header">
|
||||
<h3>{{ pkg.name }}</h3>
|
||||
<ElTag v-if="pkg.is_current" type="warning" size="small">当前套餐</ElTag>
|
||||
</div>
|
||||
<div class="package-card__price">
|
||||
<span class="price-value">¥{{ (pkg.price / 100).toFixed(2) }}</span>
|
||||
<span class="price-period">/{{ pkg.period === "year" ? "年" : "月" }}</span>
|
||||
</div>
|
||||
<div class="package-card__specs">
|
||||
<div class="spec-item">
|
||||
<span class="spec-label">最大用户数</span>
|
||||
<span class="spec-value">{{ pkg.max_users || "无限" }}</span>
|
||||
</div>
|
||||
<div class="spec-item">
|
||||
<span class="spec-label">最大角色数</span>
|
||||
<span class="spec-value">{{ pkg.max_roles || "无限" }}</span>
|
||||
</div>
|
||||
<div class="spec-item">
|
||||
<span class="spec-label">最大部门数</span>
|
||||
<span class="spec-value">{{ pkg.max_depts || "无限" }}</span>
|
||||
</div>
|
||||
<div class="spec-item" v-if="pkg.trial_days > 0">
|
||||
<span class="spec-label">试用天数</span>
|
||||
<span class="spec-value">{{ pkg.trial_days }} 天</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="package-card__actions">
|
||||
<ElButton
|
||||
v-for="action in pkg.available_actions"
|
||||
:key="action"
|
||||
:type="action === 'upgrade' ? 'primary' : 'default'"
|
||||
@click="handleAction(action, pkg)"
|
||||
>
|
||||
{{ actionLabel(action) }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-else-if="!packagesLoading" description="暂无可用套餐" />
|
||||
<ElSkeleton v-else :rows="6" animated />
|
||||
</ElTabPane>
|
||||
|
||||
<!-- ─── 我的订单 ─── -->
|
||||
<ElTabPane label="我的订单" name="orders">
|
||||
<ElCard shadow="hover" class="fa-table-card">
|
||||
<FaTableHeader
|
||||
v-model:columns="orderColumnChecks"
|
||||
:loading="orderLoading"
|
||||
@refresh="getOrderData"
|
||||
/>
|
||||
|
||||
<FaTable
|
||||
ref="orderTableRef"
|
||||
:loading="orderLoading"
|
||||
:data="orderData"
|
||||
:columns="orderColumns"
|
||||
:pagination="orderPagination"
|
||||
@pagination:size-change="handleOrderSizeChange"
|
||||
@pagination:current-change="handleOrderCurrentChange"
|
||||
/>
|
||||
</ElCard>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
|
||||
<!-- ─── 套餐操作确认弹窗 ─── -->
|
||||
<FaDialog v-model="actionDialogVisible" :title="actionDialogTitle" width="560px">
|
||||
<div v-if="preview">
|
||||
<FaDescriptions
|
||||
:column="2"
|
||||
border
|
||||
size="small"
|
||||
:data="preview"
|
||||
:items="previewDescriptionItems"
|
||||
>
|
||||
<template #target_package>
|
||||
<ElTag type="primary" size="small">{{ preview.target_package }}</ElTag>
|
||||
</template>
|
||||
<template #action>
|
||||
<ElTag :type="actionTypeTag(preview.action)" size="small">{{
|
||||
actionLabel(preview.action)
|
||||
}}</ElTag>
|
||||
</template>
|
||||
<template #amount>
|
||||
<span class="price">¥{{ (preview.amount / 100).toFixed(2) }}</span>
|
||||
/ {{ preview.period === "year" ? "年" : "月" }}
|
||||
</template>
|
||||
</FaDescriptions>
|
||||
|
||||
<div v-if="preview.gained_menus?.length" class="menu-change">
|
||||
<h4 class="menu-change__title gain">新增菜单</h4>
|
||||
<ElTag
|
||||
v-for="m in preview.gained_menus"
|
||||
:key="m.id"
|
||||
type="success"
|
||||
size="small"
|
||||
style="margin: 2px"
|
||||
>
|
||||
{{ m.name }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div v-if="preview.lost_menus?.length" class="menu-change">
|
||||
<h4 class="menu-change__title loss">失去菜单</h4>
|
||||
<ElTag
|
||||
v-for="m in preview.lost_menus"
|
||||
:key="m.id"
|
||||
type="danger"
|
||||
size="small"
|
||||
style="margin: 2px"
|
||||
>
|
||||
{{ m.name }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div v-if="preview.affected_roles?.length" style="margin-top: 12px">
|
||||
<span class="text-warning">影响角色:</span>
|
||||
<span
|
||||
v-for="r in preview.affected_roles"
|
||||
:key="r"
|
||||
style="margin-left: 4px; color: #e6a23c"
|
||||
>{{ r }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="preview.affected_users > 0" style="margin-top: 8px">
|
||||
<span class="text-warning">影响用户数:{{ preview.affected_users }} 人</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<ElButton @click="actionDialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="actionSubmitting" @click="confirmAction"
|
||||
>确认下单</ElButton
|
||||
>
|
||||
</template>
|
||||
</FaDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, onMounted } from "vue";
|
||||
import type { TabPaneName } from "element-plus";
|
||||
import { ElTag, ElButton } from "element-plus";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useTable } from "@/hooks/core/useTable";
|
||||
import FaStatsCard from "@/components/cards/fa-stats-card/index.vue";
|
||||
import FaProgressCard from "@/components/cards/fa-progress-card/index.vue";
|
||||
import FaTimelineListCard from "@/components/cards/fa-timeline-list-card/index.vue";
|
||||
import FaBasicBanner from "@/components/banners/fa-basic-banner/index.vue";
|
||||
import SelfServiceAPI from "@/api/module_platform/self_service";
|
||||
import type {
|
||||
AvailablePackage,
|
||||
PackageChangePreview,
|
||||
SelfServiceOrderItem,
|
||||
WorkspaceData,
|
||||
} from "@/api/module_platform/self_service";
|
||||
import type { ColumnOption } from "@/types/component";
|
||||
import type { DescriptionsItem } from "@/components/others/fa-descriptions/index.vue";
|
||||
|
||||
defineOptions({ name: "SelfService" });
|
||||
|
||||
const router = useRouter();
|
||||
const activeTab = ref("workspace");
|
||||
|
||||
// ─── 工作台 ───
|
||||
const workspace = ref<WorkspaceData | null>(null);
|
||||
const workspaceLoading = ref(false);
|
||||
|
||||
const recentOrderTimeline = computed(() => {
|
||||
if (!workspace.value?.recent_orders?.length) return [];
|
||||
return workspace.value.recent_orders.map((o) => {
|
||||
const statusMap: Record<number, { label: string; color: string }> = {
|
||||
0: { label: "待支付", color: "rgb(230, 162, 60)" },
|
||||
1: { label: "已支付", color: "rgb(103, 194, 58)" },
|
||||
2: { label: "已取消", color: "rgb(144, 147, 153)" },
|
||||
3: { label: "已退款", color: "rgb(245, 108, 108)" },
|
||||
};
|
||||
const s = statusMap[o.status] || { label: "未知", color: "rgb(144, 147, 153)" };
|
||||
return {
|
||||
time: o.created_at || "—",
|
||||
status: s.color,
|
||||
content: `${o.order_no} - ¥${((o.amount || 0) / 100).toFixed(2)}`,
|
||||
code: s.label,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
async function loadWorkspace() {
|
||||
workspaceLoading.value = true;
|
||||
try {
|
||||
const { data: res } = await SelfServiceAPI.getWorkspace();
|
||||
workspace.value = (res?.data as WorkspaceData) || null;
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
workspaceLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 套餐 ───
|
||||
const packages = ref<AvailablePackage[]>([]);
|
||||
const packagesLoading = ref(false);
|
||||
|
||||
// ─── 订单表格 ───
|
||||
const {
|
||||
columns: orderColumns,
|
||||
columnChecks: orderColumnChecks,
|
||||
data: orderData,
|
||||
loading: orderLoading,
|
||||
pagination: orderPagination,
|
||||
getData: getOrderData,
|
||||
handleSizeChange: handleOrderSizeChange,
|
||||
handleCurrentChange: handleOrderCurrentChange,
|
||||
} = useTable({
|
||||
core: {
|
||||
apiFn: SelfServiceAPI.listMyOrders,
|
||||
apiParams: {
|
||||
page_no: 1,
|
||||
page_size: 20,
|
||||
},
|
||||
columnsFactory: (): ColumnOption<SelfServiceOrderItem>[] => [
|
||||
{ prop: "order_no", label: "订单号", minWidth: 200, showOverflowTooltip: true },
|
||||
{ prop: "package_name", label: "套餐", width: 120 },
|
||||
{
|
||||
prop: "order_type",
|
||||
label: "类型",
|
||||
width: 100,
|
||||
formatter: (row: SelfServiceOrderItem) =>
|
||||
h(ElTag, { size: "small" as const }, () => orderTypeLabel(row.order_type)),
|
||||
},
|
||||
{
|
||||
prop: "amount",
|
||||
label: "金额",
|
||||
width: 120,
|
||||
formatter: (row: SelfServiceOrderItem) => `¥${(row.amount / 100).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
prop: "status",
|
||||
label: "状态",
|
||||
width: 100,
|
||||
formatter: (row: SelfServiceOrderItem) => {
|
||||
const typeMap = ["warning", "success", "info", "danger"] as const;
|
||||
const t = typeMap[row.status] || "info";
|
||||
return h(
|
||||
ElTag,
|
||||
{ type: t as "warning" | "success" | "info" | "danger", size: "small" as const },
|
||||
() => orderStatusLabel(row.status)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: "pay_method",
|
||||
label: "支付方式",
|
||||
width: 100,
|
||||
formatter: (row: SelfServiceOrderItem) => row.pay_method || "—",
|
||||
},
|
||||
{ prop: "pay_time", label: "支付时间", width: 160, showOverflowTooltip: true },
|
||||
{ prop: "created_at", label: "创建时间", width: 160, showOverflowTooltip: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// ─── 操作弹窗 ───
|
||||
const actionDialogVisible = ref(false);
|
||||
const actionSubmitting = ref(false);
|
||||
const actionLoading = ref(false);
|
||||
const preview = ref<PackageChangePreview | null>(null);
|
||||
const currentAction = ref("");
|
||||
const currentPackage = ref<AvailablePackage | null>(null);
|
||||
const actionDialogTitle = ref("确认操作");
|
||||
|
||||
const previewDescriptionItems: DescriptionsItem[] = [
|
||||
{ label: "当前套餐", prop: "current_package" },
|
||||
{ label: "目标套餐", prop: "target_package", slot: "target_package" },
|
||||
{ label: "操作类型", prop: "action", slot: "action" },
|
||||
{ label: "应付金额", prop: "amount", slot: "amount" },
|
||||
];
|
||||
|
||||
// ─── 标签函数 ───
|
||||
function actionLabel(action: string): string {
|
||||
const map: Record<string, string> = {
|
||||
buy: "购买",
|
||||
renew: "续费",
|
||||
upgrade: "升级",
|
||||
downgrade: "降级",
|
||||
};
|
||||
return map[action] || action;
|
||||
}
|
||||
|
||||
function actionTypeTag(
|
||||
action: string
|
||||
): "primary" | "success" | "info" | "warning" | "danger" | undefined {
|
||||
const map: Record<string, "primary" | "success" | "info" | "warning" | "danger" | undefined> = {
|
||||
buy: "success",
|
||||
renew: undefined,
|
||||
upgrade: "primary",
|
||||
downgrade: "warning",
|
||||
};
|
||||
return map[action];
|
||||
}
|
||||
|
||||
function orderTypeLabel(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
buy: "新购",
|
||||
renew: "续费",
|
||||
upgrade: "升级",
|
||||
downgrade: "降级",
|
||||
};
|
||||
return map[type] || type;
|
||||
}
|
||||
|
||||
function orderStatusLabel(status: number): string {
|
||||
const map: Record<number, string> = { 0: "待支付", 1: "已支付", 2: "已取消", 3: "已退款" };
|
||||
return map[status] || String(status);
|
||||
}
|
||||
|
||||
// ─── 加载 ───
|
||||
async function loadPackages() {
|
||||
packagesLoading.value = true;
|
||||
try {
|
||||
const { data: res } = await SelfServiceAPI.getAvailablePackages();
|
||||
const payload = res?.data as unknown as { packages?: AvailablePackage[] } | undefined;
|
||||
packages.value = payload?.packages || [];
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
packagesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange(tab: TabPaneName) {
|
||||
if (tab === "workspace") loadWorkspace();
|
||||
else if (tab === "packages") loadPackages();
|
||||
else if (tab === "orders") getOrderData();
|
||||
}
|
||||
|
||||
// ─── 动作处理 ───
|
||||
async function handleAction(action: string, pkg: AvailablePackage) {
|
||||
currentAction.value = action;
|
||||
currentPackage.value = pkg;
|
||||
actionDialogTitle.value = `${actionLabel(action)} - ${pkg.name}`;
|
||||
actionLoading.value = true;
|
||||
preview.value = null;
|
||||
actionDialogVisible.value = true;
|
||||
|
||||
try {
|
||||
const { data: res } = await SelfServiceAPI.previewPackageChange(pkg.id);
|
||||
preview.value = (res?.data as PackageChangePreview) || null;
|
||||
} catch {
|
||||
actionDialogVisible.value = false;
|
||||
} finally {
|
||||
actionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAction() {
|
||||
if (!currentPackage.value) return;
|
||||
actionSubmitting.value = true;
|
||||
try {
|
||||
const { data: res } = await SelfServiceAPI.createOrder({
|
||||
package_id: currentPackage.value.id,
|
||||
order_type: currentAction.value as "buy" | "renew" | "upgrade" | "downgrade",
|
||||
});
|
||||
const orderData = res?.data as { id: number; order_no: string; amount?: number } | undefined;
|
||||
actionDialogVisible.value = false;
|
||||
if (orderData?.id && (orderData?.amount || 0) > 0) {
|
||||
router.push(`/payment/${orderData.id}`);
|
||||
} else {
|
||||
activeTab.value = "orders";
|
||||
getOrderData();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
actionSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadWorkspace();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fa-full-height {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
:deep(.el-tabs) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
.el-tabs__content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.el-tab-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 工作台 ─── */
|
||||
.workspace-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.workspace-banner {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.workspace-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.workspace-loading {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* ─── 套餐卡片 ─── */
|
||||
.package-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.package-card {
|
||||
padding: 24px;
|
||||
background: var(--default-box-color);
|
||||
border: 1px solid var(--fa-card-border);
|
||||
border-radius: calc(var(--custom-radius) + 4px);
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
|
||||
}
|
||||
|
||||
&--current {
|
||||
background: #fdf6ec;
|
||||
border-color: #e6a23c;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__price {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
.price-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.price-period {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
&__specs {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.spec-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
font-size: 14px;
|
||||
|
||||
.spec-label {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.spec-value {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 弹窗 ─── */
|
||||
.menu-change {
|
||||
margin-top: 12px;
|
||||
|
||||
&__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
|
||||
&.gain {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
&.loss {
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
font-size: 13px;
|
||||
color: #e6a23c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,948 @@
|
||||
<!-- 租户管理:Fa 布局 + useTable + renderTableOperationCell -->
|
||||
<template>
|
||||
<div class="fa-full-height">
|
||||
<FaSearchBar
|
||||
v-show="showSearchBar"
|
||||
ref="searchBarRef"
|
||||
v-model="searchForm"
|
||||
:items="tenantSearchItems"
|
||||
:rules="searchBarRules"
|
||||
:is-expand="false"
|
||||
:show-expand="true"
|
||||
:show-reset="true"
|
||||
:show-search="true"
|
||||
:disabled-search="false"
|
||||
:default-expanded="false"
|
||||
@search="handleSearchBarSearch"
|
||||
@reset="onResetSearch"
|
||||
/>
|
||||
|
||||
<ElCard
|
||||
shadow="hover"
|
||||
class="fa-table-card"
|
||||
:style="{ 'margin-top': showSearchBar ? '12px' : '0' }"
|
||||
>
|
||||
<FaTableHeader
|
||||
v-model:columns="columnChecks"
|
||||
v-model:showSearchBar="showSearchBar"
|
||||
:loading="loading"
|
||||
@refresh="refreshData"
|
||||
>
|
||||
<template #left>
|
||||
<FaTableHeaderLeft
|
||||
:remove-ids="selectedIds"
|
||||
:perm-create="['module_system:tenant:create']"
|
||||
:perm-delete="['module_system:tenant:delete']"
|
||||
:delete-loading="batchDeleting"
|
||||
@add="handleOpenDialog('create')"
|
||||
@delete="handleBatchDelete"
|
||||
/>
|
||||
</template>
|
||||
</FaTableHeader>
|
||||
|
||||
<FaTable
|
||||
ref="faTableRef"
|
||||
:loading="loading"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:pagination="pagination"
|
||||
@selection-change="onTableSelectionChange"
|
||||
@pagination:size-change="handleSizeChange"
|
||||
@pagination:current-change="handleCurrentChange"
|
||||
/>
|
||||
</ElCard>
|
||||
|
||||
<FaDialog
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
width="1000px"
|
||||
dialog-class="crud-embed-dialog"
|
||||
modal-class="crud-embed-dialog"
|
||||
:form-mode="dialogVisible.type"
|
||||
:confirm-loading="submitLoading"
|
||||
@cancel="handleCloseDialog"
|
||||
@confirm="dialogVisible.type === 'detail' ? handleCloseDialog() : handleSubmit()"
|
||||
>
|
||||
<template v-if="dialogVisible.type === 'detail'">
|
||||
<FaDescriptions
|
||||
:column="4"
|
||||
:label-width="'100px'"
|
||||
:data="detailFormData"
|
||||
:items="tenantDetailItems"
|
||||
max-height="75vh"
|
||||
>
|
||||
<template #logo_url="{ value }">
|
||||
<img v-if="value" :src="String(value)" class="detail-image" alt="网站 Logo" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template #favicon="{ value }">
|
||||
<img
|
||||
v-if="value"
|
||||
:src="String(value)"
|
||||
class="detail-image detail-favicon"
|
||||
alt="网站图标"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template #login_bg="{ value }">
|
||||
<img v-if="value" :src="String(value)" class="detail-image" alt="登录背景图" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</FaDescriptions>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ElTabs v-model="activeTab" type="border-card" tabPosition="left">
|
||||
<ElTabPane label="基础信息" name="basic">
|
||||
<FaForm
|
||||
:key="tenantFormRenderKey"
|
||||
scrollbar
|
||||
max-height="65vh"
|
||||
ref="dataFormRef"
|
||||
v-model="formData"
|
||||
:items="basicFormItems"
|
||||
:rules="rules"
|
||||
label-suffix=":"
|
||||
:label-width="100"
|
||||
label-position="right"
|
||||
:span="12"
|
||||
:gutter="16"
|
||||
:show-reset="false"
|
||||
:show-submit="false"
|
||||
class="crud-dialog-art-form"
|
||||
/>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="网站信息" name="website">
|
||||
<FaForm
|
||||
v-model="formData"
|
||||
:items="websiteFormItems"
|
||||
:label-width="100"
|
||||
label-position="right"
|
||||
:span="12"
|
||||
:gutter="16"
|
||||
:show-reset="false"
|
||||
:show-submit="false"
|
||||
/>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="品牌标识" name="brand">
|
||||
<ElForm :model="formData" label-position="top">
|
||||
<ElRow :gutter="24">
|
||||
<ElCol :span="8">
|
||||
<ElFormItem label="网站 Logo">
|
||||
<FaUpload
|
||||
v-model="formData.logo_url"
|
||||
:data="{ type: 'tenant_logo' }"
|
||||
name="file"
|
||||
:max-file-size="5"
|
||||
:show-tip="true"
|
||||
:enable-preview="true"
|
||||
:enable-crop="true"
|
||||
v-bind="brandCropBind('tenant_logo')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="8">
|
||||
<ElFormItem label="网站图标">
|
||||
<FaUpload
|
||||
v-model="formData.favicon"
|
||||
:data="{ type: 'tenant_favicon' }"
|
||||
name="file"
|
||||
:max-file-size="5"
|
||||
:show-tip="true"
|
||||
:enable-preview="true"
|
||||
:enable-crop="true"
|
||||
v-bind="brandCropBind('tenant_favicon')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="8">
|
||||
<ElFormItem label="登录背景图">
|
||||
<FaUpload
|
||||
v-model="formData.login_bg"
|
||||
:data="{ type: 'tenant_login_bg' }"
|
||||
name="file"
|
||||
:max-file-size="10"
|
||||
:show-tip="true"
|
||||
:enable-preview="true"
|
||||
:enable-crop="true"
|
||||
v-bind="brandCropBind('tenant_login_bg')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="安全隐私" name="security">
|
||||
<FaForm
|
||||
v-model="formData"
|
||||
:items="securityFormItems"
|
||||
:label-width="100"
|
||||
label-position="right"
|
||||
:span="12"
|
||||
:gutter="16"
|
||||
:show-reset="false"
|
||||
:show-submit="false"
|
||||
/>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="用户协议" name="agreement">
|
||||
<FaForm
|
||||
v-model="formData"
|
||||
:items="agreementFormItems"
|
||||
:label-width="100"
|
||||
label-position="right"
|
||||
:span="24"
|
||||
:gutter="16"
|
||||
:show-reset="false"
|
||||
:show-submit="false"
|
||||
/>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</template>
|
||||
</FaDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTable } from "@/hooks/core/useTable";
|
||||
import { useCrudDialog } from "@/hooks/core/useCrudDialog";
|
||||
import { useTableSelection } from "@/hooks/core/useTableSelection";
|
||||
import { confirmDelete, confirmBatchDelete } from "@/hooks/core/useConfirm";
|
||||
import type { ColumnOption } from "@/types/component";
|
||||
import TenantAPI, {
|
||||
type TenantCreateForm,
|
||||
type TenantForm,
|
||||
type TenantTable,
|
||||
type TenantUpdateForm,
|
||||
} from "@/api/module_platform/tenant";
|
||||
import { useAuth } from "@/hooks/core/useAuth";
|
||||
import { renderTableOperationCell, type TableOperationAction } from "@utils";
|
||||
import FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
|
||||
import FaForm from "@/components/forms/fa-form/index.vue";
|
||||
import FaUpload from "@/components/others/fa-upload/index.vue";
|
||||
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type { FormItem } from "@/components/forms/fa-form/index.vue";
|
||||
import {
|
||||
ElTag,
|
||||
ElMessage,
|
||||
ElTabs,
|
||||
ElTabPane,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElRow,
|
||||
ElCol,
|
||||
} from "element-plus";
|
||||
import { h, ref } from "vue";
|
||||
|
||||
defineOptions({
|
||||
name: "Tenant",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const { hasAuth } = useAuth();
|
||||
|
||||
type TenantSearchForm = {
|
||||
name?: string;
|
||||
code?: string;
|
||||
status?: string;
|
||||
created_time?: string[];
|
||||
};
|
||||
|
||||
function buildTenantReplaceParams(p: TenantSearchForm): Record<string, unknown> {
|
||||
return {
|
||||
name: p.name,
|
||||
code: p.code,
|
||||
status: p.status,
|
||||
created_time:
|
||||
Array.isArray(p.created_time) && p.created_time.length === 2 ? p.created_time : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTenantRowActions(
|
||||
row: TenantTable,
|
||||
ctx: {
|
||||
onDetail: (id: number) => void;
|
||||
onEdit: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onToggleStatus: (id: number) => void;
|
||||
}
|
||||
): TableOperationAction[] {
|
||||
const all: TableOperationAction[] = [
|
||||
{
|
||||
key: "detail",
|
||||
label: "详情",
|
||||
artType: "view",
|
||||
perm: "module_system:tenant:query",
|
||||
run: () => ctx.onDetail(row.id!),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "编辑",
|
||||
artType: "edit",
|
||||
icon: "ri:edit-2-line",
|
||||
perm: "module_system:tenant:update",
|
||||
run: () => ctx.onEdit(row.id!),
|
||||
},
|
||||
{
|
||||
key: "toggle",
|
||||
label: row.status === 0 ? "禁用" : "启用",
|
||||
artType: "edit",
|
||||
icon: row.status === 0 ? "ri:forbid-2-line" : "ri:checkbox-circle-line",
|
||||
perm: "module_system:tenant:patch",
|
||||
run: () => ctx.onToggleStatus(row.id!),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
artType: "delete",
|
||||
icon: "ri:delete-bin-4-line",
|
||||
perm: "module_system:tenant:delete",
|
||||
run: () => ctx.onDelete(row.id!),
|
||||
},
|
||||
];
|
||||
return all.filter((a) => a.perm != null && hasAuth(a.perm));
|
||||
}
|
||||
|
||||
const searchForm = ref<TenantSearchForm>({
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
status: undefined,
|
||||
created_time: undefined,
|
||||
});
|
||||
|
||||
const showSearchBar = ref(true);
|
||||
const searchBarRef = ref<InstanceType<typeof FaSearchBar> | null>(null);
|
||||
const searchBarRules: Record<string, unknown> = {};
|
||||
|
||||
const statusOptions = ref([
|
||||
{ label: "正常", value: 0 },
|
||||
{ label: "禁用", value: 1 },
|
||||
]);
|
||||
|
||||
const tenantSearchItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: "租户名称",
|
||||
key: "name",
|
||||
type: "input",
|
||||
placeholder: "请输入租户名称",
|
||||
clearable: true,
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "租户编码",
|
||||
key: "code",
|
||||
type: "input",
|
||||
placeholder: "请输入租户编码",
|
||||
clearable: true,
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
key: "status",
|
||||
type: "select",
|
||||
props: {
|
||||
placeholder: "请选择状态",
|
||||
options: statusOptions.value,
|
||||
clearable: true,
|
||||
},
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "创建时间",
|
||||
key: "created_time",
|
||||
type: "datetimerange",
|
||||
span: 6,
|
||||
props: {
|
||||
type: "datetimerange",
|
||||
rangeSeparator: "至",
|
||||
startPlaceholder: "开始日期",
|
||||
endPlaceholder: "结束日期",
|
||||
format: "YYYY-MM-DD HH:mm:ss",
|
||||
valueFormat: "YYYY-MM-DD HH:mm:ss",
|
||||
style: { width: "100%" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
|
||||
|
||||
const { selectedIds, batchDeleting, onTableSelectionChange } = useTableSelection<TenantTable>();
|
||||
|
||||
async function deleteTenantRow(id: number) {
|
||||
try {
|
||||
await confirmDelete();
|
||||
await TenantAPI.deleteTenant([id]);
|
||||
faTableRef.value?.elTableRef?.clearSelection();
|
||||
await refreshRemove();
|
||||
} catch {
|
||||
ElMessage.info("删除取消");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTenantStatus(id: number) {
|
||||
try {
|
||||
await TenantAPI.toggleTenantStatus(id);
|
||||
// 直接更新当前行的 status,确保 UI 即时响应
|
||||
const row = (data.value as TenantTable[]).find((item) => item.id === id);
|
||||
if (row) {
|
||||
row.status = row.status === 0 ? 1 : 0;
|
||||
}
|
||||
await refreshData();
|
||||
} catch {
|
||||
ElMessage.error("状态切换失败");
|
||||
}
|
||||
}
|
||||
|
||||
const opCtx = {
|
||||
onDetail: (id: number) => void handleOpenDialog("detail", id),
|
||||
onEdit: (id: number) => void handleOpenDialog("update", id),
|
||||
onDelete: deleteTenantRow,
|
||||
onToggleStatus: toggleTenantStatus,
|
||||
};
|
||||
|
||||
const {
|
||||
columns,
|
||||
columnChecks,
|
||||
data,
|
||||
loading,
|
||||
pagination,
|
||||
getData,
|
||||
replaceSearchParams,
|
||||
resetSearchParams,
|
||||
handleSizeChange,
|
||||
handleCurrentChange,
|
||||
refreshData,
|
||||
refreshCreate,
|
||||
refreshUpdate,
|
||||
refreshRemove,
|
||||
} = useTable({
|
||||
core: {
|
||||
apiFn: TenantAPI.listTenant,
|
||||
apiParams: {
|
||||
page_no: 1,
|
||||
page_size: 10,
|
||||
},
|
||||
columnsFactory: (): ColumnOption<TenantTable>[] => [
|
||||
{ type: "selection", width: 48, fixed: "left" },
|
||||
{ type: "globalIndex", width: 56, label: "序号" },
|
||||
{ prop: "name", label: "租户名称", minWidth: 120, showOverflowTooltip: true },
|
||||
{ prop: "code", label: "租户编码", minWidth: 80, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "status",
|
||||
label: "状态",
|
||||
width: 80,
|
||||
formatter: (row: TenantTable) =>
|
||||
h(ElTag, { type: row.status === 0 ? "success" : "danger" }, () =>
|
||||
row.status === 0 ? "正常" : "禁用"
|
||||
),
|
||||
},
|
||||
{ prop: "contact_name", label: "联系人", minWidth: 100, showOverflowTooltip: true },
|
||||
{ prop: "contact_phone", label: "联系电话", width: 110, showOverflowTooltip: true },
|
||||
{ prop: "start_time", label: "开始时间", width: 110, showOverflowTooltip: true },
|
||||
{ prop: "end_time", label: "结束时间", width: 110, showOverflowTooltip: true },
|
||||
{ prop: "created_time", label: "创建时间", width: 140, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "operation",
|
||||
label: "操作",
|
||||
width: 200,
|
||||
fixed: "right",
|
||||
align: "right",
|
||||
formatter: (row: TenantTable) =>
|
||||
renderTableOperationCell(buildTenantRowActions(row, opCtx)),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const detailFormData = ref<TenantTable>({ code: "", name: "", status: 0 });
|
||||
|
||||
const tenantDetailItems: import("@/components/others/fa-descriptions/index.vue").DescriptionsItem[] =
|
||||
[
|
||||
{ label: "租户名称", prop: "name" },
|
||||
{ label: "租户编码", prop: "code" },
|
||||
{
|
||||
label: "状态",
|
||||
prop: "status",
|
||||
tag: {
|
||||
map: { 0: { type: "success", text: "正常" }, 1: { type: "danger", text: "禁用" } },
|
||||
},
|
||||
},
|
||||
{ label: "关联套餐ID", prop: "package_id" },
|
||||
{ label: "排序", prop: "sort" },
|
||||
{ label: "版本号", prop: "version" },
|
||||
{ label: "联系人", prop: "contact_name" },
|
||||
{ label: "联系电话", prop: "contact_phone" },
|
||||
{ label: "联系邮箱", prop: "contact_email" },
|
||||
{ label: "域名", prop: "domain" },
|
||||
{ label: "地址", prop: "address" },
|
||||
{ label: "Logo地址", prop: "logo_url", slot: "logo_url" },
|
||||
{ label: "网站图标", prop: "favicon", slot: "favicon" },
|
||||
{ label: "登录背景", prop: "login_bg", slot: "login_bg" },
|
||||
{ label: "帮助文档", prop: "help_doc" },
|
||||
{ label: "源码地址", prop: "git_code" },
|
||||
{ label: "备案号", prop: "keep_record" },
|
||||
{ label: "版权信息", prop: "copyright" },
|
||||
{ label: "隐私政策", prop: "privacy" },
|
||||
{ label: "服务条款", prop: "clause" },
|
||||
{ label: "开始时间", prop: "start_time" },
|
||||
{ label: "结束时间", prop: "end_time" },
|
||||
{ label: "创建时间", prop: "created_time" },
|
||||
{ label: "描述", prop: "description", span: 4 },
|
||||
];
|
||||
|
||||
const formData = ref<TenantForm>({
|
||||
name: "",
|
||||
code: "",
|
||||
status: 0,
|
||||
description: "",
|
||||
package_id: undefined,
|
||||
start_time: undefined,
|
||||
end_time: undefined,
|
||||
contact_name: "",
|
||||
contact_phone: "",
|
||||
contact_email: "",
|
||||
address: "",
|
||||
domain: "",
|
||||
logo_url: "",
|
||||
sort: 0,
|
||||
version: "",
|
||||
favicon: "",
|
||||
login_bg: "",
|
||||
copyright: "",
|
||||
keep_record: "",
|
||||
help_doc: "",
|
||||
privacy: "",
|
||||
clause: "",
|
||||
git_code: "",
|
||||
});
|
||||
|
||||
const { dialogVisible } = useCrudDialog();
|
||||
|
||||
const CODE_PATTERN = /^[A-Za-z0-9]+$/;
|
||||
|
||||
const validateTimeRange = (_rule: unknown, _value: unknown, callback: (e?: Error) => void) => {
|
||||
if (
|
||||
formData.value.start_time &&
|
||||
formData.value.end_time &&
|
||||
formData.value.start_time > formData.value.end_time
|
||||
) {
|
||||
callback(new Error("结束时间不能早于开始时间"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const rules = reactive({
|
||||
name: [{ required: true, message: "请输入租户名称", trigger: "blur" }],
|
||||
code: [
|
||||
{ required: true, message: "请输入租户编码", trigger: "blur" },
|
||||
{
|
||||
pattern: CODE_PATTERN,
|
||||
message: "编码仅允许字母与数字",
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
end_time: [{ validator: validateTimeRange, trigger: "change" }],
|
||||
});
|
||||
|
||||
const initialFormData: TenantForm = {
|
||||
name: "",
|
||||
code: "",
|
||||
status: 0,
|
||||
description: "",
|
||||
package_id: undefined,
|
||||
start_time: undefined,
|
||||
end_time: undefined,
|
||||
contact_name: "",
|
||||
contact_phone: "",
|
||||
contact_email: "",
|
||||
address: "",
|
||||
domain: "",
|
||||
logo_url: "",
|
||||
sort: 0,
|
||||
version: "",
|
||||
favicon: "",
|
||||
login_bg: "",
|
||||
copyright: "",
|
||||
keep_record: "",
|
||||
help_doc: "",
|
||||
privacy: "",
|
||||
clause: "",
|
||||
git_code: "",
|
||||
};
|
||||
|
||||
const dataFormRef = ref<InstanceType<typeof FaForm> | null>(null);
|
||||
const submitLoading = ref(false);
|
||||
const tenantFormRenderKey = ref(0);
|
||||
|
||||
function brandCropBind(key: string) {
|
||||
switch (key) {
|
||||
case "tenant_favicon":
|
||||
return {
|
||||
cropCutWidth: 64,
|
||||
cropCutHeight: 64,
|
||||
cropBoxWidth: 380,
|
||||
cropBoxHeight: 320,
|
||||
cropDialogTitle: "裁剪网站图标",
|
||||
cropInnerTitle: "调整图标",
|
||||
cropPreviewTitle: "预览",
|
||||
};
|
||||
case "tenant_logo":
|
||||
return {
|
||||
cropCutWidth: 320,
|
||||
cropCutHeight: 96,
|
||||
cropBoxWidth: 520,
|
||||
cropBoxHeight: 360,
|
||||
cropDialogTitle: "裁剪站点 Logo",
|
||||
cropInnerTitle: "调整 Logo",
|
||||
cropPreviewTitle: "预览",
|
||||
};
|
||||
case "tenant_login_bg":
|
||||
return {
|
||||
cropCutWidth: 960,
|
||||
cropCutHeight: 540,
|
||||
cropBoxWidth: 560,
|
||||
cropBoxHeight: 380,
|
||||
cropDialogTitle: "裁剪登录背景",
|
||||
cropInnerTitle: "调整背景图",
|
||||
cropPreviewTitle: "预览",
|
||||
cropFileType: "jpeg" as const,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenDialog(type: "create" | "update" | "detail", id?: number) {
|
||||
dialogVisible.type = type;
|
||||
if (id) {
|
||||
const detailRes = await TenantAPI.detailTenant(id);
|
||||
const detailData = detailRes.data?.data ?? detailRes.data;
|
||||
if (type === "detail") {
|
||||
dialogVisible.title = "租户详情";
|
||||
Object.assign(detailFormData.value, detailData);
|
||||
} else if (type === "update") {
|
||||
dialogVisible.title = "修改租户";
|
||||
formData.value = { ...initialFormData, ...detailData };
|
||||
}
|
||||
} else {
|
||||
dialogVisible.title = "新增租户";
|
||||
formData.value = { ...initialFormData };
|
||||
formData.value.id = undefined;
|
||||
}
|
||||
tenantFormRenderKey.value += 1;
|
||||
dialogVisible.visible = true;
|
||||
}
|
||||
|
||||
async function handleCloseDialog() {
|
||||
dialogVisible.visible = false;
|
||||
dataFormRef.value?.resetFields();
|
||||
dataFormRef.value?.clearValidate();
|
||||
formData.value = { ...initialFormData };
|
||||
}
|
||||
|
||||
const activeTab = ref("basic");
|
||||
|
||||
const basicFormItems: FormItem[] = [
|
||||
{
|
||||
label: "租户名称",
|
||||
key: "name",
|
||||
type: "input",
|
||||
props: { placeholder: "请输入租户名称", maxlength: 100 },
|
||||
},
|
||||
{
|
||||
label: "租户编码",
|
||||
key: "code",
|
||||
type: "input",
|
||||
props: {
|
||||
placeholder: "字母与数字,创建后不可改",
|
||||
maxlength: 100,
|
||||
disabled: dialogVisible.type === "update",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
key: "status",
|
||||
type: "select",
|
||||
props: {
|
||||
placeholder: "请选择状态",
|
||||
style: { width: "100%" },
|
||||
options: [
|
||||
{ label: "正常", value: 0 },
|
||||
{ label: "禁用", value: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "关联套餐ID",
|
||||
key: "package_id",
|
||||
type: "number",
|
||||
props: { placeholder: "选填", min: 1, style: { width: "100%" } },
|
||||
},
|
||||
{
|
||||
label: "排序",
|
||||
key: "sort",
|
||||
type: "number",
|
||||
props: { placeholder: "请输入排序值", min: 0, style: { width: "100%" } },
|
||||
},
|
||||
{
|
||||
label: "联系人",
|
||||
key: "contact_name",
|
||||
type: "input",
|
||||
props: { placeholder: "请输入联系人姓名", maxlength: 64 },
|
||||
},
|
||||
{
|
||||
label: "联系电话",
|
||||
key: "contact_phone",
|
||||
type: "input",
|
||||
props: { placeholder: "请输入联系电话", maxlength: 20 },
|
||||
},
|
||||
{
|
||||
label: "联系邮箱",
|
||||
key: "contact_email",
|
||||
type: "input",
|
||||
props: { placeholder: "请输入联系邮箱", maxlength: 128 },
|
||||
},
|
||||
{
|
||||
label: "域名",
|
||||
key: "domain",
|
||||
type: "input",
|
||||
slots: {
|
||||
prepend: () => h("span", "https://"),
|
||||
append: () => h("span", ".com"),
|
||||
},
|
||||
props: { placeholder: "请输入域名", maxlength: 255 },
|
||||
},
|
||||
{
|
||||
label: "地址",
|
||||
key: "address",
|
||||
type: "input",
|
||||
props: { placeholder: "请输入地址", maxlength: 255 },
|
||||
},
|
||||
{
|
||||
label: "开始时间",
|
||||
key: "start_time",
|
||||
type: "datetime",
|
||||
props: {
|
||||
style: { width: "100%" },
|
||||
placeholder: "可选",
|
||||
type: "datetime",
|
||||
valueFormat: "YYYY-MM-DD HH:mm:ss",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "结束时间",
|
||||
key: "end_time",
|
||||
type: "datetime",
|
||||
props: {
|
||||
style: { width: "100%" },
|
||||
placeholder: "可选",
|
||||
type: "datetime",
|
||||
valueFormat: "YYYY-MM-DD HH:mm:ss",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const websiteFormItems: FormItem[] = [
|
||||
{
|
||||
label: "版本号",
|
||||
key: "version",
|
||||
type: "input",
|
||||
props: { placeholder: "如 v1.0.0", maxlength: 20 },
|
||||
},
|
||||
{
|
||||
label: "描述",
|
||||
key: "description",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: {
|
||||
type: "textarea",
|
||||
rows: 3,
|
||||
maxlength: 255,
|
||||
placeholder: "请输入描述",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const securityFormItems: FormItem[] = [
|
||||
{
|
||||
label: "帮助文档",
|
||||
key: "help_doc",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: { placeholder: "帮助文档链接", maxlength: 500 },
|
||||
},
|
||||
{
|
||||
label: "源码地址",
|
||||
key: "git_code",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: { placeholder: "Git 仓库地址", maxlength: 500 },
|
||||
},
|
||||
{
|
||||
label: "备案号",
|
||||
key: "keep_record",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: { placeholder: "ICP 备案号", maxlength: 100 },
|
||||
},
|
||||
{
|
||||
label: "版权信息",
|
||||
key: "copyright",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: { placeholder: "如 © 2024 Company", maxlength: 255 },
|
||||
},
|
||||
{
|
||||
label: "隐私政策",
|
||||
key: "privacy",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: { placeholder: "隐私政策链接", maxlength: 500 },
|
||||
},
|
||||
];
|
||||
|
||||
const agreementFormItems: FormItem[] = [
|
||||
{
|
||||
label: "服务条款",
|
||||
key: "clause",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: {
|
||||
type: "textarea",
|
||||
rows: 5,
|
||||
maxlength: 500,
|
||||
placeholder: "服务条款链接",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function handleSearchBarSearch(params: TenantSearchForm) {
|
||||
await searchBarRef.value?.validate?.();
|
||||
replaceSearchParams(buildTenantReplaceParams(params));
|
||||
getData();
|
||||
}
|
||||
|
||||
function onResetSearch() {
|
||||
searchForm.value = {
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
status: undefined,
|
||||
created_time: undefined,
|
||||
};
|
||||
void resetSearchParams();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const formRef = dataFormRef.value;
|
||||
if (!formRef) return;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
const valid = await formRef.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
submitLoading.value = true;
|
||||
const id = formData.value.id as number | undefined;
|
||||
try {
|
||||
if (id) {
|
||||
const payload: TenantUpdateForm = {
|
||||
name: formData.value.name,
|
||||
code: formData.value.code,
|
||||
status: formData.value.status,
|
||||
description: formData.value.description,
|
||||
package_id: formData.value.package_id,
|
||||
start_time: formData.value.start_time,
|
||||
end_time: formData.value.end_time,
|
||||
contact_name: formData.value.contact_name,
|
||||
contact_phone: formData.value.contact_phone,
|
||||
contact_email: formData.value.contact_email,
|
||||
address: formData.value.address,
|
||||
domain: formData.value.domain,
|
||||
logo_url: formData.value.logo_url,
|
||||
favicon: formData.value.favicon,
|
||||
login_bg: formData.value.login_bg,
|
||||
copyright: formData.value.copyright,
|
||||
keep_record: formData.value.keep_record,
|
||||
help_doc: formData.value.help_doc,
|
||||
privacy: formData.value.privacy,
|
||||
clause: formData.value.clause,
|
||||
git_code: formData.value.git_code,
|
||||
sort: formData.value.sort,
|
||||
version: formData.value.version,
|
||||
};
|
||||
await TenantAPI.updateTenant(id, payload);
|
||||
await refreshUpdate();
|
||||
} else {
|
||||
const payload: TenantCreateForm = {
|
||||
name: formData.value.name as string,
|
||||
code: formData.value.code as string,
|
||||
status: formData.value.status,
|
||||
description: formData.value.description,
|
||||
package_id: formData.value.package_id,
|
||||
start_time: formData.value.start_time,
|
||||
end_time: formData.value.end_time,
|
||||
contact_name: formData.value.contact_name,
|
||||
contact_phone: formData.value.contact_phone,
|
||||
contact_email: formData.value.contact_email,
|
||||
address: formData.value.address,
|
||||
domain: formData.value.domain,
|
||||
logo_url: formData.value.logo_url,
|
||||
favicon: formData.value.favicon,
|
||||
login_bg: formData.value.login_bg,
|
||||
copyright: formData.value.copyright,
|
||||
keep_record: formData.value.keep_record,
|
||||
help_doc: formData.value.help_doc,
|
||||
privacy: formData.value.privacy,
|
||||
clause: formData.value.clause,
|
||||
git_code: formData.value.git_code,
|
||||
sort: formData.value.sort,
|
||||
version: formData.value.version,
|
||||
};
|
||||
await TenantAPI.createTenant(payload);
|
||||
await refreshCreate();
|
||||
}
|
||||
dialogVisible.visible = false;
|
||||
dataFormRef.value?.resetFields();
|
||||
dataFormRef.value?.clearValidate();
|
||||
formData.value = { ...initialFormData };
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedIds.value;
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
await confirmBatchDelete(ids.length);
|
||||
batchDeleting.value = true;
|
||||
await TenantAPI.deleteTenant(ids);
|
||||
faTableRef.value?.elTableRef?.clearSelection();
|
||||
await refreshRemove();
|
||||
} catch {
|
||||
// 用户取消
|
||||
} finally {
|
||||
batchDeleting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tenant-table-actions {
|
||||
:deep(.inline-flex) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-image {
|
||||
max-width: 120px;
|
||||
max-height: 80px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.detail-favicon {
|
||||
max-width: 48px;
|
||||
max-height: 48px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user