chore: suite

v2
xiaojunnuo 2024-12-19 01:21:55 +08:00
parent 8814ffeda6
commit 45839f227a
16 changed files with 422 additions and 89 deletions

View File

@ -49,4 +49,10 @@ export abstract class CrudController<T> extends BaseController {
await this.getService().delete([id]); await this.getService().delete([id]);
return this.ok(null); return this.ok(null);
} }
@Post('/deleteByIds')
async deleteByIds(@Body('ids') ids: number[]) {
await this.getService().delete(ids);
return this.ok(null);
}
} }

View File

@ -80,6 +80,16 @@ export const certdResources = [
auth: true auth: true
} }
}, },
{
title: "套餐购买",
name: "SuiteProductBuy",
path: "/certd/suite/buy",
component: "/certd/suite/buy.vue",
meta: {
icon: "mdi:format-list-group",
auth: true
}
},
// { // {
// title: "邮箱设置", // title: "邮箱设置",
// name: "EmailSetting", // name: "EmailSetting",

View File

@ -0,0 +1,24 @@
import { request } from "/@/api/service";
import { dict } from "@fast-crud/fast-crud";
export const durationDict = dict({
data: [
{ label: "1年", value: 365 },
{ label: "2年", value: 730 },
{ label: "3年", value: 1095 },
{ label: "4年", value: 1460 },
{ label: "5年", value: 1825 },
{ label: "6年", value: 2190 },
{ label: "7年", value: 2555 },
{ label: "8年", value: 2920 },
{ label: "9年", value: 3285 },
{ label: "10年", value: 3650 },
{ label: "永久", value: -1 }
]
});
export async function ProductList() {
return await request({
url: "/suite/product/list",
method: "POST"
});
}

View File

@ -0,0 +1,146 @@
<template>
<fs-page class="page-suite-buy">
<template #header>
<div class="title">套餐购买</div>
</template>
<div class="suite-buy-content">
<a-row :gutter="12">
<a-col v-for="item of suites" :key="item.id" class="mb-10" :xs="12" :sm="12" :md="8" :lg="6" :xl="6" :xxl="4">
<a-card :title="item.title">
<template #extra>
<a-tag>{{ item.type }}</a-tag>
</template>
<div>{{ item.intro }}</div>
<div class="hr">
<div class="flex-between mt-5">流水线条数<suite-value :model-value="item.content.maxPipelineCount" /></div>
<div class="flex-between mt-5">域名数量 <suite-value :model-value="item.content.maxDomainCount" /></div>
<div class="flex-between mt-5">部署次数 <suite-value :model-value="item.content.maxDeployCount" /></div>
<div class="flex-between mt-5">
证书监控
<span v-if="item.content.siteMonitor"></span>
<span v-else></span>
</div>
</div>
<div class="duration flex-between mt-5 hr">
<div class="flex-o">时长</div>
<div class="duration-list">
<div
v-for="dp of item.durationPrices"
:key="dp.duration"
class="duration-item"
:class="{ active: item._selected.duration === dp.duration }"
@click="item._selected = dp"
>
{{ durationDict.dataMap[dp.duration]?.label }}
</div>
</div>
</div>
<div class="price flex-between mt-5 hr">
<div class="flex-o">价格</div>
<div class="flex-o price-text">
<price-input style="font-size: 18px; color: red" :model-value="item._selected?.price" :edit="false" />
<span class="ml-5" style="font-size: 12px"> / {{ durationDict.dataMap[item._selected.duration]?.label }}</span>
</div>
</div>
<template #actions>
<setting-outlined key="setting" />
<a-button type="primary">立即购买</a-button>
</template>
</a-card>
</a-col>
</a-row>
</div>
</fs-page>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import * as api from "./api";
import { durationDict } from "./api";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
import SuiteValue from "/@/views/sys/suite/product/suite-value.vue";
import { dict } from "@fast-crud/fast-crud";
const suites = ref([]);
const optionsDict = dict({
data: []
});
async function loadSuites() {
suites.value = await api.ProductList();
for (const item of suites.value) {
item._selected = item.durationPrices[0];
}
}
loadSuites();
</script>
<style lang="less">
.page-suite-buy {
.title {
background-color: #fff;
}
background: #f0f2f5;
.suite-buy-content {
padding: 20px;
.duration-list {
display: flex;
.duration-item {
width: 50px;
border: 1px solid #cdcdcd;
text-align: center;
padding: 2px;
margin: 2px;
cursor: pointer;
&:hover {
border-color: #1890ff;
}
&.active {
border-color: #a6fba3;
background-color: #c1eafb;
}
}
}
.hr {
border-top: 1px solid #cdcdcd;
margin-top: 5px;
padding-top: 5px;
}
.price-text {
align-items: baseline;
font-family: "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
.prices {
display: flex;
justify-content: left;
margin-top: 20px;
.price-item {
border: 1px solid #c6c6c6;
background-color: #f8ebda;
padding: 10px;
text-align: center;
cursor: pointer;
width: 100px;
&:hover {
border-color: #38a0fb;
}
&.active {
border-color: #1890ff;
}
margin-right: 10px;
}
}
}
}
</style>

View File

@ -0,0 +1,3 @@
<div>
</div>

View File

@ -1,6 +1,9 @@
import { request } from "/src/api/service"; import { request } from "/src/api/service";
const apiPrefix = "/sys/suite/product"; const apiPrefix = "/sys/suite/product";
export type PriceItem = {
duration: number;
price: number;
};
export async function GetList(query: any) { export async function GetList(query: any) {
return await request({ return await request({

View File

@ -7,6 +7,7 @@ import { useUserStore } from "/@/store/modules/user";
import { useSettingStore } from "/@/store/modules/settings"; import { useSettingStore } from "/@/store/modules/settings";
import SuiteValue from "./suite-value.vue"; import SuiteValue from "./suite-value.vue";
import SuiteValueEdit from "./suite-value-edit.vue"; import SuiteValueEdit from "./suite-value-edit.vue";
import PriceEdit from "./price-edit.vue";
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet { export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const router = useRouter(); const router = useRouter();
@ -24,9 +25,6 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
}; };
const addRequest = async ({ form }: AddReq) => { const addRequest = async ({ form }: AddReq) => {
form.content = JSON.stringify({
title: form.title
});
const res = await api.AddObj(form); const res = await api.AddObj(form);
return res; return res;
}; };
@ -69,11 +67,15 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
groups: { groups: {
base: { base: {
header: "基础信息", header: "基础信息",
columns: ["title", "type", "price", "originPrice", "duration", "isBootstrap", "intro"] columns: ["title", "type", "isBootstrap", "disabled", "order", "intro"]
}, },
content: { content: {
header: "套餐内容", header: "套餐内容",
columns: ["maxDomainCount", "maxPipelineCount", "maxDeployCount", "siteMonitor"] columns: ["content.maxDomainCount", "content.maxPipelineCount", "content.maxDeployCount", "content.siteMonitor"]
},
price: {
header: "价格",
columns: ["durationPrices"]
} }
} }
} }
@ -96,6 +98,9 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
search: { search: {
show: true show: true
}, },
form: {
rules: [{ required: true, message: "此项必填" }]
},
column: { column: {
width: 200 width: 200
} }
@ -114,19 +119,41 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
{ label: "加量包", value: "addon" } { label: "加量包", value: "addon" }
] ]
}), }),
form: {
rules: [{ required: true, message: "此项必填" }]
},
column: { column: {
width: 100 width: 100
},
valueBuilder: ({ row }) => {
if (row.content) {
row.content = JSON.parse(row.content);
}
if (row.durationPrices) {
row.durationPrices = JSON.parse(row.durationPrices);
}
},
valueResolve: ({ form }) => {
debugger;
if (form.content) {
form.content = JSON.stringify(form.content);
}
if (form.durationPrices) {
form.durationPrices = JSON.stringify(form.durationPrices);
}
} }
}, },
maxDomainCount: { "content.maxDomainCount": {
title: "域名数量", title: "域名数量",
type: "number", type: "number",
form: { form: {
key: ["content", "maxDomainCount"],
component: { component: {
name: SuiteValueEdit, name: SuiteValueEdit,
vModel: "modelValue", vModel: "modelValue",
unit: "个" unit: "个"
} },
rules: [{ required: true, message: "此项必填" }]
}, },
column: { column: {
width: 100, width: 100,
@ -135,15 +162,17 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
} }
} }
}, },
maxPipelineCount: { "content.maxPipelineCount": {
title: "流水线数量", title: "流水线数量",
type: "number", type: "number",
form: { form: {
key: ["content", "maxPipelineCount"],
component: { component: {
name: SuiteValueEdit, name: SuiteValueEdit,
vModel: "modelValue", vModel: "modelValue",
unit: "条" unit: "条"
} },
rules: [{ required: true, message: "此项必填" }]
}, },
column: { column: {
width: 100, width: 100,
@ -152,15 +181,17 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
} }
} }
}, },
maxDeployCount: { "content.maxDeployCount": {
title: "部署次数", title: "部署次数",
type: "number", type: "number",
form: { form: {
key: ["content", "maxDeployCount"],
component: { component: {
name: SuiteValueEdit, name: SuiteValueEdit,
vModel: "modelValue", vModel: "modelValue",
unit: "次" unit: "次"
} },
rules: [{ required: true, message: "此项必填" }]
}, },
column: { column: {
width: 100, width: 100,
@ -169,7 +200,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
} }
} }
}, },
siteMonitor: { "content.siteMonitor": {
title: "支持证书监控", title: "支持证书监控",
type: "dict-switch", type: "dict-switch",
dict: dict({ dict: dict({
@ -178,10 +209,40 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
{ label: "否", value: false, color: "error" } { label: "否", value: false, color: "error" }
] ]
}), }),
form: {
key: ["content", "siteMonitor"],
value: false
},
column: { column: {
width: 120 width: 120
} }
}, },
durationPrices: {
title: "时长及价格",
type: "text",
form: {
title: "选择时长",
component: {
name: PriceEdit,
vModel: "modelValue",
edit: true,
style: {
minHeight: "120px"
}
},
col: {
span: 24
},
rules: [{ required: true, message: "此项必填" }]
},
column: {
component: {
name: PriceEdit,
vModel: "modelValue",
edit: false
}
}
},
isBootstrap: { isBootstrap: {
title: "是否初始套餐", title: "是否初始套餐",
type: "dict-switch", type: "dict-switch",
@ -191,27 +252,36 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
{ label: "否", value: false, color: "error" } { label: "否", value: false, color: "error" }
] ]
}), }),
form: {
value: false
},
column: { column: {
width: 120 width: 120
} }
}, },
price: { disabled: {
title: "单价", title: "上下架",
type: "number", type: "dict-radio",
dict: dict({
data: [
{ value: false, label: "上架" },
{ value: true, label: "下架" }
]
}),
form: {
value: false
},
column: { column: {
width: 100 width: 100
} }
}, },
originPrice: { order: {
title: "原价", title: "排序",
type: "number", type: "number",
column: { form: {
width: 100 helper: "越小越靠前",
} value: 0
}, },
duration: {
title: "有效时长",
type: "dict-select",
column: { column: {
width: 100 width: 100
} }
@ -223,16 +293,6 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
width: 200 width: 200
} }
}, },
order: {
title: "排序",
type: "number",
form: {
show: false
},
column: {
width: 100
}
},
createTime: { createTime: {
title: "创建时间", title: "创建时间",
type: "datetime", type: "datetime",

View File

@ -1,48 +0,0 @@
<template>
<div class="cd-suite-value-edit flex-o">
<div class="flex- 1"><a-checkbox :checked="modelValue === -1" @update:checked="onCheckedChange">无限制</a-checkbox><span class="ml-5"></span></div>
<div class="ml-10 w-50%">
<a-input-number v-if="modelValue >= 0" :value="modelValue" class="ml-5" @update:value="onValueChange">
<template v-if="unit" #addonAfter>{{ unit }}</template>
</a-input-number>
</div>
</div>
</template>
<script lang="ts" setup>
import { Form } from "ant-design-vue";
import { dict } from "@fast-crud/fast-crud";
const props = defineProps<{
modelValue?: number;
}>();
const durationDict = dict({
data: [
{ label: "1年", value: 365 },
{ label: "2年", value: 730 },
{ label: "3年", value: 1095 },
{ label: "4年", value: 1460 },
{ label: "5年", value: 1825 },
{ label: "6年", value: 2190 },
{ label: "7年", value: 2555 },
{ label: "8年", value: 2920 },
{ label: "9年", value: 3285 },
{ label: "10年", value: 3650 },
{ label: "永久", value: -1 }
]
});
const formItemContext = Form.useInjectFormItemContext();
const emit = defineEmits(["update:modelValue"]);
const onCheckedChange = (checked: boolean) => {
const value = checked ? -1 : 1;
onValueChange(value);
};
function onValueChange(value: number) {
emit("update:modelValue", value);
formItemContext.onFieldChange();
}
</script>

View File

@ -0,0 +1,94 @@
<template>
<div class="cd-price-edit">
<div v-if="edit" class="duration-list flex-o">
<div v-for="item of durationDict.data" :key="item.value" :class="{ active: isActive(item) }" class="duration-item" @click="onDurationClicked(item)">
{{ item.label }}
</div>
</div>
<div class="price-group-list">
<div v-for="item of modelValue" :key="item.duration" class="flex-o price-group-item">
<div style="width: 50px">{{ durationDict.dataMap[item.duration]?.label }}:</div>
<price-input v-model="item.price" style="width: 150px" :edit="edit" class="mr-10" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { Form } from "ant-design-vue";
import { PriceItem } from "./api";
import PriceInput from "./price-input.vue";
import { durationDict } from "../../../certd/suite/api";
const props = withDefaults(
defineProps<{
modelValue?: PriceItem[];
edit: boolean;
}>(),
{
modelValue: () => {
return [];
}
}
);
const formItemContext = Form.useInjectFormItemContext();
const emit = defineEmits(["update:modelValue"]);
function doEmit(value: PriceItem[]) {
emit("update:modelValue", value);
formItemContext.onFieldChange();
}
function isActive(item: any) {
return props.modelValue.some((v) => v.duration === item.value);
}
function onDurationClicked(item: any) {
const has = props.modelValue.some((v) => v.duration === item.value);
if (has) {
// remove
const newValue = props.modelValue.filter((v) => v.duration !== item.value);
doEmit(newValue);
} else {
// add
const newValue = [...props.modelValue, { duration: item.value, price: 0.0 }];
//sort
newValue.sort((a, b) => a.duration - b.duration);
if (newValue.length > 0) {
const first = newValue[0];
if (first.duration === -1) {
//
newValue.shift();
newValue.push(first);
}
}
doEmit(newValue);
}
}
</script>
<style lang="less">
.cd-price-edit {
.duration-item {
border: 1px solid #eee;
padding: 2px;
width: 35px;
text-align: center;
cursor: pointer;
&.active {
background: #1890ff;
color: #fff;
}
}
.price-group-list {
display: flex;
flex-wrap: wrap;
.price-group-item {
margin-right: 10px;
margin-top: 10px;
}
}
}
</style>

View File

@ -0,0 +1,35 @@
<template>
<div class="flex-o">
<a-input-number v-if="edit" prefix="¥" :value="priceValue" :precision="2" class="ml-5" @update:value="onPriceChange"> </a-input-number>
<span v-else>{{ priceLabel }}</span>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue";
const props = defineProps<{
modelValue?: number;
edit: boolean;
}>();
const priceValue = computed(() => {
if (props.modelValue == null) {
return 0;
}
return (props.modelValue / 100.0).toFixed(2);
});
const priceLabel = computed(() => {
if (priceValue.value === 0 || priceValue.value === "0.00") {
return "免费";
}
return `¥${priceValue.value}`;
});
const emit = defineEmits(["update:modelValue"]);
const onPriceChange = (price: number) => {
emit("update:modelValue", price * 100);
};
</script>

View File

@ -2,7 +2,7 @@
<div class="cd-suite-value-edit flex-o"> <div class="cd-suite-value-edit flex-o">
<div class="flex- 1"><a-checkbox :checked="modelValue === -1" @update:checked="onCheckedChange">无限制</a-checkbox><span class="ml-5"></span></div> <div class="flex- 1"><a-checkbox :checked="modelValue === -1" @update:checked="onCheckedChange">无限制</a-checkbox><span class="ml-5"></span></div>
<div class="ml-10 w-50%"> <div class="ml-10 w-50%">
<a-input-number v-if="modelValue >= 0" :value="modelValue" class="ml-5" @update:value="onValueChange"> <a-input-number v-if="modelValue == null || modelValue >= 0" :value="modelValue" class="ml-5" @update:value="onValueChange">
<template v-if="unit" #addonAfter>{{ unit }}</template> <template v-if="unit" #addonAfter>{{ unit }}</template>
</a-input-number> </a-input-number>
</div> </div>

View File

@ -1,6 +1,6 @@
import { request } from "/src/api/service"; import { request } from "/src/api/service";
const apiPrefix = "/sys/suite/order"; const apiPrefix = "/sys/suite/trade";
export async function GetList(query: any) { export async function GetList(query: any) {
return await request({ return await request({

View File

@ -55,8 +55,8 @@ export class CnameRecordController extends CrudController<CnameProviderService>
} }
@Post('/deleteByIds', { summary: 'sys:settings:edit' }) @Post('/deleteByIds', { summary: 'sys:settings:edit' })
async deleteByIds(@Body(ALL) body: { ids: number[] }) { async deleteByIds(@Body('ids') ids: number[]) {
const res = await this.service.delete(body.ids); const res = await this.service.delete(ids);
return this.ok(res); return this.ok(res);
} }

View File

@ -57,8 +57,8 @@ export class PluginController extends CrudController<PluginService> {
} }
@Post('/deleteByIds', { summary: 'sys:settings:edit' }) @Post('/deleteByIds', { summary: 'sys:settings:edit' })
async deleteByIds(@Body(ALL) body: { ids: number[] }) { async deleteByIds(@Body('ids') ids: number[]) {
const res = await this.service.delete(body.ids); const res = await this.service.delete(ids);
return this.ok(res); return this.ok(res);
} }