Initial commit: 网络验证平台
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const sendingCode = ref(false)
|
||||
|
||||
interface Permission {
|
||||
allow_email_verify: boolean
|
||||
allow_password_reset: boolean
|
||||
allow_custom_smtp: boolean
|
||||
allow_custom_template: boolean
|
||||
}
|
||||
|
||||
interface SMTPConfig {
|
||||
id: number
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
from_name: string
|
||||
from_email: string
|
||||
use_ssl: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
interface EmailConfig {
|
||||
enable_email_verify: boolean
|
||||
require_email_verify: boolean
|
||||
enable_password_reset: boolean
|
||||
permission: Permission
|
||||
smtp_config: SMTPConfig | null
|
||||
}
|
||||
|
||||
interface EmailTemplate {
|
||||
id: number
|
||||
type: string
|
||||
name: string
|
||||
subject: string
|
||||
content: string
|
||||
is_default: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
const config = ref<EmailConfig | null>(null)
|
||||
const templates = ref<EmailTemplate[]>([])
|
||||
|
||||
const form = ref({
|
||||
enable_email_verify: false,
|
||||
require_email_verify: false,
|
||||
enable_password_reset: false,
|
||||
smtp_host: '',
|
||||
smtp_port: 465,
|
||||
smtp_user: '',
|
||||
smtp_password: '',
|
||||
smtp_from_name: '',
|
||||
smtp_from_email: '',
|
||||
smtp_use_ssl: true,
|
||||
})
|
||||
|
||||
const testEmail = ref('')
|
||||
const sendCodeEmail = ref('')
|
||||
const sendCodePurpose = ref('register')
|
||||
|
||||
const canUseEmailVerify = computed(() => config.value?.permission?.allow_email_verify)
|
||||
const canUsePasswordReset = computed(() => config.value?.permission?.allow_password_reset)
|
||||
const canUseCustomSMTP = computed(() => config.value?.permission?.allow_custom_smtp)
|
||||
const canUseCustomTemplate = computed(() => config.value?.permission?.allow_custom_template)
|
||||
|
||||
async function fetchConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/email-config`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
config.value = data.data
|
||||
form.value.enable_email_verify = data.data.enable_email_verify || false
|
||||
form.value.require_email_verify = data.data.require_email_verify || false
|
||||
form.value.enable_password_reset = data.data.enable_password_reset || false
|
||||
|
||||
if (data.data.smtp_config) {
|
||||
form.value.smtp_host = data.data.smtp_config.host || ''
|
||||
form.value.smtp_port = data.data.smtp_config.port || 465
|
||||
form.value.smtp_user = data.data.smtp_config.user || ''
|
||||
form.value.smtp_from_name = data.data.smtp_config.from_name || ''
|
||||
form.value.smtp_from_email = data.data.smtp_config.from_email || ''
|
||||
form.value.smtp_use_ssl = data.data.smtp_config.use_ssl ?? true
|
||||
}
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '获取配置失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取配置失败:', error)
|
||||
toast.error('获取配置失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTemplates() {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/email-templates`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
templates.value = data.data || []
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取模板失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/email-config`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enable_email_verify: form.value.enable_email_verify,
|
||||
require_email_verify: form.value.require_email_verify,
|
||||
enable_password_reset: form.value.enable_password_reset,
|
||||
smtp_config: {
|
||||
host: form.value.smtp_host,
|
||||
port: form.value.smtp_port,
|
||||
user: form.value.smtp_user,
|
||||
password: form.value.smtp_password,
|
||||
from_name: form.value.smtp_from_name,
|
||||
from_email: form.value.smtp_from_email,
|
||||
use_ssl: form.value.smtp_use_ssl,
|
||||
},
|
||||
}),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('保存成功')
|
||||
fetchConfig()
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '保存失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
toast.error('保存失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestEmail() {
|
||||
if (!testEmail.value) {
|
||||
toast.error('请输入测试邮箱地址')
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/email-config/test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: testEmail.value,
|
||||
}),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('测试邮件已发送,请检查收件箱')
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '发送失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('发送失败:', error)
|
||||
toast.error('发送失败')
|
||||
}
|
||||
finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendVerifyCode() {
|
||||
if (!sendCodeEmail.value) {
|
||||
toast.error('请输入邮箱地址')
|
||||
return
|
||||
}
|
||||
|
||||
sendingCode.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/send-verify-code`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: sendCodeEmail.value,
|
||||
purpose: sendCodePurpose.value,
|
||||
}),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('验证码已发送')
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '发送失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('发送失败:', error)
|
||||
toast.error('发送失败')
|
||||
}
|
||||
finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchConfig()
|
||||
fetchTemplates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="邮箱设置"
|
||||
description="配置应用的邮箱验证相关设置"
|
||||
:breadcrumbs="[
|
||||
{ title: '应用管理', href: '/developer/applications' },
|
||||
{ title: '邮箱设置' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>邮箱验证设置</UiCardTitle>
|
||||
<UiCardDescription>配置应用的邮箱验证功能</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>启用邮箱验证</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
允许用户使用邮箱注册和验证
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model:checked="form.enable_email_verify" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>强制邮箱验证</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
注册时必须验证邮箱才能完成注册
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model:checked="form.require_email_verify" :disabled="!form.enable_email_verify" />
|
||||
</div>
|
||||
|
||||
<div v-if="canUsePasswordReset" class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>启用密码重置</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
允许用户通过邮箱验证码重置密码
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model:checked="form.enable_password_reset" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>SMTP 配置</UiCardTitle>
|
||||
<UiCardDescription>配置邮件发送服务器</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_host">
|
||||
SMTP 服务器
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_host" v-model="form.smtp_host" placeholder="smtp.example.com" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_port">
|
||||
端口
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_port" v-model.number="form.smtp_port" type="number" placeholder="465" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_user">
|
||||
用户名
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_user" v-model="form.smtp_user" placeholder="your@email.com" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_password">
|
||||
密码/授权码
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_password" v-model="form.smtp_password" type="password" placeholder="••••••••" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_from_name">
|
||||
发件人名称
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_from_name" v-model="form.smtp_from_name" placeholder="应用名称" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_from_email">
|
||||
发件人邮箱
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_from_email" v-model="form.smtp_from_email" placeholder="noreply@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>使用 SSL</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
推荐开启,端口 465 通常需要 SSL
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model:checked="form.smtp_use_ssl" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-4 pt-4 border-t">
|
||||
<div class="flex-1 space-y-2">
|
||||
<UiLabel for="test_email">
|
||||
测试邮箱
|
||||
</UiLabel>
|
||||
<UiInput id="test_email" v-model="testEmail" type="email" placeholder="test@example.com" />
|
||||
</div>
|
||||
<UiButton :disabled="testing" @click="handleTestEmail">
|
||||
<Icon v-if="testing" icon="lucide:loader-2" class="mr-2 size-4 animate-spin" />
|
||||
发送测试邮件
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>发送验证码测试</UiCardTitle>
|
||||
<UiCardDescription>测试验证码发送功能</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="send_code_email">
|
||||
邮箱地址
|
||||
</UiLabel>
|
||||
<UiInput id="send_code_email" v-model="sendCodeEmail" type="email" placeholder="user@example.com" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="send_code_purpose">
|
||||
用途
|
||||
</UiLabel>
|
||||
<UiSelect v-model="sendCodePurpose">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择用途" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="register">
|
||||
注册验证
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="reset_password">
|
||||
重置密码
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="change_email">
|
||||
更换邮箱
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<UiButton :disabled="sendingCode" @click="handleSendVerifyCode">
|
||||
<Icon v-if="sendingCode" icon="lucide:loader-2" class="mr-2 size-4 animate-spin" />
|
||||
发送验证码
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-if="canUseCustomTemplate">
|
||||
<UiCardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<UiCardTitle>邮件模板</UiCardTitle>
|
||||
<UiCardDescription>自定义邮件模板</UiCardDescription>
|
||||
</div>
|
||||
<UiButton size="sm">
|
||||
<Icon icon="lucide:plus" class="mr-2 size-4" />
|
||||
新建模板
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div v-if="templates.length === 0" class="text-center py-8 text-muted-foreground">
|
||||
<Icon icon="lucide:mail" class="size-12 mx-auto mb-4 opacity-50" />
|
||||
<p>暂无自定义模板,将使用系统默认模板</p>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
class="flex items-center justify-between p-4 rounded-lg border"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ template.name }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ template.subject }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiBadge v-if="template.is_default" variant="secondary">
|
||||
默认
|
||||
</UiBadge>
|
||||
<UiButton variant="ghost" size="icon">
|
||||
<Icon icon="lucide:pencil" class="size-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<UiButton :disabled="saving" @click="handleSave">
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 size-4 animate-spin" />
|
||||
保存设置
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,557 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
interface AppData {
|
||||
id: number
|
||||
encrypt_type: string
|
||||
secret_key: string
|
||||
bind_type: string
|
||||
max_devices: number
|
||||
change_limit: number
|
||||
change_interval: number
|
||||
change_exceed_action: string
|
||||
change_deduct_amount: number
|
||||
multi_open_mode: string
|
||||
max_instances: number
|
||||
heartbeat_interval: number
|
||||
heartbeat_timeout: number
|
||||
max_attempts: number
|
||||
lock_duration: number
|
||||
}
|
||||
|
||||
const app = ref<AppData | null>(null)
|
||||
|
||||
const form = ref({
|
||||
encrypt_type: 'none',
|
||||
secret_key: '',
|
||||
bind_type: 'none',
|
||||
max_devices: 1,
|
||||
change_limit: 3,
|
||||
change_interval: 7,
|
||||
change_exceed_action: 'deny',
|
||||
change_deduct_amount: 1,
|
||||
multi_open_mode: 'forbidden',
|
||||
max_instances: 1,
|
||||
heartbeat_interval: 60,
|
||||
heartbeat_timeout: 300,
|
||||
max_attempts: 5,
|
||||
lock_duration: 30,
|
||||
})
|
||||
|
||||
async function fetchApp() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const appId = route.params.id
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${appId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
const appData = data.data.application || data.data
|
||||
app.value = appData
|
||||
form.value = {
|
||||
encrypt_type: appData.encrypt_type || 'none',
|
||||
secret_key: appData.secret_key || '',
|
||||
bind_type: appData.bind_type || 'none',
|
||||
max_devices: appData.max_devices || 1,
|
||||
change_limit: appData.change_limit || 3,
|
||||
change_interval: appData.change_interval || 7,
|
||||
change_exceed_action: appData.change_exceed_action || 'deny',
|
||||
change_deduct_amount: appData.change_deduct_amount || 1,
|
||||
multi_open_mode: appData.multi_open_mode || 'forbidden',
|
||||
max_instances: appData.max_instances || 1,
|
||||
heartbeat_interval: appData.heartbeat_interval || 60,
|
||||
heartbeat_timeout: appData.heartbeat_timeout || 300,
|
||||
max_attempts: appData.max_attempts || 5,
|
||||
lock_duration: appData.lock_duration || 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用详情失败:', error)
|
||||
toast.error('获取应用详情失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function generateSecretKey() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < 32; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
form.value.secret_key = result
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(form.value),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('保存成功')
|
||||
fetchApp()
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '保存失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
toast.error('保存失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApp()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="安全设置"
|
||||
description="配置应用的安全相关设置,包括加密方式和登录策略"
|
||||
:breadcrumbs="[
|
||||
{ title: '应用管理', href: '/developer/applications' },
|
||||
{ title: '安全设置' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>加密设置</UiCardTitle>
|
||||
<UiCardDescription>配置数据传输加密方式</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>加密方式</UiLabel>
|
||||
<UiRadioGroup v-model="form.encrypt_type" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.encrypt_type === 'none' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.encrypt_type = 'none'"
|
||||
>
|
||||
<UiRadioGroupItem value="none" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
不加密
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
数据明文传输
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.encrypt_type === 'aes' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.encrypt_type = 'aes'"
|
||||
>
|
||||
<UiRadioGroupItem value="aes" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
AES 加密
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
对称加密,速度快
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.encrypt_type === 'rsa' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.encrypt_type = 'rsa'"
|
||||
>
|
||||
<UiRadioGroupItem value="rsa" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
RSA 加密
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
非对称加密,更安全
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="form.encrypt_type !== 'none'" class="space-y-2 pt-4 border-t">
|
||||
<div class="flex items-center justify-between">
|
||||
<UiLabel for="secret_key">
|
||||
加密密钥
|
||||
</UiLabel>
|
||||
<UiButton variant="outline" size="sm" @click="generateSecretKey">
|
||||
<Icon icon="lucide:refresh-cw" class="mr-2 h-4 w-4" />
|
||||
自动生成
|
||||
</UiButton>
|
||||
</div>
|
||||
<UiInput id="secret_key" v-model="form.secret_key" placeholder="请输入加密密钥" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
请妥善保管密钥,丢失后无法恢复
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>绑定策略</UiCardTitle>
|
||||
<UiCardDescription>配置应用的绑定策略</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>绑定模式</UiLabel>
|
||||
<UiRadioGroup v-model="form.bind_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.bind_type === 'none' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.bind_type = 'none'"
|
||||
>
|
||||
<UiRadioGroupItem value="none" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
不绑定
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
不限制设备或IP
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.bind_type === 'device' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.bind_type = 'device'"
|
||||
>
|
||||
<UiRadioGroupItem value="device" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
绑定设备
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
绑定用户设备硬件信息
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.bind_type === 'ip' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.bind_type = 'ip'"
|
||||
>
|
||||
<UiRadioGroupItem value="ip" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
绑定IP
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
绑定用户IP地址
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.bind_type === 'mixed' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.bind_type = 'mixed'"
|
||||
>
|
||||
<UiRadioGroupItem value="mixed" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
混合绑定
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
同时绑定设备和IP
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="form.bind_type !== 'none'" class="space-y-4 pt-4 border-t">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="max_devices">
|
||||
最大设备数
|
||||
</UiLabel>
|
||||
<UiInput id="max_devices" v-model.number="form.max_devices" type="number" min="0" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
用户最多可绑定的设备数量
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="change_limit">
|
||||
更换次数限制
|
||||
</UiLabel>
|
||||
<UiInput id="change_limit" v-model.number="form.change_limit" type="number" min="0" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
允许更换绑定的次数
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="change_interval">
|
||||
更换间隔(天)
|
||||
</UiLabel>
|
||||
<UiInput id="change_interval" v-model.number="form.change_interval" type="number" min="0" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
两次更换之间的最小间隔
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 pt-4 border-t">
|
||||
<UiLabel>超出更换次数处理</UiLabel>
|
||||
<UiRadioGroup v-model="form.change_exceed_action" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.change_exceed_action === 'deny' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.change_exceed_action = 'deny'"
|
||||
>
|
||||
<UiRadioGroupItem value="deny" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
拒绝更换
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
超出次数后禁止更换绑定
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.change_exceed_action === 'deduct_time' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.change_exceed_action = 'deduct_time'"
|
||||
>
|
||||
<UiRadioGroupItem value="deduct_time" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
扣除时长
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
超出次数后扣除使用时长
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.change_exceed_action === 'deduct_points' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.change_exceed_action = 'deduct_points'"
|
||||
>
|
||||
<UiRadioGroupItem value="deduct_points" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
扣除点数
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
超出次数后扣除点数
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
|
||||
<div v-if="form.change_exceed_action !== 'deny'" class="space-y-2 pt-2">
|
||||
<UiLabel for="change_deduct_amount">
|
||||
{{ form.change_exceed_action === 'deduct_time' ? '扣除时长(天)' : '扣除点数' }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="change_deduct_amount"
|
||||
v-model.number="form.change_deduct_amount"
|
||||
type="number"
|
||||
min="1"
|
||||
class="max-w-xs"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ form.change_exceed_action === 'deduct_time' ? '每次超出更换将扣除的时长' : '每次超出更换将扣除的点数' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>多开控制</UiCardTitle>
|
||||
<UiCardDescription>配置应用的多开限制</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>多开模式</UiLabel>
|
||||
<UiRadioGroup v-model="form.multi_open_mode" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.multi_open_mode === 'forbidden' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.multi_open_mode = 'forbidden'"
|
||||
>
|
||||
<UiRadioGroupItem value="forbidden" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
禁止多开
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
只允许运行一个实例
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.multi_open_mode === 'limited' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.multi_open_mode = 'limited'"
|
||||
>
|
||||
<UiRadioGroupItem value="limited" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
有限多开
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
限制同时运行的实例数
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.multi_open_mode === 'unlimited' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.multi_open_mode = 'unlimited'"
|
||||
>
|
||||
<UiRadioGroupItem value="unlimited" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
无限多开
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
不限制实例数量
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="form.multi_open_mode === 'limited'" class="space-y-2 pt-4 border-t">
|
||||
<UiLabel for="max_instances">
|
||||
最大实例数
|
||||
</UiLabel>
|
||||
<UiInput id="max_instances" v-model.number="form.max_instances" type="number" min="1" class="max-w-xs" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
同一账号最多可同时运行的实例数量
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.multi_open_mode === 'unlimited'" class="p-4 rounded-lg bg-muted mt-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
允许多开模式将不限制应用实例数量,请谨慎使用此选项。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.multi_open_mode === 'forbidden'" class="p-4 rounded-lg bg-muted mt-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
禁止多开模式将只允许应用运行一个实例,尝试启动第二个实例将被阻止。
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>心跳设置</UiCardTitle>
|
||||
<UiCardDescription>配置客户端心跳检测参数</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="heartbeat_interval">
|
||||
心跳间隔(秒)
|
||||
</UiLabel>
|
||||
<UiInput id="heartbeat_interval" v-model.number="form.heartbeat_interval" type="number" min="10" max="300" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
客户端发送心跳的间隔时间
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="heartbeat_timeout">
|
||||
心跳超时(秒)
|
||||
</UiLabel>
|
||||
<UiInput id="heartbeat_timeout" v-model.number="form.heartbeat_timeout" type="number" min="30" max="600" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
超过此时间未收到心跳则判定离线
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>安全防护</UiCardTitle>
|
||||
<UiCardDescription>配置登录安全策略</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="max_attempts">
|
||||
最大尝试次数
|
||||
</UiLabel>
|
||||
<UiInput id="max_attempts" v-model.number="form.max_attempts" type="number" min="1" max="10" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
连续登录失败超过此次数将锁定
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="lock_duration">
|
||||
锁定时长(分钟)
|
||||
</UiLabel>
|
||||
<UiInput id="lock_duration" v-model.number="form.lock_duration" type="number" min="1" max="1440" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
账号被锁定后的解锁时间
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<UiButton :disabled="saving" @click="handleSave">
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
保存设置
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,881 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
interface AppData {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
icon_url: string
|
||||
billing_type: string
|
||||
login_policy: string
|
||||
allow_register: boolean
|
||||
register_methods: string
|
||||
enable_trial: boolean
|
||||
trial_balance: number
|
||||
enable_free_period: boolean
|
||||
free_period_type: string
|
||||
free_period_start: string
|
||||
free_period_end: string
|
||||
free_period_weekdays: string
|
||||
free_period_start_time: string
|
||||
free_period_end_time: string
|
||||
status: string
|
||||
deduction_cycle: string
|
||||
deduction_amount: number
|
||||
}
|
||||
|
||||
const app = ref<AppData | null>(null)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
icon_url: '',
|
||||
iconFile: null as File | null,
|
||||
iconPreview: '',
|
||||
billing_type: 'free',
|
||||
login_policy: 'loose',
|
||||
allow_register: true,
|
||||
register_methods: [] as string[],
|
||||
enable_trial: false,
|
||||
trial_balance: 0,
|
||||
trial_days: 0,
|
||||
enable_free_period: false,
|
||||
free_period_type: 'range',
|
||||
free_period_start: '',
|
||||
free_period_end: '',
|
||||
free_period_weekdays: [] as number[],
|
||||
free_period_start_time: '',
|
||||
free_period_end_time: '',
|
||||
status: 'active',
|
||||
deduction_mode: 'auto',
|
||||
deduction_type: 'login',
|
||||
deduction_interval: 1,
|
||||
deduction_unit: 'minute',
|
||||
deduction_amount: 1,
|
||||
})
|
||||
|
||||
function parseWeekdays(str: string): number[] {
|
||||
if (!str)
|
||||
return []
|
||||
try {
|
||||
const parsed = JSON.parse(str)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function weekdaysToString(arr: number[]): string {
|
||||
return JSON.stringify(arr)
|
||||
}
|
||||
|
||||
function parseRegisterMethods(str: string): string[] {
|
||||
if (!str)
|
||||
return ['username']
|
||||
try {
|
||||
const parsed = JSON.parse(str)
|
||||
return Array.isArray(parsed) ? parsed : ['username']
|
||||
}
|
||||
catch {
|
||||
return ['username']
|
||||
}
|
||||
}
|
||||
|
||||
function registerMethodsToString(arr: string[]): string {
|
||||
return JSON.stringify(arr)
|
||||
}
|
||||
|
||||
function getIconUrl(url: string): string {
|
||||
if (!url)
|
||||
return ''
|
||||
if (url.startsWith('http'))
|
||||
return url
|
||||
return `${API_BASE.replace('/api/v1', '')}${url}`
|
||||
}
|
||||
|
||||
function handleIconUpload(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file) {
|
||||
form.value.iconFile = file
|
||||
form.value.iconPreview = URL.createObjectURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
function clearIcon() {
|
||||
form.value.iconFile = null
|
||||
form.value.iconPreview = ''
|
||||
form.value.icon_url = ''
|
||||
}
|
||||
|
||||
async function fetchApp() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const appId = route.params.id
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${appId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
const appData = data.data.application || data.data
|
||||
app.value = appData
|
||||
form.value = {
|
||||
name: appData.name || '',
|
||||
description: appData.description || '',
|
||||
icon_url: appData.icon_url || '',
|
||||
iconFile: null,
|
||||
iconPreview: getIconUrl(appData.icon_url),
|
||||
billing_type: appData.billing_type || 'free',
|
||||
login_policy: appData.login_policy || 'loose',
|
||||
allow_register: appData.allow_register ?? true,
|
||||
register_methods: parseRegisterMethods(appData.register_methods),
|
||||
enable_trial: appData.enable_trial || false,
|
||||
trial_balance: appData.trial_balance || 0,
|
||||
trial_days: appData.trial_days || 0,
|
||||
enable_free_period: appData.enable_free_period || false,
|
||||
free_period_type: appData.free_period_type || 'range',
|
||||
free_period_start: appData.free_period_start || '',
|
||||
free_period_end: appData.free_period_end || '',
|
||||
free_period_weekdays: parseWeekdays(appData.free_period_weekdays),
|
||||
free_period_start_time: appData.free_period_start_time || '',
|
||||
free_period_end_time: appData.free_period_end_time || '',
|
||||
status: appData.status || 'active',
|
||||
deduction_mode: appData.deduction_mode || 'auto',
|
||||
deduction_type: appData.deduction_type || 'login',
|
||||
deduction_interval: appData.deduction_interval || 1,
|
||||
deduction_unit: appData.deduction_unit || 'minute',
|
||||
deduction_amount: appData.deduction_amount || 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用详情失败:', error)
|
||||
toast.error('获取应用详情失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const formDataToSend = new FormData()
|
||||
formDataToSend.append('name', form.value.name)
|
||||
formDataToSend.append('description', form.value.description)
|
||||
formDataToSend.append('billing_type', form.value.billing_type)
|
||||
formDataToSend.append('login_policy', form.value.login_policy)
|
||||
formDataToSend.append('allow_register', form.value.allow_register.toString())
|
||||
formDataToSend.append('register_methods', registerMethodsToString(form.value.register_methods))
|
||||
formDataToSend.append('enable_trial', form.value.enable_trial.toString())
|
||||
formDataToSend.append('trial_balance', form.value.trial_balance.toString())
|
||||
formDataToSend.append('trial_days', form.value.trial_days.toString())
|
||||
formDataToSend.append('enable_free_period', form.value.enable_free_period.toString())
|
||||
formDataToSend.append('free_period_type', form.value.free_period_type)
|
||||
formDataToSend.append('free_period_start', form.value.free_period_start)
|
||||
formDataToSend.append('free_period_end', form.value.free_period_end)
|
||||
formDataToSend.append('free_period_weekdays', weekdaysToString(form.value.free_period_weekdays))
|
||||
formDataToSend.append('free_period_start_time', form.value.free_period_start_time)
|
||||
formDataToSend.append('free_period_end_time', form.value.free_period_end_time)
|
||||
formDataToSend.append('status', form.value.status)
|
||||
formDataToSend.append('deduction_mode', form.value.deduction_mode)
|
||||
formDataToSend.append('deduction_type', form.value.deduction_type)
|
||||
formDataToSend.append('deduction_interval', form.value.deduction_interval.toString())
|
||||
formDataToSend.append('deduction_unit', form.value.deduction_unit)
|
||||
formDataToSend.append('deduction_amount', form.value.deduction_amount.toString())
|
||||
|
||||
if (form.value.iconFile) {
|
||||
formDataToSend.append('icon', form.value.iconFile)
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formDataToSend,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('保存成功')
|
||||
fetchApp()
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '保存失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
toast.error('保存失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApp()
|
||||
})
|
||||
|
||||
function toggleWeekday(index: number) {
|
||||
const idx = form.value.free_period_weekdays.indexOf(index)
|
||||
if (idx === -1) {
|
||||
form.value.free_period_weekdays.push(index)
|
||||
form.value.free_period_weekdays.sort()
|
||||
}
|
||||
else {
|
||||
form.value.free_period_weekdays.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="基本设置"
|
||||
description="修改应用的基本信息,包括名称、描述和图标等"
|
||||
:breadcrumbs="[
|
||||
{ title: '应用管理', href: '/developer/applications' },
|
||||
{ title: '基本设置' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>基本信息</UiCardTitle>
|
||||
<UiCardDescription>修改应用的基本信息</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="name">
|
||||
应用名称
|
||||
</UiLabel>
|
||||
<UiInput id="name" v-model="form.name" placeholder="请输入应用名称" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>运营状态</UiLabel>
|
||||
<UiRadioGroup v-model="form.status" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.status === 'active' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.status = 'active'"
|
||||
>
|
||||
<UiRadioGroupItem value="active" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
正常
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
应用正常运行
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.status === 'maintenance' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.status = 'maintenance'"
|
||||
>
|
||||
<UiRadioGroupItem value="maintenance" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
维护
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
应用维护中
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.status === 'stopped' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.status = 'stopped'"
|
||||
>
|
||||
<UiRadioGroupItem value="stopped" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
停止
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
应用已停止
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>应用图标</UiLabel>
|
||||
<div class="flex items-center gap-4">
|
||||
<div v-if="form.iconPreview" class="relative group">
|
||||
<img
|
||||
:src="form.iconPreview"
|
||||
alt="应用图标"
|
||||
class="w-16 h-16 rounded-lg object-cover border"
|
||||
>
|
||||
<UiButton
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
class="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click="clearIcon"
|
||||
>
|
||||
<Icon icon="lucide:x" class="h-3 w-3" />
|
||||
</UiButton>
|
||||
</div>
|
||||
<label
|
||||
for="appIcon"
|
||||
class="flex items-center justify-center h-16 px-4 border-2 border-dashed rounded-lg cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<Icon icon="lucide:upload" class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
<span class="text-sm text-muted-foreground">{{ form.iconPreview ? '更换图标' : '上传图标' }}</span>
|
||||
<input
|
||||
id="appIcon"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleIconUpload"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
支持 JPG、PNG、GIF 格式,建议尺寸 512x512
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="description">
|
||||
应用描述
|
||||
</UiLabel>
|
||||
<UiTextarea id="description" v-model="form.description" placeholder="请输入应用描述" rows="3" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>运营模式</UiCardTitle>
|
||||
<UiCardDescription>选择应用的计费方式</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<UiRadioGroup v-model="form.billing_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.billing_type === 'free' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.billing_type = 'free'"
|
||||
>
|
||||
<UiRadioGroupItem value="free" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
免费模式
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
用户无需付费即可使用所有功能
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.billing_type === 'subscription' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.billing_type = 'subscription'"
|
||||
>
|
||||
<UiRadioGroupItem value="subscription" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
订阅模式
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
用户按周期付费订阅使用
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.billing_type === 'balance' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.billing_type = 'balance'"
|
||||
>
|
||||
<UiRadioGroupItem value="balance" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
余额模式
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
用户按使用量计费,从余额扣除
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
|
||||
<div v-if="form.billing_type === 'balance'" class="space-y-4 pt-4 border-t mt-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>扣费方式</UiLabel>
|
||||
<UiRadioGroup v-model="form.deduction_mode" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.deduction_mode === 'auto' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.deduction_mode = 'auto'"
|
||||
>
|
||||
<UiRadioGroupItem value="auto" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
⚡ 自动扣费
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
系统自动触发扣费
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.deduction_mode === 'manual' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.deduction_mode = 'manual'"
|
||||
>
|
||||
<UiRadioGroupItem value="manual" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
🔧 手动扣费
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
通过 API 自行控制扣费
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="form.deduction_mode === 'auto'" class="space-y-4 pt-4 border-t">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>自动扣费类型</UiLabel>
|
||||
<UiRadioGroup v-model="form.deduction_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.deduction_type === 'login' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.deduction_type = 'login'"
|
||||
>
|
||||
<UiRadioGroupItem value="login" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
登录扣费
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
每次登录验证时扣费
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.deduction_type === 'timer' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.deduction_type = 'timer'"
|
||||
>
|
||||
<UiRadioGroupItem value="timer" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
计时扣费
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
按设定的时间间隔自动扣费
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="form.deduction_type === 'timer'" class="space-y-2">
|
||||
<UiLabel>扣费间隔</UiLabel>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-muted-foreground">每</span>
|
||||
<UiNumberField
|
||||
v-model="form.deduction_interval"
|
||||
:min="1"
|
||||
:max="9999"
|
||||
:step="1"
|
||||
class="w-24"
|
||||
>
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<UiSelect v-model="form.deduction_unit">
|
||||
<UiSelectTrigger class="w-24">
|
||||
<UiSelectValue placeholder="单位" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="minute">
|
||||
分钟
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="hour">
|
||||
小时
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="day">
|
||||
天
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<span class="text-sm text-muted-foreground">扣费一次</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>每次扣费金额</UiLabel>
|
||||
<UiNumberField
|
||||
v-model="form.deduction_amount"
|
||||
:min="0.01"
|
||||
:max="10000"
|
||||
:step="0.01"
|
||||
class="max-w-xs"
|
||||
>
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每次扣费时扣除的余额数量
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="p-4 rounded-lg bg-muted/50 border">
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon icon="lucide:info" class="size-5 text-blue-500 mt-0.5" />
|
||||
<div class="space-y-2">
|
||||
<p class="font-medium text-sm">
|
||||
手动扣费说明
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
选择手动扣费后,系统不会自动扣除用户余额。您需要通过 API 接口自行控制扣费时机和金额。
|
||||
</p>
|
||||
<div class="mt-2 p-2 rounded bg-background border text-xs font-mono">
|
||||
POST /api/v1/dev/applications/:id/deduct
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-if="form.billing_type === 'subscription'">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>登录策略</UiCardTitle>
|
||||
<UiCardDescription>设置用户登录验证策略</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<UiRadioGroup v-model="form.login_policy" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.login_policy === 'loose' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.login_policy = 'loose'"
|
||||
>
|
||||
<UiRadioGroupItem value="loose" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
宽松模式
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
用户到期后仍可登录,由客户端控制功能
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.login_policy === 'strict' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.login_policy = 'strict'"
|
||||
>
|
||||
<UiRadioGroupItem value="strict" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
严格模式
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
用户到期后无法登录,必须续费
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>注册设置</UiCardTitle>
|
||||
<UiCardDescription>配置用户注册方式和权限</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
允许注册
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
是否允许新用户注册账号
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="form.allow_register" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.allow_register" class="space-y-4 pt-4 border-t">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>注册方式</UiLabel>
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
选择允许用户使用的注册方式
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<UiCheckbox
|
||||
id="register-username"
|
||||
:checked="form.register_methods.includes('username')"
|
||||
@update:checked="(checked: boolean) => {
|
||||
if (checked) {
|
||||
form.register_methods.push('username')
|
||||
}
|
||||
else {
|
||||
form.register_methods = form.register_methods.filter(m => m !== 'username')
|
||||
}
|
||||
}"
|
||||
/>
|
||||
<UiLabel for="register-username" class="font-normal cursor-pointer">
|
||||
用户名注册
|
||||
</UiLabel>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<UiCheckbox
|
||||
id="register-email"
|
||||
:checked="form.register_methods.includes('email')"
|
||||
@update:checked="(checked: boolean) => {
|
||||
if (checked) {
|
||||
form.register_methods.push('email')
|
||||
}
|
||||
else {
|
||||
form.register_methods = form.register_methods.filter(m => m !== 'email')
|
||||
}
|
||||
}"
|
||||
/>
|
||||
<UiLabel for="register-email" class="font-normal cursor-pointer">
|
||||
邮箱注册
|
||||
</UiLabel>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<UiCheckbox
|
||||
id="register-phone"
|
||||
:checked="form.register_methods.includes('phone')"
|
||||
@update:checked="(checked: boolean) => {
|
||||
if (checked) {
|
||||
form.register_methods.push('phone')
|
||||
}
|
||||
else {
|
||||
form.register_methods = form.register_methods.filter(m => m !== 'phone')
|
||||
}
|
||||
}"
|
||||
/>
|
||||
<UiLabel for="register-phone" class="font-normal cursor-pointer">
|
||||
手机号注册
|
||||
</UiLabel>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-2">
|
||||
至少选择一种注册方式
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-if="form.billing_type !== 'free'">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>试用功能</UiCardTitle>
|
||||
<UiCardDescription>为新用户提供试用体验</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
启用试用
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
新用户注册后自动获得试用资格
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="form.enable_trial" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.enable_trial" class="space-y-2 pt-4 border-t">
|
||||
<template v-if="form.billing_type === 'balance'">
|
||||
<UiLabel>试用余额</UiLabel>
|
||||
<UiNumberField
|
||||
v-model="form.trial_balance"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
class="max-w-xs"
|
||||
>
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
新用户注册后自动获得的余额数量
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="form.billing_type === 'subscription'">
|
||||
<UiLabel>试用天数</UiLabel>
|
||||
<UiNumberField
|
||||
v-model="form.trial_days"
|
||||
:min="1"
|
||||
:max="365"
|
||||
:step="1"
|
||||
class="max-w-xs"
|
||||
>
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
新用户注册后自动获得的试用天数
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>免费时段</UiCardTitle>
|
||||
<UiCardDescription>在指定时间段内所有用户免费使用</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
启用免费时段
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
在设定的时间段内,所有用户可免费使用
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="form.enable_free_period" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.enable_free_period" class="space-y-4 pt-4 border-t">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>免费时段类型</UiLabel>
|
||||
<UiRadioGroup v-model="form.free_period_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.free_period_type === 'range' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.free_period_type = 'range'"
|
||||
>
|
||||
<UiRadioGroupItem value="range" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium text-sm">
|
||||
日期范围
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
指定开始和结束日期
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.free_period_type === 'weekly' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.free_period_type = 'weekly'"
|
||||
>
|
||||
<UiRadioGroupItem value="weekly" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium text-sm">
|
||||
每周重复
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每周指定日期和时间
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="form.free_period_type === 'range'" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>开始时间</UiLabel>
|
||||
<UiInput v-model="form.free_period_start" type="datetime-local" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel>结束时间</UiLabel>
|
||||
<UiInput v-model="form.free_period_end" type="datetime-local" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.free_period_type === 'weekly'" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>选择日期</UiLabel>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UiButton
|
||||
v-for="(day, index) in ['日', '一', '二', '三', '四', '五', '六']"
|
||||
:key="index"
|
||||
:variant="form.free_period_weekdays.includes(index) ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
@click="toggleWeekday(index)"
|
||||
>
|
||||
周{{ day }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>开始时间</UiLabel>
|
||||
<UiInput v-model="form.free_period_start_time" type="time" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel>结束时间</UiLabel>
|
||||
<UiInput v-model="form.free_period_end_time" type="time" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<UiButton :disabled="saving" @click="handleSave">
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
保存设置
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import type { Composer } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle,
|
||||
Mail,
|
||||
MoreHorizontal,
|
||||
Settings,
|
||||
Shield,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { App } from '../data/schema'
|
||||
|
||||
import { Copy } from '@/components/sva-ui/copy'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
export function getColumns(actions: {
|
||||
onGoToSettings: (app: App) => void
|
||||
onGoToSecurity: (app: App) => void
|
||||
onGoToEmail: (app: App) => void
|
||||
onToggleStatus: (app: App) => void
|
||||
onDelete: (app: App) => void
|
||||
}, t: Composer['t']): ColumnDef<App>[] {
|
||||
const billingTypeMap: Record<string, { label: string, class: string }> = {
|
||||
free: { label: '免费', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||
subscription: { label: '订阅', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' },
|
||||
time: { label: '计时', class: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300' },
|
||||
count: { label: '计次', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300' },
|
||||
}
|
||||
|
||||
const loginPolicyMap: Record<string, { label: string, class: string }> = {
|
||||
loose: { label: '宽松', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||
strict: { label: '严格', class: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
|
||||
hybrid: { label: '混合', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300' },
|
||||
}
|
||||
|
||||
const statusMap: Record<string, { label: string, class: string }> = {
|
||||
active: { label: '启用', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||
inactive: { label: '禁用', class: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300' },
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: () => '应用名称',
|
||||
cell: ({ row }) => h('div', { class: 'flex items-center space-x-3' }, [
|
||||
h('div', { class: 'h-8 w-8 rounded bg-primary/10 flex items-center justify-center flex-shrink-0 overflow-hidden' }, [
|
||||
row.original.icon_url
|
||||
? h('img', {
|
||||
src: `http://localhost:8080${row.original.icon_url}`,
|
||||
alt: row.getValue('name') as string,
|
||||
class: 'w-full h-full object-cover',
|
||||
})
|
||||
: h('span', { class: 'text-primary font-bold text-sm' }, (row.getValue('name') as string).charAt(0).toUpperCase()),
|
||||
]),
|
||||
h('div', { class: 'flex flex-col' }, [
|
||||
h('p', { class: 'font-medium' }, row.getValue('name') as string),
|
||||
h('p', { class: 'text-xs text-muted-foreground truncate max-w-[200px]' }, row.original.description),
|
||||
]),
|
||||
]),
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'app_key',
|
||||
header: () => 'App Key',
|
||||
cell: ({ row }) => {
|
||||
const appKey = row.getValue('app_key') as string
|
||||
return h('div', { class: 'flex items-center space-x-2' }, [
|
||||
h('code', { class: 'text-xs bg-muted px-2 py-1 rounded font-mono' }, appKey || '-'),
|
||||
appKey && h(Copy, { class: 'h-4 w-4', size: 'sm', content: appKey }),
|
||||
])
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'billing_type',
|
||||
header: () => '运营模式',
|
||||
cell: ({ row }) => {
|
||||
const billingType = row.getValue('billing_type') as string
|
||||
const info = billingTypeMap[billingType]
|
||||
if (!info)
|
||||
return h('span', {}, billingType)
|
||||
return h('span', { class: `px-2 py-1 rounded text-xs ${info.class}` }, info.label)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'login_policy',
|
||||
header: () => '登录策略',
|
||||
cell: ({ row }) => {
|
||||
const loginPolicy = row.getValue('login_policy') as string
|
||||
const info = loginPolicyMap[loginPolicy]
|
||||
if (!info)
|
||||
return h('span', {}, loginPolicy)
|
||||
return h('span', { class: `px-2 py-1 rounded text-xs ${info.class}` }, info.label)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'enable_trial',
|
||||
header: () => '试用',
|
||||
cell: ({ row }) => {
|
||||
const enableTrial = row.getValue('enable_trial') as boolean
|
||||
return h('span', {
|
||||
class: `px-2 py-1 rounded text-xs ${enableTrial ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'}`,
|
||||
}, enableTrial ? `${row.original.trial_balance}余额` : '无')
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => '状态',
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as string
|
||||
const info = statusMap[status]
|
||||
if (!info)
|
||||
return h('span', {}, status)
|
||||
return h('span', { class: `px-2 py-1 rounded text-xs ${info.class}` }, info.label)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: () => '创建时间',
|
||||
cell: ({ row }) => {
|
||||
const createdAt = row.getValue('created_at')
|
||||
if (!createdAt)
|
||||
return '-'
|
||||
try {
|
||||
const date = new Date(createdAt as string)
|
||||
if (Number.isNaN(date.getTime()))
|
||||
return '-'
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
catch {
|
||||
return '-'
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => h('span', { class: 'sr-only' }, t('common.actions')),
|
||||
cell: ({ row }) => {
|
||||
const app = row.original
|
||||
const isActive = app.status === 'active'
|
||||
|
||||
return h(
|
||||
DropdownMenu,
|
||||
{},
|
||||
{
|
||||
default: () => [
|
||||
h(DropdownMenuTrigger, { asChild: true }, () =>
|
||||
h(Button, { variant: 'ghost', class: 'h-8 w-8 p-0' }, () => [
|
||||
h(MoreHorizontal, { class: 'h-4 w-4' }),
|
||||
h('span', { class: 'sr-only' }, t('common.openMenu')),
|
||||
])),
|
||||
h(
|
||||
DropdownMenuContent,
|
||||
{ align: 'end' },
|
||||
() => [
|
||||
h(DropdownMenuItem, { onClick: () => actions.onGoToSettings(app) }, () => [
|
||||
h(Settings, { class: 'mr-2 h-4 w-4' }),
|
||||
'基本设置',
|
||||
]),
|
||||
h(DropdownMenuItem, { onClick: () => actions.onGoToSecurity(app) }, () => [
|
||||
h(Shield, { class: 'mr-2 h-4 w-4' }),
|
||||
'安全设置',
|
||||
]),
|
||||
h(DropdownMenuItem, { onClick: () => actions.onGoToEmail(app) }, () => [
|
||||
h(Mail, { class: 'mr-2 h-4 w-4' }),
|
||||
'邮箱设置',
|
||||
]),
|
||||
h(DropdownMenuSeparator),
|
||||
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(app) }, () => [
|
||||
isActive ? h(Ban, { class: 'mr-2 h-4 w-4' }) : h(CheckCircle, { class: 'mr-2 h-4 w-4' }),
|
||||
isActive ? '禁用' : '启用',
|
||||
]),
|
||||
h(DropdownMenuSeparator),
|
||||
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(app) }, () => [
|
||||
h(Trash2, { class: 'mr-2 h-4 w-4' }),
|
||||
t('developer.applications.delete'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { DataTableProps } from '@/components/data-table/types'
|
||||
import type { App } from '@/pages/developer/applications/data/schema'
|
||||
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getColumns } from '@/pages/developer/applications/components/columns'
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<App>, 'columns'> & {
|
||||
searchFilter?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'refresh': []
|
||||
'goToSettings': [app: App]
|
||||
'goToSecurity': [app: App]
|
||||
'goToEmail': [app: App]
|
||||
'toggleStatus': [app: App]
|
||||
'delete': [app: App]
|
||||
'update:searchFilter': [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const columns = computed(() => [
|
||||
SelectColumn as ColumnDef<App>,
|
||||
...getColumns({
|
||||
onGoToSettings: (app: App) => emit('goToSettings', app),
|
||||
onGoToSecurity: (app: App) => emit('goToSecurity', app),
|
||||
onGoToEmail: (app: App) => emit('goToEmail', app),
|
||||
onToggleStatus: (app: App) => emit('toggleStatus', app),
|
||||
onDelete: (app: App) => emit('delete', app),
|
||||
}, t),
|
||||
])
|
||||
|
||||
const table = generateVueTable<App>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
select: 'developer.applications.select',
|
||||
name: 'developer.applications.columns.name',
|
||||
app_key: 'developer.applications.columns.appKey',
|
||||
billing_type: 'developer.applications.columns.billingType',
|
||||
login_policy: 'developer.applications.columns.loginPolicy',
|
||||
enable_trial: 'developer.applications.columns.trial',
|
||||
status: 'developer.applications.columns.status',
|
||||
created_at: 'developer.applications.columns.createdAt',
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UiInput
|
||||
:model-value="searchFilter"
|
||||
:placeholder="t('developer.applications.searchPlaceholder')"
|
||||
class="h-9 w-40 lg:w-[250px]"
|
||||
@update:model-value="emit('update:searchFilter', String($event))"
|
||||
/>
|
||||
</div>
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,652 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
const loading = ref(false)
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
billing_type: 'free' as 'free' | 'subscription' | 'time' | 'count',
|
||||
login_policy: 'loose' as 'loose' | 'strict' | 'hybrid',
|
||||
deduction_cycle: 'per_use' as 'per_use' | 'per_minute' | 'per_hour' | 'per_day',
|
||||
deduction_amount: 1,
|
||||
iconFile: null as File | null,
|
||||
iconPreview: '',
|
||||
encrypt_type: 'none' as 'none' | 'aes' | 'rsa' | 'rc4',
|
||||
secret_key: '',
|
||||
bind_type: 'device' as 'device' | 'account' | 'none',
|
||||
max_devices: 1,
|
||||
multi_open: false,
|
||||
enable_trial: false,
|
||||
trial_balance: 0,
|
||||
enable_free_period: false,
|
||||
free_period_type: 'range' as 'range' | 'weekly',
|
||||
free_period_start: '',
|
||||
free_period_end: '',
|
||||
free_period_weekdays: [] as number[],
|
||||
free_period_start_time: '',
|
||||
free_period_end_time: '',
|
||||
})
|
||||
|
||||
function handleIconUpload(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file) {
|
||||
formData.value.iconFile = file
|
||||
formData.value.iconPreview = URL.createObjectURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
function clearIcon() {
|
||||
formData.value.iconFile = null
|
||||
formData.value.iconPreview = ''
|
||||
}
|
||||
|
||||
function generateSecretKey() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < 32; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
formData.value.secret_key = result
|
||||
}
|
||||
|
||||
function toggleWeekday(index: number) {
|
||||
const idx = formData.value.free_period_weekdays.indexOf(index)
|
||||
if (idx === -1) {
|
||||
formData.value.free_period_weekdays.push(index)
|
||||
formData.value.free_period_weekdays.sort()
|
||||
}
|
||||
else {
|
||||
formData.value.free_period_weekdays.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!formData.value.name.trim()) {
|
||||
toast.error(t('developer.applications.appNameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const formDataToSend = new FormData()
|
||||
formDataToSend.append('name', formData.value.name)
|
||||
formDataToSend.append('description', formData.value.description)
|
||||
formDataToSend.append('billing_type', formData.value.billing_type)
|
||||
formDataToSend.append('login_policy', formData.value.login_policy)
|
||||
formDataToSend.append('encrypt_type', formData.value.encrypt_type)
|
||||
formDataToSend.append('secret_key', formData.value.secret_key)
|
||||
formDataToSend.append('bind_type', formData.value.bind_type)
|
||||
formDataToSend.append('max_devices', formData.value.max_devices.toString())
|
||||
formDataToSend.append('multi_open', formData.value.multi_open.toString())
|
||||
formDataToSend.append('enable_trial', formData.value.enable_trial.toString())
|
||||
formDataToSend.append('trial_balance', formData.value.trial_balance.toString())
|
||||
formDataToSend.append('enable_free_period', formData.value.enable_free_period.toString())
|
||||
formDataToSend.append('free_period_type', formData.value.free_period_type)
|
||||
formDataToSend.append('free_period_start', formData.value.free_period_start)
|
||||
formDataToSend.append('free_period_end', formData.value.free_period_end)
|
||||
formDataToSend.append('free_period_weekdays', JSON.stringify(formData.value.free_period_weekdays))
|
||||
formDataToSend.append('free_period_start_time', formData.value.free_period_start_time)
|
||||
formDataToSend.append('free_period_end_time', formData.value.free_period_end_time)
|
||||
|
||||
if (formData.value.billing_type !== 'free') {
|
||||
formDataToSend.append('deduction_cycle', formData.value.deduction_cycle)
|
||||
formDataToSend.append('deduction_amount', formData.value.deduction_amount.toString())
|
||||
}
|
||||
|
||||
if (formData.value.iconFile) {
|
||||
formDataToSend.append('icon', formData.value.iconFile)
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/dev/applications`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formDataToSend,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success(t('developer.applications.createSuccess'))
|
||||
router.push('/developer/applications')
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || t('developer.applications.createFailed'))
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('创建应用失败:', error)
|
||||
toast.error(t('developer.applications.createFailed'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.applications.createApp')"
|
||||
:description="t('developer.applications.createAppDesc')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('developer.applications.title'), href: '/developer/applications' },
|
||||
{ title: t('developer.applications.createApp') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.applications.basicInfo') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.applications.basicInfoDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="name">
|
||||
{{ t('developer.applications.appName') }} *
|
||||
</UiLabel>
|
||||
<UiInput id="name" v-model="formData.name" :placeholder="t('developer.applications.appNamePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.appIcon') }}</UiLabel>
|
||||
<div class="flex items-center gap-4">
|
||||
<div v-if="formData.iconPreview" class="relative group">
|
||||
<img
|
||||
:src="formData.iconPreview"
|
||||
alt="应用图标"
|
||||
class="w-12 h-12 rounded-lg object-cover border"
|
||||
>
|
||||
<UiButton
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
class="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click="clearIcon"
|
||||
>
|
||||
<Icon icon="lucide:x" class="h-3 w-3" />
|
||||
</UiButton>
|
||||
</div>
|
||||
<label
|
||||
for="appIcon"
|
||||
class="flex items-center justify-center h-12 px-4 border-2 border-dashed rounded-lg cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<Icon icon="lucide:upload" class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
<span class="text-sm text-muted-foreground">{{ formData.iconPreview ? t('developer.applications.change') : t('developer.applications.upload') }}</span>
|
||||
<input
|
||||
id="appIcon"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleIconUpload"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="description">
|
||||
{{ t('developer.applications.appDescription') }}
|
||||
</UiLabel>
|
||||
<UiTextarea id="description" v-model="formData.description" :placeholder="t('developer.applications.appDescriptionPlaceholder')" rows="3" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.applications.operationMode') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.applications.operationModeDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<UiRadioGroup v-model="formData.billing_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.billing_type === 'free' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.billing_type = 'free'"
|
||||
>
|
||||
<UiRadioGroupItem value="free" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.freeMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('developer.applications.freeModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.billing_type === 'subscription' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.billing_type = 'subscription'"
|
||||
>
|
||||
<UiRadioGroupItem value="subscription" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.subscriptionMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('developer.applications.subscriptionModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.billing_type === 'time' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.billing_type = 'time'"
|
||||
>
|
||||
<UiRadioGroupItem value="time" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.timeMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('developer.applications.timeModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.billing_type === 'count' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.billing_type = 'count'"
|
||||
>
|
||||
<UiRadioGroupItem value="count" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.countMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('developer.applications.countModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
|
||||
<div v-if="formData.billing_type !== 'free'" class="mt-4 pt-4 border-t space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.deductionCycle') }}</UiLabel>
|
||||
<UiSelect v-model="formData.deduction_cycle">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.applications.selectDeductionCycle')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="per_use">
|
||||
{{ t('developer.applications.perUse') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="per_minute">
|
||||
{{ t('developer.applications.perMinute') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="per_hour">
|
||||
{{ t('developer.applications.perHour') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="per_day">
|
||||
{{ t('developer.applications.perDay') }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.deductionAmount') }}</UiLabel>
|
||||
<UiNumberField v-model="formData.deduction_amount" :min="0" :step="0.01">
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.applications.loginPolicy') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.applications.loginPolicyDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<UiRadioGroup v-model="formData.login_policy" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.login_policy === 'loose' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.login_policy = 'loose'"
|
||||
>
|
||||
<UiRadioGroupItem value="loose" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.looseMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('developer.applications.looseModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.login_policy === 'strict' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.login_policy = 'strict'"
|
||||
>
|
||||
<UiRadioGroupItem value="strict" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.strictMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('developer.applications.strictModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.login_policy === 'hybrid' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.login_policy = 'hybrid'"
|
||||
>
|
||||
<UiRadioGroupItem value="hybrid" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.hybridMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('developer.applications.hybridModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.applications.trialSettings') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.applications.trialSettings') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.enableTrial') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
新用户注册后自动获得试用权益
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="formData.enable_trial" />
|
||||
</div>
|
||||
|
||||
<div v-if="formData.enable_trial" class="space-y-4 pt-4 border-t">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.trialBalance') }}</UiLabel>
|
||||
<UiNumberField
|
||||
v-model="formData.trial_balance"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
class="max-w-xs"
|
||||
>
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
新用户注册后获得的余额
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.applications.freePeriodSettings') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.applications.freePeriodSettings') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.enableFreePeriod') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
在设定的时间段内,所有用户可免费使用
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="formData.enable_free_period" />
|
||||
</div>
|
||||
|
||||
<div v-if="formData.enable_free_period" class="space-y-4 pt-4 border-t">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.periodType') }}</UiLabel>
|
||||
<UiRadioGroup v-model="formData.free_period_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.free_period_type === 'range' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.free_period_type = 'range'"
|
||||
>
|
||||
<UiRadioGroupItem value="range" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.dateRange') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
指定开始和结束日期时间
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.free_period_type === 'weekly' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.free_period_type = 'weekly'"
|
||||
>
|
||||
<UiRadioGroupItem value="weekly" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.weekly') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
每周固定日期和时间段
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.free_period_type === 'range'" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.startTime') }}</UiLabel>
|
||||
<UiDatePickerDateTimePicker v-model="formData.free_period_start" :placeholder="t('developer.applications.startTime')" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.endTime') }}</UiLabel>
|
||||
<UiDatePickerDateTimePicker v-model="formData.free_period_end" :placeholder="t('developer.applications.endTime')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.free_period_type === 'weekly'" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.weekdays') }}</UiLabel>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template v-for="(day, index) in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :key="index">
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="formData.free_period_weekdays.includes(index) ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
|
||||
@click="toggleWeekday(index)"
|
||||
>
|
||||
{{ day }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
选择每周哪些日期免费
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.startTime') }}</UiLabel>
|
||||
<UiDatePickerTimePicker v-model="formData.free_period_start_time" :placeholder="t('developer.applications.startTime')" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每天免费开始时间
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.endTime') }}</UiLabel>
|
||||
<UiDatePickerTimePicker v-model="formData.free_period_end_time" :placeholder="t('developer.applications.endTime')" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每天免费结束时间
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.applications.securitySettings') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.applications.securitySettingsDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.encryptionType') }}</UiLabel>
|
||||
<UiSelect v-model="formData.encrypt_type">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.applications.encryptionType')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="none">
|
||||
{{ t('developer.applications.noEncryption') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="aes">
|
||||
{{ t('developer.applications.aesEncryption') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="rsa">
|
||||
{{ t('developer.applications.rsaEncryption') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="rc4">
|
||||
{{ t('developer.applications.rc4Encryption') }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<span v-if="formData.encrypt_type === 'none'">数据明文传输,仅用于测试环境</span>
|
||||
<span v-else-if="formData.encrypt_type === 'aes'">使用AES-GCM算法加密,安全性高</span>
|
||||
<span v-else-if="formData.encrypt_type === 'rsa'">非对称加密,安全性最高</span>
|
||||
<span v-else-if="formData.encrypt_type === 'rc4'">使用RC4流加密,性能较好</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.secretKey') }}</UiLabel>
|
||||
<div class="relative">
|
||||
<UiInput
|
||||
v-model="formData.secret_key"
|
||||
type="password"
|
||||
placeholder="留空则自动生成"
|
||||
class="pr-10"
|
||||
/>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7"
|
||||
@click="generateSecretKey"
|
||||
>
|
||||
<Icon icon="lucide:refresh-cw" class="h-4 w-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
用于加密通信数据的密钥,留空则自动生成
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.applications.deviceBind') }}</UiCardTitle>
|
||||
<UiCardDescription>控制用户登录时是否需要绑定设备</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.deviceBind') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后,用户登录时需要绑定设备,可限制同时登录的设备数量
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
:model-value="formData.bind_type === 'device'"
|
||||
@update:model-value="(val: boolean) => formData.bind_type = val ? 'device' : 'none'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.bind_type === 'device'" class="space-y-2 pt-4 border-t">
|
||||
<UiLabel>{{ t('developer.applications.maxDevices') }}</UiLabel>
|
||||
<UiNumberField v-model="formData.max_devices" :min="1" :max="10">
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每个账号最多绑定的设备数量
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.allowMultiOpen') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
允许同一账号在同一设备上同时运行多个实例
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="formData.multi_open" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<UiButton variant="outline" @click="router.push('/developer/applications')">
|
||||
取消
|
||||
</UiButton>
|
||||
<UiButton :disabled="loading" @click="handleCreate">
|
||||
<Icon v-if="loading" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ t('developer.applications.createApp') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const appStatusSchema = z.enum(['active', 'inactive'])
|
||||
export type AppStatus = z.infer<typeof appStatusSchema>
|
||||
|
||||
export const billingMethodSchema = z.enum(['subscription', 'time', 'count'])
|
||||
export type BillingMethod = z.infer<typeof billingMethodSchema>
|
||||
|
||||
export const operationModeSchema = z.enum(['free', 'paid'])
|
||||
export type OperationMode = z.infer<typeof operationModeSchema>
|
||||
|
||||
export const deductionCycleSchema = z.enum(['per_use', 'per_minute', 'per_hour', 'per_day'])
|
||||
export type DeductionCycle = z.infer<typeof deductionCycleSchema>
|
||||
|
||||
export const loginPolicySchema = z.enum(['loose', 'strict', 'hybrid'])
|
||||
export type LoginPolicy = z.infer<typeof loginPolicySchema>
|
||||
|
||||
export const appSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
app_key: z.string(),
|
||||
billing_type: z.enum(['free', 'subscription', 'time', 'count']),
|
||||
login_policy: loginPolicySchema.default('loose'),
|
||||
encrypt_type: z.enum(['none', 'aes', 'rsa', 'rc4']),
|
||||
secret_key: z.string(),
|
||||
bind_type: z.enum(['device', 'account', 'none']),
|
||||
max_devices: z.number().default(1),
|
||||
change_limit: z.number().optional(),
|
||||
change_interval: z.number().optional(),
|
||||
multi_open: z.boolean().default(false),
|
||||
max_instances: z.number().optional(),
|
||||
enable_trial: z.boolean().default(false),
|
||||
trial_balance: z.number().default(0),
|
||||
enable_free_period: z.boolean().default(false),
|
||||
free_period_start: z.string().optional(),
|
||||
free_period_end: z.string().optional(),
|
||||
deduction_cycle: deductionCycleSchema.optional(),
|
||||
deduction_amount: z.number().optional(),
|
||||
heartbeat_interval: z.number().default(60),
|
||||
heartbeat_timeout: z.number().default(300),
|
||||
max_attempts: z.number().default(5),
|
||||
lock_duration: z.number().default(30),
|
||||
status: appStatusSchema,
|
||||
disabled_by_package: z.boolean().default(false),
|
||||
icon_url: z.string().optional(),
|
||||
users: z.number().optional(),
|
||||
verify_count: z.number().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().optional(),
|
||||
})
|
||||
export type App = z.infer<typeof appSchema>
|
||||
|
||||
export const appListSchema = z.array(appSchema)
|
||||
Reference in New Issue
Block a user