jcloud/dashboard/src2/pages/InstallApp.vue
2025-04-12 17:39:38 +08:00

435 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>