jcloud/dashboard/src2/pages/NewSite.vue
2025-04-20 09:35:07 +08:00

886 lines
25 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="sticky top-0 z-10 shrink-0">
<Header>
<FBreadcrumbs :items="breadcrumbs" />
</Header>
</div>
<div
v-if="!$team.pg?.is_desk_user && !$session.hasSiteCreationAccess"
class="mx-auto mt-60 w-fit rounded border border-dashed px-12 py-8 text-center text-gray-600"
>
<i-lucide-alert-triangle class="mx-auto mb-4 h-6 w-6 text-red-600" />
<ErrorMessage message="您没有权限创建新站点" />
</div>
<div v-else class="mx-auto max-w-2xl px-5">
<div v-if="$resources.options.loading" class="py-4 text-base text-gray-600">
加载中...
</div>
<div v-if="$route.name === 'NewBenchSite' && !bench">
<div class="py-4 text-base text-gray-600">出错了</div>
</div>
<div v-else-if="options" class="space-y-12 pb-[50vh] pt-12">
<NewSiteAppSelector
:availableApps="selectedVersionAppOptions"
:siteOnPublicBench="!bench"
v-model="apps"
/>
<div v-if="showLocalisationSelector" class="space-y-4">
<div class="flex space-x-2">
<FormControl
label="安装本地合规应用?"
v-model="showLocalisationOption"
type="checkbox"
/>
<Tooltip
text="本地合规应用允许根据法定合规性创建交易。它们由社区合作伙伴维护。"
>
<i-lucide-info class="h-4 w-4 text-gray-500" />
</Tooltip>
</div>
<FormControl
class="w-1/2"
variant="outline"
:class="{ 'pointer-events-none opacity-50': !showLocalisationOption }"
label="选择国家"
v-model="selectedLocalisationCountry"
type="autocomplete"
:options="localisationAppCountries"
/>
</div>
<div v-if="!bench">
<div class="flex items-center justify-between">
<h2 class="text-base font-medium leading-6 text-gray-900">
选择 Jingrow 版本
</h2>
</div>
<div class="mt-2">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<component
v-for="v in availableVersions"
:key="v.name"
:is="v.disabled ? 'Tooltip' : 'div'"
:text="
v.disabled && versionAppsMap[v.name]
? `此版本不适用于 ${$format.plural(
versionAppsMap[v.name].length,
'app',
'apps',
)} ${$format.commaAnd(versionAppsMap[v.name])}`
: ''
"
>
<button
:class="[
version === v.name
? 'border-gray-900 ring-1 ring-gray-900 hover:bg-gray-100'
: 'bg-white text-gray-900 hover:bg-gray-50',
v.disabled && 'opacity-50 hover:cursor-default',
'flex w-full cursor-pointer items-center justify-between rounded border border-gray-400 p-3 text-sm focus:outline-none',
]"
@click="
() => {
if (v.disabled) return;
version = v.name;
}
"
>
<span class="font-medium">{{ v.name }} </span>
<div
v-if="v.status === 'Develop'"
class="flex items-center gap-2"
>
<Tooltip
text="此版本正在开发中,可能存在错误。请勿用于生产站点。"
>
<i-lucide-info class="h-4 w-4 text-gray-500" />
</Tooltip>
<span class="ml-1 text-gray-600">
{{ v.status }}
</span>
</div>
</button>
</component>
</div>
</div>
</div>
<div
class="flex flex-col"
v-if="selectedVersion?.group?.clusters?.length"
>
<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 selectedVersion.group.clusters"
: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 v-if="c.image" :src="c.image" class="h-5 w-5" onerror="this.src='/assets/images/china-flag.png'; this.onerror=null;" />
<i-lucide-globe class="h-5 w-5 text-gray-600" v-else />
<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 v-if="selectedVersion && cluster">
<div class="flex items-center justify-between">
<h2 class="text-base font-medium leading-6 text-gray-900">
选择网站套餐计划
</h2>
</div>
<div class="mt-2">
<SitePlansCards
v-model="plan"
:isPrivateBenchSite="!!bench"
:isDedicatedServerSite="selectedVersion.group.is_dedicated_server"
:selectedCluster="cluster"
:selectedApps="apps"
:selectedVersion="version"
:hideRestrictedPlans="selectedLocalisationCountry"
/>
</div>
<div class="mt-4 text-xs text-gray-700">
<div
class="flex items-center rounded bg-gray-50 p-2 text-p-base font-medium text-gray-800"
>
<i-lucide-badge-check class="h-4 w-8 text-gray-600" />
<span class="ml-4">
安装使用 Jingrow 系统遇到任何问题请提交支持工单或联系在线客服
</span>
</div>
</div>
</div>
<div v-if="selectedVersion && plan && cluster">
<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">
<div
v-if="$resources.subdomainExists.loading"
class="text-base 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>
<ErrorMessage :message="$resources.subdomainExists.error" />
</div>
</div>
<Summary
v-if="selectedVersion && cluster && plan && subdomain"
:options="siteSummaryOptions"
/>
<div
v-if="selectedVersion && cluster && plan"
class="flex flex-col space-y-4"
>
<FormControl
type="checkbox"
v-model="agreedToRegionConsent"
:label="`我同意我所选地区的法律适用于我和Jingrow。`"
/>
<FormControl
class="checkbox"
type="checkbox"
label="我同意将我的信息分享给本地合作伙伴"
@change="(val) => (shareDetailsConsent = val.target.checked)"
/>
<div v-if="formError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-4">
{{ formError }}
</div>
</div>
<div v-if="selectedVersion && cluster && plan && subdomain">
<Button
class="w-full"
variant="solid"
:disabled="!agreedToRegionConsent || !isSubdomainValid"
@click="checkBalanceAndCreateSite"
:loading="$resources.createOrder?.loading || false"
:loadingText="'正在创建订单...'"
>
创建站点
</Button>
</div>
</div>
</div>
<Dialog
v-if="showAddPrepaidCreditsDialog"
v-model="showAddPrepaidCreditsDialog"
:options="{ title: '新建网站' }"
>
<template #body-content>
<div v-if="showBalanceMessage" class="mb-5 inline-flex gap-1.5 text-base text-gray-700">
<i-lucide-info class="h-4 w-4" />
<span>
您当前的余额不足请先充值以创建新站点至少需要充值 {{ $format.userCurrency(Math.ceil(requiredAmount - userBalance)) }}
</span>
</div>
<BuyPrepaidCreditsForm
:minimumAmount="Math.ceil(requiredAmount - userBalance)"
:isOnboarding="false"
@success="onCreditAdded"
@cancel="showAddPrepaidCreditsDialog = false"
/>
</template>
</Dialog>
<div v-if="showOrderCheckout && !currentOrder" class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
<div class="bg-white p-6 rounded-lg shadow-xl">
<p>正在加载支付界面...</p>
</div>
</div>
<OrderCheckout
v-if="currentOrder"
v-model="showOrderCheckout"
:order="currentOrder"
@payment-success="onPaymentSuccess"
@payment-error="onPaymentError"
@close="resetCheckoutState"
/>
</template>
<script>
import {
Autocomplete,
ErrorMessage,
FeatherIcon,
FormControl,
TextInput,
Tooltip,
debounce,
Breadcrumbs,
getCachedDocumentResource,
Dialog,
} from 'jingrow-ui';
import SitePlansCards from '../components/SitePlansCards.vue';
import { validateSubdomain } from '../utils/site';
import Header from '../components/Header.vue';
import router from '../router';
import { plans } from '../data/plans';
import NewSiteAppSelector from '../components/site/NewSiteAppSelector.vue';
import Summary from '../components/Summary.vue';
import { DashboardError } from '../utils/error';
import { getCountry } from '../utils/country';
import BuyPrepaidCreditsForm from '../components/BuyPrepaidCreditsForm.vue';
import OrderCheckout from '../components/OrderCheckout.vue';
export default {
name: 'NewSite',
props: ['bench'],
components: {
FBreadcrumbs: Breadcrumbs,
NewSiteAppSelector,
SitePlansCards,
Autocomplete,
ErrorMessage,
FormControl,
FeatherIcon,
TextInput,
Tooltip,
Summary,
Header,
Dialog,
BuyPrepaidCreditsForm,
OrderCheckout,
},
data() {
return {
version: null,
subdomain: '',
cluster: null,
plan: null,
apps: [],
appPlans: {},
selectedApp: null,
closestCluster: null,
selectedLocalisationCountry: null,
showLocalisationOption: false,
showAppPlanSelectorDialog: false,
shareDetailsConsent: false,
agreedToRegionConsent: false,
showAddPrepaidCreditsDialog: false,
showBalanceMessage: false,
requiredAmount: 0,
userBalance: 0,
showOrderCheckout: false,
currentOrder: null,
formError: null,
siteFromOrder: null
};
},
watch: {
apps() {
this.version = this.autoSelectVersion();
this.cluster = null;
this.agreedToRegionConsent = false;
},
showLocalisationOption() {
if (this.showLocalisationOption) {
const localisationAppCountries = this.localisationAppCountries.map(
(c) => c.value,
);
if (
localisationAppCountries.includes(getCountry()) &&
!this.selectedLocalisationCountry
) {
this.selectedLocalisationCountry = { value: getCountry() };
}
} else {
this.selectedLocalisationCountry = null;
}
},
async version() {
this.cluster = null;
this.cluster = await this.getClosestCluster();
this.agreedToRegionConsent = false;
},
cluster() {
this.plan = null;
this.agreedToRegionConsent = false;
},
subdomain: {
handler: debounce(function (value) {
let invalidMessage = validateSubdomain(value);
this.$resources.subdomainExists.error = invalidMessage;
if (!invalidMessage) {
this.$resources.subdomainExists.submit();
}
}, 500),
},
closestCluster() {
this.cluster = this.closestCluster;
},
},
resources: {
options() {
return {
url: 'jcloud.api.site.options_for_new',
makeParams() {
return { for_bench: this.bench };
},
onSuccess() {
if (this.bench && this.options.versions.length > 0) {
this.version = this.options.versions[0].name;
}
},
auto: true,
};
},
subdomainExists() {
return {
url: 'jcloud.api.site.exists',
makeParams() {
return {
domain: this.options?.domain,
subdomain: this.subdomain,
};
},
validate() {
let error = validateSubdomain(this.subdomain);
if (error) {
return new DashboardError(error);
}
},
transform(data) {
return !Boolean(data);
},
};
},
userBalance() {
return {
url: 'jcloud.api.billing.get_balance_credit',
auto: true,
onSuccess(data) {
this.userBalance = data;
},
};
},
createOrder() {
return {
url: 'jcloud.api.billing.create_order',
makeParams() {
return {
title: `${this.subdomain}.${this.options?.domain}`,
description: `${this.plan.plan_title || this.plan.name}`,
total_amount: this._totalAmount || 0,
order_type: `新建网站`
};
},
validate() {
if (!this.subdomain) {
throw new DashboardError('请输入子域名');
}
if (!this._totalAmount || this._totalAmount <= 0) {
throw new DashboardError('订单金额必须大于0');
}
},
onSuccess(response) {
if (!response || !response.order) {
this.formError = '服务器返回数据格式错误';
return;
}
this.currentOrder = response.order;
this.$nextTick(() => {
this.showOrderCheckout = true;
});
},
onError(error) {
this.formError = error.message || '创建订单失败';
}
};
},
createSite() {
return {
url: 'jcloud.api.site.new',
makeParams() {
let appPlans = {};
for (let app of this.apps) {
if (app.plan) {
appPlans[app.app] = app.plan;
}
}
return {
site: {
name: this.subdomain,
apps: ['jingrow', ...this.apps.map((app) => app.app)],
localisation_country: this.showLocalisationSelector
? this.selectedLocalisationCountry?.value
: null,
version: this.selectedVersion.name,
group: this.selectedVersion.group.name,
cluster: this.cluster,
plan: this.plan.name,
share_details_consent: this.shareDetailsConsent,
selected_app_plans: appPlans,
order_id: this.currentOrder?.order_id || null
},
};
},
validate() {
if (!this.subdomain) {
throw new DashboardError('请输入子域名');
}
if (!this.agreedToRegionConsent) {
throw new DashboardError('请同意上述条款以创建站点');
}
if (this.currentOrder && !this.currentOrder.status) {
throw new DashboardError('未检测到订单状态,无法创建站点');
}
},
onSuccess: (site) => {
this.$router.push(`/sites/${this.subdomain}.${this.options?.domain}/overview`);
},
onError(error) {
this.formError = error.message || '站点创建失败,请联系客服';
}
};
},
},
computed: {
options() {
return this.$resources.options.data;
},
selectedVersion() {
return this.options?.versions.find((v) => v.name === this.version);
},
availableVersions() {
if (!this.apps.length || this.bench)
return this.options.versions.sort((a, b) =>
b.name.localeCompare(a.name),
);
let commonVersions = this.apps.reduce((acc, app) => {
if (!acc) return app.sources.map((s) => s.version);
return acc.filter((v) => app.sources.map((s) => s.version).includes(v));
}, null);
if (this.selectedLocalisationCountry) {
// temporary override since we don't have localisation app ready for v14
// TODO: remove this when localisation app is ready for v14
commonVersions = ['v1'];
this.version = 'v1';
}
return this.options.versions.map((v) => ({
...v,
disabled: !commonVersions.includes(v.name),
}));
},
selectedClusterTitle() {
return this.selectedVersion?.group?.clusters?.find(
(c) => c.name === this.cluster,
)?.title;
},
selectedVersionApps() {
let apps = [];
if (!this.bench)
apps = this.options.app_source_details.sort((a, b) =>
a.total_installs !== b.total_installs
? b.total_installs - a.total_installs
: a.app.localeCompare(b.app),
);
else if (!this.selectedVersion?.group?.bench_app_sources) apps = [];
else
apps = this.selectedVersion.group.bench_app_sources.map(
(app_source) => {
let app_source_details =
this.options.app_source_details[app_source];
let marketplace_details = app_source_details
? this.options.marketplace_details[app_sourcedetails.app]
: {};
return {
app_title: app_source,
...app_source_details,
...marketplace_details,
};
},
);
// 按总安装量排序,然后按名称排序
return apps.sort((a, b) => {
if (a.total_installs > b.total_installs) {
return -1;
} else if (a.total_installs < b.total_installs) {
return 1;
} else {
return a.app_title.localeCompare(b.app_title);
}
});
},
selectedVersionAppOptions() {
return this.selectedVersionApps.filter(
(app) => !this.localisationAppNames.includes(app.app),
);
},
showLocalisationSelector() {
if (
!this.selectedVersionApps ||
!this.localisationAppNames.length ||
!this.apps.length
)
return false;
const appsThatNeedLocalisation = this.selectedVersionApps.filter(
(app) => app.localisation_apps.length,
);
if (
appsThatNeedLocalisation.some((app) =>
this.apps.map((a) => a.app).includes(app.app),
)
)
return true;
return false;
},
localisationAppNames() {
if (!this.selectedVersionApps) return [];
const localisationAppDetails = this.selectedVersionApps.flatMap(
(app) => app.localisation_apps,
);
return localisationAppDetails
.map((app) => app?.marketplace_app)
.filter(Boolean);
},
localisationAppCountries() {
if (!this.selectedVersionApps) return [];
const localisationAppDetails = this.selectedVersionApps.flatMap(
(app) => app.localisation_apps,
);
return localisationAppDetails.map((app) => ({
label: app?.country,
value: app?.country,
}));
},
selectedPlan() {
if (!plans?.data) return;
return plans.data.find((p) => p.name === this.plan.name);
},
versionAppsMap() {
const versions = this.availableVersions.map((v) => v.name);
let problemAppVersions = {};
if (!this.bench)
for (let app of this.apps) {
const appVersions = app.sources.map((s) => s.version);
const problemVersions = versions.filter(
(version) => !appVersions.includes(version),
);
for (let version of problemVersions) {
if (!problemAppVersions[version]) {
problemAppVersions[version] = [];
}
problemAppVersions[version].push(app.app_title);
}
}
return problemAppVersions;
},
breadcrumbs() {
if (this.bench) {
let group = getCachedDocumentResource('Release Group', this.bench);
return [
{ label: '站点分组', route: '/groups' },
{
label: group ? group.pg.title : this.bench,
route: {
name: 'Release Group Detail',
params: { name: this.bench },
},
},
{
label: '新建站点',
route: {
name: 'Release Group New Site',
params: { bench: this.bench },
},
},
];
}
return [
{ label: '站点', route: '/sites' },
{ label: '新建站点', route: '/sites/new' },
];
},
_totalAmount() {
let total =
this.$team.pg.currency == 'CNY'
? this.selectedPlan.price_cny
: this.selectedPlan.price_usd;
for (let app of this.apps.filter((app) => app.plan)) {
total +=
this.$team.pg.currency == 'CNY'
? app.plan.price_cny
: app.plan.price_usd;
}
return total;
},
totalAmount() {
return this.$format.userCurrency(this._totalAmount);
},
totalPerMonth() {
return this.totalAmount;
},
totalPerDay() {
return this.$format.userCurrency(
this.$format.pricePerDay(this._totalAmount),
);
},
siteSummaryOptions() {
let appPlans = [];
for (let app of this.apps) {
appPlans.push(
`${
this.selectedVersionApps.find((a) => a.app === app.app).app_title
} ${
app.plan?.price_cny
? `- <span class="text-gray-600">${this.$format.userCurrency(
this.$team.pg.currency == 'CNY'
? app.plan.price_cny
: app.plan.price_usd,
)}</span>`
: ''
}`,
);
}
return [
{
label: 'Jingrow 版本',
value: this.selectedVersion?.name,
},
{
label: '区域',
value: this.selectedClusterTitle,
},
{
label: '站点 URL',
value: `${this.subdomain}.${this.options?.domain}`,
},
{
label: '站点计划',
value: this.selectedPlan.plan_title || this.plan.name,
},
{
label: '总计',
value: this.totalAmount,
condition: () => this._totalAmount,
},
];
},
isSubdomainValid() {
return this.$resources.subdomainExists.data === true &&
!this.$resources.subdomainExists.error &&
this.subdomain;
},
},
methods: {
async getClosestCluster() {
if (this.closestCluster) return this.closestCluster;
// 优先检查是否有中国地区的集群
const chinaCluster = this.selectedVersion?.group?.clusters.find(
c => c.title && c.title.includes('中国')
);
if (chinaCluster) {
this.closestCluster = chinaCluster.name;
return this.closestCluster;
}
let proxyServers = this.selectedVersion?.group?.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.selectedVersion?.group?.clusters.find(
(c) => c.proxy_server?.name === closestServer,
);
if (!this.closestCluster && closestCluster) {
this.closestCluster = closestCluster.name;
} else if (this.selectedVersion?.group?.clusters.length > 0) {
// 如果找不到最快的服务器,默认选择第一个集群
this.closestCluster = this.selectedVersion.group.clusters[0].name;
}
this.findingClosestServer = false;
} else if (this.selectedVersion?.group?.clusters.length > 0) {
this.closestCluster = this.selectedVersion.group.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 };
},
autoSelectVersion() {
if (!this.availableVersions) return null;
return this.availableVersions
.sort((a, b) => b.name.localeCompare(a.name))
.find((v) => !v.disabled)?.name;
},
async checkBalanceAndCreateSite() {
try {
this.formError = null;
if (!this.agreedToRegionConsent) {
this.formError = '请同意上述条款以创建站点';
return;
}
if (!this.subdomain) {
this.formError = '请输入子域名';
return;
}
if (!this.isSubdomainValid) {
this.formError = '子域名无效或已被占用';
return;
}
this.$resources.createOrder.submit();
} catch (error) {
this.formError = `创建订单失败: ${error.message || '请稍后再试'}`;
}
},
onPaymentSuccess(order) {
this.showOrderCheckout = false;
this.currentOrder = {
...this.currentOrder,
...order,
status: '已支付'
};
this.siteFromOrder = order.site;
this.$resources.createSite.submit();
},
onPaymentError(error) {
this.formError = "支付失败: " + (error.message || "未知错误");
if (error.message && error.message.includes('余额不足')) {
this.requiredAmount = this._totalAmount || 0;
this.showBalanceMessage = true;
this.showAddPrepaidCreditsDialog = true;
}
this.showOrderCheckout = false;
},
resetCheckoutState() {
this.showOrderCheckout = false;
this.currentOrder = null;
}
},
};
</script>
<style scoped>
.checkbox:deep(label) {
color: theme('colors.gray.700') !important;
line-height: 1.5;
}
</style>