435 lines
12 KiB
Vue
435 lines
12 KiB
Vue
<template>
|
||
<div class="top-0 z-10 shrink-0">
|
||
<Header>
|
||
<FBreadcrumbs
|
||
:items="[
|
||
{
|
||
label: '创建站点',
|
||
},
|
||
]"
|
||
/>
|
||
</Header>
|
||
|
||
<div class="m-12 mx-auto max-w-2xl px-5">
|
||
<div
|
||
v-if="$resources.installAppOptions.loading"
|
||
class="py-4 text-base text-gray-600"
|
||
>
|
||
加载中...
|
||
</div>
|
||
<div v-else class="space-y-6">
|
||
<div class="mb-12 flex">
|
||
<img
|
||
:src="appDoc.image"
|
||
class="h-12 w-12 rounded-lg border"
|
||
:alt="appDoc.name"
|
||
/>
|
||
<div class="my-1 ml-4 flex flex-col justify-between">
|
||
<h1 class="text-lg font-semibold">{{ appDoc.title }}</h1>
|
||
<p class="text-sm text-gray-600">{{ appDoc.description }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-12">
|
||
<div v-if="$team.pg.onboarding.site_created">
|
||
<div v-if="plans.length">
|
||
<div class="flex items-center justify-between">
|
||
<h2 class="text-base font-medium leading-6 text-gray-900">
|
||
选择计划
|
||
</h2>
|
||
</div>
|
||
<div class="mt-2">
|
||
<PlansCards v-model="selectedPlan" :plans="plans" />
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="options.private_groups.length">
|
||
<h2 class="text-base font-medium leading-6 text-gray-900">
|
||
选择站点分组
|
||
<span class="text-sm text-gray-500"> (可选) </span>
|
||
</h2>
|
||
<div class="mt-2 w-full space-y-2">
|
||
<FormControl
|
||
type="autocomplete"
|
||
:options="
|
||
options.private_groups.map((b) => ({
|
||
label: b.title,
|
||
value: b.name,
|
||
}))
|
||
"
|
||
v-model="selectedGroup"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<h2 class="text-base font-medium leading-6 text-gray-900">
|
||
选择区域
|
||
</h2>
|
||
<div class="mt-2 w-full space-y-2">
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<button
|
||
v-for="c in regions"
|
||
:key="c.name"
|
||
@click="cluster = c.name"
|
||
:class="[
|
||
cluster === c.name
|
||
? 'border-gray-900 ring-1 ring-gray-900 hover:bg-gray-100'
|
||
: 'bg-white text-gray-900 hover:bg-gray-50',
|
||
'flex w-full items-center rounded border p-3 text-left text-base text-gray-900',
|
||
]"
|
||
>
|
||
<div class="flex w-full items-center justify-between">
|
||
<div class="flex w-full items-center space-x-2">
|
||
<img :src="c.image" class="h-5 w-5" />
|
||
<span class="text-sm font-medium">
|
||
{{ c.title }}
|
||
</span>
|
||
</div>
|
||
<Badge v-if="c.beta" :label="c.beta ? 'Beta' : ''" />
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<h2 class="text-base font-medium leading-6 text-gray-900">
|
||
输入子域名
|
||
</h2>
|
||
<div class="mt-2 items-center">
|
||
<div class="col-span-2 flex w-full">
|
||
<input
|
||
class="dark:[color-scheme:dark] z-10 h-7 w-full flex-1 rounded rounded-r-none border border-[--surface-gray-2] bg-surface-gray-2 py-1.5 pl-2 pr-2 text-base text-ink-gray-8 placeholder-ink-gray-4 transition-colors hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:border-outline-gray-4 focus:bg-surface-white focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3"
|
||
placeholder="子域名"
|
||
v-model="subdomain"
|
||
/>
|
||
<div
|
||
class="flex items-center rounded-r bg-gray-100 px-4 text-base"
|
||
>
|
||
.{{ options.domain }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-1">
|
||
<ErrorMessage :message="$resources.subdomainExists.error" />
|
||
<div
|
||
v-if="$resources.subdomainExists.loading"
|
||
class="text-sm text-gray-600"
|
||
>
|
||
正在检查...
|
||
</div>
|
||
<template
|
||
v-else-if="
|
||
!$resources.subdomainExists.error &&
|
||
$resources.subdomainExists.data != null
|
||
"
|
||
>
|
||
<div
|
||
v-if="$resources.subdomainExists.data"
|
||
class="text-sm text-green-600"
|
||
>
|
||
{{ subdomain }}.{{ options.domain }} 可用
|
||
</div>
|
||
<div v-else class="text-sm text-red-600">
|
||
{{ subdomain }}.{{ options.domain }} 不可用
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-col space-y-4">
|
||
<FormControl
|
||
class="checkbox"
|
||
type="checkbox"
|
||
v-model="agreedToRegionConsent"
|
||
:label="`我同意我所选区域的法律 ${
|
||
this.cluster ? `(${this.cluster})` : ''
|
||
} 适用于我和 Jingrow。`"
|
||
/>
|
||
<ErrorMessage class="my-2" :message="$resources.newSite.error" />
|
||
<Button
|
||
class="w-full"
|
||
variant="solid"
|
||
:disabled="
|
||
!agreedToRegionConsent || !$resources.subdomainExists.data
|
||
"
|
||
@click="$resources.newSite.submit()"
|
||
:loading="$resources.newSite.loading"
|
||
>
|
||
创建站点并安装 {{ appDoc.title }}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div class="flex space-x-1">
|
||
<div class="text-sm text-gray-600">
|
||
想要在现有站点或 站点分组上安装 <b>{{ appDoc.title }}</b> 吗?
|
||
</div>
|
||
<a
|
||
class="text-sm underline"
|
||
href="https://jingrow.com/docs/installing-an-app"
|
||
target="_blank"
|
||
>
|
||
阅读文档
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import { Breadcrumbs, debounce } from 'jingrow-ui';
|
||
import Header from '../components/Header.vue';
|
||
import PlansCards from '../components/PlansCards.vue';
|
||
import { DashboardError } from '../utils/error';
|
||
import { validateSubdomain } from '../utils/site';
|
||
|
||
export default {
|
||
name: 'InstallApp',
|
||
props: {
|
||
app: {
|
||
type: String,
|
||
required: true,
|
||
},
|
||
},
|
||
pageMeta() {
|
||
return {
|
||
title: `安装 ${this.appDoc.title} - 今果 Jingrow`,
|
||
};
|
||
},
|
||
components: {
|
||
FBreadcrumbs: Breadcrumbs,
|
||
PlansCards,
|
||
Header,
|
||
},
|
||
data() {
|
||
return {
|
||
plan: '',
|
||
subdomain: '',
|
||
cluster: null,
|
||
selectedPlan: null,
|
||
selectedGroup: null,
|
||
agreedToRegionConsent: false,
|
||
sitePlan: null,
|
||
trial: false,
|
||
};
|
||
},
|
||
watch: {
|
||
subdomain: {
|
||
handler: debounce(function (value) {
|
||
let invalidMessage = validateSubdomain(value);
|
||
this.$resources.subdomainExists.error = invalidMessage;
|
||
if (!invalidMessage) {
|
||
this.$resources.subdomainExists.submit();
|
||
}
|
||
}, 500),
|
||
},
|
||
},
|
||
resources: {
|
||
app() {
|
||
return {
|
||
url: 'jcloud.api.marketplace.get',
|
||
params: {
|
||
app: this.app,
|
||
},
|
||
auto: true,
|
||
};
|
||
},
|
||
installAppOptions() {
|
||
return {
|
||
url: 'jcloud.api.marketplace.get_install_app_options',
|
||
auto: true,
|
||
params: {
|
||
marketplace_app: this.app,
|
||
},
|
||
initialData: {
|
||
domain: '',
|
||
plans: [],
|
||
clusters: [],
|
||
private_groups: [],
|
||
},
|
||
async onSuccess() {
|
||
this.cluster = await this.getClosestCluster();
|
||
if (this.$resources.installAppOptions.data?.plans.length > 0) {
|
||
this.selectedPlan = this.$resources.installAppOptions.data.plans[0];
|
||
}
|
||
},
|
||
};
|
||
},
|
||
subdomainExists() {
|
||
return {
|
||
url: 'jcloud.api.site.exists',
|
||
makeParams() {
|
||
return {
|
||
domain: this.$resources.installAppOptions.data?.domain,
|
||
subdomain: this.subdomain,
|
||
};
|
||
},
|
||
validate() {
|
||
let error = validateSubdomain(this.subdomain);
|
||
if (error) {
|
||
throw new DashboardError(error);
|
||
}
|
||
},
|
||
transform(data) {
|
||
return !Boolean(data);
|
||
},
|
||
};
|
||
},
|
||
getTrialPlan() {
|
||
return {
|
||
url: 'jcloud.api.site.get_trial_plan',
|
||
auto: true,
|
||
};
|
||
},
|
||
newSite() {
|
||
if (!this.options) return;
|
||
|
||
return {
|
||
url: 'jcloud.api.marketplace.create_site_for_app',
|
||
makeParams() {
|
||
this.sitePlan = this.selectedGroup
|
||
? this.options.private_site_plan
|
||
: this.options.public_site_plan;
|
||
|
||
if (!this.$team.pg.onboarding.site_created) {
|
||
this.sitePlan = this.trialPlan;
|
||
this.trial = true;
|
||
}
|
||
return {
|
||
subdomain: this.subdomain,
|
||
site_plan: this.sitePlan,
|
||
apps: [
|
||
{
|
||
app: 'jingrow',
|
||
},
|
||
{
|
||
app: this.app,
|
||
plan: this.selectedPlan?.name,
|
||
},
|
||
],
|
||
cluster: this.cluster,
|
||
group: this.selectedGroup?.value,
|
||
trial: this.trial,
|
||
};
|
||
},
|
||
validate() {
|
||
if (
|
||
!this.$team.pg.payment_mode &&
|
||
(this.$team.pg.onboarding.site_created ||
|
||
!this.appDoc.show_for_new_site)
|
||
) {
|
||
throw new DashboardError('请添加有效的支付方式');
|
||
}
|
||
if (!this.selectedPlan && this.plans.length > 0) {
|
||
throw new DashboardError('请选择一个计划');
|
||
}
|
||
if (!this.subdomain) {
|
||
throw new DashboardError('请输入子域名');
|
||
}
|
||
if (!this.agreedToRegionConsent) {
|
||
throw new DashboardError(
|
||
'请同意上述同意书以创建站点',
|
||
);
|
||
}
|
||
},
|
||
onSuccess: (pg) => {
|
||
if (pg.pagetype === 'Site') {
|
||
this.$router.push({
|
||
name: 'Site Jobs',
|
||
params: { name: pg.name },
|
||
});
|
||
} else if (pg.pagetype === 'Site Group Deploy') {
|
||
this.$router.push({
|
||
name: 'CreateSiteForMarketplaceApp',
|
||
params: { app: this.app },
|
||
query: { siteGroupDeployName: pg.name },
|
||
});
|
||
}
|
||
},
|
||
};
|
||
},
|
||
},
|
||
methods: {
|
||
async getClosestCluster() {
|
||
if (this.closestCluster) return this.closestCluster;
|
||
let proxyServers = this.options.clusters
|
||
.flatMap((c) => c.proxy_server || [])
|
||
.map((server) => server.name);
|
||
|
||
if (proxyServers.length > 0) {
|
||
this.findingClosestServer = true;
|
||
let promises = proxyServers.map((server) => this.getPingTime(server));
|
||
let results = await Promise.allSettled(promises);
|
||
let fastestServer = results.reduce((a, b) =>
|
||
a.value.pingTime < b.value.pingTime ? a : b,
|
||
);
|
||
let closestServer = fastestServer.value.server;
|
||
let closestCluster = this.options.clusters.find(
|
||
(c) => c.proxy_server?.name === closestServer,
|
||
);
|
||
if (!this.closestCluster) {
|
||
this.closestCluster = closestCluster.name;
|
||
}
|
||
this.findingClosestServer = false;
|
||
} else if (proxyServers.length === 1) {
|
||
this.closestCluster = this.options.clusters[0].name;
|
||
}
|
||
return this.closestCluster;
|
||
},
|
||
async getPingTime(server) {
|
||
let pingTime = 999999;
|
||
try {
|
||
let t1 = new Date().getTime();
|
||
let r = await fetch(`https://${server}`);
|
||
let t2 = new Date().getTime();
|
||
pingTime = t2 - t1;
|
||
} catch (error) {
|
||
console.warn(error);
|
||
}
|
||
return { server, pingTime };
|
||
},
|
||
},
|
||
computed: {
|
||
appDoc() {
|
||
return this.$resources.app.data || {};
|
||
},
|
||
options() {
|
||
return this.$resources.installAppOptions.data;
|
||
},
|
||
plans() {
|
||
if (!this.$resources?.installAppOptions) return [];
|
||
return this.options.plans.map((plan) => ({
|
||
...plan,
|
||
label:
|
||
plan.price_cny === 0 || plan.price_usd === 0
|
||
? '免费'
|
||
: `${this.$format.userCurrency(
|
||
this.$team.pg.currency === 'CNY'
|
||
? plan.price_cny
|
||
: plan.price_usd,
|
||
)}/月`,
|
||
sublabel: ' ',
|
||
features: plan.features.map((f) => ({
|
||
value: f,
|
||
icon: 'check-circle',
|
||
})),
|
||
}));
|
||
},
|
||
regions() {
|
||
if (!this.selectedGroup) {
|
||
return this.options.clusters;
|
||
} else {
|
||
return this.options.private_groups.find(
|
||
(g) => g.name === this.selectedGroup.value,
|
||
).clusters;
|
||
}
|
||
},
|
||
trialPlan() {
|
||
return this.$resources.getTrialPlan.data;
|
||
},
|
||
},
|
||
};
|
||
</script> |