基于naive ui重构设置-开发者页面及弹窗

This commit is contained in:
jingrow 2025-12-30 00:49:55 +08:00
parent 34577e321d
commit 8b617d833b
5 changed files with 1240 additions and 485 deletions

View File

@ -1,83 +1,175 @@
<template> <template>
<Dialog <n-modal
:options="{ v-model:show="showDialog"
title: '激活Webhook' preset="card"
}" :title="$t('Activate Webhook')"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
class="activate-webhook-modal"
> >
<template #body-content> <template #header>
<div class="space-y-4"> <span class="text-lg font-semibold">{{ $t('Activate Webhook') }}</span>
<FormControl label="端点" v-model="webhook.endpoint" disabled /> </template>
<div v-if="request"> <n-space vertical :size="20">
<p class="text-xs text-gray-600">请求</p> <n-form-item :label="$t('Endpoint')">
<pre <n-input
class="mt-2 whitespace-pre-wrap rounded bg-gray-50 px-2 py-1.5 text-sm text-gray-600" :value="webhook.endpoint"
>{{ request }}</pre
>
</div>
<FormControl
v-if="response_status_code"
label="响应状态码"
v-model="response_status_code"
disabled disabled
:size="inputSize"
class="w-full"
/> />
<div v-if="response"> </n-form-item>
<p class="text-xs text-gray-600">响应</p> <div v-if="request">
<pre <p class="text-xs text-gray-600 mb-2">{{ $t('Request') }}</p>
class="mt-2 max-h-52 overflow-y-auto whitespace-pre-wrap rounded bg-gray-50 px-2 py-1.5 text-sm text-gray-600" <pre class="mt-2 whitespace-pre-wrap rounded bg-gray-50 px-3 py-2 text-sm text-gray-700 border border-gray-200">
>{{ response }}</pre {{ request }}
> </pre>
</div>
<div class="flex items-center" v-if="validated">
<ILucideCheck class="h-4 text-green-600" />
<div class="ml-2 text-sm font-medium text-gray-700">
端点已验证
</div>
</div>
<ErrorMessage :message="errorMessage" />
</div> </div>
<n-form-item v-if="response_status_code" :label="$t('Response Status Code')">
<n-input
:value="response_status_code"
disabled
:size="inputSize"
class="w-full"
/>
</n-form-item>
<div v-if="response">
<p class="text-xs text-gray-600 mb-2">{{ $t('Response') }}</p>
<pre class="mt-2 max-h-52 overflow-y-auto whitespace-pre-wrap rounded bg-gray-50 px-3 py-2 text-sm text-gray-700 border border-gray-200">
{{ response }}
</pre>
</div>
<div class="flex items-center" v-if="validated">
<n-icon class="h-5 w-5 text-green-600 mr-2">
<CheckIcon />
</n-icon>
<div class="text-sm font-medium text-gray-700">
{{ $t('Endpoint validated') }}
</div>
</div>
<n-alert v-if="errorMessage" type="error" :title="errorMessage" />
</n-space>
<template #action>
<n-space :vertical="isMobile" :size="isMobile ? 12 : 16" class="w-full">
<n-button @click="hide" :block="isMobile" :size="buttonSize">
{{ $t('Cancel') }}
</n-button>
<n-button
v-if="!validated"
type="primary"
:loading="$resources.validateEndpoint.loading"
@click="$resources.validateEndpoint.submit()"
:block="isMobile"
:size="buttonSize"
>
{{ $t('Validate Webhook') }}
</n-button>
<n-button
v-if="validated"
type="primary"
:loading="$resources.activateWebhook.loading"
@click="$resources.activateWebhook.submit()"
:block="isMobile"
:size="buttonSize"
>
{{ $t('Activate Webhook') }}
</n-button>
</n-space>
</template> </template>
<template v-slot:actions> </n-modal>
<Button
class="w-full"
theme="gray"
variant="solid"
@click="$resources.validateEndpoint.submit()"
:loading="$resources.validateEndpoint.loading"
loadingText="正在验证Webhook"
v-if="!validated"
>验证Webhook</Button
>
<Button
class="w-full"
theme="gray"
variant="solid"
@click="$resources.activateWebhook.submit()"
:loading="$resources.activateWebhook.loading"
loadingText="正在激活Webhook"
v-if="validated"
>激活Webhook</Button
>
</template>
</Dialog>
</template> </template>
<script> <script>
import {
NModal,
NFormItem,
NInput,
NSpace,
NAlert,
NButton,
NIcon,
} from 'naive-ui';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import CheckIcon from '~icons/lucide/check';
export default { export default {
emits: ['success'], components: {
props: ['webhook'], NModal,
NFormItem,
NInput,
NSpace,
NAlert,
NButton,
NIcon,
CheckIcon,
},
emits: ['success', 'update:modelValue'],
props: {
webhook: {
type: Object,
required: true
},
modelValue: {
type: Boolean,
default: false
}
},
data() { data() {
return { return {
errorMessage: '', errorMessage: '',
validated: false, validated: false,
request: null, request: null,
response: null, response: null,
response_status_code: null response_status_code: null,
windowWidth: window.innerWidth,
}; };
}, },
computed: {
showDialog: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
}
},
isMobile() {
return this.windowWidth <= 768;
},
modalStyle() {
return {
width: this.isMobile ? '95vw' : '800px',
maxWidth: this.isMobile ? '95vw' : '90vw',
};
},
inputSize() {
return this.isMobile ? 'medium' : 'large';
},
buttonSize() {
return this.isMobile ? 'medium' : 'medium';
},
},
mounted() {
this.handleResize();
window.addEventListener('resize', this.handleResize);
},
beforeUnmount() {
window.removeEventListener('resize', this.handleResize);
},
methods: {
handleResize() {
this.windowWidth = window.innerWidth;
},
hide() {
this.showDialog = false;
this.errorMessage = '';
this.validated = false;
this.request = null;
this.response = null;
this.response_status_code = null;
}
},
resources: { resources: {
validateEndpoint() { validateEndpoint() {
return { return {
@ -99,9 +191,7 @@ export default {
this.validated = true; this.validated = true;
} else { } else {
this.validated = false; this.validated = false;
this.errorMessage = this.$t('Endpoint should return a status code between 200 and 300. Please check the endpoint and try again.');
this.errorMessage =
'端点应返回200到300之间的状态\n请检查端点并重试';
} }
}, },
onError: e => { onError: e => {
@ -121,7 +211,8 @@ export default {
}; };
}, },
onSuccess(e) { onSuccess(e) {
toast.success('Webhook激活成功'); toast.success(this.$t('Webhook activated successfully'));
this.hide();
this.$emit('success'); this.$emit('success');
}, },
onError(e) { onError(e) {
@ -132,4 +223,55 @@ export default {
} }
} }
}; };
</script> </script>
<style scoped>
:deep(.activate-webhook-modal .n-card) {
width: 800px;
max-width: 90vw;
}
:deep(.activate-webhook-modal .n-card-body) {
padding: 24px;
}
:deep(.activate-webhook-modal .n-form-item) {
margin-bottom: 0;
}
:deep(.activate-webhook-modal .n-input) {
width: 100%;
}
:deep(.activate-webhook-modal .n-card__action) {
padding: 16px 24px;
}
@media (max-width: 768px) {
:deep(.activate-webhook-modal .n-card) {
width: 95vw !important;
max-width: 95vw !important;
margin: 20px auto;
border-radius: 12px;
}
:deep(.activate-webhook-modal .n-card-body) {
padding: 20px 16px;
}
:deep(.activate-webhook-modal .n-card__header) {
padding: 16px;
font-size: 18px;
}
:deep(.activate-webhook-modal .n-card__action) {
padding: 12px 16px;
}
:deep(.activate-webhook-modal .n-button) {
width: 100%;
font-size: 16px;
height: 44px;
}
}
</style>

View File

@ -1,114 +1,179 @@
<template> <template>
<Dialog <n-modal
:options="{ v-model:show="showDialog"
title: $t('Add New Webhook'), preset="card"
actions: [ :title="$t('Add New Webhook')"
{ :style="modalStyle"
label: $t('Add Webhook'), :mask-closable="true"
variant: 'solid', :close-on-esc="true"
onClick: addWebhook, class="add-webhook-modal"
loading: $resources?.addWebhook?.loading
}
]
}"
> >
<template #body-content> <template #header>
<div class="space-y-4"> <span class="text-lg font-semibold">{{ $t('Add New Webhook') }}</span>
<FormControl :label="$t('Endpoint')" v-model="endpoint" /> </template>
<div> <n-space vertical :size="20">
<FormControl :label="$t('Secret Key')" v-model="secret"> <n-form-item :label="$t('Endpoint')" :required="true">
<template #suffix> <n-input
<FeatherIcon v-model:value="endpoint"
class="w-4 cursor-pointer" :placeholder="$t('Enter webhook endpoint URL')"
name="refresh-ccw" :size="inputSize"
@click="generateRandomSecret" class="w-full"
/> />
</template> </n-form-item>
</FormControl> <n-form-item :label="$t('Secret Key')">
<p class="mt-2 text-sm text-gray-700"> <n-input
<strong>{{ $t('Note') }}:</strong> {{ $t('The secret key is optional. View') }} v-model:value="secret"
<a href="https://jingrow.com/docs/webhook-introduction" class="underline" target="_blank" :placeholder="$t('Enter secret key (optional)')"
>{{ $t('documentation') }}</a :size="inputSize"
class="w-full"
>
<template #suffix>
<n-icon
class="cursor-pointer text-gray-500 hover:text-gray-700"
@click="generateRandomSecret"
> >
{{ $t('to learn more') }} <RefreshIcon />
</p> </n-icon>
</div> </template>
<p class="text-base font-medium text-gray-900"> </n-input>
</n-form-item>
<n-alert type="info" class="mb-2">
<template #header>
<strong>{{ $t('Note') }}:</strong>
</template>
{{ $t('The secret key is optional. View') }}
<a href="https://jingrow.com/docs/webhook-introduction" class="text-primary underline" target="_blank">
{{ $t('documentation') }}
</a>
{{ $t('to learn more') }}
</n-alert>
<div class="mt-4">
<p class="text-base font-medium text-gray-900 mb-4">
{{ $t('Select Webhook Events') }} {{ $t('Select Webhook Events') }}
</p> </p>
<div <n-spin :show="$resources.events.loading">
class="text-center text-sm leading-10 text-gray-500" <n-space vertical :size="12" v-if="!$resources.events.loading">
v-if="$resources.events.loading" <div
> v-for="event in localizedEvents"
{{ $t('Loading...') }} :key="event.name"
</div> class="flex items-center justify-between p-3 rounded border border-gray-200 hover:bg-gray-50"
<div class="mt-6 flex flex-col gap-4" v-else> >
<Switch <div class="flex-1">
v-for="event in localizedEvents" <div class="text-sm font-medium text-gray-900">
:key="event.name" {{ event.title || event.name }}
:label="event.title || event.name" </div>
:description="event.description" <div v-if="event.description" class="text-xs text-gray-500 mt-1">
:modelValue="isEventSelected(event.name)" {{ event.description }}
@update:modelValue="selectEvent(event.name)" </div>
size="sm" </div>
/> <n-switch
</div> :value="isEventSelected(event.name)"
<ErrorMessage :message="errorMessage || $resources.addWebhook.error" /> @update:value="selectEvent(event.name)"
size="medium"
/>
</div>
</n-space>
</n-spin>
</div> </div>
<n-alert
v-if="errorMessage || $resources.addWebhook.error"
type="error"
:title="errorMessage || $resources.addWebhook.error"
/>
</n-space>
<template #action>
<n-space :vertical="isMobile" :size="isMobile ? 12 : 16" class="w-full">
<n-button @click="hide" :block="isMobile" :size="buttonSize">
{{ $t('Cancel') }}
</n-button>
<n-button
type="primary"
:loading="$resources.addWebhook.loading"
@click="addWebhook"
:block="isMobile"
:size="buttonSize"
>
{{ $t('Add Webhook') }}
</n-button>
</n-space>
</template> </template>
</Dialog> </n-modal>
</template> </template>
<script> <script>
import {
NModal,
NFormItem,
NInput,
NSpace,
NAlert,
NButton,
NSwitch,
NIcon,
NSpin,
} from 'naive-ui';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import RefreshIcon from '~icons/lucide/refresh-ccw';
export default { export default {
emits: ['success'], components: {
NModal,
NFormItem,
NInput,
NSpace,
NAlert,
NButton,
NSwitch,
NIcon,
NSpin,
RefreshIcon,
},
emits: ['success', 'update:modelValue'],
props: {
modelValue: {
type: Boolean,
default: false
}
},
data() { data() {
return { return {
endpoint: '', endpoint: '',
secret: '', secret: '',
selectedEvents: [], selectedEvents: [],
errorMessage: '' errorMessage: '',
windowWidth: window.innerWidth,
}; };
}, },
mounted() { computed: {
if (this.selectedEvents.length) { showDialog: {
this.selectedEvents = this.selectedEvents.map(event => event.name); get() {
} return this.modelValue;
}, },
resources: { set(value) {
events() { this.$emit('update:modelValue', value);
}
},
isMobile() {
return this.windowWidth <= 768;
},
modalStyle() {
return { return {
url: 'jcloud.api.webhook.available_events', width: this.isMobile ? '95vw' : '800px',
inititalData: [], maxWidth: this.isMobile ? '95vw' : '90vw',
auto: true
}; };
}, },
addWebhook() { inputSize() {
return { return this.isMobile ? 'medium' : 'large';
url: 'jcloud.api.webhook.add', },
params: { buttonSize() {
endpoint: this.endpoint, return this.isMobile ? 'medium' : 'medium';
secret: this.secret, },
events: this.selectedEvents
},
onSuccess() {
toast.success(this.$t('Webhook added successfully'));
this.$emit('success');
}
};
}
},
computed: {
localizedEvents() { localizedEvents() {
if (!this.$resources.events.data) return []; if (!this.$resources.events.data) return [];
return this.$resources.events.data.map(event => { return this.$resources.events.data.map(event => {
//
const localizedEvent = { ...event }; const localizedEvent = { ...event };
//
if (localizedEvent.name === 'Site Status Update') { if (localizedEvent.name === 'Site Status Update') {
localizedEvent.title = this.$t('Site Status Update'); localizedEvent.title = this.$t('Site Status Update');
localizedEvent.description = this.$t('Pending, Installing, Updating, Active, Inactive, Abnormal, Archived, Paused'); localizedEvent.description = this.$t('Pending, Installing, Updating, Active, Inactive, Abnormal, Archived, Paused');
@ -116,7 +181,6 @@ export default {
localizedEvent.title = this.$t('Site Plan Change'); localizedEvent.title = this.$t('Site Plan Change');
localizedEvent.description = this.$t('Get notifications for site subscription plan changes'); localizedEvent.description = this.$t('Get notifications for site subscription plan changes');
} else { } else {
// title使namedescription使
if (!localizedEvent.title) { if (!localizedEvent.title) {
localizedEvent.title = localizedEvent.name; localizedEvent.title = localizedEvent.name;
} }
@ -129,7 +193,20 @@ export default {
}); });
} }
}, },
mounted() {
this.handleResize();
window.addEventListener('resize', this.handleResize);
if (this.selectedEvents.length) {
this.selectedEvents = this.selectedEvents.map(event => event.name);
}
},
beforeUnmount() {
window.removeEventListener('resize', this.handleResize);
},
methods: { methods: {
handleResize() {
this.windowWidth = window.innerWidth;
},
generateRandomSecret() { generateRandomSecret() {
this.secret = Array(30) this.secret = Array(30)
.fill(0) .fill(0)
@ -162,7 +239,89 @@ export default {
} }
this.errorMessage = ''; this.errorMessage = '';
this.$resources.addWebhook.submit(); this.$resources.addWebhook.submit();
},
hide() {
this.showDialog = false;
this.endpoint = '';
this.secret = '';
this.selectedEvents = [];
this.errorMessage = '';
}
},
resources: {
events() {
return {
url: 'jcloud.api.webhook.available_events',
inititalData: [],
auto: true
};
},
addWebhook() {
return {
url: 'jcloud.api.webhook.add',
params: {
endpoint: this.endpoint,
secret: this.secret,
events: this.selectedEvents
},
onSuccess() {
toast.success(this.$t('Webhook added successfully'));
this.hide();
this.$emit('success');
}
};
} }
} }
}; };
</script> </script>
<style scoped>
:deep(.add-webhook-modal .n-card) {
width: 800px;
max-width: 90vw;
}
:deep(.add-webhook-modal .n-card-body) {
padding: 24px;
}
:deep(.add-webhook-modal .n-form-item) {
margin-bottom: 0;
}
:deep(.add-webhook-modal .n-input) {
width: 100%;
}
:deep(.add-webhook-modal .n-card__action) {
padding: 16px 24px;
}
@media (max-width: 768px) {
:deep(.add-webhook-modal .n-card) {
width: 95vw !important;
max-width: 95vw !important;
margin: 20px auto;
border-radius: 12px;
}
:deep(.add-webhook-modal .n-card-body) {
padding: 20px 16px;
}
:deep(.add-webhook-modal .n-card__header) {
padding: 16px;
font-size: 18px;
}
:deep(.add-webhook-modal .n-card__action) {
padding: 12px 16px;
}
:deep(.add-webhook-modal .n-button) {
width: 100%;
font-size: 16px;
height: 44px;
}
}
</style>

View File

@ -1,103 +1,220 @@
<template> <template>
<div class="p-5"> <div class="developer-settings-container">
<div class="grid grid-cols-1 gap-5"> <n-space vertical :size="24">
<div <!-- API Access 卡片 -->
class="mx-auto min-w-[48rem] max-w-3xl space-y-6 rounded-md border p-4" <n-card :title="$t('API Access')" class="settings-card">
> <n-space vertical :size="20">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between flex-wrap gap-4">
<div class="text-xl font-semibold">{{ $t('API Access') }}</div> <div class="text-base text-gray-700">
<Button @click="showCreateSecretDialog = true">{{
apiKeyButtonLabel
}}</Button>
</div>
<div v-if="$team.pg?.user_info?.api_key">
<ClickToCopyField
v-if="$team.pg?.user_info?.api_key"
:textContent="$team.pg.user_info.api_key"
/>
</div>
<div v-else class="pb-2 text-base text-gray-700">
{{ $t("You don't have an API key yet. Click the button above to create one.") }}
</div>
<Dialog
v-model="showCreateSecretDialog"
:options="apiKeyDialogOptions"
>
<template #body-content>
<div v-if="createSecret.data">
<p class="text-base">
{{ $t('Please copy the API key immediately. You will not be able to view it again!') }}
</p>
<label class="block pt-2">
<span class="mb-2 block text-sm leading-4 text-gray-700"
>API key</span
>
<ClickToCopyField :textContent="createSecret.data.api_key" />
</label>
<label class="block pt-2">
<span class="mb-2 block text-sm leading-4 text-gray-700"
>API secret</span
>
<ClickToCopyField :textContent="createSecret.data.api_secret" />
</label>
</div>
<div v-else class="text-base text-gray-700">
{{ $t('API key and API secret can be used to access') }} {{ $t('API key and API secret can be used to access') }}
<a href="/docs/api" class="underline" target="_blank" <a href="/docs/api" class="text-primary underline" target="_blank">
>{{ $t('Jingrow API') }}</a {{ $t('Jingrow API') }}
>. </a>
</div> </div>
</template> <n-button type="primary" @click="showCreateSecretDialog = true" :size="buttonSize">
</Dialog> {{ apiKeyButtonLabel }}
</div> </n-button>
<div </div>
class="mx-auto min-w-[48rem] max-w-3xl space-y-6 rounded-md border p-4" <div v-if="$team.pg?.user_info?.api_key">
> <ClickToCopyField :textContent="$team.pg.user_info.api_key" />
<div class="flex items-center justify-between"> </div>
<div class="text-xl font-semibold">{{ $t('SSH Keys') }}</div> <div v-else class="text-base text-gray-600">
</div> {{ $t("You don't have an API key yet. Click the button above to create one.") }}
</div>
</n-space>
</n-card>
<!-- SSH Keys 卡片 -->
<n-card :title="$t('SSH Keys')" class="settings-card">
<ObjectList :options="sshKeyListOptions" /> <ObjectList :options="sshKeyListOptions" />
</div> </n-card>
<div
<!-- Webhooks 卡片 -->
<n-card
v-if="$session.hasWebhookConfigurationAccess" v-if="$session.hasWebhookConfigurationAccess"
class="mx-auto min-w-[48rem] max-w-3xl space-y-6 rounded-md border p-4" :title="$t('Webhooks')"
class="settings-card"
> >
<div class="flex items-center justify-between">
<div class="text-xl font-semibold">{{ $t('Webhooks') }}</div>
</div>
<ObjectList :options="webhookListOptions" /> <ObjectList :options="webhookListOptions" />
<AddNewWebhookDialog </n-card>
v-if="showAddWebhookDialog" </n-space>
v-model="showAddWebhookDialog"
@success="onNewWebhookSuccess" <!-- API Key 创建/重新生成弹窗 -->
/> <n-modal
<ActivateWebhookDialog v-model:show="showCreateSecretDialog"
v-if="showActivateWebhookDialog" preset="card"
v-model="showActivateWebhookDialog" :title="$t('API Access')"
@success="onWebHookActivated" :style="modalStyle"
:webhook="selectedWebhook" :mask-closable="true"
/> :close-on-esc="true"
<EditWebhookDialog class="api-key-modal"
v-if="showEditWebhookDialog" >
v-model="showEditWebhookDialog" <template #header>
@success="onWebHookUpdated" <span class="text-lg font-semibold">{{ $t('API Access') }}</span>
:webhook="selectedWebhook" </template>
/> <n-space vertical :size="20">
<WebhookAttemptsDialog <div v-if="createSecret.data">
v-if="showWebhookAttempts" <n-alert type="warning" class="mb-4">
v-model="showWebhookAttempts" {{ $t('Please copy the API key immediately. You will not be able to view it again!') }}
:name="selectedWebhook.name" </n-alert>
/> <n-form-item :label="$t('API Key')">
<ClickToCopyField :textContent="createSecret.data.api_key" />
</n-form-item>
<n-form-item :label="$t('API Secret')">
<ClickToCopyField :textContent="createSecret.data.api_secret" />
</n-form-item>
</div>
<div v-else class="text-base text-gray-700">
{{ $t('API key and API secret can be used to access') }}
<a href="/docs/api" class="text-primary underline" target="_blank">
{{ $t('Jingrow API') }}
</a>.
</div>
</n-space>
<template #action>
<n-space :vertical="isMobile" :size="isMobile ? 12 : 16" class="w-full">
<n-button @click="showCreateSecretDialog = false" :block="isMobile" :size="buttonSize">
{{ $t('Cancel') }}
</n-button>
<n-button
type="primary"
:loading="createSecret.loading"
:disabled="!!createSecret.data"
@click="createSecret.submit()"
:block="isMobile"
:size="buttonSize"
>
{{ $team.pg.user_info.api_key ? $t('Regenerate API Key') : $t('Create New API Key') }}
</n-button>
</n-space>
</template>
</n-modal>
<!-- SSH Key 添加弹窗 -->
<n-modal
v-model:show="showAddSSHKeyDialog"
preset="card"
:title="$t('Add New SSH Key')"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
class="ssh-key-modal"
>
<template #header>
<span class="text-lg font-semibold">{{ $t('Add New SSH Key') }}</span>
</template>
<n-space vertical :size="20">
<p class="text-base text-gray-700">{{ $t('Add a new SSH key to your account') }}</p>
<n-form-item :label="$t('SSH Key')" :required="true">
<n-input
v-model:value="sshKeyValue"
type="textarea"
:placeholder="sshKeyPlaceholder"
:rows="6"
:size="inputSize"
class="w-full"
/>
</n-form-item>
<n-alert v-if="sshKeyError" type="error" :title="sshKeyError" />
</n-space>
<template #action>
<n-space :vertical="isMobile" :size="isMobile ? 12 : 16" class="w-full">
<n-button @click="showAddSSHKeyDialog = false" :block="isMobile" :size="buttonSize">
{{ $t('Cancel') }}
</n-button>
<n-button
type="primary"
:loading="addSSHKey.loading"
@click="handleAddSSHKey"
:block="isMobile"
:size="buttonSize"
>
{{ $t('Add SSH Key') }}
</n-button>
</n-space>
</template>
</n-modal>
<!-- Webhook 禁用确认弹窗 -->
<n-modal
v-model:show="showDisableWebhookDialog"
preset="dialog"
:title="$t('Disable Webhook')"
:positive-text="$t('Disable')"
:positive-button-props="{ type: 'error' }"
:loading="disableWebhook.loading"
:mask-closable="true"
:close-on-esc="true"
@positive-click="handleDisableWebhook"
>
<div class="py-4">
<p class="text-base mb-2">
{{ $t('Endpoint') }}: <strong>{{ selectedWebhookForAction?.endpoint }}</strong>
</p>
<p class="text-base">{{ $t('Are you sure you want to disable this webhook?') }}</p>
</div> </div>
</div> </n-modal>
<!-- Webhook 删除确认弹窗 -->
<n-modal
v-model:show="showDeleteWebhookDialog"
preset="dialog"
:title="$t('Delete Webhook')"
:positive-text="$t('Delete')"
:positive-button-props="{ type: 'error' }"
:loading="deleteWebhook.loading"
:mask-closable="true"
:close-on-esc="true"
@positive-click="handleDeleteWebhook"
>
<div class="py-4">
<p class="text-base mb-2">
{{ $t('Endpoint') }}: <strong>{{ selectedWebhookForAction?.endpoint }}</strong>
</p>
<p class="text-base">{{ $t('Are you sure you want to delete this webhook?') }}</p>
</div>
</n-modal>
<!-- Webhook 相关弹窗 -->
<AddNewWebhookDialog
v-if="showAddWebhookDialog"
v-model="showAddWebhookDialog"
@success="onNewWebhookSuccess"
/>
<ActivateWebhookDialog
v-if="showActivateWebhookDialog"
v-model="showActivateWebhookDialog"
@success="onWebHookActivated"
:webhook="selectedWebhook"
/>
<EditWebhookDialog
v-if="showEditWebhookDialog"
v-model="showEditWebhookDialog"
@success="onWebHookUpdated"
:webhook="selectedWebhook"
/>
<WebhookAttemptsDialog
v-if="showWebhookAttempts"
v-model="showWebhookAttempts"
:name="selectedWebhook.name"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import {
NCard,
NSpace,
NButton,
NModal,
NFormItem,
NInput,
NAlert,
} from 'naive-ui';
import { Badge, createResource } from 'jingrow-ui'; import { Badge, createResource } from 'jingrow-ui';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import { computed, h, onMounted, ref, getCurrentInstance } from 'vue'; import { computed, h, onMounted, ref, getCurrentInstance } from 'vue';
import { confirmDialog, icon } from '../../utils/components'; import { icon } from '../../utils/components';
import ObjectList from '../ObjectList.vue'; import ObjectList from '../ObjectList.vue';
import { getTeam } from '../../data/team'; import { getTeam } from '../../data/team';
import { date } from '../../utils/format'; import { date } from '../../utils/format';
@ -115,11 +232,29 @@ const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
const $team = getTeam(); const $team = getTeam();
const router = useRouter(); const router = useRouter();
let showCreateSecretDialog = ref(false); let showCreateSecretDialog = ref(false);
const showAddSSHKeyDialog = ref(false);
const sshKeyValue = ref('');
const sshKeyError = ref('');
const showAddWebhookDialog = ref(false); const showAddWebhookDialog = ref(false);
const showActivateWebhookDialog = ref(false); const showActivateWebhookDialog = ref(false);
const showEditWebhookDialog = ref(false); const showEditWebhookDialog = ref(false);
const showWebhookAttempts = ref(false); const showWebhookAttempts = ref(false);
const selectedWebhook = ref(null); const selectedWebhook = ref(null);
const selectedWebhookForAction = ref(null);
const showDisableWebhookDialog = ref(false);
const showDeleteWebhookDialog = ref(false);
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value <= 768);
const modalStyle = computed(() => ({
width: isMobile.value ? '95vw' : '700px',
maxWidth: isMobile.value ? '95vw' : '90vw',
}));
const inputSize = computed(() => (isMobile.value ? 'medium' : 'large'));
const buttonSize = computed(() => (isMobile.value ? 'medium' : 'medium'));
const sshKeyPlaceholder = computed(() => {
return $t("Starts with 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', or 'sk-ssh-ed25519@openssh.com'");
});
const createSecret = createResource({ const createSecret = createResource({
url: 'jcloud.api.account.create_api_secret', url: 'jcloud.api.account.create_api_secret',
@ -129,6 +264,7 @@ const createSecret = createResource({
} else { } else {
toast.success($t('API key created successfully')); toast.success($t('API key created successfully'));
} }
$team.reload();
} }
}); });
@ -136,6 +272,9 @@ const addSSHKey = createResource({
url: 'jcloud.api.client.insert', url: 'jcloud.api.client.insert',
onSuccess() { onSuccess() {
toast.success($t('SSH key added successfully')); toast.success($t('SSH key added successfully'));
showAddSSHKeyDialog.value = false;
sshKeyValue.value = '';
sshKeyError.value = '';
}, },
onError(err) { onError(err) {
toast.error( toast.error(
@ -209,7 +348,10 @@ const sshKeyListOptions = computed(() => ({
return { return {
label: $t('Add SSH Key'), label: $t('Add SSH Key'),
slots: { prefix: icon('plus') }, slots: { prefix: icon('plus') },
onClick: () => renderAddNewKeyDialog(listResource) onClick: () => {
showAddSSHKeyDialog.value = true;
sshKeyListResource = listResource;
}
}; };
}, },
rowActions({ row }) { rowActions({ row }) {
@ -236,42 +378,30 @@ const sshKeyListOptions = computed(() => ({
} }
})); }));
function renderAddNewKeyDialog(listResource) { let sshKeyListResource = null;
confirmDialog({
title: $t('Add New SSH Key'), function handleAddSSHKey() {
message: $t('Add a new SSH key to your account'), if (!sshKeyValue.value) {
fields: [ sshKeyError.value = $t('SSH key is required');
{ return;
label: $t('SSH Key'), }
fieldname: 'sshKey', sshKeyError.value = '';
type: 'textarea', addSSHKey
placeholder: $t("Starts with 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', or 'sk-ssh-ed25519@openssh.com'"), .submit({
required: true pg: {
pagetype: 'User SSH Key',
ssh_public_key: sshKeyValue.value,
user: $team.pg.user_info.name
} }
], })
primaryAction: { .then(() => {
label: $t('Add SSH Key'), if (sshKeyListResource) {
variant: 'solid', sshKeyListResource.reload();
onClick({ hide, values }) {
if (!values.sshKey) throw new Error($t('SSH key is required'));
addSSHKey
.submit({
pg: {
pagetype: 'User SSH Key',
ssh_public_key: values.sshKey,
user: $team.pg.user_info.name
}
})
.then(() => {
listResource.reload();
hide();
})
.catch(error => {
toast.error(error.message);
});
} }
} })
}); .catch(error => {
sshKeyError.value = error.message;
});
} }
const webhookListResource = createResource({ const webhookListResource = createResource({
@ -289,6 +419,7 @@ const deleteWebhook = createResource({
onSuccess() { onSuccess() {
toast.success($t('Webhook deleted successfully')); toast.success($t('Webhook deleted successfully'));
webhookListResource.reload(); webhookListResource.reload();
showDeleteWebhookDialog.value = false;
}, },
onError(err) { onError(err) {
toast.error( toast.error(
@ -341,25 +472,8 @@ const webhookListOptions = computed(() => ({
label: $t('Disable'), label: $t('Disable'),
condition: () => Boolean(row.enabled), condition: () => Boolean(row.enabled),
onClick: () => { onClick: () => {
confirmDialog({ selectedWebhookForAction.value = row;
title: $t('Disable Webhook'), showDisableWebhookDialog.value = true;
message: $t('Endpoint - {endpoint}<br>Are you sure you want to disable this webhook?<br>', { endpoint: row.endpoint }),
primaryAction: {
label: $t('Disable'),
variant: 'solid',
theme: 'red',
onClick({ hide }) {
disableWebhook
.submit({
dt: 'Jcloud Webhook',
dn: row.name,
method: 'disable'
})
.then(hide);
return disableWebhook.promise;
}
}
});
} }
}, },
{ {
@ -379,24 +493,8 @@ const webhookListOptions = computed(() => ({
{ {
label: $t('Delete'), label: $t('Delete'),
onClick() { onClick() {
confirmDialog({ selectedWebhookForAction.value = row;
title: $t('Delete Webhook'), showDeleteWebhookDialog.value = true;
message: $t('Endpoint - {endpoint}<br>Are you sure you want to delete this webhook?<br>', { endpoint: row.endpoint }),
primaryAction: {
label: $t('Delete'),
variant: 'solid',
theme: 'red',
onClick({ hide }) {
deleteWebhook
.submit({
pagetype: 'Jcloud Webhook',
name: row.name
})
.then(hide);
return deleteWebhook.promise;
}
}
});
} }
} }
]; ];
@ -422,6 +520,7 @@ const disableWebhook = createResource({
onSuccess() { onSuccess() {
toast.success($t('Webhook disabled successfully')); toast.success($t('Webhook disabled successfully'));
webhookListResource.reload(); webhookListResource.reload();
showDisableWebhookDialog.value = false;
}, },
onError(err) { onError(err) {
toast.error( toast.error(
@ -432,6 +531,23 @@ const disableWebhook = createResource({
} }
}); });
function handleDisableWebhook() {
if (!selectedWebhookForAction.value) return;
disableWebhook.submit({
dt: 'Jcloud Webhook',
dn: selectedWebhookForAction.value.name,
method: 'disable'
});
}
function handleDeleteWebhook() {
if (!selectedWebhookForAction.value) return;
deleteWebhook.submit({
pagetype: 'Jcloud Webhook',
name: selectedWebhookForAction.value.name
});
}
const onNewWebhookSuccess = () => { const onNewWebhookSuccess = () => {
webhookListResource.reload(); webhookListResource.reload();
showAddWebhookDialog.value = false; showAddWebhookDialog.value = false;
@ -443,7 +559,7 @@ const onWebHookActivated = () => {
}; };
const onWebHookUpdated = activationRequired => { const onWebHookUpdated = activationRequired => {
webhookListResource.reload(); webhookListResource.reload();
showEditWebhookDialog.value = false; showEditWebhookDialog.value = false;
if (activationRequired) { if (activationRequired) {
showActivateWebhookDialog.value = true; showActivateWebhookDialog.value = true;
@ -456,27 +572,104 @@ const apiKeyButtonLabel = computed(() => {
: $t('Create New API Key'); : $t('Create New API Key');
}); });
const apiKeyDialogOptions = computed(() => ({ function handleResize() {
title: $t('API Access'), windowWidth.value = window.innerWidth;
size: 'xl', }
actions: [
{
label: $team.pg.user_info.api_key
? $t('Regenerate API Key')
: $t('Create New API Key'),
variant: 'solid',
disabled: !!createSecret.data,
loading: createSecret.loading,
onClick() {
createSecret.submit();
}
}
]
}));
onMounted(() => { onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
if (session.hasWebhookConfigurationAccess) { if (session.hasWebhookConfigurationAccess) {
webhookListResource.fetch(); webhookListResource.fetch();
} }
}); });
</script> </script>
<style scoped>
.developer-settings-container {
width: 100%;
padding: 24px;
}
.settings-card {
border-radius: 12px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
transition: box-shadow 0.2s ease;
}
.settings-card:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:deep(.api-key-modal .n-card),
:deep(.ssh-key-modal .n-card) {
width: 700px;
max-width: 90vw;
}
:deep(.api-key-modal .n-card-body),
:deep(.ssh-key-modal .n-card-body) {
padding: 24px;
}
:deep(.api-key-modal .n-form-item),
:deep(.ssh-key-modal .n-form-item) {
margin-bottom: 0;
}
:deep(.api-key-modal .n-input),
:deep(.ssh-key-modal .n-input) {
width: 100%;
}
:deep(.api-key-modal .n-card__action),
:deep(.ssh-key-modal .n-card__action) {
padding: 16px 24px;
}
@media (max-width: 768px) {
.developer-settings-container {
padding: 16px;
}
.settings-card {
border-radius: 8px;
}
:deep(.api-key-modal .n-card),
:deep(.ssh-key-modal .n-card) {
width: 95vw !important;
max-width: 95vw !important;
margin: 20px auto;
border-radius: 12px;
}
:deep(.api-key-modal .n-card-body),
:deep(.ssh-key-modal .n-card-body) {
padding: 20px 16px;
}
:deep(.api-key-modal .n-card__header),
:deep(.ssh-key-modal .n-card__header) {
padding: 16px;
font-size: 18px;
}
:deep(.api-key-modal .n-card__action),
:deep(.ssh-key-modal .n-card__action) {
padding: 12px 16px;
}
:deep(.api-key-modal .n-space),
:deep(.ssh-key-modal .n-space) {
width: 100%;
}
:deep(.api-key-modal .n-button),
:deep(.ssh-key-modal .n-button) {
width: 100%;
font-size: 16px;
height: 44px;
}
}
</style>

View File

@ -1,104 +1,243 @@
<template> <template>
<Dialog <n-modal
:options="{ v-model:show="showDialog"
title: '编辑Webhook', preset="card"
actions: [ :title="$t('Edit Webhook')"
{ :style="modalStyle"
label: '保存更改', :mask-closable="true"
variant: 'solid', :close-on-esc="true"
onClick: updateWebhook, class="edit-webhook-modal"
loading: this.$resources.updateWebhook.loading
}
]
}"
> >
<template #body-content> <template #header>
<div class="space-y-4"> <span class="text-lg font-semibold">{{ $t('Edit Webhook') }}</span>
<div>
<FormControl label="端点" v-model="endpoint" />
<p class="mt-1.5 text-sm text-gray-700">
如果您更改了端点请确保重新激活webhook
</p>
</div>
<div v-if="!updateSecret">
<p class="block text-xs text-gray-600">密钥</p>
<div
class="mt-1 flex items-center justify-between text-base text-gray-700"
>
<p>想要更改密钥吗</p>
<Button @click="updateSecret = true">编辑密钥</Button>
</div>
</div>
<div v-else>
<FormControl label="密钥" v-model="secret">
<template #suffix>
<FeatherIcon
class="w-4 cursor-pointer"
name="refresh-ccw"
@click="generateRandomSecret"
/>
</template>
</FormControl>
<p class="mt-1.5 text-sm text-gray-700">
<secret>注意</secret> 密钥是可选的查看
<a
href="https://jingrow.com/docs/webhook-introduction"
class="underline"
target="_blank"
>文档</a
>
了解更多
</p>
</div>
<p class="text-base font-medium text-gray-900">
选择webhook事件
</p>
<div
class="text-center text-sm leading-10 text-gray-500"
v-if="$resources.events.loading"
>
加载中...
</div>
<div class="mt-6 flex flex-col gap-3" v-else>
<Switch
v-for="event in $resources.events.data"
:key="event.name"
:label="event.name"
:description="event.description"
:modelValue="isEventSelected(event.name)"
@update:modelValue="selectEvent(event.name)"
size="sm"
/>
</div>
<ErrorMessage
:message="errorMessage || $resources.updateWebhook.error"
/>
</div>
</template> </template>
</Dialog> <n-space vertical :size="20">
<n-form-item :label="$t('Endpoint')">
<n-input
v-model:value="endpoint"
:placeholder="$t('Enter webhook endpoint URL')"
:size="inputSize"
class="w-full"
/>
</n-form-item>
<n-alert type="info" class="mb-2">
{{ $t('If you change the endpoint, make sure to reactivate the webhook.') }}
</n-alert>
<div v-if="!updateSecret">
<p class="block text-xs text-gray-600 mb-2">{{ $t('Secret Key') }}</p>
<div class="mt-1 flex items-center justify-between text-base text-gray-700">
<p>{{ $t('Want to change the secret key?') }}</p>
<n-button size="small" @click="updateSecret = true">
{{ $t('Edit Secret') }}
</n-button>
</div>
</div>
<div v-else>
<n-form-item :label="$t('Secret Key')">
<n-input
v-model:value="secret"
:placeholder="$t('Enter secret key (optional)')"
:size="inputSize"
class="w-full"
>
<template #suffix>
<n-icon
class="cursor-pointer text-gray-500 hover:text-gray-700"
@click="generateRandomSecret"
>
<RefreshIcon />
</n-icon>
</template>
</n-input>
</n-form-item>
<n-alert type="info" class="mb-2">
<template #header>
<strong>{{ $t('Note') }}:</strong>
</template>
{{ $t('The secret key is optional. View') }}
<a href="https://jingrow.com/docs/webhook-introduction" class="text-primary underline" target="_blank">
{{ $t('documentation') }}
</a>
{{ $t('to learn more') }}
</n-alert>
</div>
<div class="mt-4">
<p class="text-base font-medium text-gray-900 mb-4">
{{ $t('Select Webhook Events') }}
</p>
<n-spin :show="$resources.events.loading">
<n-space vertical :size="12" v-if="!$resources.events.loading">
<div
v-for="event in $resources.events.data"
:key="event.name"
class="flex items-center justify-between p-3 rounded border border-gray-200 hover:bg-gray-50"
>
<div class="flex-1">
<div class="text-sm font-medium text-gray-900">
{{ event.name }}
</div>
<div v-if="event.description" class="text-xs text-gray-500 mt-1">
{{ event.description }}
</div>
</div>
<n-switch
:value="isEventSelected(event.name)"
@update:value="selectEvent(event.name)"
size="medium"
/>
</div>
</n-space>
</n-spin>
</div>
<n-alert
v-if="errorMessage || $resources.updateWebhook.error"
type="error"
:title="errorMessage || $resources.updateWebhook.error"
/>
</n-space>
<template #action>
<n-space :vertical="isMobile" :size="isMobile ? 12 : 16" class="w-full">
<n-button @click="hide" :block="isMobile" :size="buttonSize">
{{ $t('Cancel') }}
</n-button>
<n-button
type="primary"
:loading="$resources.updateWebhook.loading"
@click="updateWebhook"
:block="isMobile"
:size="buttonSize"
>
{{ $t('Save Changes') }}
</n-button>
</n-space>
</template>
</n-modal>
</template> </template>
<script> <script>
import {
NModal,
NFormItem,
NInput,
NSpace,
NAlert,
NButton,
NSwitch,
NIcon,
NSpin,
} from 'naive-ui';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import { getToastErrorMessage } from '../../utils/toast'; import { getToastErrorMessage } from '../../utils/toast';
import RefreshIcon from '~icons/lucide/refresh-ccw';
export default { export default {
emits: ['success'], components: {
props: ['webhook'], NModal,
NFormItem,
NInput,
NSpace,
NAlert,
NButton,
NSwitch,
NIcon,
NSpin,
RefreshIcon,
},
emits: ['success', 'update:modelValue'],
props: {
webhook: {
type: Object,
required: true
},
modelValue: {
type: Boolean,
default: false
}
},
data() { data() {
return { return {
endpoint: '', endpoint: '',
secret: '', secret: '',
updateSecret: false, updateSecret: false,
selectedEvents: [], selectedEvents: [],
errorMessage: '' errorMessage: '',
windowWidth: window.innerWidth,
}; };
}, },
computed: {
showDialog: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
}
},
isMobile() {
return this.windowWidth <= 768;
},
modalStyle() {
return {
width: this.isMobile ? '95vw' : '800px',
maxWidth: this.isMobile ? '95vw' : '90vw',
};
},
inputSize() {
return this.isMobile ? 'medium' : 'large';
},
buttonSize() {
return this.isMobile ? 'medium' : 'medium';
},
},
mounted() { mounted() {
this.handleResize();
window.addEventListener('resize', this.handleResize);
if (this.selectedEvents.length) { if (this.selectedEvents.length) {
this.selectedEvents = this.selectedEvents.map(event => event.name); this.selectedEvents = this.selectedEvents.map(event => event.name);
} }
}, },
beforeUnmount() {
window.removeEventListener('resize', this.handleResize);
},
methods: {
handleResize() {
this.windowWidth = window.innerWidth;
},
generateRandomSecret() {
this.secret = Array(30)
.fill(0)
.map(
() =>
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'[
Math.floor(Math.random() * 62)
]
)
.join('');
},
selectEvent(event) {
if (this.selectedEvents.includes(event)) {
this.selectedEvents = this.selectedEvents.filter(e => e !== event);
} else {
this.selectedEvents.push(event);
}
},
isEventSelected(event) {
return this.selectedEvents.includes(event);
},
updateWebhook() {
if (this.selectedEvents.length === 0) {
this.errorMessage = this.$t('Please enable at least one event');
return;
}
this.errorMessage = '';
this.$resources.updateWebhook.submit();
},
hide() {
this.showDialog = false;
this.updateSecret = false;
this.errorMessage = '';
}
},
resources: { resources: {
events() { events() {
return { return {
@ -125,8 +264,8 @@ export default {
return { return {
url: 'jcloud.api.webhook.update', url: 'jcloud.api.webhook.update',
validate: () => { validate: () => {
if (!this.selectedEvents) { if (!this.selectedEvents || this.selectedEvents.length === 0) {
return '请至少启用一个事件'; return this.$t('Please enable at least one event');
} }
}, },
makeParams: () => { makeParams: () => {
@ -138,52 +277,72 @@ export default {
}; };
}, },
onSuccess: () => { onSuccess: () => {
toast.success('Webhook更新成功'); toast.success(this.$t('Webhook updated successfully'));
const activationRequired = this.webhook.endpoint !== this.endpoint; const activationRequired = this.webhook.endpoint !== this.endpoint;
this.hide();
this.$emit('success', activationRequired); this.$emit('success', activationRequired);
}, },
onError: e => { onError: e => {
toast.error( toast.error(
getToastErrorMessage( getToastErrorMessage(
e, e,
'更新webhook失败请重试' this.$t('Failed to update webhook, please try again')
) )
); );
} }
}; };
} }
},
computed: {},
methods: {
generateRandomSecret() {
this.secret = Array(30)
.fill(0)
.map(
() =>
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'[
Math.floor(Math.random() * 62)
]
)
.join('');
},
selectEvent(event) {
if (this.selectedEvents.includes(event)) {
this.selectedEvents = this.selectedEvents.filter(e => e !== event);
} else {
this.selectedEvents.push(event);
}
},
isEventSelected(event) {
return this.selectedEvents.includes(event);
},
updateWebhook() {
if (this.selectedEvents.length === 0) {
this.errorMessage = '请至少选择一个事件来添加';
return;
}
this.errorMessage = '';
this.$resources.updateWebhook.submit();
}
} }
}; };
</script> </script>
<style scoped>
:deep(.edit-webhook-modal .n-card) {
width: 800px;
max-width: 90vw;
}
:deep(.edit-webhook-modal .n-card-body) {
padding: 24px;
}
:deep(.edit-webhook-modal .n-form-item) {
margin-bottom: 0;
}
:deep(.edit-webhook-modal .n-input) {
width: 100%;
}
:deep(.edit-webhook-modal .n-card__action) {
padding: 16px 24px;
}
@media (max-width: 768px) {
:deep(.edit-webhook-modal .n-card) {
width: 95vw !important;
max-width: 95vw !important;
margin: 20px auto;
border-radius: 12px;
}
:deep(.edit-webhook-modal .n-card-body) {
padding: 20px 16px;
}
:deep(.edit-webhook-modal .n-card__header) {
padding: 16px;
font-size: 18px;
}
:deep(.edit-webhook-modal .n-card__action) {
padding: 12px 16px;
}
:deep(.edit-webhook-modal .n-button) {
width: 100%;
font-size: 16px;
height: 44px;
}
}
</style>

View File

@ -1,83 +1,124 @@
<template> <template>
<Dialog <n-modal
:options="{ v-model:show="showDialog"
title: selectedWebhookAttemptId preset="card"
? `Webhook 尝试 - ${selectedWebhookAttemptId}` :title="selectedWebhookAttemptId ? $t('Webhook Attempt - {id}', { id: selectedWebhookAttemptId }) : $t('Webhook Attempts')"
: 'Webhook 尝试', :style="modalStyle"
size: '4xl' :mask-closable="true"
}" :close-on-esc="true"
class="webhook-attempts-modal"
> >
<template #body-content> <template #header>
<p class="text-sm mb-2 text-gray-700" v-if="!selectedWebhookAttemptId"> <span class="text-lg font-semibold">
<strong>注意</strong> 您只能查看过去24小时的日志 {{ selectedWebhookAttemptId ? $t('Webhook Attempt - {id}', { id: selectedWebhookAttemptId }) : $t('Webhook Attempts') }}
</p> </span>
</template>
<n-space vertical :size="20">
<n-alert type="info" v-if="!selectedWebhookAttemptId">
<template #header>
<strong>{{ $t('Note') }}:</strong>
</template>
{{ $t('You can only view logs from the past 24 hours') }}
</n-alert>
<ObjectList :options="listOptions" v-if="!selectedWebhookAttemptId" /> <ObjectList :options="listOptions" v-if="!selectedWebhookAttemptId" />
<Button <n-button
class="mb-2"
iconLeft="arrow-left"
v-if="selectedWebhookAttemptId" v-if="selectedWebhookAttemptId"
@click="selectedWebhookAttemptId = null" @click="selectedWebhookAttemptId = null"
:size="buttonSize"
> >
返回 <template #icon>
</Button> <n-icon><ArrowLeftIcon /></n-icon>
</template>
{{ $t('Back') }}
</n-button>
<WebhookAttemptDetails <WebhookAttemptDetails
:id="selectedWebhookAttemptId" :id="selectedWebhookAttemptId"
v-if="selectedWebhookAttemptId" v-if="selectedWebhookAttemptId"
/> />
</template> </n-space>
</Dialog> </n-modal>
</template> </template>
<script> <script>
import { Breadcrumbs, Badge } from 'jingrow-ui'; import {
import Header from '../Header.vue'; NModal,
import ObjectList from '../ObjectList.vue'; NSpace,
NAlert,
NButton,
NIcon,
} from 'naive-ui';
import { Badge } from 'jingrow-ui';
import { h } from 'vue'; import { h } from 'vue';
import ObjectList from '../ObjectList.vue';
import WebhookAttemptDetails from './WebhookAttemptDetails.vue'; import WebhookAttemptDetails from './WebhookAttemptDetails.vue';
import ArrowLeftIcon from '~icons/lucide/arrow-left';
export default { export default {
name: 'WebhookAttempts',
props: ['name'],
components: { components: {
Header, NModal,
Breadcrumbs, NSpace,
NAlert,
NButton,
NIcon,
ObjectList, ObjectList,
WebhookAttemptDetails WebhookAttemptDetails,
ArrowLeftIcon,
},
name: 'WebhookAttempts',
props: {
name: {
type: String,
required: true
},
modelValue: {
type: Boolean,
default: false
}
}, },
data() { data() {
return { return {
selectedWebhookAttemptId: null selectedWebhookAttemptId: null,
windowWidth: window.innerWidth,
}; };
}, },
resources: {
attempts() {
return {
url: 'jcloud.api.webhook.attempts',
params: {
webhook: this.$props.name
},
inititalData: [],
auto: true
};
}
},
computed: { computed: {
showDialog: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
}
},
isMobile() {
return this.windowWidth <= 768;
},
modalStyle() {
return {
width: this.isMobile ? '95vw' : '1200px',
maxWidth: this.isMobile ? '95vw' : '90vw',
};
},
buttonSize() {
return this.isMobile ? 'medium' : 'medium';
},
listOptions() { listOptions() {
return { return {
data: () => this.$resources?.attempts?.data || [], data: () => this.$resources?.attempts?.data || [],
columns: [ columns: [
{ {
label: '事件', label: this.$t('Event'),
fieldname: 'event', fieldname: 'event',
width: 0.25 width: 0.25
}, },
{ {
label: '端点', label: this.$t('Endpoint'),
fieldname: 'endpoint', fieldname: 'endpoint',
width: 0.5, width: 0.5,
format: value => value.substring(0, 50) format: value => value.substring(0, 50)
}, },
{ {
label: '状态', label: this.$t('Status'),
fieldname: 'status', fieldname: 'status',
width: 0.1, width: 0.1,
type: 'Component', type: 'Component',
@ -94,7 +135,7 @@ export default {
} }
}, },
{ {
label: '代码', label: this.$t('Code'),
fieldname: 'response_status_code', fieldname: 'response_status_code',
width: 0.1, width: 0.1,
format: val => { format: val => {
@ -104,7 +145,7 @@ export default {
align: 'center' align: 'center'
}, },
{ {
label: '时间戳', label: this.$t('Timestamp'),
fieldname: 'timestamp', fieldname: 'timestamp',
width: 0.3, width: 0.3,
format(value) { format(value) {
@ -117,6 +158,67 @@ export default {
} }
}; };
} }
},
mounted() {
this.handleResize();
window.addEventListener('resize', this.handleResize);
},
beforeUnmount() {
window.removeEventListener('resize', this.handleResize);
},
methods: {
handleResize() {
this.windowWidth = window.innerWidth;
}
},
resources: {
attempts() {
return {
url: 'jcloud.api.webhook.attempts',
params: {
webhook: this.$props.name
},
inititalData: [],
auto: true
};
}
} }
}; };
</script> </script>
<style scoped>
:deep(.webhook-attempts-modal .n-card) {
width: 1200px;
max-width: 90vw;
}
:deep(.webhook-attempts-modal .n-card-body) {
padding: 24px;
}
:deep(.webhook-attempts-modal .n-card__action) {
padding: 16px 24px;
}
@media (max-width: 768px) {
:deep(.webhook-attempts-modal .n-card) {
width: 95vw !important;
max-width: 95vw !important;
margin: 20px auto;
border-radius: 12px;
}
:deep(.webhook-attempts-modal .n-card-body) {
padding: 20px 16px;
}
:deep(.webhook-attempts-modal .n-card__header) {
padding: 16px;
font-size: 18px;
}
:deep(.webhook-attempts-modal .n-card__action) {
padding: 12px 16px;
}
}
</style>