refactor: 大规模代码整理与功能优化

1. 重构后端API路由、CRUD与模块结构,整合日志管理,移除废弃demo代码
2. 优化前端组件类型定义、样式与路由配置,修复权限判断逻辑
3. 调整默认排序规则、滚动条样式与工具类函数,更新依赖与配置文件
4. 修复多处类型不匹配与默认值问题,完善表单与菜单验证逻辑
This commit is contained in:
zhangtao
2026-06-17 01:56:31 +08:00
parent 17b3cd0a4c
commit 73f2823692
500 changed files with 40763 additions and 26616 deletions
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>