feat: add comprehensive i18n support across all pages
- Add i18n support to admin sidebar, breadcrumbs, and nav-footer - Add i18n support to admin pages: system-settings, users, versions, risk-control, sessions, cloud-variables, cloud-constants - Add i18n support to auth pages: login, register - Add i18n support to install and profile pages - Add i18n support to agent pages: dashboard, finance, users, apps, cards - Replace all hard-coded Chinese text with i18n translation keys - Add comprehensive translation keys to zh.json and en.json - Fix date formatting to use locale-agnostic toLocaleDateString() - Pass t function as parameter for i18n in non-component files
This commit is contained in:
@@ -155,7 +155,7 @@ export function getColumns(actions: {
|
||||
items.push(
|
||||
h(DropdownMenuItem, { onClick: () => actions.onDownload(constant) }, () => [
|
||||
h(Download, { class: 'mr-2 h-4 w-4' }),
|
||||
'下载文件',
|
||||
t('admin.cloudConstants.downloadFile'),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function getColumns(actions: {
|
||||
decimal: t('admin.cloudVariables.types.decimal'),
|
||||
string: t('admin.cloudVariables.types.string'),
|
||||
binary: t('admin.cloudVariables.types.binary'),
|
||||
stream: '记录',
|
||||
stream: t('admin.cloudVariables.columns.stream'),
|
||||
}
|
||||
return h(Badge, { variant: 'outline' }, () => typeMap[type || 'string'] || type || 'string')
|
||||
},
|
||||
@@ -189,7 +189,7 @@ export function getColumns(actions: {
|
||||
items.push(
|
||||
h(DropdownMenuItem, { onClick: () => actions.onDownload(variable) }, () => [
|
||||
h(Download, { class: 'mr-2 h-4 w-4' }),
|
||||
'下载文件',
|
||||
t('admin.cloudVariables.downloadFile'),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<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 { RiskRule } from '@/pages/admin/risk-control/data/schema'
|
||||
|
||||
@@ -12,6 +15,8 @@ import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getColumns } from '@/pages/admin/risk-control/components/columns'
|
||||
import DataTableToolbar from '@/pages/admin/risk-control/components/data-table-toolbar.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<RiskRule>, 'columns'> & {
|
||||
onToggleStatus: (row: RiskRule) => void
|
||||
onEdit: (row: RiskRule) => void
|
||||
@@ -40,13 +45,13 @@ const table = generateVueTable<RiskRule>({
|
||||
})
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
select: '选择',
|
||||
type: '类型',
|
||||
value: '封禁值',
|
||||
reason: '原因',
|
||||
status: '状态',
|
||||
expires_at: '过期时间',
|
||||
created_at: '创建时间',
|
||||
select: t('common.select'),
|
||||
type: t('common.type'),
|
||||
value: t('admin.riskControl.columns.value'),
|
||||
reason: t('common.reason'),
|
||||
status: t('common.status'),
|
||||
expires_at: t('common.expiresAt'),
|
||||
created_at: t('common.createdAt'),
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
@@ -60,13 +65,13 @@ defineExpose({
|
||||
<div class="space-y-3">
|
||||
<BulkActions :table="table" entity-name="rules">
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchEnable')">
|
||||
批量启用
|
||||
{{ t('common.batchEnable') }}
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchDisable')">
|
||||
批量禁用
|
||||
{{ t('common.batchDisable') }}
|
||||
</UiButton>
|
||||
<UiButton variant="destructive" size="sm" @click="emit('batchDelete')">
|
||||
批量删除
|
||||
{{ t('common.batchDelete') }}
|
||||
</UiButton>
|
||||
</BulkActions>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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 { Session } from '@/pages/admin/sessions/data/schema'
|
||||
@@ -12,6 +13,8 @@ import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getColumns } from '@/pages/admin/sessions/components/columns'
|
||||
import DataTableToolbar from '@/pages/admin/sessions/components/data-table-toolbar.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<Session>, 'columns'> & {
|
||||
applications: { id: number, name: string }[]
|
||||
appFilter?: string
|
||||
@@ -35,13 +38,13 @@ const table = generateVueTable<Session>({
|
||||
})
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
instance_id: '实例标识',
|
||||
device_identifier: '设备指纹',
|
||||
device_name: '设备名称',
|
||||
username: '所属用户',
|
||||
app_name: '所属应用',
|
||||
last_heartbeat: '最后心跳',
|
||||
created_at: '创建时间',
|
||||
instance_id: t('admin.sessions.columns.instanceId'),
|
||||
device_identifier: t('admin.sessions.columns.deviceIdentifier'),
|
||||
device_name: t('admin.sessions.columns.deviceName'),
|
||||
username: t('admin.sessions.columns.username'),
|
||||
app_name: t('admin.sessions.columns.appName'),
|
||||
last_heartbeat: t('admin.sessions.columns.lastHeartbeat'),
|
||||
created_at: t('common.createdAt'),
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { Bell, Database, Loader2, Settings, Shield, ToggleLeft, Trash2, Upload } from 'lucide-vue-next'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const uploadingLogo = ref(false)
|
||||
@@ -110,7 +113,7 @@ async function loadSettings() {
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
toast.error('加载设置失败')
|
||||
toast.error(t('admin.systemSettings.loadFailed'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
@@ -146,11 +149,11 @@ async function saveSettings() {
|
||||
...notificationForm.value,
|
||||
}
|
||||
await api.put('/dev/system-settings', payload)
|
||||
toast.success('保存成功')
|
||||
toast.success(t('admin.systemSettings.saveSuccess'))
|
||||
updateGlobalSettings()
|
||||
}
|
||||
catch (error) {
|
||||
toast.error('保存失败')
|
||||
toast.error(t('admin.systemSettings.saveFailed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
@@ -171,10 +174,10 @@ async function saveCleanupSettings() {
|
||||
cleanupLoading.value = true
|
||||
try {
|
||||
await api.put('/dev/system-settings/cleanup', cleanupForm.value)
|
||||
toast.success('清理设置保存成功')
|
||||
toast.success(t('admin.systemSettings.cleanup.cleanupSaveSuccess'))
|
||||
}
|
||||
catch (error) {
|
||||
toast.error('保存清理设置失败')
|
||||
toast.error(t('admin.systemSettings.cleanup.cleanupSaveFailed'))
|
||||
}
|
||||
finally {
|
||||
cleanupLoading.value = false
|
||||
@@ -185,10 +188,10 @@ async function runManualCleanup() {
|
||||
manualCleanupLoading.value = true
|
||||
try {
|
||||
await api.post('/dev/system-settings/cleanup/run')
|
||||
toast.success('手动清理完成')
|
||||
toast.success(t('admin.systemSettings.cleanup.manualCleanupSuccess'))
|
||||
}
|
||||
catch (error) {
|
||||
toast.error('手动清理失败')
|
||||
toast.error(t('admin.systemSettings.cleanup.manualCleanupFailed'))
|
||||
}
|
||||
finally {
|
||||
manualCleanupLoading.value = false
|
||||
@@ -213,10 +216,10 @@ async function handleLogoUpload(event: Event) {
|
||||
|
||||
const data = await api.postFormData<any>('/dev/system-settings/upload', formData)
|
||||
basicForm.value.site_logo = data.url
|
||||
toast.success('Logo上传成功')
|
||||
toast.success(t('admin.systemSettings.upload.logoSuccess'))
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.error(error.message || '上传失败')
|
||||
toast.error(error.message || t('admin.systemSettings.upload.uploadFailed'))
|
||||
}
|
||||
finally {
|
||||
uploadingLogo.value = false
|
||||
@@ -242,10 +245,10 @@ async function handleFaviconUpload(event: Event) {
|
||||
|
||||
const data = await api.postFormData<any>('/dev/system-settings/upload', formData)
|
||||
basicForm.value.site_favicon = data.url
|
||||
toast.success('图标上传成功')
|
||||
toast.success(t('admin.systemSettings.upload.faviconSuccess'))
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.error(error.message || '上传失败')
|
||||
toast.error(error.message || t('admin.systemSettings.upload.uploadFailed'))
|
||||
}
|
||||
finally {
|
||||
uploadingFavicon.value = false
|
||||
@@ -253,14 +256,14 @@ async function handleFaviconUpload(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'basic', label: '基本设置', icon: Settings },
|
||||
{ id: 'security', label: '安全设置', icon: Shield },
|
||||
{ id: 'backup', label: '备份设置', icon: Database },
|
||||
{ id: 'cleanup', label: '数据清理', icon: Trash2 },
|
||||
{ id: 'feature', label: '功能设置', icon: ToggleLeft },
|
||||
{ id: 'notification', label: '通知设置', icon: Bell },
|
||||
]
|
||||
const tabs = computed(() => [
|
||||
{ id: 'basic', label: t('admin.systemSettings.tabs.basic'), icon: Settings },
|
||||
{ id: 'security', label: t('admin.systemSettings.tabs.security'), icon: Shield },
|
||||
{ id: 'backup', label: t('admin.systemSettings.tabs.backup'), icon: Database },
|
||||
{ id: 'cleanup', label: t('admin.systemSettings.tabs.cleanup'), icon: Trash2 },
|
||||
{ id: 'feature', label: t('admin.systemSettings.tabs.feature'), icon: ToggleLeft },
|
||||
{ id: 'notification', label: t('admin.systemSettings.tabs.notification'), icon: Bell },
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
@@ -268,7 +271,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage title="系统设置" description="配置系统各项设置">
|
||||
<BasicPage :title="t('admin.systemSettings.title')" :description="t('admin.systemSettings.description')">
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
@@ -291,26 +294,26 @@ onMounted(() => {
|
||||
|
||||
<UiCard v-show="activeTab === 'basic'">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>基本设置</UiCardTitle>
|
||||
<UiCardDescription>配置网站基本信息</UiCardDescription>
|
||||
<UiCardTitle>{{ t('admin.systemSettings.basic.title') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('admin.systemSettings.basic.description') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="site_name">网站名称</UiLabel>
|
||||
<UiInput id="site_name" v-model="basicForm.site_name" placeholder="请输入网站名称" />
|
||||
<UiLabel for="site_name">{{ t('admin.systemSettings.basic.siteName') }}</UiLabel>
|
||||
<UiInput id="site_name" v-model="basicForm.site_name" :placeholder="t('admin.systemSettings.basic.siteNamePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>网站Logo</UiLabel>
|
||||
<UiLabel>{{ t('admin.systemSettings.basic.siteLogo') }}</UiLabel>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<UiButton variant="outline" size="sm" :disabled="uploadingLogo" @click="triggerLogoUpload">
|
||||
<Loader2 v-if="uploadingLogo" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Upload v-else class="mr-2 h-4 w-4" />
|
||||
上传Logo
|
||||
{{ t('admin.systemSettings.basic.uploadLogo') }}
|
||||
</UiButton>
|
||||
<span class="text-xs text-muted-foreground">支持 JPG、PNG、SVG 格式</span>
|
||||
<span class="text-xs text-muted-foreground">{{ t('admin.systemSettings.basic.logoHint') }}</span>
|
||||
</div>
|
||||
<input
|
||||
ref="logoInputRef"
|
||||
@@ -332,7 +335,7 @@ onMounted(() => {
|
||||
class="text-destructive hover:text-destructive"
|
||||
@click="basicForm.site_logo = ''"
|
||||
>
|
||||
移除
|
||||
{{ t('admin.systemSettings.basic.remove') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,16 +343,16 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>网站图标 (Favicon)</UiLabel>
|
||||
<UiLabel>{{ t('admin.systemSettings.basic.favicon') }}</UiLabel>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<UiButton variant="outline" size="sm" :disabled="uploadingFavicon" @click="triggerFaviconUpload">
|
||||
<Loader2 v-if="uploadingFavicon" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Upload v-else class="mr-2 h-4 w-4" />
|
||||
上传图标
|
||||
{{ t('admin.systemSettings.basic.uploadFavicon') }}
|
||||
</UiButton>
|
||||
<span class="text-xs text-muted-foreground">推荐 32x32 或 64x64 像素的 ICO/PNG</span>
|
||||
<span class="text-xs text-muted-foreground">{{ t('admin.systemSettings.basic.faviconHint') }}</span>
|
||||
</div>
|
||||
<input
|
||||
ref="faviconInputRef"
|
||||
@@ -371,7 +374,7 @@ onMounted(() => {
|
||||
class="text-destructive hover:text-destructive"
|
||||
@click="basicForm.site_favicon = ''"
|
||||
>
|
||||
移除
|
||||
{{ t('admin.systemSettings.basic.remove') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -379,23 +382,23 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="site_footer">页脚信息</UiLabel>
|
||||
<UiInput id="site_footer" v-model="basicForm.site_footer" placeholder="请输入页脚信息" />
|
||||
<UiLabel for="site_footer">{{ t('admin.systemSettings.basic.siteFooter') }}</UiLabel>
|
||||
<UiInput id="site_footer" v-model="basicForm.site_footer" :placeholder="t('admin.systemSettings.basic.siteFooterPlaceholder')" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-show="activeTab === 'security'">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>安全设置</UiCardTitle>
|
||||
<UiCardDescription>配置系统安全相关选项</UiCardDescription>
|
||||
<UiCardTitle>{{ t('admin.systemSettings.security.title') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('admin.systemSettings.security.description') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>登录验证码</UiLabel>
|
||||
<UiLabel>{{ t('admin.systemSettings.security.enableCaptcha') }}</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后登录时需要输入验证码
|
||||
{{ t('admin.systemSettings.security.enableCaptchaDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="securityForm.enable_captcha" />
|
||||
@@ -403,7 +406,7 @@ onMounted(() => {
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="login_fail_lock_count">登录失败锁定次数</UiLabel>
|
||||
<UiLabel for="login_fail_lock_count">{{ t('admin.systemSettings.security.loginFailLockCount') }}</UiLabel>
|
||||
<UiInput
|
||||
id="login_fail_lock_count"
|
||||
v-model.number="securityForm.login_fail_lock_count"
|
||||
@@ -412,12 +415,12 @@ onMounted(() => {
|
||||
max="10"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
连续失败多少次后锁定账户
|
||||
{{ t('admin.systemSettings.security.loginFailLockCountHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="login_fail_lock_minutes">锁定时长(分钟)</UiLabel>
|
||||
<UiLabel for="login_fail_lock_minutes">{{ t('admin.systemSettings.security.lockMinutes') }}</UiLabel>
|
||||
<UiInput
|
||||
id="login_fail_lock_minutes"
|
||||
v-model.number="securityForm.login_fail_lock_minutes"
|
||||
@@ -426,14 +429,14 @@ onMounted(() => {
|
||||
max="1440"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
账户锁定持续时间
|
||||
{{ t('admin.systemSettings.security.lockMinutesHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="password_min_length">密码最小长度</UiLabel>
|
||||
<UiLabel for="password_min_length">{{ t('admin.systemSettings.security.passwordMinLength') }}</UiLabel>
|
||||
<UiInput
|
||||
id="password_min_length"
|
||||
v-model.number="securityForm.password_min_length"
|
||||
@@ -442,12 +445,12 @@ onMounted(() => {
|
||||
max="32"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
用户密码最小字符数
|
||||
{{ t('admin.systemSettings.security.passwordMinLengthHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="session_timeout">会话超时(小时)</UiLabel>
|
||||
<UiLabel for="session_timeout">{{ t('admin.systemSettings.security.sessionTimeout') }}</UiLabel>
|
||||
<UiInput
|
||||
id="session_timeout"
|
||||
v-model.number="securityForm.session_timeout"
|
||||
@@ -456,7 +459,7 @@ onMounted(() => {
|
||||
max="720"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
用户登录会话有效期
|
||||
{{ t('admin.systemSettings.security.sessionTimeoutHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -465,15 +468,15 @@ onMounted(() => {
|
||||
|
||||
<UiCard v-show="activeTab === 'backup'">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>备份设置</UiCardTitle>
|
||||
<UiCardDescription>配置数据库自动备份</UiCardDescription>
|
||||
<UiCardTitle>{{ t('admin.systemSettings.backup.title') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('admin.systemSettings.backup.description') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>启用自动备份</UiLabel>
|
||||
<UiLabel>{{ t('admin.systemSettings.backup.enableBackup') }}</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
定时自动备份数据库
|
||||
{{ t('admin.systemSettings.backup.enableBackupDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="backupForm.enable_backup" />
|
||||
@@ -481,7 +484,7 @@ onMounted(() => {
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="backup_interval">备份间隔(小时)</UiLabel>
|
||||
<UiLabel for="backup_interval">{{ t('admin.systemSettings.backup.backupInterval') }}</UiLabel>
|
||||
<UiInput
|
||||
id="backup_interval"
|
||||
v-model.number="backupForm.backup_interval"
|
||||
@@ -490,12 +493,12 @@ onMounted(() => {
|
||||
max="168"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每隔多少小时备份一次
|
||||
{{ t('admin.systemSettings.backup.backupIntervalHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="backup_retention">保留天数</UiLabel>
|
||||
<UiLabel for="backup_retention">{{ t('admin.systemSettings.backup.backupRetention') }}</UiLabel>
|
||||
<UiInput
|
||||
id="backup_retention"
|
||||
v-model.number="backupForm.backup_retention"
|
||||
@@ -504,37 +507,37 @@ onMounted(() => {
|
||||
max="90"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
备份文件保留多少天
|
||||
{{ t('admin.systemSettings.backup.backupRetentionHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="backup_storage_type">存储位置</UiLabel>
|
||||
<UiLabel for="backup_storage_type">{{ t('admin.systemSettings.backup.storageType') }}</UiLabel>
|
||||
<UiSelect v-model="backupForm.backup_storage_type">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择存储位置" />
|
||||
<UiSelectValue :placeholder="t('admin.systemSettings.backup.storageTypePlaceholder')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="local">
|
||||
本地存储
|
||||
{{ t('admin.systemSettings.backup.local') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="s3">
|
||||
S3存储
|
||||
{{ t('admin.systemSettings.backup.s3') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="webdav">
|
||||
WebDAV
|
||||
{{ t('admin.systemSettings.backup.webdav') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="ftp">
|
||||
FTP
|
||||
{{ t('admin.systemSettings.backup.ftp') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="sftp">
|
||||
SFTP
|
||||
{{ t('admin.systemSettings.backup.sftp') }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
备份文件存储位置,可在存储管理中配置
|
||||
{{ t('admin.systemSettings.backup.storageTypeHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
@@ -542,15 +545,15 @@ onMounted(() => {
|
||||
|
||||
<UiCard v-show="activeTab === 'feature'">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>功能设置</UiCardTitle>
|
||||
<UiCardDescription>配置系统功能开关</UiCardDescription>
|
||||
<UiCardTitle>{{ t('admin.systemSettings.feature.title') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('admin.systemSettings.feature.description') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>工单系统</UiLabel>
|
||||
<UiLabel>{{ t('admin.systemSettings.feature.ticketSystem') }}</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后用户可以提交工单
|
||||
{{ t('admin.systemSettings.feature.ticketSystemDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="featureForm.enable_ticket_system" />
|
||||
@@ -558,34 +561,34 @@ onMounted(() => {
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>多语言支持</UiLabel>
|
||||
<UiLabel>{{ t('admin.systemSettings.feature.multiLang') }}</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后用户可以切换语言
|
||||
{{ t('admin.systemSettings.feature.multiLangDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="featureForm.enable_multi_lang" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="default_theme">默认颜色模式</UiLabel>
|
||||
<UiLabel for="default_theme">{{ t('admin.systemSettings.feature.defaultTheme') }}</UiLabel>
|
||||
<UiSelect v-model="featureForm.default_theme">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择默认颜色模式" />
|
||||
<UiSelectValue :placeholder="t('admin.systemSettings.feature.defaultThemePlaceholder')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="system">
|
||||
跟随系统
|
||||
{{ t('admin.systemSettings.feature.followSystem') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="light">
|
||||
浅色模式
|
||||
{{ t('admin.systemSettings.feature.lightMode') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="dark">
|
||||
深色模式
|
||||
{{ t('admin.systemSettings.feature.darkMode') }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
新用户默认的颜色模式
|
||||
{{ t('admin.systemSettings.feature.defaultThemeHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
@@ -593,22 +596,22 @@ onMounted(() => {
|
||||
|
||||
<UiCard v-show="activeTab === 'notification'">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>通知设置</UiCardTitle>
|
||||
<UiCardDescription>配置系统通知选项</UiCardDescription>
|
||||
<UiCardTitle>{{ t('admin.systemSettings.notification.title') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('admin.systemSettings.notification.description') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>启用邮件通知</UiLabel>
|
||||
<UiLabel>{{ t('admin.systemSettings.notification.enableNotification') }}</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后系统将发送邮件通知
|
||||
{{ t('admin.systemSettings.notification.enableNotificationDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="notificationForm.enable_notification" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="admin_notify_email">管理员通知邮箱</UiLabel>
|
||||
<UiLabel for="admin_notify_email">{{ t('admin.systemSettings.notification.adminNotifyEmail') }}</UiLabel>
|
||||
<UiInput
|
||||
id="admin_notify_email"
|
||||
v-model="notificationForm.admin_notify_email"
|
||||
@@ -616,20 +619,20 @@ onMounted(() => {
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
接收系统通知的管理员邮箱
|
||||
{{ t('admin.systemSettings.notification.adminNotifyEmailHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<UiLabel>通知事件</UiLabel>
|
||||
<UiLabel>{{ t('admin.systemSettings.notification.notifyEvents') }}</UiLabel>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<p class="text-sm font-medium">
|
||||
异常登录通知
|
||||
{{ t('admin.systemSettings.notification.loginNotify') }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
检测到异常登录时发送通知
|
||||
{{ t('admin.systemSettings.notification.loginNotifyDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="notificationForm.notify_on_login" />
|
||||
@@ -638,10 +641,10 @@ onMounted(() => {
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<p class="text-sm font-medium">
|
||||
充值通知
|
||||
{{ t('admin.systemSettings.notification.rechargeNotify') }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
用户充值成功时发送通知
|
||||
{{ t('admin.systemSettings.notification.rechargeNotifyDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="notificationForm.notify_on_recharge" />
|
||||
@@ -650,10 +653,10 @@ onMounted(() => {
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<p class="text-sm font-medium">
|
||||
工单通知
|
||||
{{ t('admin.systemSettings.notification.ticketNotify') }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
新工单提交时发送通知
|
||||
{{ t('admin.systemSettings.notification.ticketNotifyDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="notificationForm.notify_on_ticket" />
|
||||
@@ -665,15 +668,15 @@ onMounted(() => {
|
||||
|
||||
<UiCard v-show="activeTab === 'cleanup'">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>数据清理</UiCardTitle>
|
||||
<UiCardDescription>配置过期数据自动清理规则,释放数据库空间</UiCardDescription>
|
||||
<UiCardTitle>{{ t('admin.systemSettings.cleanup.title') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('admin.systemSettings.cleanup.description') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>启用自动清理</UiLabel>
|
||||
<UiLabel>{{ t('admin.systemSettings.cleanup.enableAutoCleanup') }}</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
定时自动清理过期数据
|
||||
{{ t('admin.systemSettings.cleanup.enableAutoCleanupDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="cleanupForm.enable_auto_cleanup" />
|
||||
@@ -681,7 +684,7 @@ onMounted(() => {
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="cleanup_interval_hours">清理间隔(小时)</UiLabel>
|
||||
<UiLabel for="cleanup_interval_hours">{{ t('admin.systemSettings.cleanup.cleanupInterval') }}</UiLabel>
|
||||
<UiInput
|
||||
id="cleanup_interval_hours"
|
||||
v-model.number="cleanupForm.cleanup_interval_hours"
|
||||
@@ -690,12 +693,12 @@ onMounted(() => {
|
||||
max="168"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每隔多少小时执行一次清理
|
||||
{{ t('admin.systemSettings.cleanup.cleanupIntervalHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="captcha_retention_days">验证码保留天数</UiLabel>
|
||||
<UiLabel for="captcha_retention_days">{{ t('admin.systemSettings.cleanup.captchaRetention') }}</UiLabel>
|
||||
<UiInput
|
||||
id="captcha_retention_days"
|
||||
v-model.number="cleanupForm.captcha_retention_days"
|
||||
@@ -704,14 +707,14 @@ onMounted(() => {
|
||||
max="30"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
图形验证码过期后保留天数
|
||||
{{ t('admin.systemSettings.cleanup.captchaRetentionHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="verify_code_retention_days">邮箱/短信验证码保留天数</UiLabel>
|
||||
<UiLabel for="verify_code_retention_days">{{ t('admin.systemSettings.cleanup.verifyCodeRetention') }}</UiLabel>
|
||||
<UiInput
|
||||
id="verify_code_retention_days"
|
||||
v-model.number="cleanupForm.verify_code_retention_days"
|
||||
@@ -720,12 +723,12 @@ onMounted(() => {
|
||||
max="90"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
已使用的邮箱/短信验证码保留天数
|
||||
{{ t('admin.systemSettings.cleanup.verifyCodeRetentionHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="api_usage_retention_days">API调用日志保留天数</UiLabel>
|
||||
<UiLabel for="api_usage_retention_days">{{ t('admin.systemSettings.cleanup.apiUsageRetention') }}</UiLabel>
|
||||
<UiInput
|
||||
id="api_usage_retention_days"
|
||||
v-model.number="cleanupForm.api_usage_retention_days"
|
||||
@@ -734,14 +737,14 @@ onMounted(() => {
|
||||
max="365"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
API调用统计记录保留天数
|
||||
{{ t('admin.systemSettings.cleanup.apiUsageRetentionHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="webhook_log_retention_days">Webhook日志保留天数</UiLabel>
|
||||
<UiLabel for="webhook_log_retention_days">{{ t('admin.systemSettings.cleanup.webhookLogRetention') }}</UiLabel>
|
||||
<UiInput
|
||||
id="webhook_log_retention_days"
|
||||
v-model.number="cleanupForm.webhook_log_retention_days"
|
||||
@@ -750,12 +753,12 @@ onMounted(() => {
|
||||
max="365"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Webhook发送日志保留天数
|
||||
{{ t('admin.systemSettings.cleanup.webhookLogRetentionHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="device_session_retention_days">设备会话保留天数</UiLabel>
|
||||
<UiLabel for="device_session_retention_days">{{ t('admin.systemSettings.cleanup.deviceSessionRetention') }}</UiLabel>
|
||||
<UiInput
|
||||
id="device_session_retention_days"
|
||||
v-model.number="cleanupForm.device_session_retention_days"
|
||||
@@ -764,7 +767,7 @@ onMounted(() => {
|
||||
max="90"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
过期的设备会话记录保留天数
|
||||
{{ t('admin.systemSettings.cleanup.deviceSessionRetentionHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -772,12 +775,12 @@ onMounted(() => {
|
||||
<div class="flex items-center gap-4 border-t pt-4">
|
||||
<UiButton :disabled="cleanupLoading" @click="saveCleanupSettings">
|
||||
<Loader2 v-if="cleanupLoading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
保存清理设置
|
||||
{{ t('admin.systemSettings.cleanup.saveCleanupSettings') }}
|
||||
</UiButton>
|
||||
<UiButton variant="outline" :disabled="manualCleanupLoading" @click="runManualCleanup">
|
||||
<Loader2 v-if="manualCleanupLoading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Trash2 v-else class="mr-2 h-4 w-4" />
|
||||
立即执行清理
|
||||
{{ t('admin.systemSettings.cleanup.runManualCleanup') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
@@ -786,7 +789,7 @@ onMounted(() => {
|
||||
<div class="flex justify-end">
|
||||
<UiButton :disabled="saving" @click="saveSettings">
|
||||
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
|
||||
保存设置
|
||||
{{ t('admin.systemSettings.saveSettings') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,7 @@ export function getColumns(actions: {
|
||||
header: () => t('admin.users.columns.status'),
|
||||
cell: ({ row }) => {
|
||||
const status = (row.getValue('online_status') as string) || 'offline'
|
||||
return h(Badge, { variant: getOnlineStatusVariant(status as any) }, () => getOnlineStatusLabel(status as any))
|
||||
return h(Badge, { variant: getOnlineStatusVariant(status as any) }, () => getOnlineStatusLabel(t, status as any))
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Composer } from 'vue-i18n'
|
||||
|
||||
import { Ban, CheckCircle2, Clock } from 'lucide-vue-next'
|
||||
import { h } from 'vue'
|
||||
|
||||
@@ -5,45 +7,49 @@ import type { FacetedFilterOption } from '@/components/data-table/types'
|
||||
|
||||
import type { OnlineStatus } from './schema'
|
||||
|
||||
export const userStatuses: FacetedFilterOption[] = [
|
||||
{
|
||||
label: '正常',
|
||||
value: 'active',
|
||||
icon: h(CheckCircle2),
|
||||
},
|
||||
{
|
||||
label: '封禁',
|
||||
value: 'banned',
|
||||
icon: h(Ban),
|
||||
},
|
||||
]
|
||||
export function getUserStatuses(t: Composer['t']): FacetedFilterOption[] {
|
||||
return [
|
||||
{
|
||||
label: t('common.active'),
|
||||
value: 'active',
|
||||
icon: h(CheckCircle2),
|
||||
},
|
||||
{
|
||||
label: t('common.banned'),
|
||||
value: 'banned',
|
||||
icon: h(Ban),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const onlineStatuses: (FacetedFilterOption & { style: string })[] = [
|
||||
{
|
||||
label: '在线',
|
||||
value: 'online',
|
||||
icon: h(CheckCircle2),
|
||||
style: 'bg-green-500/10 text-green-500',
|
||||
},
|
||||
{
|
||||
label: '离线',
|
||||
value: 'offline',
|
||||
icon: h(Clock),
|
||||
style: 'bg-gray-500/10 text-gray-500',
|
||||
},
|
||||
{
|
||||
label: '封禁',
|
||||
value: 'banned',
|
||||
icon: h(Ban),
|
||||
style: 'bg-red-500/10 text-red-500',
|
||||
},
|
||||
]
|
||||
export function getOnlineStatuses(t: Composer['t']): (FacetedFilterOption & { style: string })[] {
|
||||
return [
|
||||
{
|
||||
label: t('common.online'),
|
||||
value: 'online',
|
||||
icon: h(CheckCircle2),
|
||||
style: 'bg-green-500/10 text-green-500',
|
||||
},
|
||||
{
|
||||
label: t('common.offline'),
|
||||
value: 'offline',
|
||||
icon: h(Clock),
|
||||
style: 'bg-gray-500/10 text-gray-500',
|
||||
},
|
||||
{
|
||||
label: t('common.banned'),
|
||||
value: 'banned',
|
||||
icon: h(Ban),
|
||||
style: 'bg-red-500/10 text-red-500',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function getOnlineStatusLabel(status: OnlineStatus): string {
|
||||
export function getOnlineStatusLabel(t: Composer['t'], status: OnlineStatus): string {
|
||||
const labels: Record<OnlineStatus, string> = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
banned: '封禁',
|
||||
online: t('common.online'),
|
||||
offline: t('common.offline'),
|
||||
banned: t('common.banned'),
|
||||
}
|
||||
return labels[status] ?? status
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, CheckCircle, Eye, FileArchive, FilePlus, GitBranch, Loader2, Upload, UploadCloud, X } from 'lucide-vue-next'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
@@ -94,7 +97,7 @@ async function fetchVersion() {
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取版本信息失败:', error)
|
||||
toast.error('获取版本信息失败')
|
||||
toast.error(t('admin.versions.createForm.fetchFailed'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
@@ -116,7 +119,7 @@ function handleFileSelect(event: Event) {
|
||||
if (target.files && target.files.length > 0) {
|
||||
const file = target.files[0]
|
||||
if (!file.name.toLowerCase().endsWith('.zip')) {
|
||||
toast.error('只支持ZIP格式文件')
|
||||
toast.error(t('admin.versions.createForm.zipOnly'))
|
||||
return
|
||||
}
|
||||
formData.value.file = file
|
||||
@@ -134,7 +137,7 @@ function removeFile() {
|
||||
|
||||
async function uploadZipFile() {
|
||||
if (!formData.value.file) {
|
||||
toast.error('请先选择ZIP文件')
|
||||
toast.error(t('admin.versions.createForm.selectZipFirst'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -145,11 +148,11 @@ async function uploadZipFile() {
|
||||
|
||||
const response = await api.postFormData<UploadResponse>('/dev/versions/upload-zip', formDataObj)
|
||||
uploadedData.value = response
|
||||
toast.success('文件上传成功')
|
||||
toast.success(t('admin.versions.createForm.uploadSuccess'))
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('上传文件失败:', error)
|
||||
toast.error(error.message || '上传失败')
|
||||
toast.error(error.message || t('admin.versions.createForm.uploadFailed'))
|
||||
}
|
||||
finally {
|
||||
uploading.value = false
|
||||
@@ -158,11 +161,11 @@ async function uploadZipFile() {
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.application_id) {
|
||||
toast.error('请选择应用')
|
||||
toast.error(t('admin.versions.createForm.selectAppRequired'))
|
||||
return
|
||||
}
|
||||
if (!formData.value.version) {
|
||||
toast.error('请输入版本号')
|
||||
toast.error(t('admin.versions.createForm.versionRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -186,12 +189,12 @@ async function handleSubmit() {
|
||||
}
|
||||
|
||||
await api.put(`/dev/versions/${versionId.value}`, payload)
|
||||
toast.success('版本更新成功')
|
||||
toast.success(t('admin.versions.createForm.updateSuccess'))
|
||||
router.push('/admin/versions')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('更新版本失败:', error)
|
||||
toast.error(error.message || '更新失败')
|
||||
toast.error(error.message || t('admin.versions.createForm.updateFailed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
@@ -206,11 +209,11 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="编辑版本"
|
||||
description="修改版本信息和更新策略"
|
||||
:title="t('admin.versions.createForm.editTitle')"
|
||||
:description="t('admin.versions.createForm.editDescription')"
|
||||
:breadcrumbs="[
|
||||
{ title: '版本管理', href: '/admin/versions' },
|
||||
{ title: '编辑版本' },
|
||||
{ title: t('admin.versions.title'), href: '/admin/versions' },
|
||||
{ title: t('admin.versions.createForm.editTitle') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
@@ -225,16 +228,16 @@ onMounted(() => {
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<GitBranch class="size-5" />
|
||||
版本配置
|
||||
{{ t('admin.versions.createForm.versionConfig') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>修改版本的基本信息和上传更新文件</UiCardDescription>
|
||||
<UiCardDescription>{{ t('admin.versions.createForm.versionConfigDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>选择应用</UiLabel>
|
||||
<UiLabel>{{ t('admin.versions.createForm.selectApp') }}</UiLabel>
|
||||
<UiSelect v-model="formData.application_id" :disabled="saving">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择应用" />
|
||||
<UiSelectValue :placeholder="t('admin.versions.createForm.selectAppPlaceholder')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem
|
||||
@@ -250,42 +253,42 @@ onMounted(() => {
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="version">
|
||||
版本号
|
||||
{{ t('admin.versions.createForm.versionNumber') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="version"
|
||||
v-model="formData.version"
|
||||
placeholder="例如: 1.0.0"
|
||||
:placeholder="t('admin.versions.createForm.versionPlaceholder')"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="min_version">
|
||||
最低版本
|
||||
{{ t('admin.versions.createForm.minVersion') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="min_version"
|
||||
v-model="formData.min_version"
|
||||
placeholder="例如: 0.9.0"
|
||||
placeholder="0.9.0"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="description">
|
||||
版本描述
|
||||
{{ t('admin.versions.createForm.versionDesc') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="description"
|
||||
v-model="formData.description"
|
||||
placeholder="简短描述此版本的更新内容"
|
||||
:placeholder="t('admin.versions.createForm.versionDescPlaceholder')"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>更新文件 (ZIP格式,可选)</UiLabel>
|
||||
<UiLabel>{{ t('admin.versions.createForm.updateFile') }}</UiLabel>
|
||||
<div class="border-2 border-dashed rounded-lg p-4">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
@@ -297,11 +300,11 @@ onMounted(() => {
|
||||
<div v-if="!formData.file" class="text-center">
|
||||
<UploadCloud class="size-10 mx-auto text-muted-foreground mb-2" />
|
||||
<p class="text-sm text-muted-foreground mb-2">
|
||||
拖拽ZIP文件到此处或点击上传
|
||||
{{ t('admin.versions.createForm.dragOrClick') }}
|
||||
</p>
|
||||
<UiButton variant="outline" size="sm" :disabled="saving || uploading" @click="fileInputRef?.click()">
|
||||
<FilePlus class="h-4 w-4 mr-2" />
|
||||
选择文件
|
||||
{{ t('admin.versions.createForm.selectFile') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
@@ -326,7 +329,7 @@ onMounted(() => {
|
||||
>
|
||||
<Loader2 v-if="uploading" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Upload v-else class="h-4 w-4 mr-2" />
|
||||
上传解析
|
||||
{{ t('admin.versions.createForm.uploadAndParse') }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" size="icon" :disabled="saving" @click="removeFile">
|
||||
<X class="size-4" />
|
||||
@@ -336,11 +339,11 @@ onMounted(() => {
|
||||
<div v-if="uploadedData" class="rounded-lg bg-muted/50 p-3">
|
||||
<div class="flex items-center gap-2 text-sm text-green-600 mb-2">
|
||||
<CheckCircle class="size-4" />
|
||||
<span>文件已上传并解析</span>
|
||||
<span>{{ t('admin.versions.createForm.fileUploaded') }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground space-y-1">
|
||||
<p>文件哈希: {{ uploadedData.file_hash.substring(0, 16) }}...</p>
|
||||
<p>包含 {{ uploadedData.files.length }} 个文件</p>
|
||||
<p>{{ t('admin.versions.createForm.fileHash') }}: {{ uploadedData.file_hash.substring(0, 16) }}...</p>
|
||||
<p>{{ t('admin.versions.createForm.containsFiles', { count: uploadedData.files.length }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -348,22 +351,22 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-if="uploadedData && uploadedData.files.length > 0" class="space-y-2">
|
||||
<UiLabel>文件列表</UiLabel>
|
||||
<UiLabel>{{ t('admin.versions.createForm.fileList') }}</UiLabel>
|
||||
<div class="border rounded-lg max-h-[300px] overflow-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th class="text-left p-2 font-medium">
|
||||
文件名
|
||||
{{ t('admin.versions.createForm.fileName') }}
|
||||
</th>
|
||||
<th class="text-left p-2 font-medium">
|
||||
路径
|
||||
{{ t('admin.versions.createForm.filePath') }}
|
||||
</th>
|
||||
<th class="text-right p-2 font-medium">
|
||||
大小
|
||||
{{ t('admin.versions.createForm.fileSize') }}
|
||||
</th>
|
||||
<th class="text-left p-2 font-medium">
|
||||
类型
|
||||
{{ t('admin.versions.createForm.fileType') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -394,10 +397,10 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-if="executableFiles.length > 0" class="space-y-2">
|
||||
<UiLabel>入口文件</UiLabel>
|
||||
<UiLabel>{{ t('admin.versions.createForm.entryFile') }}</UiLabel>
|
||||
<UiSelect v-model="formData.entry_file" :disabled="saving">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择入口文件(可选)" />
|
||||
<UiSelectValue :placeholder="t('admin.versions.createForm.entryFilePlaceholder')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem
|
||||
@@ -414,10 +417,10 @@ onMounted(() => {
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel class="text-base">
|
||||
强制更新
|
||||
{{ t('admin.versions.createForm.forcedUpdate') }}
|
||||
</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后用户必须更新到此版本才能继续使用
|
||||
{{ t('admin.versions.createForm.forcedUpdateDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
@@ -430,10 +433,10 @@ onMounted(() => {
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel class="text-base">
|
||||
自动更新
|
||||
{{ t('admin.versions.createForm.autoUpdate') }}
|
||||
</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后应用将自动下载并安装更新
|
||||
{{ t('admin.versions.createForm.autoUpdateDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
@@ -445,13 +448,13 @@ onMounted(() => {
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="changelog">
|
||||
更新日志
|
||||
{{ t('admin.versions.createForm.changelog') }}
|
||||
</UiLabel>
|
||||
<textarea
|
||||
id="changelog"
|
||||
v-model="formData.changelog"
|
||||
class="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="详细描述此版本的更新内容..."
|
||||
:placeholder="t('admin.versions.createForm.changelogPlaceholder')"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
@@ -464,44 +467,44 @@ onMounted(() => {
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Eye class="size-5" />
|
||||
预览
|
||||
{{ t('admin.versions.createForm.preview') }}
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">所属应用</span>
|
||||
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewApp') }}</span>
|
||||
<span>{{ selectedApplication?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">版本号</span>
|
||||
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewVersion') }}</span>
|
||||
<span>{{ formData.version || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">最低版本</span>
|
||||
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewMinVersion') }}</span>
|
||||
<span>{{ formData.min_version || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">版本描述</span>
|
||||
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewDesc') }}</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.description || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">强制更新</span>
|
||||
<span>{{ formData.update_strategy === 'forced' ? '是' : '否' }}</span>
|
||||
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewForcedUpdate') }}</span>
|
||||
<span>{{ formData.update_strategy === 'forced' ? t('common.yes') : t('common.no') }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">自动更新</span>
|
||||
<span>{{ formData.update_method === 'auto' ? '是' : '否' }}</span>
|
||||
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewAutoUpdate') }}</span>
|
||||
<span>{{ formData.update_method === 'auto' ? t('common.yes') : t('common.no') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 mt-4">
|
||||
<p class="text-sm text-muted-foreground mb-2">
|
||||
更新日志预览
|
||||
{{ t('admin.versions.createForm.changelogPreview') }}
|
||||
</p>
|
||||
<div class="rounded-lg bg-muted/50 p-3 min-h-[80px]">
|
||||
<p class="text-sm whitespace-pre-wrap">
|
||||
{{ formData.changelog || '暂无更新日志' }}
|
||||
{{ formData.changelog || t('admin.versions.createForm.noChangelog') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -519,14 +522,14 @@ onMounted(() => {
|
||||
>
|
||||
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Check v-else class="mr-2 h-4 w-4" />
|
||||
保存修改
|
||||
{{ t('admin.versions.createForm.updateVersion') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
取消
|
||||
{{ t('common.cancel') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle, Eye, FileArchive, FilePlus, GitBranch, Loader2, Rocket, Upload, UploadCloud, X } from 'lucide-vue-next'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
@@ -99,7 +102,7 @@ function handleFileSelect(event: Event) {
|
||||
if (target.files && target.files.length > 0) {
|
||||
const file = target.files[0]
|
||||
if (!file.name.toLowerCase().endsWith('.zip')) {
|
||||
toast.error('只支持ZIP格式文件')
|
||||
toast.error(t('admin.versions.createForm.zipOnly'))
|
||||
return
|
||||
}
|
||||
formData.value.file = file
|
||||
@@ -117,7 +120,7 @@ function removeFile() {
|
||||
|
||||
async function uploadZipFile() {
|
||||
if (!formData.value.file) {
|
||||
toast.error('请先选择ZIP文件')
|
||||
toast.error(t('admin.versions.createForm.selectZipFirst'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -128,11 +131,11 @@ async function uploadZipFile() {
|
||||
|
||||
const response = await api.postFormData<UploadResponse>('/dev/versions/upload-zip', formDataObj)
|
||||
uploadedData.value = response
|
||||
toast.success('文件上传成功')
|
||||
toast.success(t('admin.versions.createForm.uploadSuccess'))
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('上传文件失败:', error)
|
||||
toast.error(error.message || '上传失败')
|
||||
toast.error(error.message || t('admin.versions.createForm.uploadFailed'))
|
||||
}
|
||||
finally {
|
||||
uploading.value = false
|
||||
@@ -141,15 +144,15 @@ async function uploadZipFile() {
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.application_id) {
|
||||
toast.error('请选择应用')
|
||||
toast.error(t('admin.versions.createForm.selectAppRequired'))
|
||||
return
|
||||
}
|
||||
if (!formData.value.version) {
|
||||
toast.error('请输入版本号')
|
||||
toast.error(t('admin.versions.createForm.versionRequired'))
|
||||
return
|
||||
}
|
||||
if (!uploadedData.value) {
|
||||
toast.error('请先上传ZIP文件')
|
||||
toast.error(t('admin.versions.createForm.uploadZipRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -168,12 +171,12 @@ async function handleSubmit() {
|
||||
file_size: uploadedData.value.file_size,
|
||||
file_hash: uploadedData.value.file_hash,
|
||||
})
|
||||
toast.success('版本创建成功')
|
||||
toast.success(t('admin.versions.createForm.createSuccess'))
|
||||
router.push('/admin/versions')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('创建版本失败:', error)
|
||||
toast.error(error.message || '创建失败')
|
||||
toast.error(error.message || t('admin.versions.createForm.createFailed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
@@ -187,11 +190,11 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="创建版本"
|
||||
description="为应用创建新的版本,上传ZIP更新包并配置更新策略"
|
||||
:title="t('admin.versions.createForm.title')"
|
||||
:description="t('admin.versions.createForm.description')"
|
||||
:breadcrumbs="[
|
||||
{ title: '版本管理', href: '/admin/versions' },
|
||||
{ title: '创建版本' },
|
||||
{ title: t('admin.versions.title'), href: '/admin/versions' },
|
||||
{ title: t('admin.versions.createForm.title') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
@@ -202,16 +205,16 @@ onMounted(() => {
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<GitBranch class="size-5" />
|
||||
版本配置
|
||||
{{ t('admin.versions.createForm.versionConfig') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>填写版本的基本信息和上传更新文件</UiCardDescription>
|
||||
<UiCardDescription>{{ t('admin.versions.createForm.versionConfigDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>选择应用 <span class="text-destructive">*</span></UiLabel>
|
||||
<UiLabel>{{ t('admin.versions.createForm.selectApp') }} <span class="text-destructive">*</span></UiLabel>
|
||||
<UiSelect v-model="formData.application_id" :disabled="saving || loading">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择应用" />
|
||||
<UiSelectValue :placeholder="t('admin.versions.createForm.selectAppPlaceholder')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem
|
||||
@@ -227,42 +230,42 @@ onMounted(() => {
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="version">
|
||||
版本号 <span class="text-destructive">*</span>
|
||||
{{ t('admin.versions.createForm.versionNumber') }} <span class="text-destructive">*</span>
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="version"
|
||||
v-model="formData.version"
|
||||
placeholder="例如: 1.0.0"
|
||||
:placeholder="t('admin.versions.createForm.versionPlaceholder')"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="min_version">
|
||||
最低版本
|
||||
{{ t('admin.versions.createForm.minVersion') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="min_version"
|
||||
v-model="formData.min_version"
|
||||
placeholder="例如: 0.9.0 (低于此版本需要完整安装)"
|
||||
:placeholder="t('admin.versions.createForm.minVersionPlaceholder')"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="description">
|
||||
版本描述
|
||||
{{ t('admin.versions.createForm.versionDesc') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="description"
|
||||
v-model="formData.description"
|
||||
placeholder="简短描述此版本的更新内容"
|
||||
:placeholder="t('admin.versions.createForm.versionDescPlaceholder')"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>更新文件 (ZIP格式)</UiLabel>
|
||||
<UiLabel>{{ t('admin.versions.createForm.updateFile') }}</UiLabel>
|
||||
<div class="border-2 border-dashed rounded-lg p-4">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
@@ -274,11 +277,11 @@ onMounted(() => {
|
||||
<div v-if="!formData.file" class="text-center">
|
||||
<UploadCloud class="size-10 mx-auto text-muted-foreground mb-2" />
|
||||
<p class="text-sm text-muted-foreground mb-2">
|
||||
拖拽ZIP文件到此处或点击上传
|
||||
{{ t('admin.versions.createForm.dragOrClick') }}
|
||||
</p>
|
||||
<UiButton variant="outline" size="sm" :disabled="saving || uploading" @click="fileInputRef?.click()">
|
||||
<FilePlus class="h-4 w-4 mr-2" />
|
||||
选择文件
|
||||
{{ t('admin.versions.createForm.selectFile') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
@@ -303,7 +306,7 @@ onMounted(() => {
|
||||
>
|
||||
<Loader2 v-if="uploading" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Upload v-else class="h-4 w-4 mr-2" />
|
||||
上传解析
|
||||
{{ t('admin.versions.createForm.uploadAndParse') }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" size="icon" :disabled="saving" @click="removeFile">
|
||||
<X class="size-4" />
|
||||
@@ -313,11 +316,11 @@ onMounted(() => {
|
||||
<div v-if="uploadedData" class="rounded-lg bg-muted/50 p-3">
|
||||
<div class="flex items-center gap-2 text-sm text-green-600 mb-2">
|
||||
<CheckCircle class="size-4" />
|
||||
<span>文件已上传并解析</span>
|
||||
<span>{{ t('admin.versions.createForm.fileUploaded') }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground space-y-1">
|
||||
<p>文件哈希: {{ uploadedData.file_hash.substring(0, 16) }}...</p>
|
||||
<p>包含 {{ uploadedData.files.length }} 个文件</p>
|
||||
<p>{{ t('admin.versions.createForm.fileHash') }}: {{ uploadedData.file_hash.substring(0, 16) }}...</p>
|
||||
<p>{{ t('admin.versions.createForm.containsFiles', { count: uploadedData.files.length }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -325,22 +328,22 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-if="uploadedData && uploadedData.files.length > 0" class="space-y-2">
|
||||
<UiLabel>文件列表</UiLabel>
|
||||
<UiLabel>{{ t('admin.versions.createForm.fileList') }}</UiLabel>
|
||||
<div class="border rounded-lg max-h-[300px] overflow-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th class="text-left p-2 font-medium">
|
||||
文件名
|
||||
{{ t('admin.versions.createForm.fileName') }}
|
||||
</th>
|
||||
<th class="text-left p-2 font-medium">
|
||||
路径
|
||||
{{ t('admin.versions.createForm.filePath') }}
|
||||
</th>
|
||||
<th class="text-right p-2 font-medium">
|
||||
大小
|
||||
{{ t('admin.versions.createForm.fileSize') }}
|
||||
</th>
|
||||
<th class="text-left p-2 font-medium">
|
||||
类型
|
||||
{{ t('admin.versions.createForm.fileType') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -371,13 +374,13 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-if="executableFiles.length > 0" class="space-y-2">
|
||||
<UiLabel>入口文件</UiLabel>
|
||||
<UiLabel>{{ t('admin.versions.createForm.entryFile') }}</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
选择更新完成后要启动的主程序文件(可选)
|
||||
{{ t('admin.versions.createForm.entryFileDesc') }}
|
||||
</p>
|
||||
<UiSelect v-model="formData.entry_file" :disabled="saving">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择入口文件(可选)" />
|
||||
<UiSelectValue :placeholder="t('admin.versions.createForm.entryFilePlaceholder')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem
|
||||
@@ -394,10 +397,10 @@ onMounted(() => {
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel class="text-base">
|
||||
强制更新
|
||||
{{ t('admin.versions.createForm.forcedUpdate') }}
|
||||
</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后用户必须更新到此版本才能继续使用
|
||||
{{ t('admin.versions.createForm.forcedUpdateDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
@@ -410,10 +413,10 @@ onMounted(() => {
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel class="text-base">
|
||||
自动更新
|
||||
{{ t('admin.versions.createForm.autoUpdate') }}
|
||||
</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后应用将自动下载并安装更新
|
||||
{{ t('admin.versions.createForm.autoUpdateDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
@@ -425,13 +428,13 @@ onMounted(() => {
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="changelog">
|
||||
更新日志
|
||||
{{ t('admin.versions.createForm.changelog') }}
|
||||
</UiLabel>
|
||||
<textarea
|
||||
id="changelog"
|
||||
v-model="formData.changelog"
|
||||
class="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="详细描述此版本的更新内容、修复的问题、新增的功能等..."
|
||||
:placeholder="t('admin.versions.createForm.changelogPlaceholder')"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
@@ -444,56 +447,56 @@ onMounted(() => {
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Eye class="size-5" />
|
||||
预览
|
||||
{{ t('admin.versions.createForm.preview') }}
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">所属应用</span>
|
||||
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewApp') }}</span>
|
||||
<span>{{ selectedApplication?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">版本号</span>
|
||||
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewVersion') }}</span>
|
||||
<span>{{ formData.version || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">最低版本</span>
|
||||
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewMinVersion') }}</span>
|
||||
<span>{{ formData.min_version || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">版本描述</span>
|
||||
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewDesc') }}</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.description || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">更新文件</span>
|
||||
<span>{{ formData.file ? formData.file.name : '未上传' }}</span>
|
||||
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewFile') }}</span>
|
||||
<span>{{ formData.file ? formData.file.name : t('admin.versions.createForm.notUploaded') }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">文件数量</span>
|
||||
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewFileCount') }}</span>
|
||||
<span>{{ uploadedData ? uploadedData.files.length : '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">入口文件</span>
|
||||
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewEntryFile') }}</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.entry_file || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">强制更新</span>
|
||||
<span>{{ formData.update_strategy === 'forced' ? '是' : '否' }}</span>
|
||||
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewForcedUpdate') }}</span>
|
||||
<span>{{ formData.update_strategy === 'forced' ? t('common.yes') : t('common.no') }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">自动更新</span>
|
||||
<span>{{ formData.update_method === 'auto' ? '是' : '否' }}</span>
|
||||
<span class="text-muted-foreground">{{ t('admin.versions.createForm.previewAutoUpdate') }}</span>
|
||||
<span>{{ formData.update_method === 'auto' ? t('common.yes') : t('common.no') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 mt-4">
|
||||
<p class="text-sm text-muted-foreground mb-2">
|
||||
更新日志预览
|
||||
{{ t('admin.versions.createForm.changelogPreview') }}
|
||||
</p>
|
||||
<div class="rounded-lg bg-muted/50 p-3 min-h-[80px]">
|
||||
<p class="text-sm whitespace-pre-wrap">
|
||||
{{ formData.changelog || '暂无更新日志' }}
|
||||
{{ formData.changelog || t('admin.versions.createForm.noChangelog') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -511,14 +514,14 @@ onMounted(() => {
|
||||
>
|
||||
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Rocket v-else class="mr-2 h-4 w-4" />
|
||||
发布版本
|
||||
{{ t('admin.versions.createForm.publishVersion') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
取消
|
||||
{{ t('common.cancel') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, Box, Loader2 } from 'lucide-vue-next'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -19,7 +21,7 @@ onMounted(async () => {
|
||||
cardTypes.value = data?.cardTypes || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用详情失败:', error)
|
||||
console.error('Load app detail failed:', error)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
@@ -43,21 +45,21 @@ onMounted(async () => {
|
||||
{{ app.name }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground">
|
||||
{{ app.description || '暂无描述' }}
|
||||
{{ app.description || t('agent.apps.detail.noDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>可用卡类</UiCardTitle>
|
||||
<UiCardTitle>{{ t('agent.apps.detail.availableCardTypes') }}</UiCardTitle>
|
||||
<UiCardDescription>
|
||||
您可以为此应用生成以下类型的卡密
|
||||
{{ t('agent.apps.detail.cardTypeDesc') }}
|
||||
</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div v-if="cardTypes.length === 0" class="text-center py-8 text-muted-foreground">
|
||||
暂无可用卡类
|
||||
{{ t('agent.apps.detail.noCardTypes') }}
|
||||
</div>
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<UiCard v-for="ct in cardTypes" :key="ct.id" class="bg-muted/50">
|
||||
@@ -68,11 +70,11 @@ onMounted(async () => {
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">时长</span>
|
||||
<span>{{ ct.duration_days }} 天</span>
|
||||
<span class="text-muted-foreground">{{ t('agent.apps.detail.duration') }}</span>
|
||||
<span>{{ ct.duration_days }} {{ t('agent.apps.detail.days') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm mt-2">
|
||||
<span class="text-muted-foreground">价格</span>
|
||||
<span class="text-muted-foreground">{{ t('agent.apps.detail.price') }}</span>
|
||||
<span class="font-medium">¥{{ ct.price }}</span>
|
||||
</div>
|
||||
<UiButton
|
||||
@@ -81,7 +83,7 @@ onMounted(async () => {
|
||||
as-child
|
||||
>
|
||||
<router-link :to="`/agent/cards/create?app_id=${app.id}&card_type_id=${ct.id}`">
|
||||
生成卡密
|
||||
{{ t('agent.apps.detail.generateCards') }}
|
||||
</router-link>
|
||||
</UiButton>
|
||||
</UiCardContent>
|
||||
@@ -93,7 +95,7 @@ onMounted(async () => {
|
||||
|
||||
<div v-else class="text-center py-12 text-muted-foreground">
|
||||
<AlertCircle class="size-16 mx-auto mb-4 opacity-30" />
|
||||
<p>应用不存在或无权访问</p>
|
||||
<p>{{ t('agent.apps.detail.notFound') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Box, Boxes, Loader2 } from 'lucide-vue-next'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface App {
|
||||
id: number
|
||||
name: string
|
||||
@@ -22,7 +25,7 @@ onMounted(async () => {
|
||||
apps.value = data || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
console.error('Load apps failed:', error)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
@@ -30,7 +33,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -39,10 +42,10 @@ function formatDate(dateStr: string) {
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">
|
||||
应用管理
|
||||
{{ t('agent.apps.title') }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground">
|
||||
管理您授权的应用
|
||||
{{ t('agent.apps.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +58,7 @@ function formatDate(dateStr: string) {
|
||||
|
||||
<div v-else-if="apps.length === 0" class="text-center py-12 text-muted-foreground">
|
||||
<Boxes class="size-16 mx-auto mb-4 opacity-30" />
|
||||
<p>暂无授权应用</p>
|
||||
<p>{{ t('agent.apps.noApps') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y">
|
||||
@@ -73,14 +76,14 @@ function formatDate(dateStr: string) {
|
||||
{{ app.name }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ app.description || '暂无描述' }}
|
||||
{{ app.description || t('agent.apps.noDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-medium">
|
||||
{{ app.card_types_count }} 种卡类
|
||||
{{ app.card_types_count }} {{ t('agent.apps.cardTypesCount') }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ formatDate(app.created_at) }}
|
||||
@@ -88,7 +91,7 @@ function formatDate(dateStr: string) {
|
||||
</div>
|
||||
<UiButton variant="outline" size="sm" as-child>
|
||||
<router-link :to="`/agent/apps/${app.id}`">
|
||||
查看
|
||||
{{ t('agent.apps.view') }}
|
||||
</router-link>
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -43,21 +45,21 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取数据失败:', error)
|
||||
console.error('Load data failed:', error)
|
||||
}
|
||||
})
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!form.value.app_id) {
|
||||
toast.error('请选择应用')
|
||||
toast.error(t('agent.cards.create.selectApp'))
|
||||
return
|
||||
}
|
||||
if (!form.value.card_type_id) {
|
||||
toast.error('请选择卡类')
|
||||
toast.error(t('agent.cards.create.selectCardType'))
|
||||
return
|
||||
}
|
||||
if (form.value.quantity < 1 || form.value.quantity > 100) {
|
||||
toast.error('生成数量需要在 1-100 之间')
|
||||
toast.error(t('agent.cards.create.quantityRange'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,17 +71,17 @@ async function handleGenerate() {
|
||||
quantity: form.value.quantity,
|
||||
})
|
||||
|
||||
toast.success(`成功生成 ${data.codes.length} 张卡密`)
|
||||
toast.success(t('agent.cards.create.generateSuccess', { count: data.codes.length }))
|
||||
|
||||
const codesText = data.codes.join('\n')
|
||||
await navigator.clipboard.writeText(codesText)
|
||||
toast.success('卡密已复制到剪贴板')
|
||||
toast.success(t('agent.cards.create.copiedToClipboard'))
|
||||
|
||||
router.push('/agent/cards')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('生成卡密失败:', error)
|
||||
toast.error(error.message || '生成失败')
|
||||
console.error('Generate cards failed:', error)
|
||||
toast.error(error.message || t('agent.cards.create.generateFailed'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
@@ -91,26 +93,26 @@ async function handleGenerate() {
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">
|
||||
生成卡密
|
||||
{{ t('agent.cards.create.title') }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground">
|
||||
为授权应用生成卡密
|
||||
{{ t('agent.cards.create.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UiCard class="max-w-xl">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>生成设置</UiCardTitle>
|
||||
<UiCardTitle>{{ t('agent.cards.create.generateSettings') }}</UiCardTitle>
|
||||
<UiCardDescription>
|
||||
选择应用和卡类,设置生成数量
|
||||
{{ t('agent.cards.create.settingsDesc') }}
|
||||
</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>选择应用</UiLabel>
|
||||
<UiLabel>{{ t('agent.cards.create.selectAppLabel') }}</UiLabel>
|
||||
<UiSelect v-model="form.app_id">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="请选择应用" />
|
||||
<UiSelectValue :placeholder="t('agent.cards.create.selectAppPlaceholder')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="app in apps" :key="app.id" :value="String(app.id)">
|
||||
@@ -121,36 +123,36 @@ async function handleGenerate() {
|
||||
</div>
|
||||
|
||||
<div v-if="form.app_id" class="space-y-2">
|
||||
<UiLabel>选择卡类</UiLabel>
|
||||
<UiLabel>{{ t('agent.cards.create.selectCardTypeLabel') }}</UiLabel>
|
||||
<UiSelect v-model="form.card_type_id">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="请选择卡类" />
|
||||
<UiSelectValue :placeholder="t('agent.cards.create.selectCardTypePlaceholder')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="ct in availableCardTypes" :key="ct.id" :value="String(ct.id)">
|
||||
{{ ct.name }} - {{ ct.duration_days }}天 - ¥{{ ct.price }}
|
||||
{{ ct.name }} - {{ ct.duration_days }}{{ t('agent.apps.detail.days') }} - ¥{{ ct.price }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>生成数量</UiLabel>
|
||||
<UiLabel>{{ t('agent.cards.create.quantity') }}</UiLabel>
|
||||
<UiInput
|
||||
v-model.number="form.quantity"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="100"
|
||||
placeholder="请输入生成数量"
|
||||
:placeholder="t('agent.cards.create.quantityPlaceholder')"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
单次最多生成 100 张卡密
|
||||
{{ t('agent.cards.create.quantityHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPrice > 0" class="p-4 rounded-lg bg-muted/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-foreground">预计费用</span>
|
||||
<span class="text-muted-foreground">{{ t('agent.cards.create.estimatedCost') }}</span>
|
||||
<span class="text-xl font-bold">¥{{ totalPrice.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,7 +160,7 @@ async function handleGenerate() {
|
||||
<div class="flex gap-3">
|
||||
<UiButton variant="outline" as-child>
|
||||
<router-link to="/agent/cards">
|
||||
取消
|
||||
{{ t('agent.cards.create.cancel') }}
|
||||
</router-link>
|
||||
</UiButton>
|
||||
<UiButton
|
||||
@@ -166,7 +168,7 @@ async function handleGenerate() {
|
||||
@click="handleGenerate"
|
||||
>
|
||||
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ loading ? '生成中...' : '生成卡密' }}
|
||||
{{ loading ? t('agent.cards.create.generating') : t('agent.cards.create.generateBtn') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Key, Loader2, Plus } from 'lucide-vue-next'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Card {
|
||||
id: number
|
||||
code: string
|
||||
@@ -23,7 +26,7 @@ onMounted(async () => {
|
||||
cards.value = data || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取卡密列表失败:', error)
|
||||
console.error('Load cards failed:', error)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
@@ -33,15 +36,15 @@ onMounted(async () => {
|
||||
function formatDate(dateStr: string | null) {
|
||||
if (!dateStr)
|
||||
return '-'
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const map: Record<string, { label: string, class: string }> = {
|
||||
unused: { label: '未使用', class: 'bg-green-500/10 text-green-500' },
|
||||
used: { label: '已使用', class: 'bg-blue-500/10 text-blue-500' },
|
||||
expired: { label: '已过期', class: 'bg-red-500/10 text-red-500' },
|
||||
disabled: { label: '已禁用', class: 'bg-gray-500/10 text-gray-500' },
|
||||
unused: { label: t('agent.cards.unused'), class: 'bg-green-500/10 text-green-500' },
|
||||
used: { label: t('agent.cards.used'), class: 'bg-blue-500/10 text-blue-500' },
|
||||
expired: { label: t('agent.cards.expired'), class: 'bg-red-500/10 text-red-500' },
|
||||
disabled: { label: t('agent.cards.disabled'), class: 'bg-gray-500/10 text-gray-500' },
|
||||
}
|
||||
return map[status] || { label: status, class: '' }
|
||||
}
|
||||
@@ -52,16 +55,16 @@ function getStatusBadge(status: string) {
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">
|
||||
卡密管理
|
||||
{{ t('agent.cards.title') }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground">
|
||||
管理您生成的卡密
|
||||
{{ t('agent.cards.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiButton as-child>
|
||||
<router-link to="/agent/cards/create">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
生成卡密
|
||||
{{ t('agent.cards.generateCards') }}
|
||||
</router-link>
|
||||
</UiButton>
|
||||
</div>
|
||||
@@ -74,10 +77,10 @@ function getStatusBadge(status: string) {
|
||||
|
||||
<div v-else-if="cards.length === 0" class="text-center py-12 text-muted-foreground">
|
||||
<Key class="size-16 mx-auto mb-4 opacity-30" />
|
||||
<p>暂无卡密记录</p>
|
||||
<p>{{ t('agent.cards.noCards') }}</p>
|
||||
<UiButton class="mt-4" as-child>
|
||||
<router-link to="/agent/cards/create">
|
||||
生成卡密
|
||||
{{ t('agent.cards.generateCards') }}
|
||||
</router-link>
|
||||
</UiButton>
|
||||
</div>
|
||||
@@ -87,22 +90,22 @@ function getStatusBadge(status: string) {
|
||||
<thead class="bg-muted/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">
|
||||
卡密
|
||||
{{ t('agent.cards.cardCode') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">
|
||||
应用
|
||||
{{ t('agent.cards.app') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">
|
||||
卡类
|
||||
{{ t('agent.cards.cardType') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">
|
||||
状态
|
||||
{{ t('agent.cards.status') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">
|
||||
创建时间
|
||||
{{ t('agent.cards.createTime') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">
|
||||
使用时间
|
||||
{{ t('agent.cards.useTime') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowDownLeft, ArrowUpRight, Loader2, Receipt, RotateCcw } from 'lucide-vue-next'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Transaction {
|
||||
id: number
|
||||
type: string
|
||||
@@ -24,7 +27,7 @@ onMounted(async () => {
|
||||
transactions.value = data?.transactions || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取财务数据失败:', error)
|
||||
console.error('Load finance data failed:', error)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
@@ -32,14 +35,14 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function getTypeLabel(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
recharge: '充值',
|
||||
consume: '消费',
|
||||
refund: '退款',
|
||||
recharge: t('agent.finance.recharge'),
|
||||
consume: t('agent.finance.consume'),
|
||||
refund: t('agent.finance.refund'),
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
@@ -58,10 +61,10 @@ function getTypeClass(type: string) {
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">
|
||||
财务管理
|
||||
{{ t('agent.finance.title') }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground">
|
||||
查看您的账户余额和交易记录
|
||||
{{ t('agent.finance.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +72,7 @@ function getTypeClass(type: string) {
|
||||
<UiCard>
|
||||
<UiCardHeader class="pb-2">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
账户余额
|
||||
{{ t('agent.finance.accountBalance') }}
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
@@ -82,7 +85,7 @@ function getTypeClass(type: string) {
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>交易记录</UiCardTitle>
|
||||
<UiCardTitle>{{ t('agent.finance.transactionRecords') }}</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="p-0">
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
@@ -91,7 +94,7 @@ function getTypeClass(type: string) {
|
||||
|
||||
<div v-else-if="transactions.length === 0" class="text-center py-12 text-muted-foreground">
|
||||
<Receipt class="size-16 mx-auto mb-4 opacity-30" />
|
||||
<p>暂无交易记录</p>
|
||||
<p>{{ t('agent.finance.noTransactions') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y">
|
||||
@@ -125,7 +128,7 @@ function getTypeClass(type: string) {
|
||||
{{ tx.type === 'recharge' || tx.type === 'refund' ? '+' : '-' }}¥{{ tx.amount.toFixed(2) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
余额: ¥{{ tx.balance.toFixed(2) }}
|
||||
{{ t('agent.finance.balance') }}: ¥{{ tx.balance.toFixed(2) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Boxes, CreditCard, DollarSign, Key, Plus, Users } from 'lucide-vue-next'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(true)
|
||||
const stats = ref({
|
||||
totalApps: 0,
|
||||
@@ -38,7 +41,7 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取统计数据失败:', error)
|
||||
console.error('Load stats failed:', error)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
@@ -50,10 +53,10 @@ onMounted(async () => {
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">
|
||||
控制台
|
||||
{{ t('agent.dashboard.title') }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground">
|
||||
欢迎回来,查看您的业务概况
|
||||
{{ t('agent.dashboard.welcomeBack') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -61,7 +64,7 @@ onMounted(async () => {
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
授权应用
|
||||
{{ t('agent.dashboard.authorizedApps') }}
|
||||
</UiCardTitle>
|
||||
<Boxes class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
@@ -70,7 +73,7 @@ onMounted(async () => {
|
||||
{{ stats.totalApps }}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
已授权的应用数量
|
||||
{{ t('agent.dashboard.authorizedAppsCount') }}
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
@@ -78,7 +81,7 @@ onMounted(async () => {
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
卡密总数
|
||||
{{ t('agent.dashboard.totalCards') }}
|
||||
</UiCardTitle>
|
||||
<Key class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
@@ -87,7 +90,7 @@ onMounted(async () => {
|
||||
{{ stats.totalCards.toLocaleString() }}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
今日生成: {{ stats.todayCards }}
|
||||
{{ t('agent.dashboard.todayGenerated') }}: {{ stats.todayCards }}
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
@@ -95,7 +98,7 @@ onMounted(async () => {
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
用户总数
|
||||
{{ t('agent.dashboard.totalUsers') }}
|
||||
</UiCardTitle>
|
||||
<Users class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
@@ -104,7 +107,7 @@ onMounted(async () => {
|
||||
{{ stats.totalUsers.toLocaleString() }}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
累计注册用户
|
||||
{{ t('agent.dashboard.totalUsersDesc') }}
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
@@ -112,7 +115,7 @@ onMounted(async () => {
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
累计收入
|
||||
{{ t('agent.dashboard.totalRevenue') }}
|
||||
</UiCardTitle>
|
||||
<DollarSign class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
@@ -121,7 +124,7 @@ onMounted(async () => {
|
||||
¥{{ stats.totalRevenue.toFixed(2) }}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
今日: ¥{{ stats.todayRevenue.toFixed(2) }}
|
||||
{{ t('agent.dashboard.todayRevenue') }}: ¥{{ stats.todayRevenue.toFixed(2) }}
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
@@ -130,31 +133,31 @@ onMounted(async () => {
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>快捷操作</UiCardTitle>
|
||||
<UiCardTitle>{{ t('agent.dashboard.quickActions') }}</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="grid gap-4 sm:grid-cols-2">
|
||||
<router-link to="/agent/cards/create">
|
||||
<UiButton variant="outline" class="w-full h-20 flex-col gap-2">
|
||||
<Plus class="size-5" />
|
||||
<span>生成卡密</span>
|
||||
<span>{{ t('agent.dashboard.generateCards') }}</span>
|
||||
</UiButton>
|
||||
</router-link>
|
||||
<router-link to="/agent/apps">
|
||||
<UiButton variant="outline" class="w-full h-20 flex-col gap-2">
|
||||
<Boxes class="size-5" />
|
||||
<span>应用管理</span>
|
||||
<span>{{ t('agent.dashboard.appManagement') }}</span>
|
||||
</UiButton>
|
||||
</router-link>
|
||||
<router-link to="/agent/users">
|
||||
<UiButton variant="outline" class="w-full h-20 flex-col gap-2">
|
||||
<Users class="size-5" />
|
||||
<span>用户管理</span>
|
||||
<span>{{ t('agent.dashboard.userManagement') }}</span>
|
||||
</UiButton>
|
||||
</router-link>
|
||||
<router-link to="/agent/finance">
|
||||
<UiButton variant="outline" class="w-full h-20 flex-col gap-2">
|
||||
<CreditCard class="size-5" />
|
||||
<span>财务管理</span>
|
||||
<span>{{ t('agent.dashboard.financeManagement') }}</span>
|
||||
</UiButton>
|
||||
</router-link>
|
||||
</UiCardContent>
|
||||
@@ -162,7 +165,7 @@ onMounted(async () => {
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>最近生成卡密</UiCardTitle>
|
||||
<UiCardTitle>{{ t('agent.dashboard.recentCards') }}</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div v-if="recentCards.length > 0" class="space-y-4">
|
||||
@@ -176,13 +179,13 @@ onMounted(async () => {
|
||||
</p>
|
||||
</div>
|
||||
<UiBadge :variant="card.status === 'unused' ? 'default' : 'secondary'">
|
||||
{{ card.status === 'unused' ? '未使用' : '已使用' }}
|
||||
{{ card.status === 'unused' ? t('agent.dashboard.unused') : t('agent.dashboard.used') }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 text-muted-foreground">
|
||||
<Key class="size-12 mx-auto mb-2 opacity-30" />
|
||||
<p>暂无卡密记录</p>
|
||||
<p>{{ t('agent.dashboard.noCards') }}</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Loader2, Users } from 'lucide-vue-next'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
username: string
|
||||
@@ -22,7 +25,7 @@ onMounted(async () => {
|
||||
users.value = data || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取用户列表失败:', error)
|
||||
console.error('Load users failed:', error)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
@@ -32,7 +35,7 @@ onMounted(async () => {
|
||||
function formatDate(dateStr: string | null) {
|
||||
if (!dateStr)
|
||||
return '-'
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -40,10 +43,10 @@ function formatDate(dateStr: string | null) {
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">
|
||||
用户管理
|
||||
{{ t('agent.users.title') }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground">
|
||||
管理您应用下的用户
|
||||
{{ t('agent.users.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +58,7 @@ function formatDate(dateStr: string | null) {
|
||||
|
||||
<div v-else-if="users.length === 0" class="text-center py-12 text-muted-foreground">
|
||||
<Users class="size-16 mx-auto mb-4 opacity-30" />
|
||||
<p>暂无用户记录</p>
|
||||
<p>{{ t('agent.users.noUsers') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
@@ -63,19 +66,19 @@ function formatDate(dateStr: string | null) {
|
||||
<thead class="bg-muted/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">
|
||||
用户名
|
||||
{{ t('agent.users.username') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">
|
||||
邮箱
|
||||
{{ t('agent.users.email') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">
|
||||
状态
|
||||
{{ t('agent.users.status') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">
|
||||
注册时间
|
||||
{{ t('agent.users.registerTime') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">
|
||||
最后登录
|
||||
{{ t('agent.users.lastLogin') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -92,7 +95,7 @@ function formatDate(dateStr: string | null) {
|
||||
:class="user.status === 'active' ? 'bg-green-500/10 text-green-500' : ''"
|
||||
variant="outline"
|
||||
>
|
||||
{{ user.status === 'active' ? '正常' : user.status }}
|
||||
{{ user.status === 'active' ? t('agent.users.active') : user.status }}
|
||||
</UiBadge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ChartLine, Loader2, Shield, ShieldCheck, Users, Zap } from 'lucide-vue-next'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -21,7 +23,7 @@ const isValid = computed(() => {
|
||||
|
||||
async function handleLogin() {
|
||||
if (!isValid.value) {
|
||||
toast.error('请填写用户名和密码')
|
||||
toast.error(t('auth.login.fillRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -36,7 +38,7 @@ async function handleLogin() {
|
||||
localStorage.setItem('token', data.token)
|
||||
localStorage.setItem('user', JSON.stringify(data.user))
|
||||
|
||||
toast.success('登录成功')
|
||||
toast.success(t('auth.login.loginSuccess'))
|
||||
|
||||
if (data.user.role === 'agent' || data.user.parent_agent_id) {
|
||||
router.push('/agent')
|
||||
@@ -47,8 +49,8 @@ async function handleLogin() {
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('登录失败:', error)
|
||||
toast.error(error.message || '登录失败,请检查用户名和密码')
|
||||
console.error('Login failed:', error)
|
||||
toast.error(error.message || t('auth.login.loginFailed'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
@@ -68,30 +70,30 @@ async function handleLogin() {
|
||||
<div class="size-12 rounded-xl bg-primary flex items-center justify-center">
|
||||
<ShieldCheck class="size-7 text-primary-foreground" />
|
||||
</div>
|
||||
<span class="text-2xl font-bold">管理平台</span>
|
||||
<span class="text-2xl font-bold">{{ t('auth.login.platformName') }}</span>
|
||||
</div>
|
||||
<h1 class="text-4xl font-bold mb-4">
|
||||
软件授权管理平台
|
||||
{{ t('auth.login.heroTitle') }}
|
||||
</h1>
|
||||
<p class="text-lg text-muted-foreground mb-8">
|
||||
专业的软件验证与授权管理解决方案,为您的软件提供全方位的保护
|
||||
{{ t('auth.login.heroDesc') }}
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex items-center gap-3 p-4 rounded-lg bg-background/50 backdrop-blur">
|
||||
<Zap class="size-5 text-primary" />
|
||||
<span class="text-sm">快速集成</span>
|
||||
<span class="text-sm">{{ t('auth.login.quickIntegration') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 p-4 rounded-lg bg-background/50 backdrop-blur">
|
||||
<Shield class="size-5 text-primary" />
|
||||
<span class="text-sm">安全可靠</span>
|
||||
<span class="text-sm">{{ t('auth.login.secureReliable') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 p-4 rounded-lg bg-background/50 backdrop-blur">
|
||||
<ChartLine class="size-5 text-primary" />
|
||||
<span class="text-sm">数据分析</span>
|
||||
<span class="text-sm">{{ t('auth.login.dataAnalysis') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 p-4 rounded-lg bg-background/50 backdrop-blur">
|
||||
<Users class="size-5 text-primary" />
|
||||
<span class="text-sm">用户管理</span>
|
||||
<span class="text-sm">{{ t('auth.login.userManagement') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,38 +106,38 @@ async function handleLogin() {
|
||||
<div class="size-10 rounded-xl bg-primary flex items-center justify-center">
|
||||
<ShieldCheck class="size-6 text-primary-foreground" />
|
||||
</div>
|
||||
<span class="text-xl font-bold">管理平台</span>
|
||||
<span class="text-xl font-bold">{{ t('auth.login.platformName') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold mb-2">
|
||||
欢迎回来
|
||||
{{ t('auth.login.welcomeBack') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground">
|
||||
请输入您的账号信息登录系统
|
||||
{{ t('auth.login.loginDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="space-y-6" @submit.prevent="handleLogin">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="username">用户名</UiLabel>
|
||||
<UiLabel for="username">{{ t('auth.login.username') }}</UiLabel>
|
||||
<UiInput
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
placeholder="请输入用户名"
|
||||
:placeholder="t('auth.login.usernamePlaceholder')"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<UiLabel for="password">密码</UiLabel>
|
||||
<UiLabel for="password">{{ t('auth.login.password') }}</UiLabel>
|
||||
<router-link to="/forgot-password" class="text-sm text-primary hover:underline">
|
||||
忘记密码?
|
||||
{{ t('auth.login.forgotPassword') }}
|
||||
</router-link>
|
||||
</div>
|
||||
<UiInput
|
||||
@@ -144,7 +146,7 @@ async function handleLogin() {
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
:placeholder="t('auth.login.passwordPlaceholder')"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
@@ -156,7 +158,7 @@ async function handleLogin() {
|
||||
v-model:checked="form.remember"
|
||||
/>
|
||||
<UiLabel for="remember" class="text-sm font-normal cursor-pointer">
|
||||
记住我
|
||||
{{ t('auth.login.rememberMe') }}
|
||||
</UiLabel>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,14 +169,14 @@ async function handleLogin() {
|
||||
:disabled="loading || !isValid"
|
||||
>
|
||||
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
{{ loading ? t('auth.login.loggingIn') : t('auth.login.loginBtn') }}
|
||||
</UiButton>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center text-sm text-muted-foreground">
|
||||
还没有账号?
|
||||
{{ t('auth.login.noAccount') }}
|
||||
<router-link to="/register" class="text-primary hover:underline">
|
||||
立即注册
|
||||
{{ t('auth.login.registerNow') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, Loader2, ShieldCheck } from 'lucide-vue-next'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -27,27 +29,27 @@ const isValid = computed(() => {
|
||||
|
||||
async function handleRegister() {
|
||||
if (!form.value.username) {
|
||||
toast.error('请输入用户名')
|
||||
toast.error(t('auth.register.usernameRequired'))
|
||||
return
|
||||
}
|
||||
if (!form.value.email) {
|
||||
toast.error('请输入邮箱')
|
||||
toast.error(t('auth.register.emailRequired'))
|
||||
return
|
||||
}
|
||||
if (!form.value.password) {
|
||||
toast.error('请输入密码')
|
||||
toast.error(t('auth.register.passwordRequired'))
|
||||
return
|
||||
}
|
||||
if (form.value.password.length < 6) {
|
||||
toast.error('密码长度至少6位')
|
||||
toast.error(t('auth.register.passwordMinLength'))
|
||||
return
|
||||
}
|
||||
if (form.value.password !== form.value.confirmPassword) {
|
||||
toast.error('两次输入的密码不一致')
|
||||
toast.error(t('auth.register.passwordMismatch'))
|
||||
return
|
||||
}
|
||||
if (!form.value.agree) {
|
||||
toast.error('请阅读并同意服务条款')
|
||||
toast.error(t('auth.register.agreeRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -59,12 +61,12 @@ async function handleRegister() {
|
||||
password: form.value.password,
|
||||
})
|
||||
|
||||
toast.success('注册成功,请登录')
|
||||
toast.success(t('auth.register.registerSuccess'))
|
||||
router.push('/login')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('注册失败:', error)
|
||||
toast.error(error.message || '注册失败')
|
||||
console.error('Registration failed:', error)
|
||||
toast.error(error.message || t('auth.register.registerFailed'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
@@ -84,32 +86,32 @@ async function handleRegister() {
|
||||
<div class="size-12 rounded-xl bg-primary flex items-center justify-center">
|
||||
<ShieldCheck class="size-7 text-primary-foreground" />
|
||||
</div>
|
||||
<span class="text-2xl font-bold">管理平台</span>
|
||||
<span class="text-2xl font-bold">{{ t('auth.register.platformName') }}</span>
|
||||
</div>
|
||||
<h1 class="text-4xl font-bold mb-4">
|
||||
开始使用
|
||||
{{ t('auth.register.heroTitle') }}
|
||||
</h1>
|
||||
<p class="text-lg text-muted-foreground mb-8">
|
||||
创建账号,立即体验专业的软件授权管理服务
|
||||
{{ t('auth.register.heroDesc') }}
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="size-8 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<Check class="size-4 text-primary" />
|
||||
</div>
|
||||
<span>免费创建应用,快速集成 SDK</span>
|
||||
<span>{{ t('auth.register.feature1') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="size-8 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<Check class="size-4 text-primary" />
|
||||
</div>
|
||||
<span>完善的用户管理和数据分析</span>
|
||||
<span>{{ t('auth.register.feature2') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="size-8 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<Check class="size-4 text-primary" />
|
||||
</div>
|
||||
<span>灵活的卡密授权方案</span>
|
||||
<span>{{ t('auth.register.feature3') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,60 +124,60 @@ async function handleRegister() {
|
||||
<div class="size-10 rounded-xl bg-primary flex items-center justify-center">
|
||||
<ShieldCheck class="size-6 text-primary-foreground" />
|
||||
</div>
|
||||
<span class="text-xl font-bold">管理平台</span>
|
||||
<span class="text-xl font-bold">{{ t('auth.register.platformName') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold mb-2">
|
||||
创建账号
|
||||
{{ t('auth.register.createAccount') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground">
|
||||
填写以下信息完成注册
|
||||
{{ t('auth.register.registerDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="space-y-5" @submit.prevent="handleRegister">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="username">用户名</UiLabel>
|
||||
<UiLabel for="username">{{ t('auth.register.username') }}</UiLabel>
|
||||
<UiInput
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
:placeholder="t('auth.register.usernamePlaceholder')"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="email">邮箱</UiLabel>
|
||||
<UiLabel for="email">{{ t('auth.register.email') }}</UiLabel>
|
||||
<UiInput
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
:placeholder="t('auth.register.emailPlaceholder')"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="password">密码</UiLabel>
|
||||
<UiLabel for="password">{{ t('auth.register.password') }}</UiLabel>
|
||||
<UiInput
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码(至少6位)"
|
||||
:placeholder="t('auth.register.passwordPlaceholder')"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="confirmPassword">确认密码</UiLabel>
|
||||
<UiLabel for="confirmPassword">{{ t('auth.register.confirmPassword') }}</UiLabel>
|
||||
<UiInput
|
||||
id="confirmPassword"
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请再次输入密码"
|
||||
:placeholder="t('auth.register.confirmPasswordPlaceholder')"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
@@ -186,10 +188,10 @@ async function handleRegister() {
|
||||
v-model:checked="form.agree"
|
||||
/>
|
||||
<UiLabel for="agree" class="text-sm font-normal cursor-pointer leading-tight">
|
||||
我已阅读并同意
|
||||
<a href="#" class="text-primary hover:underline">服务条款</a>
|
||||
和
|
||||
<a href="#" class="text-primary hover:underline">隐私政策</a>
|
||||
{{ t('auth.register.agreeTerms') }}
|
||||
<a href="#" class="text-primary hover:underline">{{ t('auth.register.termsOfService') }}</a>
|
||||
{{ t('auth.register.and') }}
|
||||
<a href="#" class="text-primary hover:underline">{{ t('auth.register.privacyPolicy') }}</a>
|
||||
</UiLabel>
|
||||
</div>
|
||||
|
||||
@@ -199,14 +201,14 @@ async function handleRegister() {
|
||||
:disabled="loading || !isValid"
|
||||
>
|
||||
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ loading ? '注册中...' : '注册' }}
|
||||
{{ loading ? t('auth.register.registering') : t('auth.register.registerBtn') }}
|
||||
</UiButton>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center text-sm text-muted-foreground">
|
||||
已有账号?
|
||||
{{ t('auth.register.hasAccount') }}
|
||||
<router-link to="/login" class="text-primary hover:underline">
|
||||
立即登录
|
||||
{{ t('auth.register.loginNow') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronRight, Database, Loader2, RefreshCw, Server, ShieldCheck, User } from 'lucide-vue-next'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { resetInstallCheck } from '@/router/guard/index'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -32,10 +34,10 @@ const form = ref({
|
||||
redis_db: 0,
|
||||
})
|
||||
|
||||
const dbTypes = [
|
||||
{ value: 'sqlite', label: 'SQLite', desc: '轻量级,无需额外服务,适合小型部署' },
|
||||
{ value: 'mysql', label: 'MySQL', desc: '高性能,适合生产环境' },
|
||||
]
|
||||
const dbTypes = computed(() => [
|
||||
{ value: 'sqlite', label: t('install.dbTypeSqlite'), desc: t('install.dbTypeSqliteDesc') },
|
||||
{ value: 'mysql', label: t('install.dbTypeMysql'), desc: t('install.dbTypeMysqlDesc') },
|
||||
])
|
||||
|
||||
async function checkInstallStatus() {
|
||||
checkingStatus.value = true
|
||||
@@ -49,7 +51,7 @@ async function checkInstallStatus() {
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('检查安装状态失败:', error)
|
||||
console.error('Check install status failed:', error)
|
||||
}
|
||||
finally {
|
||||
checkingStatus.value = false
|
||||
@@ -76,12 +78,12 @@ async function testDatabase() {
|
||||
})
|
||||
const result = await res.json()
|
||||
if (!res.ok || result.code !== 200) {
|
||||
throw new Error(result.message || '数据库连接失败')
|
||||
throw new Error(result.message || t('install.dbConnectFailed'))
|
||||
}
|
||||
toast.success(result.data?.message || '数据库连接成功')
|
||||
toast.success(result.data?.message || t('install.dbConnectSuccess'))
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.error(error.message || '数据库连接失败')
|
||||
toast.error(error.message || t('install.dbConnectFailed'))
|
||||
}
|
||||
finally {
|
||||
testing.value = false
|
||||
@@ -145,13 +147,13 @@ async function handleInstall() {
|
||||
})
|
||||
const result = await res.json()
|
||||
if (!res.ok || result.code !== 200) {
|
||||
throw new Error(result.message || '安装失败')
|
||||
throw new Error(result.message || t('install.installFailed'))
|
||||
}
|
||||
toast.success(result.data?.message || '安装成功')
|
||||
toast.success(result.data?.message || t('install.installSuccess'))
|
||||
step.value = 4
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.error(error.message || '安装失败')
|
||||
toast.error(error.message || t('install.installFailed'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
@@ -180,7 +182,7 @@ onMounted(() => {
|
||||
<div class="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-primary/5 via-background to-background">
|
||||
<div v-if="checkingStatus" class="flex items-center gap-2">
|
||||
<Loader2 class="size-5 animate-spin" />
|
||||
<span>检查安装状态...</span>
|
||||
<span>{{ t('install.checkingStatus') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="w-full max-w-2xl">
|
||||
@@ -191,10 +193,10 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold">
|
||||
系统安装向导
|
||||
{{ t('install.wizardTitle') }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
欢迎使用软件授权管理平台,请完成以下配置
|
||||
{{ t('install.wizardDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -224,12 +226,12 @@ onMounted(() => {
|
||||
<div v-if="step === 1">
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<Database class="size-5 text-primary" />
|
||||
<h2 class="text-lg font-semibold">数据库配置</h2>
|
||||
<h2 class="text-lg font-semibold">{{ t('install.dbConfig') }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<UiLabel>数据库类型</UiLabel>
|
||||
<UiLabel>{{ t('install.dbType') }}</UiLabel>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
v-for="db in dbTypes"
|
||||
@@ -248,31 +250,31 @@ onMounted(() => {
|
||||
<template v-if="form.db_type === 'mysql'">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="db_host">主机地址</UiLabel>
|
||||
<UiLabel for="db_host">{{ t('install.dbHost') }}</UiLabel>
|
||||
<UiInput id="db_host" v-model="form.db_host" placeholder="localhost" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="db_port">端口</UiLabel>
|
||||
<UiLabel for="db_port">{{ t('install.dbPort') }}</UiLabel>
|
||||
<UiInput id="db_port" v-model="form.db_port" placeholder="3306" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="db_name">数据库名</UiLabel>
|
||||
<UiLabel for="db_name">{{ t('install.dbName') }}</UiLabel>
|
||||
<UiInput id="db_name" v-model="form.db_name" placeholder="verification_platform" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="db_username">用户名</UiLabel>
|
||||
<UiLabel for="db_username">{{ t('install.dbUsername') }}</UiLabel>
|
||||
<UiInput id="db_username" v-model="form.db_username" placeholder="root" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="db_password">密码</UiLabel>
|
||||
<UiLabel for="db_password">{{ t('install.dbPassword') }}</UiLabel>
|
||||
<UiInput id="db_password" v-model="form.db_password" type="password" placeholder="••••••••" />
|
||||
</div>
|
||||
</div>
|
||||
<UiButton variant="outline" :disabled="testing" @click="testDatabase">
|
||||
<Loader2 v-if="testing" class="mr-2 size-4 animate-spin" />
|
||||
测试连接
|
||||
{{ t('install.testConnection') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</div>
|
||||
@@ -281,17 +283,17 @@ onMounted(() => {
|
||||
<div v-else-if="step === 2">
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<ShieldCheck class="size-5 text-primary" />
|
||||
<h2 class="text-lg font-semibold">安全配置</h2>
|
||||
<h2 class="text-lg font-semibold">{{ t('install.securityConfig') }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="jwt_secret">JWT 密钥</UiLabel>
|
||||
<UiLabel for="jwt_secret">{{ t('install.jwtSecret') }}</UiLabel>
|
||||
<div class="flex gap-2">
|
||||
<UiInput
|
||||
id="jwt_secret"
|
||||
v-model="form.jwt_secret"
|
||||
placeholder="留空则自动生成"
|
||||
:placeholder="t('install.jwtSecretPlaceholder')"
|
||||
class="flex-1"
|
||||
/>
|
||||
<UiButton variant="outline" @click="generateJwtSecret">
|
||||
@@ -299,33 +301,33 @@ onMounted(() => {
|
||||
</UiButton>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
JWT 密钥用于签名认证令牌,请妥善保管
|
||||
{{ t('install.jwtSecretHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 rounded-lg bg-muted/50">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Server class="size-4 text-muted-foreground" />
|
||||
<span class="font-medium">Redis 缓存(可选)</span>
|
||||
<span class="font-medium">{{ t('install.redisCache') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<UiCheckbox id="use_redis" v-model:checked="form.use_redis" />
|
||||
<UiLabel for="use_redis" class="text-sm font-normal cursor-pointer">启用 Redis</UiLabel>
|
||||
<UiLabel for="use_redis" class="text-sm font-normal cursor-pointer">{{ t('install.enableRedis') }}</UiLabel>
|
||||
</div>
|
||||
<template v-if="form.use_redis">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="redis_host">主机</UiLabel>
|
||||
<UiLabel for="redis_host">{{ t('install.redisHost') }}</UiLabel>
|
||||
<UiInput id="redis_host" v-model="form.redis_host" placeholder="localhost" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="redis_port">端口</UiLabel>
|
||||
<UiLabel for="redis_port">{{ t('install.redisPort') }}</UiLabel>
|
||||
<UiInput id="redis_port" v-model="form.redis_port" placeholder="6379" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 space-y-2">
|
||||
<UiLabel for="redis_password">密码</UiLabel>
|
||||
<UiInput id="redis_password" v-model="form.redis_password" type="password" placeholder="可选" />
|
||||
<UiLabel for="redis_password">{{ t('install.redisPassword') }}</UiLabel>
|
||||
<UiInput id="redis_password" v-model="form.redis_password" type="password" :placeholder="t('install.redisPasswordPlaceholder')" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -335,23 +337,23 @@ onMounted(() => {
|
||||
<div v-else-if="step === 3">
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<User class="size-5 text-primary" />
|
||||
<h2 class="text-lg font-semibold">管理员账号</h2>
|
||||
<h2 class="text-lg font-semibold">{{ t('install.adminAccount') }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="admin_user">用户名</UiLabel>
|
||||
<UiLabel for="admin_user">{{ t('install.adminUser') }}</UiLabel>
|
||||
<UiInput id="admin_user" v-model="form.admin_user" placeholder="admin" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="admin_pass">密码</UiLabel>
|
||||
<UiInput id="admin_pass" v-model="form.admin_pass" type="password" placeholder="至少6位" />
|
||||
<UiLabel for="admin_pass">{{ t('install.adminPass') }}</UiLabel>
|
||||
<UiInput id="admin_pass" v-model="form.admin_pass" type="password" :placeholder="t('install.adminPassPlaceholder')" />
|
||||
<p v-if="form.admin_pass && form.admin_pass.length < 6" class="text-sm text-destructive">
|
||||
密码长度至少6位
|
||||
{{ t('install.adminPassMinLength') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="admin_email">邮箱(可选)</UiLabel>
|
||||
<UiLabel for="admin_email">{{ t('install.adminEmail') }}</UiLabel>
|
||||
<UiInput id="admin_email" v-model="form.admin_email" type="email" placeholder="admin@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,12 +364,12 @@ onMounted(() => {
|
||||
<div class="size-16 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center mx-auto mb-4">
|
||||
<Check class="size-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold mb-2">安装完成</h2>
|
||||
<h2 class="text-xl font-semibold mb-2">{{ t('install.installComplete') }}</h2>
|
||||
<p class="text-muted-foreground mb-6">
|
||||
系统已成功安装,您现在可以使用管理员账号登录
|
||||
{{ t('install.installCompleteDesc') }}
|
||||
</p>
|
||||
<UiButton @click="goLogin">
|
||||
前往登录
|
||||
{{ t('install.goToLogin') }}
|
||||
<ChevronRight class="ml-2 size-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
@@ -380,7 +382,7 @@ onMounted(() => {
|
||||
variant="outline"
|
||||
@click="step--"
|
||||
>
|
||||
上一步
|
||||
{{ t('install.prevStep') }}
|
||||
</UiButton>
|
||||
<div v-else />
|
||||
|
||||
@@ -389,7 +391,7 @@ onMounted(() => {
|
||||
:disabled="!canProceed"
|
||||
@click="goNext"
|
||||
>
|
||||
下一步
|
||||
{{ t('install.nextStep') }}
|
||||
<ChevronRight class="ml-2 size-4" />
|
||||
</UiButton>
|
||||
<UiButton
|
||||
@@ -398,7 +400,7 @@ onMounted(() => {
|
||||
@click="handleInstall"
|
||||
>
|
||||
<Loader2 v-if="loading" class="mr-2 size-4 animate-spin" />
|
||||
{{ loading ? '安装中...' : '开始安装' }}
|
||||
{{ loading ? t('install.installing') : t('install.startInstall') }}
|
||||
</UiButton>
|
||||
</UiCardFooter>
|
||||
</UiCard>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity, ArrowDownLeft, ArrowRight, ArrowUpRight, Calendar, Camera, Copy, Crown, Eye, EyeOff, HardDrive, Key, Loader2, Mail, Receipt, RefreshCw, RotateCcw, Save, Shield, User, Zap } from 'lucide-vue-next'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface UserProfile {
|
||||
id: number
|
||||
username: string
|
||||
@@ -105,8 +108,8 @@ async function fetchProfile() {
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取用户信息失败:', error)
|
||||
toast.error('获取用户信息失败')
|
||||
console.error('Fetch profile failed:', error)
|
||||
toast.error(t('profile.loadUserFailed'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
@@ -115,7 +118,7 @@ async function fetchProfile() {
|
||||
|
||||
async function handleUpdateProfile() {
|
||||
if (!isProfileFormValid.value) {
|
||||
toast.error('请填写完整信息')
|
||||
toast.error(t('profile.fillComplete'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -126,7 +129,7 @@ async function handleUpdateProfile() {
|
||||
email: profileForm.value.email,
|
||||
phone: profileForm.value.phone,
|
||||
})
|
||||
toast.success('个人信息更新成功')
|
||||
toast.success(t('profile.profileUpdateSuccess'))
|
||||
|
||||
if (user.value) {
|
||||
user.value.username = profileForm.value.username
|
||||
@@ -136,8 +139,8 @@ async function handleUpdateProfile() {
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('更新个人信息失败:', error)
|
||||
toast.error(error.message || '更新失败')
|
||||
console.error('Update profile failed:', error)
|
||||
toast.error(error.message || t('profile.profileUpdateFailed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
@@ -146,19 +149,19 @@ async function handleUpdateProfile() {
|
||||
|
||||
async function handleChangePassword() {
|
||||
if (!passwordForm.value.currentPassword) {
|
||||
toast.error('请输入当前密码')
|
||||
toast.error(t('profile.currentPasswordRequired'))
|
||||
return
|
||||
}
|
||||
if (!passwordForm.value.newPassword) {
|
||||
toast.error('请输入新密码')
|
||||
toast.error(t('profile.newPasswordRequired'))
|
||||
return
|
||||
}
|
||||
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
|
||||
toast.error('两次输入的密码不一致')
|
||||
toast.error(t('profile.passwordMismatch'))
|
||||
return
|
||||
}
|
||||
if (passwordForm.value.newPassword.length < 6) {
|
||||
toast.error('密码长度至少6位')
|
||||
toast.error(t('profile.passwordMinLength'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -168,7 +171,7 @@ async function handleChangePassword() {
|
||||
current_password: passwordForm.value.currentPassword,
|
||||
new_password: passwordForm.value.newPassword,
|
||||
})
|
||||
toast.success('密码修改成功')
|
||||
toast.success(t('profile.passwordChangeSuccess'))
|
||||
showPasswordDialog.value = false
|
||||
passwordForm.value = {
|
||||
currentPassword: '',
|
||||
@@ -177,8 +180,8 @@ async function handleChangePassword() {
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('修改密码失败:', error)
|
||||
toast.error(error.message || '修改密码失败')
|
||||
console.error('Change password failed:', error)
|
||||
toast.error(error.message || t('profile.passwordChangeFailed'))
|
||||
}
|
||||
finally {
|
||||
changingPassword.value = false
|
||||
@@ -196,12 +199,12 @@ async function handleAvatarChange(event: Event) {
|
||||
return
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('请选择图片文件')
|
||||
toast.error(t('profile.selectImageFile'))
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('图片大小不能超过2MB')
|
||||
toast.error(t('profile.imageSizeExceeded'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -224,12 +227,12 @@ async function handleAvatarChange(event: Event) {
|
||||
user.value.avatar = data.avatar
|
||||
localStorage.setItem('user', JSON.stringify(user.value))
|
||||
}
|
||||
toast.success('头像更新成功')
|
||||
toast.success(t('profile.avatarUpdateSuccess'))
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('上传头像失败:', error)
|
||||
toast.error(error.message || '上传头像失败')
|
||||
console.error('Upload avatar failed:', error)
|
||||
toast.error(error.message || t('profile.avatarUpdateFailed'))
|
||||
}
|
||||
finally {
|
||||
uploadingAvatar.value = false
|
||||
@@ -273,26 +276,26 @@ function formatNumber(num: number) {
|
||||
|
||||
function getRoleName(role: string) {
|
||||
const roles: Record<string, string> = {
|
||||
admin: '管理员',
|
||||
agent: '代理商',
|
||||
admin: t('profile.roleAdmin'),
|
||||
agent: t('profile.roleAgent'),
|
||||
}
|
||||
return roles[role] || role
|
||||
}
|
||||
|
||||
function getStatusName(status: string) {
|
||||
const statuses: Record<string, string> = {
|
||||
active: '正常',
|
||||
inactive: '未激活',
|
||||
banned: '已封禁',
|
||||
active: t('profile.statusActive'),
|
||||
inactive: t('profile.statusInactive'),
|
||||
banned: t('profile.statusBanned'),
|
||||
}
|
||||
return statuses[status] || status
|
||||
}
|
||||
|
||||
function getTransactionType(type: string) {
|
||||
const types: Record<string, string> = {
|
||||
recharge: '充值',
|
||||
consume: '消费',
|
||||
refund: '退款',
|
||||
recharge: t('profile.txRecharge'),
|
||||
consume: t('profile.txConsume'),
|
||||
refund: t('profile.txRefund'),
|
||||
}
|
||||
return types[type] || type
|
||||
}
|
||||
@@ -312,12 +315,12 @@ async function handleRegenerateApiToken() {
|
||||
const data = await api.post<{ api_token: string }>('/dev/profile/api-token')
|
||||
if (data?.api_token && user.value) {
|
||||
user.value.api_token = data.api_token
|
||||
toast.success('API Token 已重新生成')
|
||||
toast.success(t('profile.tokenRegenerated'))
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('重新生成API Token失败:', error)
|
||||
toast.error(error.message || '重新生成失败')
|
||||
console.error('Regenerate API token failed:', error)
|
||||
toast.error(error.message || t('profile.tokenRegenerateFailed'))
|
||||
}
|
||||
finally {
|
||||
regeneratingToken.value = false
|
||||
@@ -327,7 +330,7 @@ async function handleRegenerateApiToken() {
|
||||
function copyApiToken() {
|
||||
if (user.value?.api_token) {
|
||||
navigator.clipboard.writeText(user.value.api_token)
|
||||
toast.success('API Token 已复制到剪贴板')
|
||||
toast.success(t('profile.tokenCopied'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,7 +383,7 @@ onMounted(() => {
|
||||
<div class="absolute -bottom-12 right-8 flex items-center gap-2">
|
||||
<UiButton variant="outline" size="sm" @click="showPasswordDialog = true">
|
||||
<Key class="mr-2 h-4 w-4" />
|
||||
修改密码
|
||||
{{ t('profile.changePassword') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
<input
|
||||
@@ -412,7 +415,7 @@ onMounted(() => {
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<Calendar class="size-4" />
|
||||
加入于 {{ formatDateShort(user?.created_at || '') }}
|
||||
{{ t('profile.joinedAt') }} {{ formatDateShort(user?.created_at || '') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -423,45 +426,45 @@ onMounted(() => {
|
||||
<div class="bg-muted/30 px-6 py-4 border-b">
|
||||
<h2 class="font-semibold flex items-center gap-2">
|
||||
<User class="size-5 text-primary" />
|
||||
基本信息
|
||||
{{ t('profile.basicInfo') }}
|
||||
</h2>
|
||||
</div>
|
||||
<UiCardContent class="p-6">
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="username" class="text-muted-foreground">
|
||||
用户名
|
||||
{{ t('profile.username') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="username"
|
||||
v-model="profileForm.username"
|
||||
placeholder="请输入用户名"
|
||||
:placeholder="t('profile.usernamePlaceholder')"
|
||||
class="bg-background"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="email" class="text-muted-foreground">
|
||||
邮箱
|
||||
{{ t('profile.email') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="email"
|
||||
v-model="profileForm.email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
:placeholder="t('profile.emailPlaceholder')"
|
||||
class="bg-background"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 sm:col-span-2">
|
||||
<UiLabel for="phone" class="text-muted-foreground">
|
||||
手机号
|
||||
{{ t('profile.phone') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="phone"
|
||||
v-model="profileForm.phone"
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
:placeholder="t('profile.phonePlaceholder')"
|
||||
class="bg-background"
|
||||
/>
|
||||
</div>
|
||||
@@ -474,7 +477,7 @@ onMounted(() => {
|
||||
>
|
||||
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Save v-else class="mr-2 h-4 w-4" />
|
||||
保存修改
|
||||
{{ t('profile.saveChanges') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
@@ -490,7 +493,7 @@ onMounted(() => {
|
||||
<UiCardContent class="p-6">
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
API Token 用于调用开放 API 接口,请妥善保管,不要泄露给他人。
|
||||
{{ t('profile.apiTokenDesc') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="flex-1 p-3 bg-muted rounded-lg text-sm font-mono break-all border">
|
||||
@@ -509,7 +512,7 @@ onMounted(() => {
|
||||
</UiButton>
|
||||
</UiTooltipTrigger>
|
||||
<UiTooltipContent>
|
||||
{{ showApiToken ? '隐藏' : '显示' }}
|
||||
{{ showApiToken ? t('profile.hide') : t('profile.show') }}
|
||||
</UiTooltipContent>
|
||||
</UiTooltip>
|
||||
</UiTooltipProvider>
|
||||
@@ -526,7 +529,7 @@ onMounted(() => {
|
||||
</UiButton>
|
||||
</UiTooltipTrigger>
|
||||
<UiTooltipContent>
|
||||
复制
|
||||
{{ t('profile.copy') }}
|
||||
</UiTooltipContent>
|
||||
</UiTooltip>
|
||||
</UiTooltipProvider>
|
||||
@@ -539,7 +542,7 @@ onMounted(() => {
|
||||
>
|
||||
<Loader2 v-if="regeneratingToken" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<RefreshCw v-else class="mr-2 h-4 w-4" />
|
||||
{{ user?.api_token ? '重新生成' : '生成 Token' }}
|
||||
{{ user?.api_token ? t('profile.regenerateToken') : t('profile.generateToken') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -550,11 +553,11 @@ onMounted(() => {
|
||||
<div class="bg-muted/30 px-6 py-4 border-b flex items-center justify-between">
|
||||
<h2 class="font-semibold flex items-center gap-2">
|
||||
<Receipt class="size-5 text-primary" />
|
||||
最近交易
|
||||
{{ t('profile.recentTransactions') }}
|
||||
</h2>
|
||||
<UiButton variant="ghost" size="sm" as-child>
|
||||
<router-link to="/admin/finance" class="text-primary">
|
||||
查看全部
|
||||
{{ t('profile.viewAll') }}
|
||||
<ArrowRight class="ml-1 h-4 w-4" />
|
||||
</router-link>
|
||||
</UiButton>
|
||||
@@ -604,7 +607,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div v-else class="text-center py-12 text-muted-foreground">
|
||||
<Receipt class="size-16 mx-auto mb-3 opacity-30" />
|
||||
<p>暂无交易记录</p>
|
||||
<p>{{ t('profile.noTransactions') }}</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
@@ -616,10 +619,10 @@ onMounted(() => {
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
当前套餐
|
||||
{{ t('profile.currentPlan') }}
|
||||
</p>
|
||||
<p class="text-2xl font-bold mt-1">
|
||||
{{ subscription?.plan || '基础版' }}
|
||||
{{ subscription?.plan || t('profile.basicPlan') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="size-12 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
@@ -627,11 +630,11 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mt-2">
|
||||
有效期至 {{ formatDateShort(subscription?.expire_date || '') }}
|
||||
{{ t('profile.expiresAt') }} {{ formatDateShort(subscription?.expire_date || '') }}
|
||||
</p>
|
||||
<UiButton class="w-full mt-4" as-child>
|
||||
<router-link to="/billing">
|
||||
升级套餐
|
||||
{{ t('profile.upgradePlan') }}
|
||||
</router-link>
|
||||
</UiButton>
|
||||
</div>
|
||||
@@ -640,7 +643,7 @@ onMounted(() => {
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground flex items-center gap-1">
|
||||
<Zap class="size-4" />
|
||||
API 调用
|
||||
{{ t('profile.apiCalls') }}
|
||||
</span>
|
||||
<span class="font-medium">{{ formatNumber(subscription?.api_used || 0) }} / {{ formatNumber(subscription?.api_quota || 10000) }}</span>
|
||||
</div>
|
||||
@@ -657,7 +660,7 @@ onMounted(() => {
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground flex items-center gap-1">
|
||||
<HardDrive class="size-4" />
|
||||
存储空间
|
||||
{{ t('profile.storage') }}
|
||||
</span>
|
||||
<span class="font-medium">{{ formatBytes(subscription?.storage_used || 0) }} / {{ formatBytes(subscription?.storage_quota || 100 * 1024 * 1024) }}</span>
|
||||
</div>
|
||||
@@ -676,23 +679,23 @@ onMounted(() => {
|
||||
<div class="bg-muted/30 px-6 py-4 border-b">
|
||||
<h2 class="font-semibold flex items-center gap-2">
|
||||
<Activity class="size-5 text-primary" />
|
||||
账户统计
|
||||
{{ t('profile.accountStats') }}
|
||||
</h2>
|
||||
</div>
|
||||
<UiCardContent class="p-6">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-muted-foreground text-sm">上次登录</span>
|
||||
<span class="text-muted-foreground text-sm">{{ t('profile.lastLogin') }}</span>
|
||||
<span class="text-sm font-medium">{{ formatDate(user?.last_login_at || '') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-t">
|
||||
<span class="text-muted-foreground text-sm">账户状态</span>
|
||||
<span class="text-muted-foreground text-sm">{{ t('profile.accountStatus') }}</span>
|
||||
<UiBadge :class="user?.status === 'active' ? 'bg-green-500/10 text-green-500' : ''" variant="outline">
|
||||
{{ getStatusName(user?.status || '') }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-t">
|
||||
<span class="text-muted-foreground text-sm">账户类型</span>
|
||||
<span class="text-muted-foreground text-sm">{{ t('profile.accountType') }}</span>
|
||||
<UiBadge variant="outline">
|
||||
{{ getRoleName(user?.role || '') }}
|
||||
</UiBadge>
|
||||
@@ -708,56 +711,56 @@ onMounted(() => {
|
||||
<UiDialog v-model:open="showPasswordDialog">
|
||||
<UiDialogContent class="sm:max-w-md">
|
||||
<UiDialogHeader>
|
||||
<UiDialogTitle>修改密码</UiDialogTitle>
|
||||
<UiDialogTitle>{{ t('profile.changePassword') }}</UiDialogTitle>
|
||||
<UiDialogDescription>
|
||||
请输入当前密码和新密码,密码长度至少6位
|
||||
{{ t('profile.changePasswordDesc') }}
|
||||
</UiDialogDescription>
|
||||
</UiDialogHeader>
|
||||
<div class="space-y-4 py-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="currentPassword">
|
||||
当前密码
|
||||
{{ t('profile.currentPassword') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="currentPassword"
|
||||
v-model="passwordForm.currentPassword"
|
||||
type="password"
|
||||
placeholder="请输入当前密码"
|
||||
:placeholder="t('profile.currentPasswordPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="newPassword">
|
||||
新密码
|
||||
{{ t('profile.newPassword') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="newPassword"
|
||||
v-model="passwordForm.newPassword"
|
||||
type="password"
|
||||
placeholder="请输入新密码"
|
||||
:placeholder="t('profile.newPasswordPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="confirmPassword">
|
||||
确认密码
|
||||
{{ t('profile.confirmPassword') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="confirmPassword"
|
||||
v-model="passwordForm.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请再次输入新密码"
|
||||
:placeholder="t('profile.confirmPasswordPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UiDialogFooter>
|
||||
<UiButton variant="outline" @click="showPasswordDialog = false">
|
||||
取消
|
||||
{{ t('profile.cancel') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
:disabled="changingPassword || !isPasswordFormValid"
|
||||
@click="handleChangePassword"
|
||||
>
|
||||
<Loader2 v-if="changingPassword" class="mr-2 h-4 w-4 animate-spin" />
|
||||
确认修改
|
||||
{{ t('profile.confirmChange') }}
|
||||
</UiButton>
|
||||
</UiDialogFooter>
|
||||
</UiDialogContent>
|
||||
|
||||
Reference in New Issue
Block a user