Merge branch 'master' of https://git.yunzer.cn/yunzerwebsite/yunzerwebsiteallinone
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/*************************************************
|
||||
* 产品定价(产品 / 功能节点 / 价格档位 / 购买订单)
|
||||
*************************************************/
|
||||
|
||||
/** 产品列表 */
|
||||
export function getProductList(params) {
|
||||
return request({
|
||||
url: "/platform/product/list",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 产品详情(含功能节点与价格档位) */
|
||||
export function getProductDetail(id) {
|
||||
return request({
|
||||
url: `/platform/product/detail/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/** 新增产品 */
|
||||
export function createProduct(data) {
|
||||
return request({
|
||||
url: "/platform/product/create",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 编辑产品 */
|
||||
export function updateProduct(id, data) {
|
||||
return request({
|
||||
url: `/platform/product/edit/${id}`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除产品 */
|
||||
export function deleteProduct(id) {
|
||||
return request({
|
||||
url: `/platform/product/delete/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
}
|
||||
|
||||
/** 购买订单列表 */
|
||||
export function getProductOrders(params) {
|
||||
return request({
|
||||
url: "/platform/product/orders",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 手动开通订单 */
|
||||
export function activateProductOrder(id) {
|
||||
return request({
|
||||
url: `/platform/product/order/activate/${id}`,
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
@@ -63,6 +63,23 @@ export function deleteTenantPackage(id) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 套餐购买链接(租户端未开通功能「购买」按钮跳转地址) */
|
||||
export function getPackagePurchaseUrl() {
|
||||
return request({
|
||||
url: "/platform/tenantPackage/purchaseUrl",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存套餐购买链接 */
|
||||
export function savePackagePurchaseUrl(purchaseUrl) {
|
||||
return request({
|
||||
url: "/platform/tenantPackage/purchaseUrl",
|
||||
method: "post",
|
||||
data: { purchase_url: purchaseUrl },
|
||||
});
|
||||
}
|
||||
|
||||
/** 用户数加购规格列表(enabled=1 仅启用) */
|
||||
export function getQuotaPackageList(params) {
|
||||
return request({
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="isEdit ? '编辑产品' : '添加产品'"
|
||||
width="860px"
|
||||
top="5vh"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="产品名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="如:进销存 ERP" maxlength="60" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="产品编码" prop="code">
|
||||
<el-input
|
||||
v-model="form.code"
|
||||
placeholder="如 erp,建议与功能模块编码一致"
|
||||
maxlength="50"
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="价格" prop="price">
|
||||
<el-input-number
|
||||
v-model="form.price"
|
||||
:min="0"
|
||||
:max="9999999"
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
class="full"
|
||||
/>
|
||||
<span class="suffix">元</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="关联功能" prop="module_code">
|
||||
<el-select
|
||||
v-model="form.module_code"
|
||||
placeholder="选择关联的功能模块"
|
||||
clearable
|
||||
filterable
|
||||
class="full"
|
||||
>
|
||||
<el-option
|
||||
v-for="m in moduleOptions"
|
||||
:key="m.code"
|
||||
:label="`${m.name}(${m.code})`"
|
||||
:value="m.code"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :value="1">上架</el-radio>
|
||||
<el-radio :value="0">下架</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 功能节点树 -->
|
||||
<el-form-item label="功能节点">
|
||||
<div class="feature-box">
|
||||
<div class="feature-toolbar">
|
||||
<span class="toolbar-tip">支持父子层级:可在任意节点下添加子功能</span>
|
||||
<el-button size="small" type="primary" @click="addFeature(0)">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加顶级节点
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
:data="featureTree"
|
||||
row-key="key"
|
||||
default-expand-all
|
||||
:tree-props="{ children: 'children' }"
|
||||
size="small"
|
||||
border
|
||||
class="feature-table"
|
||||
>
|
||||
<el-table-column label="功能名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.title" placeholder="如:采购管理" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="说明" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.description" placeholder="功能说明(可留空)" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="排序" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.sort"
|
||||
:min="0"
|
||||
:max="999"
|
||||
controls-position="right"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="addFeature(row.key)">
|
||||
子功能
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" link @click="removeFeature(row.key)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无功能节点" :image-size="60" />
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { createProduct, updateProduct, getProductDetail } from "@/api/product";
|
||||
import { getTenantPackageModuleOptions } from "@/api/tenantPackage";
|
||||
|
||||
interface FeatureNode {
|
||||
key: number; // 前端唯一标识:已有节点取库中ID,新增节点取负数临时ID
|
||||
id: number; // 库中ID(0=新增)
|
||||
pid: number; // 父节点 key(0=顶级)
|
||||
title: string;
|
||||
description: string;
|
||||
sort: number;
|
||||
children?: FeatureNode[];
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
product: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue", "success"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const submitting = ref(false);
|
||||
const formRef = ref();
|
||||
const moduleOptions = ref<any[]>([]);
|
||||
|
||||
let tempKey = 0;
|
||||
const nextTempKey = () => -1 * ++tempKey;
|
||||
|
||||
const emptyForm = () => ({
|
||||
id: 0,
|
||||
name: "",
|
||||
code: "",
|
||||
price: 0,
|
||||
module_code: "",
|
||||
sort: 0,
|
||||
status: 1,
|
||||
features: [] as FeatureNode[],
|
||||
});
|
||||
|
||||
const form = ref<any>(emptyForm());
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: "请输入产品名称", trigger: "blur" }],
|
||||
code: [{ required: true, message: "请输入产品编码", trigger: "blur" }],
|
||||
};
|
||||
|
||||
/** 扁平节点 -> 树(供 el-table 树形展示) */
|
||||
const featureTree = computed<FeatureNode[]>(() => {
|
||||
const list = form.value.features as FeatureNode[];
|
||||
const map = new Map<number, FeatureNode>();
|
||||
const roots: FeatureNode[] = [];
|
||||
list.forEach((n) => map.set(n.key, { ...n, children: [] }));
|
||||
list.forEach((n) => {
|
||||
const node = map.get(n.key)!;
|
||||
const parent = n.pid ? map.get(n.pid) : undefined;
|
||||
if (parent) {
|
||||
parent.children!.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
return roots;
|
||||
});
|
||||
|
||||
/** 收集某节点及其全部后代的 key */
|
||||
const collectKeys = (key: number): number[] => {
|
||||
const keys = [key];
|
||||
const children = (form.value.features as FeatureNode[]).filter((n) => n.pid === key);
|
||||
children.forEach((c) => keys.push(...collectKeys(c.key)));
|
||||
return keys;
|
||||
};
|
||||
|
||||
const addFeature = (parentKey: number) => {
|
||||
const list = form.value.features as FeatureNode[];
|
||||
const key = nextTempKey();
|
||||
list.push({
|
||||
key,
|
||||
id: 0,
|
||||
pid: parentKey || 0,
|
||||
title: "",
|
||||
description: "",
|
||||
sort: list.length + 1,
|
||||
});
|
||||
};
|
||||
|
||||
const removeFeature = (key: number) => {
|
||||
const keys = new Set(collectKeys(key));
|
||||
form.value.features = (form.value.features as FeatureNode[]).filter((n) => !keys.has(n.key));
|
||||
};
|
||||
|
||||
const loadOptions = async () => {
|
||||
try {
|
||||
const res = await getTenantPackageModuleOptions();
|
||||
if (res.code === 200) moduleOptions.value = res.data?.list || [];
|
||||
} catch {
|
||||
moduleOptions.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadDetail = async (id: number) => {
|
||||
const res = await getProductDetail(id);
|
||||
if (res.code === 200 && res.data) {
|
||||
const d = res.data;
|
||||
const features: FeatureNode[] = (d.features || []).map((f: any) => ({
|
||||
key: Number(f.id),
|
||||
id: Number(f.id),
|
||||
pid: Number(f.pid || 0),
|
||||
title: f.title || "",
|
||||
description: f.description || "",
|
||||
sort: Number(f.sort || 0),
|
||||
}));
|
||||
form.value = {
|
||||
id: d.id,
|
||||
name: d.name || "",
|
||||
code: d.code || "",
|
||||
price: Number(d.price || 0),
|
||||
module_code: d.module_code || "",
|
||||
sort: Number(d.sort || 0),
|
||||
status: Number(d.status ?? 1),
|
||||
features,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (val) => {
|
||||
visible.value = val;
|
||||
if (!val) return;
|
||||
await loadOptions();
|
||||
if (props.product) {
|
||||
isEdit.value = true;
|
||||
await loadDetail(props.product.id);
|
||||
} else {
|
||||
isEdit.value = false;
|
||||
form.value = emptyForm();
|
||||
}
|
||||
nextTick(() => formRef.value?.clearValidate());
|
||||
}
|
||||
);
|
||||
|
||||
watch(visible, (val) => emit("update:modelValue", val));
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
form.value = emptyForm();
|
||||
formRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
const buildPayload = () => ({
|
||||
name: form.value.name,
|
||||
code: form.value.code,
|
||||
price: Number(form.value.price || 0),
|
||||
module_code: form.value.module_code,
|
||||
sort: Number(form.value.sort || 0),
|
||||
status: Number(form.value.status),
|
||||
features: (form.value.features as FeatureNode[])
|
||||
.filter((f) => (f.title || "").trim() !== "")
|
||||
.map((f) => ({
|
||||
id: f.key, // 已有节点=库中ID,新增节点=负数临时ID(后端据此建立父子映射)
|
||||
pid: f.pid,
|
||||
title: f.title,
|
||||
description: f.description,
|
||||
sort: Number(f.sort || 0),
|
||||
})),
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
const res = isEdit.value
|
||||
? await updateProduct(form.value.id, payload)
|
||||
: await createProduct(payload);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(isEdit.value ? "编辑成功" : "创建成功");
|
||||
emit("success");
|
||||
handleClose();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "保存失败");
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || "保存失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.suffix {
|
||||
margin-left: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.feature-box {
|
||||
width: 100%;
|
||||
|
||||
.feature-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.toolbar-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-table {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>产品定价</h2>
|
||||
<div class="header-actions">
|
||||
<el-button v-if="activeTab === 'product'" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加产品
|
||||
</el-button>
|
||||
<el-button @click="refresh">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<el-alert type="info" :closable="false" show-icon class="tip-alert">
|
||||
<template #default>
|
||||
<p>1、产品只维护「名称 / 编码 / 价格 / 关联功能 / 上下架」,功能清单在编辑弹窗里按树形维护(支持父子层级)。</p>
|
||||
<p>2、产品编码建议与功能模块编码(yz_system_modules.code)一致,租户端 home 未开通卡片点「购买」即可定位到对应产品页。</p>
|
||||
<p>3、租户下单支付成功后,由平台在「购买订单」里开通(订单绑定套餐后自动为租户开通对应功能)。</p>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-tabs v-model="activeTab" class="pricing-tabs">
|
||||
<!-- 产品定价 -->
|
||||
<el-tab-pane label="产品定价" name="product">
|
||||
<el-table :data="products" style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||
<el-table-column label="产品名称" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="product-name">{{ row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="产品编码" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="info" effect="plain">{{ row.code }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="price">¥{{ Number(row.price || 0).toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联功能" width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.module_code" size="small" effect="plain">
|
||||
{{ moduleName(row.module_code) }}
|
||||
</el-tag>
|
||||
<span v-else class="muted">未关联</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="功能节点" width="100" align="center">
|
||||
<template #default="{ row }">{{ row.features?.length || 0 }} 个</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="Number(row.status) === 1 ? 'success' : 'info'" size="small">
|
||||
{{ Number(row.status) === 1 ? "已上架" : "已下架" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 购买订单 -->
|
||||
<el-tab-pane label="购买订单" name="order">
|
||||
<div class="order-filter">
|
||||
<el-select v-model="orderStatus" placeholder="订单状态" clearable style="width: 160px">
|
||||
<el-option label="待支付" value="pending" />
|
||||
<el-option label="已支付" value="paid" />
|
||||
<el-option label="已开通" value="activated" />
|
||||
<el-option label="已关闭" value="closed" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadOrders">查询</el-button>
|
||||
</div>
|
||||
<el-table :data="orders" style="width: 100%" v-loading="orderLoading">
|
||||
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||
<el-table-column prop="order_no" label="订单号" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="tenant_name" label="租户" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="product_name" label="产品" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="金额" width="110" align="center">
|
||||
<template #default="{ row }">¥{{ Number(row.amount || 0).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="绑定套餐" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.package_id" size="small" effect="plain">{{ row.package_id }}</el-tag>
|
||||
<span v-else class="muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="orderStatusType(row.status)" size="small">
|
||||
{{ orderStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="下单时间" width="170" align="center" />
|
||||
<el-table-column label="操作" width="120" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.status !== 'activated'"
|
||||
size="small"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleActivate(row)"
|
||||
>
|
||||
手动开通
|
||||
</el-button>
|
||||
<span v-else class="muted">已开通</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<ProductEditDialog v-model="dialogVisible" :product="currentProduct" @success="loadProducts" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Refresh } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getProductList,
|
||||
deleteProduct,
|
||||
getProductOrders,
|
||||
activateProductOrder,
|
||||
} from "@/api/product";
|
||||
import { getTenantPackageModuleOptions } from "@/api/tenantPackage";
|
||||
import ProductEditDialog from "./components/edit.vue";
|
||||
|
||||
const activeTab = ref("product");
|
||||
const loading = ref(false);
|
||||
const orderLoading = ref(false);
|
||||
const products = ref<any[]>([]);
|
||||
const orders = ref<any[]>([]);
|
||||
const orderStatus = ref("");
|
||||
const moduleOptions = ref<any[]>([]);
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const currentProduct = ref<any>(null);
|
||||
|
||||
const moduleName = (code: string) => {
|
||||
const m = moduleOptions.value.find((x) => x.code === code);
|
||||
return m ? m.name : code;
|
||||
};
|
||||
|
||||
const orderStatusText = (s: string) =>
|
||||
({ pending: "待支付", paid: "已支付", activated: "已开通", closed: "已关闭" }[s] || s);
|
||||
const orderStatusType = (s: string) =>
|
||||
({ pending: "warning", paid: "primary", activated: "success", closed: "info" }[s] || "info");
|
||||
|
||||
const loadModules = async () => {
|
||||
try {
|
||||
const res = await getTenantPackageModuleOptions();
|
||||
if (res.code === 200) moduleOptions.value = res.data?.list || [];
|
||||
} catch {
|
||||
moduleOptions.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadProducts = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getProductList();
|
||||
if (res.code === 200) {
|
||||
products.value = res.data?.list || [];
|
||||
} else {
|
||||
ElMessage.error(res.msg || "获取产品列表失败");
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || "获取产品列表失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadOrders = async () => {
|
||||
orderLoading.value = true;
|
||||
try {
|
||||
const res = await getProductOrders({ status: orderStatus.value });
|
||||
if (res.code === 200) {
|
||||
orders.value = res.data?.list || [];
|
||||
} else {
|
||||
ElMessage.error(res.msg || "获取订单失败");
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || "获取订单失败");
|
||||
} finally {
|
||||
orderLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
loadProducts();
|
||||
loadOrders();
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
currentProduct.value = null;
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
currentProduct.value = { ...row };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleDelete = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除产品「${row.name}」吗?删除后租户端购买页将不再展示该产品。`,
|
||||
"警告",
|
||||
{ type: "warning" }
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await deleteProduct(row.id);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
loadProducts();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "删除失败");
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认手动开通订单「${row.order_no}」?开通后租户将获得对应功能。`,
|
||||
"手动开通",
|
||||
{ type: "warning" }
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await activateProductOrder(row.id);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("开通成功");
|
||||
loadOrders();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "开通失败");
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || "开通失败");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadModules();
|
||||
refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container-box {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tip-alert {
|
||||
margin-bottom: 8px;
|
||||
|
||||
p {
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.pricing-tabs {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.price {
|
||||
color: #f56c6c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.order-filter {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -62,7 +62,7 @@
|
||||
<span class="field-tip">元/人,单个增购用户数时的默认单价(如 200)</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="套餐售价" prop="price">
|
||||
<el-form-item label="套餐价格" prop="price">
|
||||
<el-input-number
|
||||
v-model="form.price"
|
||||
:min="0"
|
||||
@@ -70,7 +70,17 @@
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="field-tip">元,仅作记录展示,可填 0</span>
|
||||
<span class="field-tip">{{ priceUnitText }},绑定/续费按该价格一次性计费</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="有效时长" prop="duration_days">
|
||||
<el-input-number
|
||||
v-model="form.duration_days"
|
||||
:min="1"
|
||||
:max="3650"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="field-tip">天(365 = 按年计费);租户套餐到期后自动切换为基础套餐</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="套餐说明" prop="description">
|
||||
@@ -147,6 +157,7 @@ const emptyForm = () => ({
|
||||
code: "",
|
||||
description: "",
|
||||
price: 0,
|
||||
duration_days: 365,
|
||||
user_quota: 20,
|
||||
extra_user_price: 200,
|
||||
is_default: 0,
|
||||
@@ -158,6 +169,15 @@ const emptyForm = () => ({
|
||||
|
||||
const form = ref<any>(emptyForm());
|
||||
|
||||
/** 价格单位随有效时长变化:365天=元/年,30=元/月,90=元/季,其余=元/N天 */
|
||||
const priceUnitText = computed(() => {
|
||||
const d = Number(form.value.duration_days || 365);
|
||||
if (d === 365) return "元/年";
|
||||
if (d === 30) return "元/月";
|
||||
if (d === 90) return "元/季";
|
||||
return `元 / ${d}天`;
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: "请输入套餐名称", trigger: "blur" }],
|
||||
code: [{ required: true, message: "请输入套餐编码", trigger: "blur" }],
|
||||
@@ -208,6 +228,7 @@ const loadDetail = async (id: number) => {
|
||||
code: d.code || "",
|
||||
description: d.description || "",
|
||||
price: Number(d.price || 0),
|
||||
duration_days: Number(d.duration_days || 365),
|
||||
user_quota: Number(d.user_quota || 20),
|
||||
extra_user_price: Number(d.extra_user_price || 0),
|
||||
is_default: Number(d.is_default || 0),
|
||||
|
||||
@@ -23,10 +23,25 @@
|
||||
<el-alert type="info" :closable="false" show-icon class="tip-alert">
|
||||
<template #default>
|
||||
<p>1、套餐决定租户能开通哪些功能:勾选「功能模块」后,绑定该套餐的租户端只展示对应功能菜单(如 ERP 套餐开通进销存)。</p>
|
||||
<p>2、所有租户都有用户数上限(默认 20 人):达到上限后无法再开账号,可在「租户管理 → 租户详情 → 套餐与用户数」中按单个(如 200 元/人)或加购套餐(如 5 人 500 元)增购。</p>
|
||||
<p>2、套餐按「有效时长」计费(默认 365 天,价格即元/年):到期后自动切换为基础套餐,续费后恢复对应功能。</p>
|
||||
<p>3、所有租户都有用户数上限(默认 20 人):达到上限后无法再开账号,可在「租户管理 → 租户详情 → 套餐与用户数」中按单个(如 200 元/人)或加购套餐(如 5 人 500 元)增购。</p>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<div class="purchase-url-bar">
|
||||
<span class="bar-label">套餐购买链接</span>
|
||||
<el-input
|
||||
v-model="purchaseUrl"
|
||||
placeholder="租户端未开通功能卡片「购买」按钮跳转的链接,如 https://www.example.com/package"
|
||||
clearable
|
||||
class="url-input"
|
||||
/>
|
||||
<el-button type="primary" :loading="purchaseUrlSaving" @click="handleSavePurchaseUrl">
|
||||
保存
|
||||
</el-button>
|
||||
<span class="muted">留空时租户端点击「购买」仅提示联系管理员</span>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab" class="package-tabs">
|
||||
<!-- 功能套餐 -->
|
||||
<el-tab-pane label="功能套餐" name="package">
|
||||
@@ -45,6 +60,12 @@
|
||||
<el-tag size="small" type="info" effect="plain">{{ row.code }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="套餐价格" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'price-zero': !Number(row.price) }">{{ formatMoney(row.price) }}</span>
|
||||
<span class="muted">{{ priceUnit(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开通功能" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
@@ -139,6 +160,8 @@ import {
|
||||
deleteTenantPackage,
|
||||
getQuotaPackageList,
|
||||
deleteQuotaPackage,
|
||||
getPackagePurchaseUrl,
|
||||
savePackagePurchaseUrl,
|
||||
} from "@/api/tenantPackage";
|
||||
import PackageEditDialog from "./components/edit.vue";
|
||||
import QuotaEditDialog from "./components/quotaEdit.vue";
|
||||
@@ -154,7 +177,46 @@ const quotaDialogVisible = ref(false);
|
||||
const currentPackage = ref<any>(null);
|
||||
const currentQuota = ref<any>(null);
|
||||
|
||||
// 套餐购买链接(租户端未开通功能卡片「购买」按钮跳转地址)
|
||||
const purchaseUrl = ref("");
|
||||
const purchaseUrlSaving = ref(false);
|
||||
|
||||
const loadPurchaseUrl = async () => {
|
||||
try {
|
||||
const res = await getPackagePurchaseUrl();
|
||||
if (res.code === 200) {
|
||||
purchaseUrl.value = res.data?.purchase_url || "";
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("获取套餐购买链接失败:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePurchaseUrl = async () => {
|
||||
purchaseUrlSaving.value = true;
|
||||
try {
|
||||
const res = await savePackagePurchaseUrl(purchaseUrl.value);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("保存成功");
|
||||
} else {
|
||||
ElMessage.error(res.msg || "保存失败");
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || "保存失败");
|
||||
} finally {
|
||||
purchaseUrlSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatMoney = (v: any) => `¥${Number(v || 0).toFixed(2)}`;
|
||||
/** 价格单位随有效时长变化:365天=元/年,30=元/月,90=元/季,其余=元/N天 */
|
||||
const priceUnit = (row: any) => {
|
||||
const d = Number(row.duration_days || 365);
|
||||
if (d === 365) return "/年";
|
||||
if (d === 30) return "/月";
|
||||
if (d === 90) return "/季";
|
||||
return `/${d}天`;
|
||||
};
|
||||
const unitPrice = (row: any) => {
|
||||
const count = Number(row.user_count || 0);
|
||||
if (count <= 0) return 0;
|
||||
@@ -196,6 +258,7 @@ const loadQuotaPackages = async () => {
|
||||
const refresh = () => {
|
||||
loadPackages();
|
||||
loadQuotaPackages();
|
||||
loadPurchaseUrl();
|
||||
};
|
||||
|
||||
const handleAddPackage = () => {
|
||||
@@ -311,6 +374,25 @@ onMounted(refresh);
|
||||
}
|
||||
}
|
||||
|
||||
.purchase-url-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.bar-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.url-input {
|
||||
width: 420px;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.module-tag {
|
||||
margin: 0 6px 6px 0;
|
||||
}
|
||||
@@ -324,6 +406,10 @@ onMounted(refresh);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.price-zero {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
:deep(.el-empty) {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
@@ -12,11 +12,25 @@
|
||||
<el-option
|
||||
v-for="p in packageOptions"
|
||||
:key="p.id"
|
||||
:label="p.name"
|
||||
:label="`${p.name}(${formatMoney(p.price)}${priceUnit(p)})`"
|
||||
:value="p.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="套餐到期">
|
||||
<template v-if="info.package_expire_time">
|
||||
<span>{{ formatDateTime(info.package_expire_time) }}</span>
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="expireTagType"
|
||||
effect="plain"
|
||||
class="ml8"
|
||||
>
|
||||
剩余 {{ info.days_remaining }} 天
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else class="muted">永久有效</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="开通功能">
|
||||
<template v-if="info.module_names && info.module_names.length">
|
||||
<el-tag v-for="(m, i) in info.module_names" :key="i" size="small" class="module-tag">
|
||||
@@ -51,6 +65,14 @@
|
||||
class="quota-alert"
|
||||
title="该租户用户数已达上限,新增账号前请先增购用户数"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="expiringSoon"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="quota-alert"
|
||||
title="该租户套餐即将到期,到期后将自动切换为基础套餐,请及时续费"
|
||||
/>
|
||||
|
||||
<div class="section-title">增购记录</div>
|
||||
<el-table :data="orders" style="width: 100%" size="small" v-loading="ordersLoading">
|
||||
@@ -181,6 +203,27 @@ const rechargeForm = ref({
|
||||
});
|
||||
|
||||
const formatMoney = (v: any) => `¥${Number(v || 0).toFixed(2)}`;
|
||||
/** 套餐价格单位随有效时长变化:365天=元/年,30=元/月,90=元/季,其余=元/N天 */
|
||||
const priceUnit = (row: any) => {
|
||||
const d = Number(row?.duration_days || 365);
|
||||
if (d === 365) return "/年";
|
||||
if (d === 30) return "/月";
|
||||
if (d === 90) return "/季";
|
||||
return `/${d}天`;
|
||||
};
|
||||
|
||||
/** 套餐剩余天数:<=30 天给出预警 */
|
||||
const expiringSoon = computed(() => {
|
||||
if (!info.value.package_expire_time) return false;
|
||||
return Number(info.value.days_remaining || 0) <= 30;
|
||||
});
|
||||
|
||||
const expireTagType = computed(() => {
|
||||
const days = Number(info.value.days_remaining || 0);
|
||||
if (days <= 7) return "danger";
|
||||
if (days <= 30) return "warning";
|
||||
return "success";
|
||||
});
|
||||
|
||||
const usedPercent = computed(() => {
|
||||
const quota = Number(info.value.quota || 0);
|
||||
@@ -264,9 +307,13 @@ watch(
|
||||
const handleSetPackage = async (id: number) => {
|
||||
const tid = props.tid;
|
||||
if (tid == null || !id) return;
|
||||
const isRenewal = Number(id) === Number(info.value.package_id) && !!info.value.package_expire_time;
|
||||
const billingTip = isRenewal
|
||||
? "同套餐未到期再次绑定视为续费,将在原到期时间上顺延一个有效时长"
|
||||
: "绑定套餐将按套餐有效时长计费(默认一年),到期后自动切换为基础套餐";
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
"调整套餐会改变该租户可用的功能模块(用户数上限只增不减),确定继续吗?",
|
||||
`调整套餐会改变该租户可用的功能模块(用户数上限只增不减)。${billingTip},确定继续吗?`,
|
||||
"提示",
|
||||
{ type: "warning" }
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user