feat:新增授权管理/查看详情页面

This commit is contained in:
13895237362 committed 2025-10-22 16:08:24 +08:00
1 parent 961be26353
commit 1f06896f6d
22 files changed
+1512 -701

No files matched your search

+2 -2
View File
@@ -8,8 +8,8 @@
###
# 开发环境接口地址
# VITE_API_BASE_URL = 'http://localhost:18888'
# VITE_API_BASE_URL = 'http://111.113.215.88:18888'
VITE_API_BASE_URL = 'http://lm.cedar-v.com:18080'
VITE_API_BASE_URL = 'http://111.113.214.109:18888'
# VITE_API_BASE_URL = 'http://lm.cedar-v.com:18080'
# # 开发环境打包名称
Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

@@ -0,0 +1,66 @@
<!--
* @Description: License Tab Icon Component
* 用于授权详情页面左侧Tab导航的图标组件
-->
<template>
<div class="license-tab-icon" :class="iconClass">
<component :is="iconComponent" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import BasicInfoIcon from './svg/BasicInfoIcon.vue'
import AuthorizationIcon from './svg/AuthorizationIcon.vue'
import LicenseCertIcon from './svg/LicenseCertIcon.vue'
import HistoryIcon from './svg/HistoryIcon.vue'
interface Props {
name: string
active?: boolean
}
const props = withDefaults(defineProps<Props>(), {
active: false
})
const iconMap = {
'basic': BasicInfoIcon,
'authorization': AuthorizationIcon,
'license': LicenseCertIcon,
'history': HistoryIcon
}
const iconComponent = computed(() => {
return iconMap[props.name as keyof typeof iconMap] || BasicInfoIcon
})
const iconClass = computed(() => ({
'license-tab-icon--active': props.active
}))
</script>
<style scoped>
.license-tab-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.license-tab-icon :deep(svg) {
width: 100%;
height: 100%;
}
.license-tab-icon :deep(path) {
fill: #B2B8C2;
transition: fill 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.license-tab-icon--active :deep(path) {
fill: #019C7C;
}
</style>
@@ -0,0 +1,5 @@
<template>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.1837 5.50067C12.694 6.022 13.0003 6.752 13.0003 7.534C13.0003 8.316 12.694 9.04567 12.1837 9.56733C11.826 9.932 11.1623 10.1927 10.652 10.297V11.6627H12.0303C12.2347 11.6627 12.682 11.9943 12.682 12.3293C12.682 12.664 12.2347 13.0007 12.0303 13.0007H10.652V14.9367C10.652 15.1453 10.3277 15.6667 10.0013 15.6667C9.67499 15.6667 9.34833 15.1453 9.34833 14.9367V10.2947C8.83799 10.1903 8.50799 9.92967 8.15033 9.56467C7.64033 9.044 7.33366 8.314 7.33366 7.532C7.33366 6.75 7.63999 6.02033 8.15033 5.49867C8.71199 4.97967 9.42699 4.66667 10.1927 4.66667C10.9583 4.66667 11.673 4.97933 12.1837 5.50067ZM11.667 7.51667C11.667 7.1 11.5303 6.72967 11.212 6.45167C10.621 5.85 9.71099 5.85 9.12199 6.45167C8.84866 6.72933 8.66699 7.1 8.66699 7.51667C8.66699 7.93333 8.80366 8.30367 9.12199 8.58133C9.39466 8.85933 9.75866 8.998 10.168 8.998C10.9847 9.04467 11.667 8.35 11.667 7.51667ZM9.97566 0L17.8673 2.46233C18.126 2.565 18.3337 2.82167 18.3337 3.12967V12.9557C18.3337 13.007 17.9163 14.6777 16.307 16.2157C14.8543 17.602 11.065 19.4993 10.286 19.859L10.0783 19.9613C10.0302 19.9866 9.97656 19.9998 9.92216 19.9998C9.86775 19.9998 9.81416 19.9866 9.76599 19.9613L9.60966 19.8587C9.55833 19.8073 9.40233 19.7047 9.19466 19.602C7.89566 18.9347 4.88533 17.343 3.63999 16.1643C2.03033 14.624 1.66699 12.9627 1.66699 12.9113V3.13C1.66699 2.822 1.87466 2.56533 2.13366 2.46267L9.97533 0H9.97566ZM17.0003 12.8087V3.79433C17.0003 3.63967 16.9493 3.53667 16.7947 3.485L10.1927 1.45933C10.0887 1.408 9.93599 1.408 9.78099 1.45933L3.48099 3.485C3.37699 3.53667 3.32599 3.63967 3.32599 3.79433V12.8087C3.37699 13.0663 3.69966 14.3267 4.88333 15.458C5.96299 16.5403 8.70132 17.774 9.83166 18.3413C9.87937 18.3668 9.9326 18.38 9.98666 18.38C10.0407 18.38 10.0939 18.3668 10.1417 18.3413C11.221 17.774 13.841 16.798 14.9737 15.6667C16.1043 14.4833 16.947 13.0667 17.0003 12.8087Z"/>
</svg>
</template>
@@ -0,0 +1,5 @@
<template>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.58301 1.04175C12.4596 1.04175 14.7918 3.37324 14.792 6.24976C14.792 8.11641 13.8094 9.75308 12.334 10.6726C12.7113 10.8051 13.0798 10.9622 13.4346 11.1492C13.581 11.2264 13.6912 11.3582 13.7402 11.5164C13.7892 11.6746 13.7735 11.8463 13.6963 11.9929C13.619 12.1395 13.4864 12.2495 13.3281 12.2986C13.1698 12.3476 12.9982 12.3319 12.8516 12.2546C11.8552 11.7297 10.7453 11.4567 9.61914 11.4587C5.80144 11.459 2.70801 14.5376 2.70801 18.3337C2.70791 18.4994 2.64154 18.658 2.52441 18.7751C2.40724 18.8922 2.24867 18.9587 2.08301 18.9587C1.91725 18.9587 1.75784 18.8924 1.64062 18.7751C1.52367 18.658 1.45811 18.4992 1.45801 18.3337C1.45801 14.8123 3.70815 11.814 6.85449 10.6863C5.36693 9.76934 4.375 8.12562 4.375 6.24976C4.37518 3.37335 6.7066 1.04192 9.58301 1.04175ZM17.5 17.4998C17.6604 17.4999 17.8146 17.5619 17.9307 17.6726C18.0468 17.7834 18.1163 17.9351 18.124 18.0955C18.1316 18.2556 18.0766 18.4127 17.9717 18.5339C17.8667 18.6552 17.7192 18.7313 17.5596 18.7468L17.5 18.7498H12.5C12.3395 18.7497 12.1845 18.6877 12.0684 18.5769C11.9524 18.4662 11.8837 18.3152 11.876 18.155C11.8683 17.9948 11.9224 17.8379 12.0273 17.7166C12.1323 17.5952 12.2797 17.5182 12.4395 17.5027L12.5 17.4998H17.5ZM17.5 13.7498C17.6604 13.7499 17.8146 13.8119 17.9307 13.9226C18.0468 14.0334 18.1163 14.1851 18.124 14.3455C18.1316 14.5056 18.0766 14.6627 17.9717 14.7839C17.8667 14.9052 17.7192 14.9813 17.5596 14.9968L17.5 14.9998H12.5C12.3395 14.9997 12.1845 14.9377 12.0684 14.8269C11.9524 14.7162 11.8837 14.5652 11.876 14.405C11.8683 14.2448 11.9224 14.0879 12.0273 13.9666C12.1323 13.8452 12.2797 13.7682 12.4395 13.7527L12.5 13.7498H17.5ZM9.58301 2.29175C8.53331 2.29183 7.52643 2.70867 6.78418 3.45093C6.04192 4.19318 5.62509 5.20006 5.625 6.24976C5.625 7.29942 6.04205 8.30628 6.78418 9.04858C7.52643 9.79084 8.53331 10.2087 9.58301 10.2087C10.6328 10.2087 11.6395 9.79092 12.3818 9.04858C13.1242 8.30625 13.542 7.29957 13.542 6.24976C13.5419 5.20006 13.1241 4.19318 12.3818 3.45093C11.6395 2.7088 10.6327 2.29175 9.58301 2.29175Z"/>
</svg>
</template>
@@ -0,0 +1,5 @@
<template>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.8844 10.4095C15.2379 8.81409 15.8512 5.61243 14.2562 3.25833C12.6612 0.904236 9.46256 0.292951 7.10908 1.88836C4.7556 3.48377 4.14231 6.68326 5.7373 9.03736C6.10354 9.57711 6.56947 10.0432 7.10908 10.4095C3.55936 11.4435 1 14.3243 1 17.7167V19H6.14255V17.7167H2.2851C2.2851 14.2289 5.62027 11.3871 9.77679 11.2917H10.2102C11.4086 11.3178 12.5897 11.5822 13.6841 12.0743L9.8418 15.9111L8.0301 14.0945L7.12425 15.005L9.85264 17.7341L10.3532 17.2333L14.8543 12.7332C16.5815 13.8127 17.6521 15.6878 17.7062 17.7232H13.8531V19H19V17.7167C19 14.3243 16.4406 11.4435 12.8844 10.4095ZM10 9.99764C7.8719 9.99764 6.14688 8.27217 6.14688 6.14351C6.14688 4.01485 7.8719 2.28938 10 2.28938C12.1281 2.28938 13.8531 4.01485 13.8531 6.14351C13.8531 8.27217 12.1281 9.99764 10 9.99764Z"/>
</svg>
</template>
@@ -0,0 +1,5 @@
<template>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.3329 15.9501H1.66714C0.59 15.9501 0 15.3191 0 14.2526V3.20044C0 2.13384 0.59 1.5 1.66714 1.5H18.3343C19.41 1.50146 20 2.1353 20 3.20044V14.2511C20 15.3191 19.41 15.9501 18.3329 15.9501ZM18.3329 3.20044H1.66714V14.2496H18.3343V3.20044H18.3329ZM9.11572 11.7828L8.52714 11.181L6.17 8.77676L7.34857 7.57466L9.11572 9.37709L12.6514 5.77076L13.83 6.97287L9.70572 11.181L9.11572 11.7828ZM5.42857 17.2119H7.28571L7.5 16.801H12.5L12.7143 17.2119H14.5714C14.9314 17.2119 15.2229 17.5004 15.2229 17.856C15.2229 18.2115 14.9314 18.5 14.5714 18.5H5.42857C5.06857 18.5 4.77857 18.2115 4.77857 17.856C4.77857 17.5004 5.06857 17.2119 5.42857 17.2119Z"/>
</svg>
</template>
+56 -16
View File
@@ -59,20 +59,21 @@
"users": "系统用户"
},
"toggleSidebar": "切换侧边栏",
"breadcrumb": {
"dashboard": "仪表盘",
"customers": "客户管理",
"licenses": "授权管理",
"licenseList": "授权列表",
"createLicense": "创建授权",
"roles": "角色权限",
"users": "系统用户",
"login": "登录",
"settings": "设置",
"profile": "个人资料",
"workspace": "工作台",
"unknown": "未知页面"
}
"breadcrumb": {
"dashboard": "仪表盘",
"customers": "客户管理",
"licenses": "授权管理",
"licenseList": "授权列表",
"createLicense": "创建授权",
"viewLicense": "查看授权",
"roles": "角色权限",
"users": "系统用户",
"login": "登录",
"settings": "设置",
"profile": "个人资料",
"workspace": "工作台",
"unknown": "未知页面"
}
},
"chart": {
"licenseTrend": {
@@ -126,7 +127,10 @@
"renew": "续期",
"viewDetails": "查看详情",
"export": "导出",
"refresh": "刷新"
"refresh": "刷新",
"back": "返回",
"lock": "锁定授权",
"unlock": "解锁授权"
},
"search": {
"placeholder": "搜索授权码、客户名称、描述",
@@ -202,7 +206,43 @@
"loadError": "加载授权列表失败",
"createError": "创建授权失败",
"updateError": "更新授权失败",
"deleteError": "删除授权失败"
"deleteError": "删除授权失败",
"noLicenseWarning": "您还没有授权,请创建授权!",
"selectCustomerFirst": "请先选择一个客户",
"queryLicenseError": "查询授权失败,请稍后重试"
},
"view": {
"title": "查看授权",
"basicInfo": "基本信息",
"licenseConfig": "授权配置",
"remarks": "备注信息",
"operationHistory": "操作历史",
"fields": {
"licenseCode": "授权码",
"customerName": "客户名称",
"customerId": "客户ID",
"createdBy": "创建人",
"createdAt": "创建时间",
"updatedAt": "更新时间",
"validityPeriod": "授权期限",
"startDate": "开始时间",
"endDate": "结束时间",
"deploymentType": "部署类型",
"lockStatus": "锁定状态",
"description": "备注"
},
"status": {
"locked": "已锁定",
"unlocked": "未锁定"
},
"actions": {
"confirmLock": "确定要锁定此授权吗?",
"confirmUnlock": "确定要解锁此授权吗?",
"confirmDelete": "确定要删除此授权吗?删除后无法恢复!",
"lockSuccess": "授权已锁定",
"unlockSuccess": "授权已解锁",
"deleteSuccess": "授权删除成功"
}
},
"stats": {
"total": "总授权数",
+13 -1
View File
@@ -2,7 +2,7 @@
* @Author: 13895237362 2205451508@qq.com
* @Date: 2025-08-01 09:32:42
* @LastEditors: 13895237362 2205451508@qq.com
* @LastEditTime: 2025-10-13 16:00:25
* @LastEditTime: 2025-10-21 16:59:51
* @FilePath: /frontend/src/router/index.ts
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
*/
@@ -35,6 +35,12 @@ const routes: Array<RouteRecordRaw> = [
component: () => import("@/views/Licenses/index.vue"),
meta: { title: "授权管理", requiresAuth: true },
children: [
{
path: "",
name: "licenses-search",
component: () => import("@/views/Licenses/LicenseSearch.vue"),
meta: { title: "授权搜索", requiresAuth: true }
},
{
path: "list",
name: "licenses-list",
@@ -46,6 +52,12 @@ const routes: Array<RouteRecordRaw> = [
name: "licenses-create",
component: () => import("@/views/Licenses/LicenseForm.vue"),
meta: { title: "创建授权", requiresAuth: true }
},
{
path: "view/:id",
name: "licenses-view",
component: () => import("@/views/Licenses/LicensesView/index.vue"),
meta: { title: "查看授权", requiresAuth: true }
}
]
},
+27 -23
View File
@@ -46,7 +46,7 @@
<el-form-item label="客户ID" prop="customer_code" required class="field-item">
<el-input
v-model="customerCode"
v-model="formData.customer_code"
placeholder="自动生成或手动输入"
disabled
/>
@@ -54,7 +54,7 @@
<el-form-item label="创建人" prop="created_by" required class="field-item">
<el-input
v-model="createdBy"
v-model="formData.created_by"
placeholder="当前登录用户"
disabled
/>
@@ -80,7 +80,7 @@
<div class="fields-row">
<el-form-item label="授权期限" prop="validity_type" required class="field-item">
<el-select
v-model="validityType"
v-model="formData.validity_type"
placeholder="请选择授权期限类型"
style="width: 100%"
@change="handleValidityTypeChange"
@@ -97,7 +97,7 @@
</el-form-item>
<el-form-item
v-if="validityType === 'limited'"
v-if="formData.validity_type === 'limited'"
label="起止时间"
prop="date_range"
required
@@ -117,7 +117,7 @@
</el-form-item>
<el-form-item
v-if="validityType === 'permanent'"
v-if="formData.validity_type === 'permanent'"
label="有效期天数"
class="field-item"
>
@@ -203,8 +203,15 @@ const deploymentTypeOptions = ref<RawEnumItem[]>([])
const encryptionTypeOptions = ref<RawEnumItem[]>([])
// 表单数据
const formData = reactive<AuthorizationCodeCreateRequest>({
const formData = reactive<AuthorizationCodeCreateRequest & {
customer_code: string
created_by: string
validity_type: 'permanent' | 'limited'
}>({
customer_id: '',
customer_code: '',
created_by: '',
validity_type: 'limited',
description: '',
validity_days: 365,
deployment_type: 'standalone',
@@ -216,10 +223,7 @@ const formData = reactive<AuthorizationCodeCreateRequest>({
})
// 独立的响应式变量
const customerCode = ref('')
const createdBy = ref('')
const dateRange = ref<[string, string] | null>(null)
const validityType = ref<'permanent' | 'limited'>('limited')
// 计算属性
const isEdit = computed(() => {
@@ -259,7 +263,7 @@ const formRules: FormRules = {
message: '请选择起止时间',
trigger: 'change',
validator: (_rule: any, _value: any, callback: any) => {
if (validityType.value === 'limited' && (!dateRange.value || dateRange.value.length !== 2)) {
if (formData.validity_type === 'limited' && (!dateRange.value || dateRange.value.length !== 2)) {
callback(new Error('请选择起止时间'))
} else {
callback()
@@ -268,7 +272,7 @@ const formRules: FormRules = {
},
{
validator: (_rule: any, _value: any, callback: any) => {
if (validityType.value === 'limited' && dateRange.value && dateRange.value.length === 2) {
if (formData.validity_type === 'limited' && dateRange.value && dateRange.value.length === 2) {
const startDate = new Date(dateRange.value[0])
const endDate = new Date(dateRange.value[1])
const today = new Date()
@@ -410,9 +414,9 @@ const loadCustomerInfo = () => {
// 设置创建人(从用户store获取当前登录用户)
if (userStore.userInfo) {
createdBy.value = userStore.userInfo.username
formData.created_by = userStore.userInfo.username
} else {
createdBy.value = '未知用户'
formData.created_by = '未知用户'
}
}
@@ -465,10 +469,10 @@ const loadLicenseDetail = async () => {
formData.encryption_type = data.encryption_type || 'standard'
formData.max_activations = data.max_activations || 1
// 设置独立字段
customerCode.value = (data as any).customer_code || data.customer_id || ''
// 设置表单字段
formData.customer_code = (data as any).customer_code || data.customer_id || ''
// 编辑模式下显示原始创建人,如果没有则显示当前用户
createdBy.value = (data as any).created_by || (userStore.userInfo ? userStore.userInfo.username : '未知用户')
formData.created_by = (data as any).created_by || (userStore.userInfo ? userStore.userInfo.username : '未知用户')
// 计算有效期天数和设置日期范围
if (data.start_date && data.end_date) {
@@ -480,20 +484,20 @@ const loadLicenseDetail = async () => {
// 根据天数判断是永久还是有限
if (days >= 365000) {
validityType.value = 'permanent'
formData.validity_type = 'permanent'
dateRange.value = null
} else {
validityType.value = 'limited'
formData.validity_type = 'limited'
dateRange.value = [data.start_date, data.end_date]
}
} else {
// 如果没有日期信息,根据validity_days判断
const validityDays = (data as any).validity_days
if (validityDays && validityDays >= 365000) {
validityType.value = 'permanent'
formData.validity_type = 'permanent'
formData.validity_days = 365000
} else {
validityType.value = 'limited'
formData.validity_type = 'limited'
formData.validity_days = validityDays || 365
}
}
@@ -608,13 +612,13 @@ watch(() => formData.customer_id, (newCustomerId) => {
if (newCustomerId) {
const selectedCustomer = customerOptions.value.find(c => c.id === newCustomerId)
if (selectedCustomer) {
customerCode.value = selectedCustomer.customer_code || selectedCustomer.id
formData.customer_code = selectedCustomer.customer_code || selectedCustomer.id
} else {
// 如果没有找到客户,清空客户代码
customerCode.value = ''
formData.customer_code = ''
}
} else {
customerCode.value = ''
formData.customer_code = ''
}
})
@@ -0,0 +1,370 @@
<template>
<div class="license-search-container">
<div class="background-section">
<div class="center-title">
<h1 class="platform-title">{{ t('pages.licenses.platform') }}</h1>
</div>
<div class="action-section">
<div class="action-row">
<div class="customer-select-wrapper">
<el-select
v-model="selectedCustomer"
:placeholder="t('pages.licenses.search.selectCustomer')"
clearable
filterable
size="large"
class="customer-select"
@change="handleCustomerChange"
>
<el-option
:label="t('pages.licenses.search.allCustomers')"
value=""
/>
<el-option
v-for="customer in customers"
:key="customer.id"
:label="customer.customer_name"
:value="customer.id"
/>
</el-select>
</div>
<div class="action-buttons">
<el-button
type="primary"
size="large"
class="action-btn query-btn"
@click="handleQuery"
>
{{ t('pages.licenses.actions.query') }}
</el-button>
<el-button
type="primary"
size="large"
class="action-btn create-btn"
@click="handleCreateLicense"
>
{{ t('pages.licenses.actions.createLicense') }}
</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getCustomers, type Customer } from '@/api/customer'
import { getLicenses } from '@/api/license'
const { t } = useI18n()
const router = useRouter()
const selectedCustomer = ref<string>('')
const customers = ref<Customer[]>([])
const selectedCustomerInfo = computed(() => {
if (!selectedCustomer.value) return null
return customers.value.find(c => c.id === selectedCustomer.value) || null
})
const loadCustomers = async () => {
try {
const response = await getCustomers({ status: 'active', page_size: 100 })
const allCustomers = response.data.list
const customersWithLicenses = []
for (const customer of allCustomers) {
try {
const licenseResponse = await getLicenses({
customer_id: customer.id,
page_size: 1
})
if (licenseResponse.data.list.length > 0) {
customersWithLicenses.push(customer)
}
} catch (error) {
console.warn(`��7 ${customer.customer_name} ��C1%:`, error)
customersWithLicenses.push(customer)
}
}
customers.value = customersWithLicenses
} catch (error) {
console.error('Failed to load customers:', error)
ElMessage.error('�}�7h1%')
}
}
const handleCustomerChange = () => {
// �7 ����;�
}
const handleQuery = async () => {
if (!selectedCustomer.value) {
ElMessage.warning(t('pages.licenses.message.selectCustomerFirst'))
return
}
try {
const response = await getLicenses({
customer_id: selectedCustomer.value,
page_size: 1
})
if (response.data.list.length === 0) {
ElMessage.warning(t('pages.licenses.message.noLicenseWarning'))
return
}
router.push({
name: 'licenses-list',
query: {
customerId: selectedCustomer.value,
customerName: selectedCustomerInfo.value?.customer_name || ''
}
})
} catch (error) {
console.error('��C1%:', error)
ElMessage.error(t('pages.licenses.message.queryLicenseError'))
}
}
const handleCreateLicense = () => {
router.push({
name: 'licenses-create',
query: {
customerId: selectedCustomer.value || '',
customerName: selectedCustomerInfo.value?.customer_name || ''
}
})
}
onMounted(() => {
loadCustomers()
})
</script>
<style scoped lang="scss">
@import '@/assets/styles/variables.scss';
.license-search-container {
height: calc(100vh - 80px);
width: 100%;
overflow: hidden;
}
.background-section {
position: relative;
height: 100%;
background-image: url('/src/assets/images/license-bg.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.center-title {
position: relative;
z-index: 2;
margin-bottom: 60px;
}
.platform-title {
font-family: 'PangMenZhengDao', 'Source Han Sans CN', sans-serif;
font-size: 56px;
font-weight: 400;
color: #1C1C28;
text-align: center;
margin: 0;
letter-spacing: 0.06em;
line-height: 1.2;
}
.action-section {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 1200px;
padding: 0 24px;
}
.action-row {
display: flex;
align-items: stretch;
gap: 24px;
width: 100%;
max-width: 1200px;
> * {
vertical-align: top;
margin: 0;
}
}
.customer-select-wrapper {
position: relative;
flex: 1;
max-width: 900px;
height: 60px;
display: flex;
align-items: center;
}
.customer-select {
width: 100%;
:deep(.el-select) {
margin: 0;
}
:deep(.el-input) {
margin: 0;
height: 60px;
}
:deep(.el-input__wrapper) {
height: 60px !important;
font-size: 20px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #E3E3E3;
border-radius: 8px;
padding: 0 48px 0 16px;
box-sizing: border-box;
display: flex;
align-items: center;
margin: 0;
box-shadow: none;
.el-input__inner {
color: #A9A9AF;
font-size: 20px;
line-height: 1.5;
height: 100%;
margin: 0;
padding: 0;
&::placeholder {
color: #A9A9AF;
}
}
}
:deep(.el-input__suffix) {
display: none;
}
:deep(.el-select__wrapper) {
margin: 0;
padding: 0 20px;
}
}
.action-buttons {
display: flex;
gap: 16px;
flex-shrink: 0;
align-items: center;
height: 60px;
}
.action-btn {
height: 40px !important;
padding: 0 24px;
font-size: 20px;
font-weight: 500;
letter-spacing: 0.15em;
border-radius: 8px;
border: none;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
vertical-align: top;
&:deep(.el-button) {
margin: 0;
height: 40px;
line-height: 1;
}
&.query-btn {
min-width: 91px;
background-color: $primary-color;
&:hover {
background-color: darken($primary-color, 10%);
}
}
&.create-btn {
min-width: 137px;
background-color: $primary-color;
&:hover {
background-color: darken($primary-color, 10%);
}
}
}
@media (max-width: 768px) {
.platform-title {
font-size: 36px;
}
.action-section {
padding: 0 16px;
}
.action-row {
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.customer-select-wrapper {
max-width: none;
}
.action-buttons {
justify-content: center;
width: 100%;
.action-btn {
flex: 1;
max-width: 200px;
}
}
}
@media (max-width: 480px) {
.platform-title {
font-size: 28px;
}
.action-btn {
height: 32px;
font-size: 16px;
}
.customer-select {
:deep(.el-input__wrapper) {
height: 48px;
font-size: 16px;
}
}
}
</style>
@@ -0,0 +1,198 @@
<template>
<div class="basic-info-tab">
<!-- 基础信息部分 -->
<div class="info-section">
<div class="info-row">
<div class="info-label">客户ID</div>
<div class="info-value">{{ licenseData?.customer_id || '-' }}</div>
</div>
<div class="info-row">
<div class="info-label">客户名称</div>
<div class="info-value">{{ licenseData?.customer_name || '-' }}</div>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-cards">
<!-- 已激活设备数量 -->
<div class="stat-card">
<div class="card-background">
<img src="@/assets/images/licenseIcon1.png" alt="" class="bg-image" />
</div>
<div class="card-content">
<div class="stat-value">{{ licenseData?.current_activations || 0 }}</div>
<div class="stat-label">已激活设备数量</div>
</div>
</div>
<!-- 创建时间 -->
<div class="stat-card">
<div class="card-background">
<img src="@/assets/images/licenseIcon2.png" alt="" class="bg-image" />
</div>
<div class="card-content">
<div class="stat-value">{{ formatDate(licenseData?.start_date) }}</div>
<div class="stat-label">创建时间</div>
</div>
</div>
<!-- 到期时间 -->
<div class="stat-card">
<div class="card-background">
<img src="@/assets/images/licenseIcon3.png" alt="" class="bg-image" />
</div>
<div class="card-content">
<div class="stat-value">{{ formatDate(licenseData?.end_date) }}</div>
<div class="stat-label">到期时间</div>
</div>
</div>
<!-- 剩余时间(天) -->
<div class="stat-card">
<div class="card-background">
<img src="@/assets/images/licenseIcon4.png" alt="" class="bg-image" />
</div>
<div class="card-content">
<div class="stat-value">{{ getRemainingDays(licenseData?.end_date) }}</div>
<div class="stat-label">剩余时间(天)</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { AuthorizationCode } from '@/api/license'
import { formatDate as formatDateUtil } from '@/utils/date'
interface Props {
licenseData: AuthorizationCode | null
}
defineProps<Props>()
const formatDate = (date?: string) => {
if (!date) return '-'
return formatDateUtil(date)
}
const getRemainingDays = (endDate?: string) => {
if (!endDate) return '-'
const now = new Date()
const end = new Date(endDate)
const diff = end.getTime() - now.getTime()
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
return days > 0 ? days : 0
}
</script>
<style lang="scss" scoped>
.basic-info-tab {
width: 100%;
padding: 0;
}
.info-section {
margin-bottom: 32px;
.info-row {
display: flex;
align-items: center;
gap: 40px;
padding: 8px 0;
.info-label {
width: 80px;
font-family: 'Source Han Sans CN', sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 2.2857142857142856em;
color: rgba(29, 29, 29, 0.87);
text-align: right;
flex-shrink: 0;
}
.info-value {
flex: 1;
font-family: 'Source Han Sans CN', sans-serif;
font-size: 14px;
font-weight: 500;
line-height: 1.5em;
color: rgba(29, 29, 29, 0.87);
}
}
}
.stats-cards {
display: flex;
gap: 16px;
margin-top: 24px;
flex-wrap: wrap;
@media (max-width: 1200px) {
flex-wrap: wrap;
}
@media (max-width: 768px) {
flex-direction: column;
}
}
.stat-card {
position: relative;
flex: 1;
min-width: 250px;
height: 193.58px;
border-radius: 11.504px;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 12px 25px;
box-sizing: border-box;
@media (max-width: 768px) {
min-width: 100%;
flex: none;
}
.card-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
.bg-image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.card-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 12px;
.stat-value {
font-family: 'DINPro', sans-serif;
font-size: 30px;
font-weight: 700;
line-height: 1.287999979654948em;
color: #2C2F33;
}
.stat-label {
font-family: 'Source Han Sans CN', sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 1.307861600603376em;
color: rgba(99, 110, 131, 1);
}
}
}
</style>
@@ -0,0 +1,86 @@
<template>
<div class="change-history-tab">
<div class="history-section">
<h3 class="section-title">Øô†ò</h3>
<el-empty v-if="!historyData || historyData.length === 0" description="‚àØô°U" />
<div v-else class="history-list">
<div v-for="item in historyData" :key="item.id" class="history-item">
<div class="history-time">{{ formatDateTime(item.created_at) }}</div>
<div class="history-content">
<div class="history-type">{{ item.change_type }}</div>
<div class="history-reason">{{ item.reason }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface HistoryItem {
id: string
change_type: string
reason: string
created_at: string
}
interface Props {
historyData: HistoryItem[]
}
defineProps<Props>()
const formatDateTime = (date?: string) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
</script>
<style lang="scss" scoped>
.change-history-tab {
width: 100%;
}
.history-section {
.section-title {
font-size: 16px;
font-weight: 500;
color: var(--app-text-primary);
margin: 0 0 16px 0;
}
.history-list {
.history-item {
padding: 16px;
border-left: 3px solid var(--el-color-primary);
background: var(--app-bg-color);
border-radius: 4px;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
.history-time {
font-size: 12px;
color: var(--app-text-secondary);
margin-bottom: 8px;
}
.history-content {
.history-type {
font-size: 14px;
font-weight: 500;
color: var(--app-text-primary);
margin-bottom: 4px;
}
.history-reason {
font-size: 14px;
color: var(--app-text-secondary);
}
}
}
}
}
</style>
@@ -0,0 +1,471 @@
<template>
<div class="license-view-page">
<!-- 面包屑导航 -->
<div class="breadcrumb-section">
<span class="breadcrumb-item clickable" @click="goBack">授权管理</span>/<span class="breadcrumb-item active">授权详情</span>
</div>
<!-- 授权详情内容 -->
<div class="content-section" v-loading="loading">
<!-- 左侧菜单栏 -->
<div class="left-sidebar">
<div
class="sidebar-item"
:class="{ active: activeTab === 'basic' }"
@click="activeTab = 'basic'"
>
<LicenseTabIcon name="basic" :active="activeTab === 'basic'" />
<span class="text">基础信息</span>
</div>
<div
class="sidebar-item"
:class="{ active: activeTab === 'authorization' }"
@click="activeTab = 'authorization'"
>
<LicenseTabIcon name="authorization" :active="activeTab === 'authorization'" />
<span class="text">授权信息</span>
</div>
<div
class="sidebar-item"
:class="{ active: activeTab === 'license' }"
@click="activeTab = 'license'"
>
<LicenseTabIcon name="license" :active="activeTab === 'license'" />
<span class="text">许可证信息</span>
</div>
<div
class="sidebar-item"
:class="{ active: activeTab === 'history' }"
@click="activeTab = 'history'"
>
<LicenseTabIcon name="history" :active="activeTab === 'history'" />
<span class="text">授权变更历史</span>
</div>
</div>
<div class="right-content">
<!-- 授权码和状态标签 -->
<div class="license-header">
<div class="license-code-group">
<h2 class="license-code">{{ licenseData?.code || '-' }}</h2>
<el-tag :type="getStatusType(licenseData?.status)" class="status-tag">
{{ licenseData?.status_display || '-' }}
</el-tag>
</div>
<div class="action-buttons-inline">
<el-button class="action-btn-inline copy-btn" @click="handleCopyCode">
复制授权码
</el-button>
<el-button class="action-btn-inline update-btn" @click="handleUpdateLicense">
变更授权
</el-button>
<el-button class="action-btn-inline renew-btn" @click="handleRenewLicense">
延期授权
</el-button>
<el-button class="action-btn-inline revoke-btn" @click="handleRevokeLicense">
吊销授权
</el-button>
<el-button class="action-btn-inline download-btn" @click="handleDownloadCertificate">
下载部署包
</el-button>
</div>
</div>
<!-- Tab 内容区域 -->
<div class="tab-content-area">
<BasicInfo v-if="activeTab === 'basic'" :license-data="licenseData" />
<BasicInfo v-if="activeTab === 'authorization'" :license-data="licenseData" />
<LicenseInfo v-if="activeTab === 'license'" :license-data="licenseData" />
<ChangeHistory v-if="activeTab === 'history'" :history-data="historyData" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getLicenseDetail, type AuthorizationCode } from '@/api/license'
import LicenseTabIcon from '@/components/common/icons/LicenseTabIcon.vue'
import BasicInfo from './components/BasicInfo.vue'
import LicenseInfo from './components/LicenseInfo.vue'
import ChangeHistory from './components/ChangeHistory.vue'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const activeTab = ref('basic')
const licenseData = ref<AuthorizationCode | null>(null)
const historyData = ref<any[]>([])
const goBack = () => {
router.back()
}
const getStatusType = (status?: string) => {
switch (status) {
case 'normal':
return 'success'
case 'locked':
return 'warning'
case 'expired':
return 'danger'
default:
return 'info'
}
}
const handleCopyCode = () => {
if (!licenseData.value?.code) {
ElMessage.warning('授权码为空')
return
}
navigator.clipboard.writeText(licenseData.value.code).then(() => {
ElMessage.success('授权码已复制到剪贴板')
}).catch(() => {
ElMessage.error('复制失败,请手动复制')
})
}
const handleUpdateLicense = () => {
ElMessage.info('变更授权功能开发中...')
}
const handleRenewLicense = () => {
ElMessage.info('延期授权功能开发中...')
}
const handleRevokeLicense = () => {
ElMessageBox.confirm('确定要吊销此授权吗?此操作不可撤销!', '警告', {
confirmButtonText: '确定吊销',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
ElMessage.info('吊销授权功能开发中...')
}).catch(() => {
// 取消操作
})
}
const handleDownloadCertificate = () => {
ElMessage.info('下载凭单功能开发中...')
}
const loadLicenseData = async () => {
const id = route.params.id as string
if (!id) {
ElMessage.error('缺少授权ID')
router.back()
return
}
loading.value = true
try {
const response = await getLicenseDetail(id)
if (response.code === '000000' && response.data) {
licenseData.value = response.data
} else {
throw new Error(response.message || '获取授权详情失败')
}
} catch (error: any) {
console.error('加载授权详情失败:', error)
ElMessage.error(error.message || '加载授权详情失败')
router.back()
} finally {
loading.value = false
}
}
onMounted(() => {
loadLicenseData()
})
</script>
<style lang="scss" scoped>
.license-view-page {
height: 100%;
display: flex;
flex-direction: column;
background: var(--app-bg-color);
padding: 80px 60px 40px 60px;
overflow: hidden;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 400px;
background-image: url('@/assets/images/licenseView-bg.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
z-index: 0;
pointer-events: none;
}
> * {
position: relative;
z-index: 1;
}
}
.breadcrumb-section {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 0 4px;
position: absolute;
left: 20px;
top: 15px;
.breadcrumb-item {
font-family: 'Source Han Sans CN', sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 1.4285714285714286em;
color: #888888;
&.clickable {
cursor: pointer;
transition: color 0.3s;
&:hover {
color: #019C7C;
}
}
&.active {
color: #1D1D1D;
}
}
.breadcrumb-separator {
flex-shrink: 0;
}
}
.content-section {
display: flex;
gap: 0;
flex: 1;
overflow: hidden;
background: white;
border-radius: 12px;
box-shadow: 0px 0px 20px 0px rgba(0, 0, 0, 0.2);
}
.left-sidebar {
width: 300px;
background: var(--app-content-bg);
border-radius: 12px 0 0 12px;
border-right: 1px solid #DCDEE2;
padding: 13px 0;
flex-shrink: 0;
.sidebar-item {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px 0;
margin: 8px 16px;
cursor: pointer;
transition: all 0.3s;
color: #B2B8C2;
background: transparent;
border-radius: 28px;
.text {
font-family: 'Source Han Sans CN', sans-serif;
font-size: 16px;
font-weight: 500;
line-height: 1.3;
}
&:hover {
background: rgba(0, 194, 124, 0.08);
}
&.active {
background: rgba(0, 194, 124, 0.12);
color: #019C7C;
font-weight: 700;
box-shadow: 0px 2px 32px 0px rgba(0, 0, 0, 0.02);
.text {
font-weight: 700;
}
}
}
}
.right-content {
flex: 1;
background: white;
border-radius: 0 12px 12px 0;
padding: 0;
overflow-y: auto;
border-top: 1px solid #DCDEE2;
display: flex;
flex-direction: column;
}
.license-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 24px;
border-bottom: 1px solid #DCDEE2;
flex-shrink: 0;
}
.license-code-group {
display: flex;
align-items: center;
gap: 16px;
}
.license-code {
font-family: 'Source Han Sans CN', sans-serif;
font-size: 18px;
font-weight: 700;
line-height: 1.5;
color: #1D1D1D;
margin: 0;
}
.status-tag {
padding: 7px 16px;
font-size: 14px;
font-weight: 500;
line-height: 1.5;
border-radius: 4px;
}
.action-buttons-inline {
display: flex;
gap: 4px;
}
.action-btn-inline {
padding: 7px 16px;
font-size: 14px;
font-weight: 500;
line-height: 1.5;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
font-family: 'Source Han Sans CN', sans-serif;
height: 32px;
&.copy-btn {
background: transparent;
color: #019C7C;
border: 1px solid #019C7C;
&:hover {
background: rgba(1, 156, 124, 0.1);
}
}
&.update-btn {
background: transparent;
color: #019C7C;
border: 1px solid #019C7C;
&:hover {
background: rgba(1, 156, 124, 0.1);
}
}
&.renew-btn {
background: #00C27C;
color: white;
border: none;
&:hover {
background: #019C7C;
}
}
&.revoke-btn {
background: #F0142F;
color: white;
border: none;
&:hover {
background: #d01228;
}
}
&.download-btn {
background: #00C27C;
color: white;
border: none;
&:hover {
background: #019C7C;
}
}
}
.tab-content-area {
flex: 1;
padding: 24px;
overflow-y: auto;
}
@media (max-width: 1024px) {
.content-section {
flex-direction: column;
}
.left-sidebar {
width: 100%;
display: flex;
padding: 8px;
overflow-x: auto;
.sidebar-item {
flex-shrink: 0;
padding: 8px 16px;
white-space: nowrap;
&.active {
border-left: none;
border-bottom: 3px solid var(--el-color-primary);
}
}
}
}
@media (max-width: 768px) {
.license-view-page {
padding: 16px;
}
.license-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.action-buttons-inline {
width: 100%;
flex-wrap: wrap;
.action-btn-inline {
flex: 1 1 calc(50% - 2px);
min-width: 100px;
}
}
}
</style>
+197 -199
View File
@@ -2,52 +2,56 @@
<div class="license-list-page">
<!-- 顶部操作区 -->
<div class="top-actions">
<el-button type="primary" @click="handleCreate">
创建授权
</el-button>
<el-button type="primary" @click="handleCreate"> 创建授权 </el-button>
<div class="filters">
<span class="filter-label">筛选:</span>
<el-input
v-model="filterCode"
placeholder="授权码"
clearable
class="filter-input"
/>
<el-input v-model="filterCode" placeholder="授权码" clearable class="filter-input" />
<el-select v-model="filterStatus" placeholder="状态" clearable class="filter-select">
<el-option
v-for="option in statusOptions"
:key="option.key"
:label="option.display"
:value="option.key"
<el-option
v-for="option in statusOptions"
:key="option.key"
:label="option.display"
:value="option.key"
/>
</el-select>
<el-button type="primary" @click="handleQuery">
查询
</el-button>
<el-button type="primary" @click="handleQuery"> 查询 </el-button>
</div>
</div>
<!-- 数据表格 -->
<div class="table-container">
<div class="table-wrapper">
<el-table
:data="tableData"
<el-table
:data="tableData"
v-loading="loading"
:element-loading-text="'加载中...'"
stripe
stripe
border
style="width: 100%;"
:header-cell-style="{ backgroundColor: 'var(--app-bg-color)', color: 'var(--app-text-primary)' }"
style="width: 100%"
:header-cell-style="{
backgroundColor: 'var(--app-bg-color)',
color: 'var(--app-text-primary)'
}"
:row-style="getRowStyle"
:max-height="'calc(100vh - 280px)'"
>
<el-table-column prop="code" label="授权码" :width="200" show-overflow-tooltip align="left">
<el-table-column
prop="code"
label="授权码"
:width="200"
show-overflow-tooltip
align="left"
>
<template #default="scope">
<span class="license-code" style="white-space: nowrap; overflow: visible; text-overflow: initial;">{{ scope.row.code }}</span>
<span
class="license-code"
style="white-space: nowrap; overflow: visible; text-overflow: initial"
>{{ scope.row.code }}</span
>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" :width="100" align="center">
@@ -57,33 +61,44 @@
</div>
</template>
</el-table-column>
<el-table-column prop="deployment_type_display" label="部署类型" :width="120" align="center" />
<el-table-column
prop="deployment_type_display"
label="部署类型"
:width="120"
align="center"
/>
<el-table-column prop="end_date" label="到期时间" :width="180" align="center">
<template #default="scope">
{{ formatDate(scope.row.end_date) }}
</template>
</el-table-column>
<el-table-column prop="description" label="备注" :min-width="200" show-overflow-tooltip align="center">
</template>
</el-table-column>
<el-table-column
prop="description"
label="备注"
:min-width="200"
show-overflow-tooltip
align="center"
>
<template #default="scope">
<span class="ellipsis-text">{{ scope.row.description }}</span>
</template>
</el-table-column>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" class-name="action-column" align="center">
<template #default="scope">
<div class="action-buttons">
<button class="action-btn primary" @click="handleDetail(scope.row)">详情</button>
<button
class="action-btn"
:class="scope.row.is_locked ? 'success' : 'warning'"
<button
class="action-btn"
:class="scope.row.is_locked ? 'success' : 'warning'"
@click="scope.row.is_locked ? handleUnlock(scope.row) : handleLock(scope.row)"
>
{{ scope.row.is_locked ? '解锁' : '锁定' }}
</button>
<button class="action-btn danger" @click="handleDelete(scope.row)">删除</button>
</div>
</template>
</el-table-column>
</el-table>
</template>
</el-table-column>
</el-table>
</div>
</div>
@@ -107,7 +122,13 @@
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter, useRoute } from 'vue-router'
import { getLicenses, lockAuthorizationCode, deleteLicense, getLicenseDetail, type AuthorizationCode, type LicenseQueryRequest } from '@/api/license'
import {
getLicenses,
lockAuthorizationCode,
deleteLicense,
type AuthorizationCode,
type LicenseQueryRequest
} from '@/api/license'
import { getAuthorizationStatusEnums, type RawEnumItem } from '@/api/enum'
import { formatDate } from '@/utils/date'
@@ -117,8 +138,8 @@ const route = useRoute()
// 从路由参数获取客户信息
const customerInfo = computed(() => {
return {
id: route.query.customerId as string || '',
name: route.query.customerName as string || ''
id: (route.query.customerId as string) || '',
name: (route.query.customerName as string) || ''
}
})
@@ -134,13 +155,10 @@ const statusOptions = ref<RawEnumItem[]>([])
// 方法
const getStatusText = (status: string) => {
const option = statusOptions.value.find(opt => opt.key === status)
return option ? option.display : status
}
const getRowStyle = ({ rowIndex }: { rowIndex: number }) => {
return rowIndex % 2 === 1 ? { backgroundColor: 'var(--app-bg-color)' } : { backgroundColor: 'var(--app-content-bg)' }
return rowIndex % 2 === 1
? { backgroundColor: 'var(--app-bg-color)' }
: { backgroundColor: 'var(--app-content-bg)' }
}
const getStatusClass = (status: string) => {
@@ -151,9 +169,8 @@ const getStatusClass = (status: string) => {
}
}
const handleCreate = () => {
router.push({
router.push({
name: 'licenses-create',
query: {
customerId: customerInfo.value.id,
@@ -167,46 +184,18 @@ const handleQuery = () => {
loadData()
}
const handleDetail = async (row: any) => {
try {
loading.value = true
const response = await getLicenseDetail(row.id)
console.log('授权详情:', response.data)
if (!response.data) {
ElMessage.error('获取详情失败:数据为空')
return
const handleDetail = (row: any) => {
// 跳转到授权详情页面
router.push({
name: 'licenses-view',
params: {
id: row.id
},
query: {
customerId: customerInfo.value.id,
customerName: customerInfo.value.name
}
const data = response.data
// 显示详情信息
ElMessageBox.alert(
`
<div style="text-align: left;">
<p><strong>授权码:</strong>${data.code}</p>
<p><strong>状态:</strong>${getStatusText(data.status)}</p>
<p><strong>客户:</strong>${data.customer_name || '未指定'}</p>
<p><strong>描述:</strong>${data.description || '无'}</p>
<p><strong>创建时间:</strong>${formatDate(data.created_at)}</p>
<p><strong>到期时间:</strong>${formatDate(data.end_date)}</p>
<p><strong>最大激活数:</strong>${data.max_activations || '无限制'}</p>
<p><strong>当前激活数:</strong>${data.current_activations || 0}</p>
<p><strong>是否锁定:</strong>${data.is_locked ? '是' : '否'}</p>
</div>
`,
'授权详情',
{
dangerouslyUseHTMLString: true,
confirmButtonText: '确定'
}
)
} catch (error) {
console.error('获取详情失败:', error)
ElMessage.error('获取详情失败')
} finally {
loading.value = false
}
})
}
const handleLock = async (row: any) => {
@@ -214,22 +203,24 @@ const handleLock = async (row: any) => {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
await lockAuthorizationCode(row.id, {
is_locked: true,
lock_reason: '手动锁定',
reason: '管理员手动锁定'
})
ElMessage.success('锁定成功')
loadData()
} catch (error) {
console.error('锁定失败:', error)
ElMessage.error('锁定失败')
}
}).catch(() => {
// 取消操作
})
.then(async () => {
try {
await lockAuthorizationCode(row.id, {
is_locked: true,
lock_reason: '手动锁定',
reason: '管理员手动锁定'
})
ElMessage.success('锁定成功')
loadData()
} catch (error) {
console.error('锁定失败:', error)
ElMessage.error('锁定失败')
}
})
.catch(() => {
// 取消操作
})
}
const handleUnlock = async (row: any) => {
@@ -237,22 +228,24 @@ const handleUnlock = async (row: any) => {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
await lockAuthorizationCode(row.id, {
is_locked: false,
lock_reason: '手动解锁',
reason: '管理员手动解锁'
})
ElMessage.success('解锁成功')
loadData()
} catch (error) {
console.error('解锁失败:', error)
ElMessage.error('解锁失败')
}
}).catch(() => {
// 取消操作
})
.then(async () => {
try {
await lockAuthorizationCode(row.id, {
is_locked: false,
lock_reason: '手动解锁',
reason: '管理员手动解锁'
})
ElMessage.success('解锁成功')
loadData()
} catch (error) {
console.error('解锁失败:', error)
ElMessage.error('解锁失败')
}
})
.catch(() => {
// 取消操作
})
}
const handleDelete = async (row: any) => {
@@ -260,18 +253,20 @@ const handleDelete = async (row: any) => {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
await deleteLicense(row.id)
ElMessage.success('删除成功')
loadData()
} catch (error) {
console.error('删除失败:', error)
ElMessage.error('删除失败')
}
}).catch(() => {
// 取消操作
})
.then(async () => {
try {
await deleteLicense(row.id)
ElMessage.success('删除成功')
loadData()
} catch (error) {
console.error('删除失败:', error)
ElMessage.error('删除失败')
}
})
.catch(() => {
// 取消操作
})
}
const handleSizeChange = (size: number) => {
@@ -359,7 +354,7 @@ onMounted(async () => {
box-sizing: border-box;
width: 100%;
margin: 0 auto;
@include mobile {
padding: $spacing-base;
}
@@ -371,7 +366,7 @@ onMounted(async () => {
margin-bottom: $spacing-medium;
padding: $spacing-large $spacing-large 0 $spacing-large; // 只在顶部和两侧添加padding
flex-shrink: 0;
@include mobile {
@include flex-responsive;
align-items: stretch;
@@ -381,15 +376,15 @@ onMounted(async () => {
.filters {
@include flex-center-vertical;
gap: $spacing-small;
min-width: 288px; // 改为288px (36*8)
min-width: 288px; // 改为288px (36*8)
flex-shrink: 1;
@include mobile {
width: 100%;
flex-wrap: wrap;
flex-wrap: wrap;
gap: $spacing-small;
}
@include mobile {
flex-direction: column;
align-items: stretch;
@@ -401,13 +396,13 @@ onMounted(async () => {
font-weight: $font-weight-primary;
color: var(--app-text-primary);
margin-right: $spacing-small;
min-width: 48px; // 改为48px (6*8)
min-width: 48px; // 改为48px (6*8)
@include mobile {
width: auto;
margin-right: $spacing-small;
}
@include mobile {
align-self: flex-start;
}
@@ -418,8 +413,7 @@ onMounted(async () => {
}
.filter-select {
width: 120px; // 符合8px栅格(15*8)
height: 40px; // 改为40px符合8px栅格
width: 120px; // 符合8px栅格(15*8)
}
.table-container {
@@ -454,7 +448,7 @@ onMounted(async () => {
gap: $spacing-small;
padding: 0 $spacing-large $spacing-large $spacing-large; // 添加下部和两侧padding
flex-shrink: 0;
@include mobile {
@include flex-center;
flex-wrap: wrap;
@@ -467,14 +461,14 @@ onMounted(async () => {
min-width: 1408px; /* 改为1408px (176*8) */
border: 1px solid var(--app-border-light);
table-layout: fixed; /* 固定表格布局防止错位 */
/* 表头和表体宽度一致 */
.el-table__header-wrapper,
.el-table__body-wrapper {
width: 100% !important;
overflow-x: auto !important; /* 允许水平滚动以支持固定列 */
}
.el-table__body-wrapper {
overflow-y: auto !important;
/* 使用flex布局自动计算高度 */
@@ -483,22 +477,23 @@ onMounted(async () => {
margin-right: 0 !important;
padding-right: 0 !important;
}
/* 表格主体和表头宽度一致 */
.el-table__header,
.el-table__body {
width: 100% !important;
table-layout: fixed !important;
}
/* 确保所有列宽度一致 */
th, td {
th,
td {
box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 列宽度分配 - 防止错位 */
colgroup {
width: 100% !important;
@@ -513,7 +508,7 @@ onMounted(async () => {
--el-pagination-bg-color: var(--app-content-bg);
--el-pagination-text-color: var(--app-text-primary);
--el-pagination-border-color: var(--app-border-color);
--el-pagination-hover-color: #019C7C;
--el-pagination-hover-color: #019c7c;
font-family: 'Source Han Sans CN', sans-serif;
}
@@ -564,7 +559,7 @@ onMounted(async () => {
/* 每页条数选择器 */
:deep(.el-pagination .el-pagination__sizes .el-select) {
width: 88px; // 88px符合8px栅格(11*8)
width: 88px; // 88px符合8px栅格(11*8)
}
:deep(.el-pagination .el-pagination__sizes .el-select .el-select__wrapper) {
@@ -584,7 +579,7 @@ onMounted(async () => {
}
:deep(.el-pagination .el-pagination__jump .el-input) {
width: 48px; // 符合8px栅格
width: 48px; // 符合8px栅格
margin: 0 8px;
}
@@ -602,23 +597,25 @@ onMounted(async () => {
color: var(--app-text-primary);
}
/* 筛选器样式 */
:deep(.filter-select) {
width: 120px; // 符合8px栅格(15*8)
height: 40px; // 改为40px符合8px栅格
/* 筛选器样式 - 统一输入框和下拉框高度 */
:deep(.filter-input .el-input__wrapper) {
height: 36px;
min-height: 36px;
padding: 1px 11px;
}
:deep(.filter-select .el-select__wrapper) {
align-items: center;
background-color: var(--el-fill-color-blank);
border-radius: var(--el-border-radius-base);
box-shadow: 0 0 0 1px #DCDEE2 inset;
box-shadow: 0 0 0 1px #dcdee2 inset;
box-sizing: border-box;
cursor: pointer;
display: flex;
font-size: 14px;
gap: 8px; // 改为8px符合8px栅格
gap: 8px; // 改为8px符合8px栅格
line-height: 24px;
height: 36px;
min-height: 36px;
padding: 0px 12px;
position: relative;
@@ -655,11 +652,11 @@ onMounted(async () => {
}
:deep(.filter-select.is-focus .el-select__wrapper) {
box-shadow: 0 0 0 1px #019C7C inset;
box-shadow: 0 0 0 1px #019c7c inset;
}
:deep(.filter-select:hover .el-select__wrapper) {
box-shadow: 0 0 0 1px #019C7C inset;
box-shadow: 0 0 0 1px #019c7c inset;
}
/* 授权码样式 */
@@ -668,7 +665,7 @@ onMounted(async () => {
font-family: 'Source Han Sans CN', sans-serif;
font-weight: 400;
font-size: 14px;
line-height: 24px; // 改为24px符合8px栅格
line-height: 24px; // 改为24px符合8px栅格
}
/* 表格精确样式 - 匹配Figma设计 */
@@ -700,7 +697,7 @@ onMounted(async () => {
overflow: hidden !important;
text-overflow: ellipsis !important;
}
.cell {
white-space: nowrap !important;
overflow: hidden !important;
@@ -714,12 +711,12 @@ onMounted(async () => {
border-radius: $border-radius-small;
font-size: $font-size-base;
font-weight: $font-weight-secondary;
height: 24px; // 改为24px符合8px栅格
height: 24px; // 改为24px符合8px栅格
min-width: fit-content;
@include flex-center;
@include text-ellipsis;
&.status-normal {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
@@ -727,12 +724,12 @@ onMounted(async () => {
&.status-locked {
background: rgba(230, 162, 60, 0.08);
color: #E6A23C;
color: #e6a23c;
}
&.status-expired {
background: rgba(244, 108, 108, 0.08);
color: #F46C6C;
color: #f46c6c;
}
}
@@ -747,7 +744,7 @@ onMounted(async () => {
justify-content: center;
flex-wrap: nowrap;
overflow: visible;
@include mobile {
display: flex !important;
flex-wrap: wrap !important;
@@ -758,7 +755,7 @@ onMounted(async () => {
align-content: flex-start !important;
padding: $spacing-small 0 !important;
}
@include mobile {
max-width: 130px !important;
gap: math.div($spacing-extra-small, 2) !important;
@@ -767,18 +764,18 @@ onMounted(async () => {
.action-btn {
padding: $spacing-extra-small $spacing-base;
height: 32px; // 改为32px符合8px栅格
height: 32px; // 改为32px符合8px栅格
border-radius: $border-radius-small;
font-size: $font-size-small;
font-weight: $font-weight-primary;
line-height: 20px; // 改为20px符合8px栅格
line-height: 20px; // 改为20px符合8px栅格
flex-shrink: 0;
width: auto;
min-width: fit-content;
white-space: nowrap;
@include button-base;
@include mobile {
flex: 0 0 calc(50% - #{math.div($spacing-extra-small, 2)}) !important;
width: calc(50% - #{math.div($spacing-extra-small, 2)}) !important;
@@ -788,7 +785,7 @@ onMounted(async () => {
height: 20px !important;
min-width: 0 !important;
}
@include mobile {
padding: 1px 2px !important;
font-size: 8px !important;
@@ -813,7 +810,7 @@ onMounted(async () => {
&.danger {
background: rgba(244, 108, 108, 0.08);
color: #F46C6C;
color: #f46c6c;
@include non-touch-device {
&:hover {
@@ -824,7 +821,7 @@ onMounted(async () => {
&.warning {
background: rgba(230, 162, 60, 0.08);
color: #E6A23C;
color: #e6a23c;
@include non-touch-device {
&:hover {
@@ -835,7 +832,7 @@ onMounted(async () => {
&.success {
background: rgba(103, 194, 58, 0.08);
color: #67C23A;
color: #67c23a;
@include non-touch-device {
&:hover {
@@ -868,11 +865,11 @@ onMounted(async () => {
margin-right: 8px;
}
.filter-input,
.filter-select {
flex: 1;
min-width: 100px;
}
.filter-input,
.filter-select {
flex: 1;
min-width: 100px;
}
/* 表格容器移动端优化 */
.table-wrapper {
@@ -880,35 +877,36 @@ onMounted(async () => {
-webkit-overflow-scrolling: touch;
width: 100% !important;
}
/* 移动端表格样式修复 */
:deep(.el-table) {
min-width: 1200px !important; /* 移动端最小宽度 */
.el-table__header-wrapper,
.el-table__body-wrapper {
width: 100% !important;
overflow-x: visible !important;
}
.el-table__body-wrapper {
max-height: calc(100vh - 300px) !important;
}
/* 移动端表头和表体对齐 */
.el-table__header,
.el-table__body {
width: 100% !important;
min-width: 1200px !important;
}
/* 移动端列宽度修复 */
th, td {
th,
td {
min-width: 80px !important;
white-space: nowrap !important;
padding: 8px 12px !important;
}
/* 移动端操作列自适应宽度 */
.action-column {
width: auto !important;
@@ -920,13 +918,13 @@ onMounted(async () => {
:deep(.el-table) {
--el-table-action-column-width: 160px;
}
:deep(.el-table__header-wrapper colgroup col:last-child),
:deep(.el-table__body-wrapper colgroup col:last-child),
:deep(.el-table__fixed-right colgroup col:last-child) {
width: 160px !important;
}
:deep(.el-table__header-wrapper .action-column),
:deep(.el-table__body-wrapper .action-column),
:deep(.el-table th:last-child),
@@ -935,27 +933,27 @@ onMounted(async () => {
min-width: 160px !important;
max-width: 160px !important;
}
:deep(.el-table__fixed-right) {
width: 160px !important;
right: 0 !important;
}
:deep(.el-table__fixed-right .el-table__fixed-body-wrapper),
:deep(.el-table__fixed-right .el-table__fixed-header-wrapper),
:deep(.el-table__fixed-right .el-table__fixed-footer-wrapper) {
width: 160px !important;
}
:deep(.el-table__fixed-right-patch) {
width: 160px !important;
}
/* 强制覆盖内联样式 */
:deep(.el-table [style*="width: 300px"]) {
:deep(.el-table [style*='width: 300px']) {
width: 160px !important;
}
/* 移动端操作按钮样式 - 2行2列布局 */
.action-buttons {
display: flex !important;
@@ -967,7 +965,7 @@ onMounted(async () => {
align-content: flex-start !important;
padding: 6px 0 !important;
}
.action-btn {
flex: 0 0 calc(50% - 2px) !important;
width: calc(50% - 2px) !important;
@@ -982,14 +980,14 @@ onMounted(async () => {
box-sizing: border-box !important;
border-radius: 2px !important;
}
/* 移动端分页样式 */
:deep(.el-pagination) {
justify-content: center;
flex-wrap: wrap;
gap: 4px;
}
:deep(.el-pagination .el-pagination__total),
:deep(.el-pagination .el-pagination__sizes) {
order: -1;
@@ -1008,22 +1006,22 @@ onMounted(async () => {
flex-direction: column;
align-items: stretch;
}
.filter-label {
align-self: flex-start;
}
.filter-input,
.filter-select {
width: 100% !important;
}
/* 超小屏幕进一步压缩 */
.action-buttons {
max-width: 130px !important;
gap: 3px !important;
}
.action-btn {
padding: 1px 2px !important;
font-size: 8px !important;
+6 -460
View File
@@ -2,477 +2,23 @@
* @Author: 13895237362 2205451508@qq.com
* @Date: 2025-08-12 00:00:00
* @LastEditors: 13895237362 2205451508@qq.com
* @LastEditTime: 2025-10-17 10:51:55
* @FilePath: /frontend/src/views/Licenses.vue
* @Description: 授权管理页面
* @LastEditTime: 2025-10-21 17:30:00
* @FilePath: /frontend/src/views/Licenses/index.vue
* @Description: 授权管理公共路由容器
-->
<template>
<Layout app-name="Cedar-V" :page-title="t('pages.licenses.title')">
<div class="license-container">
<!-- 主要内容区域 -->
<div v-if="!showSubRoute" class="main-content">
<!-- 背景图片 -->
<div class="background-section">
<!-- 中央标题 -->
<div class="center-title">
<h1 class="platform-title">{{ t('pages.licenses.platform') }}</h1>
</div>
<!-- 操作区域 -->
<div class="action-section">
<!-- 客户选择下拉框和操作按钮 -->
<div class="action-row">
<!-- 客户选择下拉框 -->
<div class="customer-select-wrapper">
<el-select
v-model="selectedCustomer"
:placeholder="t('pages.licenses.search.selectCustomer')"
clearable
filterable
size="large"
class="customer-select"
@change="handleCustomerChange"
>
<el-option
:label="t('pages.licenses.search.allCustomers')"
value=""
/>
<el-option
v-for="customer in customers"
:key="customer.id"
:label="customer.customer_name"
:value="customer.id"
/>
</el-select>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<el-button
type="primary"
size="large"
class="action-btn query-btn"
@click="handleQuery"
>
{{ t('pages.licenses.actions.query') }}
</el-button>
<el-button
type="primary"
size="large"
class="action-btn create-btn"
@click="handleCreateLicense"
>
{{ t('pages.licenses.actions.createLicense') }}
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 子路由视图区域 -->
<div v-if="showSubRoute" class="sub-route-section">
<router-view />
</div>
</div>
<router-view />
</Layout>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import Layout from '@/components/common/layout/Layout.vue'
import { getCustomers, type Customer } from '@/api/customer'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
// 响应式数据
const selectedCustomer = ref<string>('')
const customers = ref<Customer[]>([])
const showSubRoute = ref(false)
// 选中的客户信息
const selectedCustomerInfo = computed(() => {
if (!selectedCustomer.value) return null
return customers.value.find(c => c.id === selectedCustomer.value) || null
})
// 方法
const loadCustomers = async () => {
try {
const response = await getCustomers({ status: 'active', page_size: 100 })
customers.value = response.data.list
} catch (error) {
console.error('Failed to load customers:', error)
ElMessage.error('加载客户列表失败')
}
}
const handleCustomerChange = () => {
// 客户选择变化时的处理逻辑
}
const handleQuery = () => {
// 跳转到列表页,并传递客户信息参数
showSubRoute.value = true
router.push({
name: 'licenses-list',
query: {
customerId: selectedCustomer.value || '',
customerName: selectedCustomerInfo.value?.customer_name || ''
}
})
}
const handleCreateLicense = () => {
// 跳转到创建授权页,并传递客户信息参数
showSubRoute.value = true
router.push({
name: 'licenses-create',
query: {
customerId: selectedCustomer.value || '',
customerName: selectedCustomerInfo.value?.customer_name || ''
}
})
}
// 监听路由变化,控制子路由显示
watch(() => route.name, (newName) => {
// 如果是子路由,显示子路由区域
showSubRoute.value = newName === 'licenses-list' || newName === 'licenses-create'
}, { immediate: true })
// 生命周期
onMounted(() => {
loadCustomers()
})
</script>
<style scoped lang="scss">
@import '@/assets/styles/variables.scss';
.license-container {
height: calc(100vh - 80px);
width: 100%;
overflow: hidden;
}
.main-content {
height: 100%;
display: flex;
flex-direction: column;
}
.background-section {
position: relative;
height: 100%;
background-image: url('/src/assets/images/license-bg.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
transition: height 0.3s ease;
&.collapsed {
height: 300px;
}
}
.background-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.center-title {
position: relative;
z-index: 2;
margin-bottom: 60px;
}
.platform-title {
font-family: 'PangMenZhengDao', 'Source Han Sans CN', sans-serif;
font-size: 56px;
font-weight: 400;
color: #1C1C28;
text-align: center;
margin: 0;
letter-spacing: 0.06em;
line-height: 1.2;
}
.action-section {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 1200px;
padding: 0 24px;
}
.action-row {
display: flex;
align-items: stretch;
gap: 24px;
width: 100%;
max-width: 1200px;
// 重置所有子元素的基线对齐
> * {
vertical-align: top;
margin: 0;
}
}
.customer-select-wrapper {
position: relative;
flex: 1;
max-width: 900px;
height: 60px;
display: flex;
align-items: center;
}
.customer-select {
width: 100%;
// 重置Element Plus的默认margin
:deep(.el-select) {
margin: 0;
}
// 重置el-input的默认样式
:deep(.el-input) {
margin: 0;
height: 60px;
}
:deep(.el-input__wrapper) {
height: 60px !important;
font-size: 20px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #E3E3E3;
border-radius: 8px;
padding: 0 48px 0 16px;
box-sizing: border-box;
display: flex;
align-items: center;
margin: 0;
box-shadow: none;
.el-input__inner {
color: #A9A9AF;
font-size: 20px;
line-height: 1.5;
height: 100%;
margin: 0;
padding: 0;
&::placeholder {
color: #A9A9AF;
}
}
}
:deep(.el-input__suffix) {
display: none;
}
// 确保选择器本身没有额外的margin或padding
:deep(.el-select__wrapper) {
margin: 0;
padding: 0 20px;
}
}
.action-buttons {
display: flex;
gap: 16px;
flex-shrink: 0;
align-items: center;
height: 60px;
}
.action-btn {
height: 40px !important;
padding: 0 24px;
font-size: 20px;
font-weight: 500;
letter-spacing: 0.15em;
border-radius: 8px;
border: none;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
vertical-align: top;
// 重置Element Plus按钮的默认样式
&:deep(.el-button) {
margin: 0;
height: 40px;
line-height: 1;
}
&.query-btn {
min-width: 91px;
background-color: $primary-color;
&:hover {
background-color: darken($primary-color, 10%);
}
}
&.create-btn {
min-width: 137px;
background-color: $primary-color;
&:hover {
background-color: darken($primary-color, 10%);
}
}
}
.sub-route-section {
flex: 1;
background: #fff;
display: flex;
flex-direction: column;
overflow: hidden;
}
.license-list-section {
flex: 1;
background: #fff;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-header {
padding: 24px;
border-bottom: 1px solid $border-color-light;
}
.search-controls {
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
}
.search-input {
min-width: 300px;
flex: 1;
}
.date-picker {
min-width: 300px;
}
.table-container {
flex: 1;
padding: 0 24px 24px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.license-table {
flex: 1;
overflow: auto;
}
.pagination-container {
padding: 16px 0;
display: flex;
justify-content: center;
}
// 响应式设计
@media (max-width: 768px) {
.platform-title {
font-size: 36px;
}
.action-section {
padding: 0 16px;
}
.action-row {
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.customer-select-wrapper {
max-width: none;
}
.action-buttons {
justify-content: center;
width: 100%;
.action-btn {
flex: 1;
max-width: 200px;
}
}
.search-controls {
flex-direction: column;
align-items: stretch;
.search-input,
.date-picker {
min-width: auto;
width: 100%;
}
}
.table-container {
padding: 0 16px 16px;
}
}
@media (max-width: 480px) {
.platform-title {
font-size: 28px;
}
.action-btn {
height: 32px;
font-size: 16px;
}
.customer-select {
:deep(.el-input__wrapper) {
height: 48px;
font-size: 16px;
}
}
}
</style>
// 公共路由容器,不需要额外样式
</style>