chore: 初始化平台管理端
34
src/App.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<ElConfigProvider size="default" :locale="locales[language]" :z-index="3000">
|
||||
<RouterView></RouterView>
|
||||
</ElConfigProvider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from './store/modules/user'
|
||||
import zh from 'element-plus/es/locale/lang/zh-cn'
|
||||
import en from 'element-plus/es/locale/lang/en'
|
||||
import { systemUpgrade } from './utils/sys'
|
||||
import { toggleTransition } from './utils/ui/animation'
|
||||
import { checkStorageCompatibility } from './utils/storage'
|
||||
import { initializeTheme } from './hooks/core/useTheme'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { language } = storeToRefs(userStore)
|
||||
|
||||
const locales = {
|
||||
zh: zh,
|
||||
en: en
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
toggleTransition(true)
|
||||
initializeTheme()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
checkStorageCompatibility()
|
||||
toggleTransition(false)
|
||||
systemUpgrade()
|
||||
})
|
||||
</script>
|
||||
254
src/api/announcement.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 文件用途:公告模块 API 封装
|
||||
* 说明:统一对接平台/租户/应用端公告接口
|
||||
* 日期:2025-12-20
|
||||
*/
|
||||
|
||||
import request from '@/utils/http'
|
||||
import type {
|
||||
AnnouncementCommand,
|
||||
AnnouncementFormData,
|
||||
AnnouncementQueryParams,
|
||||
TenantAnnouncementDto
|
||||
} from '@/types/announcement'
|
||||
import type { PagedResult } from '@/types/common/response'
|
||||
|
||||
/** 发布/撤销命令体(仅包含 RowVersion) */
|
||||
type RowVersionCommand = Pick<AnnouncementCommand, 'rowVersion'>
|
||||
|
||||
const withTenantHeader = (tenantId: string) => ({
|
||||
headers: {
|
||||
'X-Tenant-Id': tenantId
|
||||
}
|
||||
})
|
||||
|
||||
const normalizeQueryParams = (params: AnnouncementQueryParams) => {
|
||||
const { dateFrom, dateTo, ...rest } = params
|
||||
return {
|
||||
...rest,
|
||||
effectiveFrom: params.effectiveFrom ?? dateFrom,
|
||||
effectiveTo: params.effectiveTo ?? dateTo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台公告 API
|
||||
* 路由前缀:/api/platform/announcements
|
||||
*/
|
||||
export const platformAnnouncementApi = {
|
||||
/**
|
||||
* 创建平台公告
|
||||
* @param data 创建参数
|
||||
*/
|
||||
create: (data: AnnouncementFormData) => {
|
||||
return request.post<TenantAnnouncementDto>({
|
||||
url: '/api/platform/announcements',
|
||||
data
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 查询平台公告列表
|
||||
* @param params 查询参数
|
||||
*/
|
||||
list: (params: AnnouncementQueryParams) => {
|
||||
return request.get<PagedResult<TenantAnnouncementDto>>({
|
||||
url: '/api/platform/announcements',
|
||||
params: normalizeQueryParams(params)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取平台公告详情
|
||||
* @param announcementId 公告ID
|
||||
*/
|
||||
detail: (announcementId: string) => {
|
||||
return request.get<TenantAnnouncementDto>({
|
||||
url: `/api/platform/announcements/${announcementId}`
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新平台公告(仅草稿)
|
||||
* @param announcementId 公告ID
|
||||
* @param data 更新参数
|
||||
*/
|
||||
update: (announcementId: string, data: AnnouncementFormData) => {
|
||||
return request.put<TenantAnnouncementDto>({
|
||||
url: `/api/platform/announcements/${announcementId}`,
|
||||
data
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 发布平台公告
|
||||
* @param announcementId 公告ID
|
||||
* @param command 命令体(包含 rowVersion)
|
||||
*/
|
||||
publish: (announcementId: string, command: RowVersionCommand) => {
|
||||
return request.post<TenantAnnouncementDto>({
|
||||
url: `/api/platform/announcements/${announcementId}/publish`,
|
||||
data: command
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 撤销平台公告
|
||||
* @param announcementId 公告ID
|
||||
* @param command 命令体(包含 rowVersion)
|
||||
*/
|
||||
revoke: (announcementId: string, command: RowVersionCommand) => {
|
||||
return request.post<TenantAnnouncementDto>({
|
||||
url: `/api/platform/announcements/${announcementId}/revoke`,
|
||||
data: command
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 租户公告 API
|
||||
* 路由前缀:/api/admin/v1/tenants/{tenantId}/announcements
|
||||
*/
|
||||
export const tenantAnnouncementApi = {
|
||||
/**
|
||||
* 分页查询租户公告
|
||||
* @param tenantId 租户ID
|
||||
* @param params 查询参数
|
||||
*/
|
||||
list: (tenantId: string, params: AnnouncementQueryParams) => {
|
||||
return request.get<PagedResult<TenantAnnouncementDto>>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/announcements`,
|
||||
params: normalizeQueryParams(params),
|
||||
...withTenantHeader(tenantId)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取租户公告详情
|
||||
* @param tenantId 租户ID
|
||||
* @param announcementId 公告ID
|
||||
*/
|
||||
detail: (tenantId: string, announcementId: string) => {
|
||||
return request.get<TenantAnnouncementDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/announcements/${announcementId}`,
|
||||
...withTenantHeader(tenantId)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建租户公告
|
||||
* @param tenantId 租户ID
|
||||
* @param data 创建参数
|
||||
*/
|
||||
create: (tenantId: string, data: AnnouncementFormData) => {
|
||||
return request.post<TenantAnnouncementDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/announcements`,
|
||||
data,
|
||||
...withTenantHeader(tenantId)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新租户公告(仅草稿)
|
||||
* @param tenantId 租户ID
|
||||
* @param announcementId 公告ID
|
||||
* @param data 更新参数
|
||||
*/
|
||||
update: (tenantId: string, announcementId: string, data: AnnouncementFormData) => {
|
||||
return request.put<TenantAnnouncementDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/announcements/${announcementId}`,
|
||||
data,
|
||||
...withTenantHeader(tenantId)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 发布租户公告
|
||||
* @param tenantId 租户ID
|
||||
* @param announcementId 公告ID
|
||||
* @param command 命令体(包含 rowVersion)
|
||||
*/
|
||||
publish: (tenantId: string, announcementId: string, command: RowVersionCommand) => {
|
||||
return request.post<TenantAnnouncementDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/announcements/${announcementId}/publish`,
|
||||
data: command,
|
||||
...withTenantHeader(tenantId)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 撤销租户公告
|
||||
* @param tenantId 租户ID
|
||||
* @param announcementId 公告ID
|
||||
* @param command 命令体(包含 rowVersion)
|
||||
*/
|
||||
revoke: (tenantId: string, announcementId: string, command: RowVersionCommand) => {
|
||||
return request.post<TenantAnnouncementDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/announcements/${announcementId}/revoke`,
|
||||
data: command,
|
||||
...withTenantHeader(tenantId)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除租户公告
|
||||
* @param tenantId 租户ID
|
||||
* @param announcementId 公告ID
|
||||
*/
|
||||
delete: (tenantId: string, announcementId: string) => {
|
||||
return request.del<boolean>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/announcements/${announcementId}`,
|
||||
...withTenantHeader(tenantId)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 标记公告已读(兼容旧路径)
|
||||
* @param tenantId 租户ID
|
||||
* @param announcementId 公告ID
|
||||
*/
|
||||
markRead: (tenantId: string, announcementId: string) => {
|
||||
return request.post<TenantAnnouncementDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/announcements/${announcementId}/read`,
|
||||
...withTenantHeader(tenantId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用端公告 API
|
||||
* 路由前缀:/api/app/announcements
|
||||
*/
|
||||
export const appAnnouncementApi = {
|
||||
/**
|
||||
* 获取可见公告列表(已发布且有效期内)
|
||||
* @param params 查询参数
|
||||
*/
|
||||
list: (params: AnnouncementQueryParams) => {
|
||||
return request.get<PagedResult<TenantAnnouncementDto>>({
|
||||
url: '/api/app/announcements',
|
||||
params: normalizeQueryParams(params)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取未读公告列表
|
||||
* @param params 查询参数
|
||||
*/
|
||||
unread: (params: AnnouncementQueryParams) => {
|
||||
return request.get<PagedResult<TenantAnnouncementDto>>({
|
||||
url: '/api/app/announcements/unread',
|
||||
params: normalizeQueryParams(params)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 标记公告已读
|
||||
* @param announcementId 公告ID
|
||||
*/
|
||||
markRead: (announcementId: string) => {
|
||||
return request.post<TenantAnnouncementDto>({
|
||||
url: `/api/app/announcements/${announcementId}/mark-read`
|
||||
})
|
||||
}
|
||||
}
|
||||
71
src/api/auth.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 登录
|
||||
* @param params 登录参数
|
||||
* @returns 登录响应
|
||||
*/
|
||||
export function fetchLogin(params: Api.Auth.LoginParams) {
|
||||
return request.post<Api.Auth.LoginResponse>({
|
||||
url: '/api/admin/v1/auth/login',
|
||||
params
|
||||
// showSuccessMessage: true // 显示成功消息
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 免租户号登录(仅账号+密码)
|
||||
*/
|
||||
export function fetchLoginSimple(params: Api.Auth.LoginParams) {
|
||||
return request.post<Api.Auth.LoginResponse>({
|
||||
url: '/api/admin/v1/auth/login/simple',
|
||||
params,
|
||||
skipTenantHeader: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
* @returns 用户信息
|
||||
*/
|
||||
export function fetchGetUserInfo() {
|
||||
return request.get<Api.Auth.UserInfo>({
|
||||
url: '/api/admin/v1/auth/profile'
|
||||
// 自定义请求头
|
||||
// headers: {
|
||||
// 'X-Custom-Header': 'your-custom-value'
|
||||
// }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户菜单
|
||||
* @returns 菜单列表
|
||||
*/
|
||||
export function fetchGetMenu() {
|
||||
return request.get<Api.Auth.MenuListResponse>({
|
||||
url: '/api/admin/v1/auth/menu'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户权限
|
||||
* @param userId 用户ID
|
||||
* @returns 用户权限信息
|
||||
*/
|
||||
export function fetchGetUserPermissions(userId: string) {
|
||||
return request.get<Api.Auth.UserPermissionsResponse>({
|
||||
url: `/api/admin/v1/auth/permissions/${userId}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过重置链接令牌重置管理员密码
|
||||
*/
|
||||
export function fetchResetAdminPassword(data: Api.Auth.ResetAdminPasswordRequest) {
|
||||
return request.post<void>({
|
||||
url: '/api/admin/v1/auth/reset-password',
|
||||
data
|
||||
})
|
||||
}
|
||||
222
src/api/billing.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 账单接口基础路径
|
||||
*
|
||||
* 说明:后端路由为 /api/admin/v1/billings。
|
||||
*/
|
||||
const BILLING_BASE_URL = '/api/admin/v1/billings'
|
||||
|
||||
// ==================== 查询类 API ====================
|
||||
|
||||
/**
|
||||
* 获取账单列表
|
||||
* @param params 查询参数
|
||||
*/
|
||||
export function fetchBillingList(params?: Partial<Api.Billing.BillingListParams>) {
|
||||
return request.get<Api.Billing.BillingListResponse>({
|
||||
url: BILLING_BASE_URL,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账单列表(导出用:自动分页拉取)
|
||||
* @param params 查询参数(不含分页也可)
|
||||
* @param options 导出拉取选项
|
||||
*/
|
||||
export async function fetchBillingListForExport(
|
||||
params?: Partial<Api.Billing.BillingListParams>,
|
||||
options?: {
|
||||
/** 单页拉取条数(越大越快,但后端可能有限制) */
|
||||
pageSize?: number
|
||||
/** 最大导出条数(防止一次性导出过大) */
|
||||
maxRows?: number
|
||||
}
|
||||
) {
|
||||
// 1. 兜底配置
|
||||
const pageSize = Math.min(Math.max(options?.pageSize ?? 200, 50), 2000)
|
||||
const maxRows = Math.min(Math.max(options?.maxRows ?? 10000, 1), 100000)
|
||||
|
||||
// 2. 循环分页拉取(直到 totalPages 或达到 maxRows)
|
||||
const result: Api.Billing.BillingListDto[] = []
|
||||
let pageNumber = 1
|
||||
let totalPages = 1
|
||||
|
||||
while (pageNumber <= totalPages && result.length < maxRows) {
|
||||
const res = await fetchBillingList({
|
||||
...params,
|
||||
PageNumber: pageNumber,
|
||||
PageSize: pageSize
|
||||
})
|
||||
|
||||
const items = res.items || []
|
||||
result.push(...items)
|
||||
|
||||
totalPages = res.totalPages || 1
|
||||
if (items.length === 0) break
|
||||
pageNumber += 1
|
||||
}
|
||||
|
||||
// 3. 截断到 maxRows,避免极端情况内存爆炸
|
||||
return result.slice(0, maxRows)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账单详情
|
||||
* @param id 账单 ID
|
||||
*/
|
||||
export function fetchBillingDetail(id: string) {
|
||||
return request.get<Api.Billing.BillingDetailDto>({
|
||||
url: `${BILLING_BASE_URL}/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账单支付记录
|
||||
* @param billingId 账单 ID
|
||||
*/
|
||||
export function fetchBillingPayments(billingId: string) {
|
||||
return request.get<Api.Billing.PaymentRecordListResponse>({
|
||||
url: `${BILLING_BASE_URL}/${billingId}/payments`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账单统计数据
|
||||
* @param params 统计查询参数
|
||||
*/
|
||||
export function fetchBillingStatistics(params: Api.Billing.BillingStatisticsParams) {
|
||||
return request.get<Api.Billing.BillingStatisticsDto>({
|
||||
url: `${BILLING_BASE_URL}/statistics`,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取逾期账单列表
|
||||
* @param params 分页参数
|
||||
*/
|
||||
export function fetchOverdueBillings(params?: Partial<Api.Billing.BillingListParams>) {
|
||||
return request.get<Api.Billing.BillingListResponse>({
|
||||
url: `${BILLING_BASE_URL}/overdue`,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 命令类 API ====================
|
||||
|
||||
/**
|
||||
* 创建账单
|
||||
* @param data 创建账单数据
|
||||
*/
|
||||
export function createBilling(data: Api.Billing.CreateBillingCommand) {
|
||||
return request.post<Api.Billing.BillingDetailDto>({
|
||||
url: BILLING_BASE_URL,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账单状态
|
||||
* @param id 账单 ID
|
||||
* @param data 更新状态数据
|
||||
*/
|
||||
export function updateBillingStatus(id: string, data: Api.Billing.UpdateStatusCommand) {
|
||||
return request.put<Api.Billing.BillingDetailDto>({
|
||||
url: `${BILLING_BASE_URL}/${id}/status`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消账单
|
||||
* @param id 账单 ID
|
||||
* @param data 取消原因
|
||||
*/
|
||||
export function cancelBilling(id: string, data: Api.Billing.CancelBillingCommand) {
|
||||
return request.del<void>({
|
||||
url: `${BILLING_BASE_URL}/${id}`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录支付(仅创建待审核支付记录,不会立即更新账单状态)
|
||||
* @param billingId 账单 ID
|
||||
* @param data 支付记录数据
|
||||
*/
|
||||
export function recordPayment(billingId: string, data: Api.Billing.RecordPaymentCommand) {
|
||||
return request.post<Api.Billing.PaymentRecordDto>({
|
||||
url: `${BILLING_BASE_URL}/${billingId}/payments`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键确认收款(记录支付 + 立即审核通过 + 同步更新账单状态)
|
||||
* @param billingId 账单 ID
|
||||
* @param data 支付记录数据
|
||||
*/
|
||||
export function confirmPayment(billingId: string, data: Api.Billing.RecordPaymentCommand) {
|
||||
return request.post<Api.Billing.PaymentRecordDto>({
|
||||
url: `${BILLING_BASE_URL}/${billingId}/payments/confirm`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核支付记录
|
||||
* @param paymentId 支付记录 ID
|
||||
* @param data 审核数据
|
||||
*/
|
||||
export function verifyPayment(paymentId: string, data: Api.Billing.VerifyPaymentCommand) {
|
||||
return request.put<Api.Billing.PaymentRecordDto>({
|
||||
url: `${BILLING_BASE_URL}/payments/${paymentId}/verify`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新账单状态
|
||||
* @param data 批量更新数据
|
||||
*/
|
||||
export function batchUpdateStatus(data: Api.Billing.BatchUpdateStatusCommand) {
|
||||
return request.post<void>({
|
||||
url: `${BILLING_BASE_URL}/batch/status`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出账单
|
||||
* @param data 导出参数
|
||||
* @returns Blob 对象(用于下载)
|
||||
*/
|
||||
export function exportBillings(data: Api.Billing.ExportParams) {
|
||||
return request.post<Blob>({
|
||||
url: `${BILLING_BASE_URL}/export`,
|
||||
data,
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 兼容导出(适配现有页面命名) ====================
|
||||
|
||||
/** 获取账单列表(兼容旧命名) */
|
||||
export const fetchGetBillList = fetchBillingList
|
||||
|
||||
/** 获取账单详情(兼容旧命名) */
|
||||
export const fetchGetBillDetail = fetchBillingDetail
|
||||
|
||||
/** 获取账单列表(导出用,兼容旧命名) */
|
||||
export const fetchGetBillListForExport = fetchBillingListForExport
|
||||
|
||||
/** 创建账单(兼容旧命名) */
|
||||
export const fetchCreateBill = createBilling
|
||||
|
||||
/** 更新账单状态(兼容旧命名) */
|
||||
export const fetchUpdateBillStatus = updateBillingStatus
|
||||
|
||||
/** 记录支付(兼容旧命名) */
|
||||
export const fetchRecordPayment = recordPayment
|
||||
70
src/api/dictionary/group.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
const GROUP_BASE_URL = '/api/admin/v1/dictionary/groups'
|
||||
|
||||
export function getGroups(params?: Api.Dictionary.DictionaryGroupQueryParams) {
|
||||
return request.get<Api.Common.PageResult<Api.Dictionary.DictionaryGroupDto>>({
|
||||
url: GROUP_BASE_URL,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getGroupById(groupId: string) {
|
||||
return request.get<Api.Dictionary.DictionaryGroupDto>({
|
||||
url: `${GROUP_BASE_URL}/${groupId}`
|
||||
})
|
||||
}
|
||||
|
||||
export function createGroup(data: Api.Dictionary.CreateDictionaryGroupRequest) {
|
||||
return request.post<Api.Dictionary.DictionaryGroupDto>({
|
||||
url: GROUP_BASE_URL,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateGroup(groupId: string, data: Api.Dictionary.UpdateDictionaryGroupRequest) {
|
||||
return request.put<Api.Dictionary.DictionaryGroupDto>({
|
||||
url: `${GROUP_BASE_URL}/${groupId}`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteGroup(groupId: string) {
|
||||
return request.del<void>({
|
||||
url: `${GROUP_BASE_URL}/${groupId}`
|
||||
})
|
||||
}
|
||||
|
||||
export function exportGroup(
|
||||
groupId: string,
|
||||
format: Api.Dictionary.DictionaryExportRequest['format']
|
||||
) {
|
||||
return request.post<Blob>({
|
||||
url: `${GROUP_BASE_URL}/${groupId}/export`,
|
||||
data: { format },
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
|
||||
export function importGroup(
|
||||
groupId: string,
|
||||
payload: {
|
||||
file: File
|
||||
conflictMode?: Api.Dictionary.ConflictResolutionMode | string
|
||||
format?: 'csv' | 'json'
|
||||
}
|
||||
) {
|
||||
const formData = new FormData()
|
||||
formData.append('File', payload.file)
|
||||
if (payload.conflictMode) {
|
||||
formData.append('ConflictMode', String(payload.conflictMode))
|
||||
}
|
||||
if (payload.format) {
|
||||
formData.append('Format', payload.format)
|
||||
}
|
||||
|
||||
return request.post<Api.Dictionary.DictionaryImportResultDto>({
|
||||
url: `${GROUP_BASE_URL}/${groupId}/import`,
|
||||
data: formData
|
||||
})
|
||||
}
|
||||
33
src/api/dictionary/item.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
const GROUP_BASE_URL = '/api/admin/v1/dictionary/groups'
|
||||
|
||||
export function getItems(groupId: string) {
|
||||
return request.get<Api.Dictionary.DictionaryItemDto[]>({
|
||||
url: `${GROUP_BASE_URL}/${groupId}/items`
|
||||
})
|
||||
}
|
||||
|
||||
export function createItem(groupId: string, data: Api.Dictionary.CreateDictionaryItemRequest) {
|
||||
return request.post<Api.Dictionary.DictionaryItemDto>({
|
||||
url: `${GROUP_BASE_URL}/${groupId}/items`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateItem(
|
||||
groupId: string,
|
||||
itemId: string,
|
||||
data: Api.Dictionary.UpdateDictionaryItemRequest
|
||||
) {
|
||||
return request.put<Api.Dictionary.DictionaryItemDto>({
|
||||
url: `${GROUP_BASE_URL}/${groupId}/items/${itemId}`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteItem(groupId: string, itemId: string) {
|
||||
return request.del<void>({
|
||||
url: `${GROUP_BASE_URL}/${groupId}/items/${itemId}`
|
||||
})
|
||||
}
|
||||
70
src/api/dictionary/labelOverride.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
const LABEL_OVERRIDE_BASE_URL = '/api/admin/v1/dictionary/label-overrides'
|
||||
|
||||
// ==================== 租户端 API ====================
|
||||
|
||||
/**
|
||||
* 获取当前租户的标签覆盖列表
|
||||
*/
|
||||
export function getTenantLabelOverrides() {
|
||||
return request.get<Api.Dictionary.LabelOverrideDto[]>({
|
||||
url: `${LABEL_OVERRIDE_BASE_URL}/tenant`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 租户创建/更新标签覆盖
|
||||
*/
|
||||
export function upsertTenantLabelOverride(data: Api.Dictionary.UpsertLabelOverrideRequest) {
|
||||
return request.post<Api.Dictionary.LabelOverrideDto>({
|
||||
url: `${LABEL_OVERRIDE_BASE_URL}/tenant`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 租户删除标签覆盖
|
||||
*/
|
||||
export function deleteTenantLabelOverride(dictionaryItemId: string) {
|
||||
return request.del<void>({
|
||||
url: `${LABEL_OVERRIDE_BASE_URL}/tenant/${dictionaryItemId}`
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 平台端 API ====================
|
||||
|
||||
/**
|
||||
* 获取指定租户的所有标签覆盖(平台管理员用)
|
||||
*/
|
||||
export function getPlatformLabelOverrides(
|
||||
targetTenantId: string,
|
||||
overrideType?: Api.Dictionary.OverrideType
|
||||
) {
|
||||
return request.get<Api.Dictionary.LabelOverrideDto[]>({
|
||||
url: `${LABEL_OVERRIDE_BASE_URL}/platform/${targetTenantId}`,
|
||||
params: { overrideType }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台强制覆盖租户字典项的标签
|
||||
*/
|
||||
export function upsertPlatformLabelOverride(
|
||||
targetTenantId: string,
|
||||
data: Api.Dictionary.UpsertLabelOverrideRequest
|
||||
) {
|
||||
return request.post<Api.Dictionary.LabelOverrideDto>({
|
||||
url: `${LABEL_OVERRIDE_BASE_URL}/platform/${targetTenantId}`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台删除对租户的强制覆盖
|
||||
*/
|
||||
export function deletePlatformLabelOverride(targetTenantId: string, dictionaryItemId: string) {
|
||||
return request.del<void>({
|
||||
url: `${LABEL_OVERRIDE_BASE_URL}/platform/${targetTenantId}/${dictionaryItemId}`
|
||||
})
|
||||
}
|
||||
35
src/api/dictionary/metrics.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
const METRICS_BASE_URL = '/api/admin/v1/dictionary/metrics'
|
||||
|
||||
export function getCacheStats(timeRange: '1h' | '24h' | '7d' = '1h') {
|
||||
return request.get<{
|
||||
totalHits: number
|
||||
totalMisses: number
|
||||
hitRatio: number
|
||||
hitsByLevel: { l1: number; l2: number }
|
||||
missesByLevel: { l1: number; l2: number }
|
||||
averageQueryDurationMs: number
|
||||
topQueriedDictionaries: Array<{ code: string; queryCount: number }>
|
||||
}>({
|
||||
url: `${METRICS_BASE_URL}/cache-stats`,
|
||||
params: { timeRange }
|
||||
})
|
||||
}
|
||||
|
||||
export function getInvalidationEvents(params: {
|
||||
page: number
|
||||
pageSize: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}) {
|
||||
return request.get<Api.Common.PageResult<Api.Dictionary.CacheInvalidationLogDto>>({
|
||||
url: `${METRICS_BASE_URL}/invalidation-events`,
|
||||
params: {
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate
|
||||
}
|
||||
})
|
||||
}
|
||||
49
src/api/dictionary/override.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
const OVERRIDE_BASE_URL = '/api/admin/v1/dictionary/overrides'
|
||||
|
||||
export function getOverrides() {
|
||||
return request.get<Api.Dictionary.OverrideConfigDto[]>({
|
||||
url: OVERRIDE_BASE_URL
|
||||
})
|
||||
}
|
||||
|
||||
export function getOverride(groupCode: string) {
|
||||
return request.get<Api.Dictionary.OverrideConfigDto>({
|
||||
url: `${OVERRIDE_BASE_URL}/${groupCode}`
|
||||
})
|
||||
}
|
||||
|
||||
export function enableOverride(groupCode: string) {
|
||||
return request.post<Api.Dictionary.OverrideConfigDto>({
|
||||
url: `${OVERRIDE_BASE_URL}/${groupCode}/enable`,
|
||||
data: {}
|
||||
})
|
||||
}
|
||||
|
||||
export function disableOverride(groupCode: string) {
|
||||
return request.post<void>({
|
||||
url: `${OVERRIDE_BASE_URL}/${groupCode}/disable`,
|
||||
data: {}
|
||||
})
|
||||
}
|
||||
|
||||
export function updateHiddenItems(
|
||||
groupCode: string,
|
||||
hiddenItemIds: Api.Dictionary.DictionaryOverrideHiddenItemsRequest['hiddenItemIds']
|
||||
) {
|
||||
return request.put<Api.Dictionary.OverrideConfigDto>({
|
||||
url: `${OVERRIDE_BASE_URL}/${groupCode}/hidden-items`,
|
||||
data: { hiddenItemIds }
|
||||
})
|
||||
}
|
||||
|
||||
export function updateSortOrder(
|
||||
groupCode: string,
|
||||
sortOrder: Api.Dictionary.DictionaryOverrideSortOrderRequest['sortOrder']
|
||||
) {
|
||||
return request.put<Api.Dictionary.OverrideConfigDto>({
|
||||
url: `${OVERRIDE_BASE_URL}/${groupCode}/sort-order`,
|
||||
data: { sortOrder }
|
||||
})
|
||||
}
|
||||
27
src/api/dictionary/query.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
const QUERY_BASE_URL = '/api/admin/v1/dictionaries'
|
||||
|
||||
const normalizeCode = (code: string) => code.trim().toLowerCase()
|
||||
|
||||
export async function getDictionary(code: string) {
|
||||
const result = await batchGetDictionaries([code])
|
||||
return result[code] ?? []
|
||||
}
|
||||
|
||||
export async function batchGetDictionaries(codes: string[]) {
|
||||
if (!codes.length) return {}
|
||||
|
||||
const result = await request.post<Record<string, Api.Dictionary.DictionaryItemDto[]>>({
|
||||
url: `${QUERY_BASE_URL}/batch`,
|
||||
data: { codes }
|
||||
})
|
||||
|
||||
const mapped: Record<string, Api.Dictionary.DictionaryItemDto[]> = {}
|
||||
codes.forEach((code) => {
|
||||
const key = normalizeCode(code)
|
||||
mapped[code] = result[key] ?? result[code] ?? []
|
||||
})
|
||||
|
||||
return mapped
|
||||
}
|
||||
22
src/api/files.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 文件上传 API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 上传图片或文件(Admin)
|
||||
* @param file 上传的文件
|
||||
* @param type 上传类型(可选)
|
||||
*/
|
||||
export function fetchUploadFile(file: File, type: Api.Files.UploadFileType = 'other') {
|
||||
const formData = new FormData()
|
||||
formData.append('File', file)
|
||||
formData.append('Type', type)
|
||||
|
||||
return request.post<Api.Files.FileUploadResponse>({
|
||||
url: '/api/admin/v1/files/upload',
|
||||
// 重要:不要手动设置 Content-Type,让浏览器自动带 boundary
|
||||
data: formData
|
||||
})
|
||||
}
|
||||
126
src/api/merchant.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import api from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 获取商户列表
|
||||
*/
|
||||
export function fetchMerchantList(params?: Partial<Api.Merchant.MerchantListParams>) {
|
||||
return api.get<Api.Merchant.MerchantListResponse>({
|
||||
url: '/api/admin/v1/merchants',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商户详情
|
||||
*/
|
||||
export function fetchMerchantDetail(merchantId: string, options?: { showErrorMessage?: boolean }) {
|
||||
return api.get<Api.Merchant.MerchantDetail>({
|
||||
url: `/api/admin/v1/merchants/${merchantId}`,
|
||||
showErrorMessage: options?.showErrorMessage
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新商户信息
|
||||
*/
|
||||
export function fetchUpdateMerchant(
|
||||
merchantId: string,
|
||||
data: Api.Merchant.UpdateMerchantRequest,
|
||||
options?: { showErrorMessage?: boolean }
|
||||
) {
|
||||
return api.put<Api.Merchant.UpdateMerchantResult>({
|
||||
url: `/api/admin/v1/merchants/${merchantId}`,
|
||||
data,
|
||||
showErrorMessage: options?.showErrorMessage
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商户审核历史
|
||||
*/
|
||||
export function fetchMerchantAuditHistory(merchantId: string) {
|
||||
return api.get<Api.Merchant.MerchantAuditRecord[]>({
|
||||
url: `/api/admin/v1/merchants/${merchantId}/audit-history`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商户变更历史
|
||||
*/
|
||||
export function fetchMerchantChangeLogs(merchantId: string, params?: { fieldName?: string }) {
|
||||
return api.get<Api.Merchant.MerchantChangeLogRecord[]>({
|
||||
url: `/api/admin/v1/merchants/${merchantId}/change-history`,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待审核商户列表
|
||||
*/
|
||||
export function fetchPendingReviewList(params?: Partial<Api.Merchant.MerchantListParams>) {
|
||||
return api.get<Api.Merchant.MerchantReviewListResponse>({
|
||||
url: '/api/admin/v1/merchants/pending-review',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审核领取信息
|
||||
*/
|
||||
export function fetchMerchantReviewClaim(merchantId: string) {
|
||||
return api.get<Api.Merchant.ClaimInfo | null>({
|
||||
url: `/api/admin/v1/merchants/${merchantId}/review/claim`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取审核
|
||||
*/
|
||||
export function fetchClaimMerchantReview(merchantId: string) {
|
||||
return api.post<Api.Merchant.ClaimInfo>({
|
||||
url: `/api/admin/v1/merchants/${merchantId}/review/claim`,
|
||||
data: {}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放审核
|
||||
*/
|
||||
export function fetchReleaseMerchantReview(merchantId: string) {
|
||||
return api.del<Api.Merchant.ClaimInfo | null>({
|
||||
url: `/api/admin/v1/merchants/${merchantId}/review/claim`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行审核
|
||||
*/
|
||||
export function fetchReviewMerchant(merchantId: string, data: Api.Merchant.ReviewMerchantRequest) {
|
||||
return api.post<void>({
|
||||
url: `/api/admin/v1/merchants/${merchantId}/review`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销审核
|
||||
*/
|
||||
export function fetchRevokeMerchantReview(
|
||||
merchantId: string,
|
||||
data: Api.Merchant.RevokeMerchantRequest
|
||||
) {
|
||||
return api.post<void>({
|
||||
url: `/api/admin/v1/merchants/${merchantId}/review/revoke`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出商户 PDF
|
||||
*/
|
||||
export function fetchExportMerchantPdf(merchantId: string) {
|
||||
return api.get<Blob>({
|
||||
url: `/api/admin/v1/merchants/${merchantId}/export-pdf`,
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
21
src/api/permission.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 获取权限列表(分页)
|
||||
* @param params 查询参数
|
||||
*/
|
||||
export function fetchGetPermissions(params?: Api.Permission.PermissionQueryParams) {
|
||||
return request.get<Api.Permission.PermissionPageResult>({
|
||||
url: '/api/admin/v1/permissions',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取权限树
|
||||
*/
|
||||
export function fetchGetPermissionTree() {
|
||||
return request.get<Api.Permission.PermissionDto[]>({
|
||||
url: '/api/admin/v1/permissions/tree'
|
||||
})
|
||||
}
|
||||
100
src/api/quotaPackage.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 配额包管理 API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取配额包列表
|
||||
*/
|
||||
export function fetchQuotaPackageList(params?: {
|
||||
quotaType?: number
|
||||
isActive?: boolean
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
return request.get<Api.Common.PageResult<Api.QuotaPackage.QuotaPackageListDto>>({
|
||||
url: '/api/admin/v1/quota-packages',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建配额包
|
||||
*/
|
||||
export function fetchCreateQuotaPackage(data: Api.QuotaPackage.CreateQuotaPackageCommand) {
|
||||
return request.post<Api.QuotaPackage.QuotaPackageDto>({
|
||||
url: '/api/admin/v1/quota-packages',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配额包
|
||||
*/
|
||||
export function fetchUpdateQuotaPackage(
|
||||
quotaPackageId: string,
|
||||
data: Api.QuotaPackage.UpdateQuotaPackageCommand
|
||||
) {
|
||||
return request.put<Api.QuotaPackage.QuotaPackageDto>({
|
||||
url: `/api/admin/v1/quota-packages/${quotaPackageId}`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除配额包(软删)
|
||||
*/
|
||||
export function fetchDeleteQuotaPackage(quotaPackageId: string) {
|
||||
return request.del<boolean>({
|
||||
url: `/api/admin/v1/quota-packages/${quotaPackageId}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配额包状态(上架/下架)
|
||||
*/
|
||||
export function fetchUpdateQuotaPackageStatus(quotaPackageId: string, isActive: boolean) {
|
||||
return request.put<boolean>({
|
||||
url: `/api/admin/v1/quota-packages/${quotaPackageId}/status`,
|
||||
data: {
|
||||
isActive
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 为租户购买配额包
|
||||
*/
|
||||
export function fetchPurchaseQuotaPackage(
|
||||
tenantId: string,
|
||||
data: Api.QuotaPackage.PurchaseQuotaPackageCommand
|
||||
) {
|
||||
return request.post<Api.QuotaPackage.TenantQuotaPurchaseDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/quota-packages`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户配额使用情况
|
||||
*/
|
||||
export function fetchTenantQuotaUsage(tenantId: string, params?: { quotaType?: number }) {
|
||||
return request.get<Api.QuotaPackage.TenantQuotaUsageDto[]>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/quota-usage`,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户配额购买记录
|
||||
*/
|
||||
export function fetchTenantQuotaPurchases(
|
||||
tenantId: string,
|
||||
params?: { page?: number; pageSize?: number }
|
||||
) {
|
||||
return request.get<Api.Common.PageResult<Api.QuotaPackage.TenantQuotaPurchaseDto>>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/quota-purchases`,
|
||||
params
|
||||
})
|
||||
}
|
||||
94
src/api/role-template.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 获取角色模板列表
|
||||
* @param params 查询参数
|
||||
*/
|
||||
export function fetchGetRoleTemplates(params?: any) {
|
||||
return request.get<Api.RoleTemplate.RoleTemplateDto[]>({
|
||||
url: '/api/admin/v1/role-templates',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色模板详情
|
||||
* @param templateCode 模板编码
|
||||
*/
|
||||
export function fetchGetRoleTemplateDetail(templateCode: string) {
|
||||
return request.get<Api.RoleTemplate.RoleTemplateDto>({
|
||||
url: `/api/admin/v1/role-templates/${templateCode}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建角色模板
|
||||
* @param data 创建参数
|
||||
*/
|
||||
export function fetchCreateRoleTemplate(data: Api.RoleTemplate.CreateRoleTemplateCommand) {
|
||||
return request.post<Api.RoleTemplate.RoleTemplateDto>({
|
||||
url: '/api/admin/v1/role-templates',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新角色模板
|
||||
* @param templateCode 模板编码
|
||||
* @param data 更新参数
|
||||
*/
|
||||
export function fetchUpdateRoleTemplate(
|
||||
templateCode: string,
|
||||
data: Api.RoleTemplate.UpdateRoleTemplateCommand
|
||||
) {
|
||||
return request.put<Api.RoleTemplate.RoleTemplateDto>({
|
||||
url: `/api/admin/v1/role-templates/${templateCode}`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除角色模板
|
||||
* @param templateCode 模板编码
|
||||
*/
|
||||
export function fetchDeleteRoleTemplate(templateCode: string) {
|
||||
return request.del<boolean>({
|
||||
url: `/api/admin/v1/role-templates/${templateCode}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 克隆角色模板
|
||||
* @param templateCode 源模板编码
|
||||
* @param data 克隆参数
|
||||
*/
|
||||
export function fetchCloneRoleTemplate(
|
||||
templateCode: string,
|
||||
data: Api.RoleTemplate.CloneRoleTemplateCommand
|
||||
) {
|
||||
return request.post<Api.RoleTemplate.RoleTemplateDto>({
|
||||
url: `/api/admin/v1/role-templates/${templateCode}/clone`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化角色模板
|
||||
* @param templateCodes 模板编码列表
|
||||
*/
|
||||
export function fetchInitRoleTemplates(templateCodes: string[]) {
|
||||
return request.post<any>({
|
||||
url: '/api/admin/v1/role-templates/init',
|
||||
data: { templateCodes }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色模板权限列表
|
||||
* @param templateCode 模板编码
|
||||
*/
|
||||
export function fetchGetRoleTemplatePermissions(templateCode: string) {
|
||||
return request.get<Api.RoleTemplate.PermissionTemplateDto[]>({
|
||||
url: `/api/admin/v1/role-templates/${templateCode}/permissions`
|
||||
})
|
||||
}
|
||||
47
src/api/statistics.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 统计分析 API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取订阅概览统计
|
||||
*/
|
||||
export function fetchSubscriptionOverview() {
|
||||
return request.get<Api.Statistics.SubscriptionOverview>({
|
||||
url: '/api/admin/v1/statistics/subscriptions/overview'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配额使用排行
|
||||
* @param params 查询参数
|
||||
*/
|
||||
export function fetchQuotaUsageRanking(params?: Api.Statistics.QuotaUsageRankingParams) {
|
||||
return request.get<Api.Statistics.QuotaUsageRankingResponse>({
|
||||
url: '/api/admin/v1/statistics/quota/ranking',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收入统计
|
||||
* @param params 查询参数
|
||||
*/
|
||||
export function fetchRevenueStatistics(params?: Api.Statistics.RevenueStatisticsParams) {
|
||||
return request.get<Api.Statistics.RevenueStatisticsResponse>({
|
||||
url: '/api/admin/v1/statistics/revenue',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取即将到期订阅列表
|
||||
* @param params 查询参数
|
||||
*/
|
||||
export function fetchExpiringSubscriptions(params?: Api.Statistics.ExpiringSubscriptionsParams) {
|
||||
return request.get<Api.Statistics.ExpiringSubscriptionsResponse>({
|
||||
url: '/api/admin/v1/statistics/subscriptions/expiring',
|
||||
params
|
||||
})
|
||||
}
|
||||
309
src/api/store.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import api from '@/utils/http'
|
||||
|
||||
interface StoreRequestOptions {
|
||||
tenantId?: string
|
||||
}
|
||||
|
||||
const buildTenantHeader = (options?: StoreRequestOptions) => {
|
||||
// 1. 未提供租户ID时返回空配置
|
||||
if (!options?.tenantId) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// 2. 返回携带租户头的请求配置
|
||||
return {
|
||||
headers: {
|
||||
'X-Tenant-Id': String(options.tenantId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取门店列表
|
||||
*/
|
||||
export function fetchStoreList(params?: Partial<Api.Store.StoreListParams>) {
|
||||
return api.get<Api.Store.StoreListResponse>({
|
||||
url: '/api/admin/v1/stores',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取门店详情
|
||||
*/
|
||||
export function fetchStoreDetail(storeId: string, options?: { showErrorMessage?: boolean }) {
|
||||
return api.get<Api.Store.StoreDto>({
|
||||
url: `/api/admin/v1/stores/${storeId}`,
|
||||
showErrorMessage: options?.showErrorMessage
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建门店
|
||||
*/
|
||||
export function fetchCreateStore(data: Api.Store.CreateStoreRequest) {
|
||||
return api.post<Api.Store.StoreDto>({
|
||||
url: '/api/admin/v1/stores',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新门店
|
||||
*/
|
||||
export function fetchUpdateStore(storeId: string, data: Api.Store.UpdateStoreRequest) {
|
||||
return api.put<Api.Store.StoreDto>({
|
||||
url: `/api/admin/v1/stores/${storeId}`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除门店
|
||||
*/
|
||||
export function fetchDeleteStore(storeId: string) {
|
||||
return api.del<void>({
|
||||
url: `/api/admin/v1/stores/${storeId}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交门店审核
|
||||
*/
|
||||
export function fetchSubmitStoreAudit(storeId: string) {
|
||||
return api.post<boolean>({
|
||||
url: `/api/admin/v1/stores/${storeId}/submit`,
|
||||
data: {}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换门店经营状态
|
||||
*/
|
||||
export function fetchToggleBusinessStatus(
|
||||
storeId: string,
|
||||
data: Api.Store.ToggleBusinessStatusRequest
|
||||
) {
|
||||
return api.post<Api.Store.StoreDto>({
|
||||
url: `/api/admin/v1/stores/${storeId}/business-status`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取门店资质列表
|
||||
*/
|
||||
export function fetchStoreQualifications(storeId: string, options?: StoreRequestOptions) {
|
||||
return api.get<Api.Store.StoreQualificationDto[]>({
|
||||
url: `/api/admin/v1/stores/${storeId}/qualifications`,
|
||||
...buildTenantHeader(options)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查门店资质完整性
|
||||
*/
|
||||
export function fetchCheckStoreQualifications(storeId: string, options?: StoreRequestOptions) {
|
||||
return api.get<Api.Store.StoreQualificationCheckResultDto>({
|
||||
url: `/api/admin/v1/stores/${storeId}/qualifications/check`,
|
||||
...buildTenantHeader(options)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建门店资质
|
||||
*/
|
||||
export function fetchCreateStoreQualification(
|
||||
storeId: string,
|
||||
data: Api.Store.CreateStoreQualificationRequest,
|
||||
options?: StoreRequestOptions
|
||||
) {
|
||||
return api.post<Api.Store.StoreQualificationDto>({
|
||||
url: `/api/admin/v1/stores/${storeId}/qualifications`,
|
||||
data,
|
||||
...buildTenantHeader(options)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新门店资质
|
||||
*/
|
||||
export function fetchUpdateStoreQualification(
|
||||
storeId: string,
|
||||
qualificationId: string,
|
||||
data: Api.Store.UpdateStoreQualificationRequest,
|
||||
options?: StoreRequestOptions
|
||||
) {
|
||||
return api.put<Api.Store.StoreQualificationDto>({
|
||||
url: `/api/admin/v1/stores/${storeId}/qualifications/${qualificationId}`,
|
||||
data,
|
||||
...buildTenantHeader(options)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除门店资质
|
||||
*/
|
||||
export function fetchDeleteStoreQualification(
|
||||
storeId: string,
|
||||
qualificationId: string,
|
||||
options?: StoreRequestOptions
|
||||
) {
|
||||
return api.del<boolean>({
|
||||
url: `/api/admin/v1/stores/${storeId}/qualifications/${qualificationId}`,
|
||||
...buildTenantHeader(options)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取门店营业时段
|
||||
*/
|
||||
export function fetchStoreBusinessHours(storeId: string) {
|
||||
return api.get<Api.Store.StoreBusinessHourDto[]>({
|
||||
url: `/api/admin/v1/stores/${storeId}/business-hours`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新门店营业时段
|
||||
*/
|
||||
export function fetchBatchUpdateBusinessHours(
|
||||
storeId: string,
|
||||
data: Api.Store.BatchUpdateBusinessHoursRequest
|
||||
) {
|
||||
return api.put<Api.Store.StoreBusinessHourDto[]>({
|
||||
url: `/api/admin/v1/stores/${storeId}/business-hours/batch`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配送区域
|
||||
*/
|
||||
export function fetchStoreDeliveryZones(storeId: string) {
|
||||
return api.get<Api.Store.StoreDeliveryZoneDto[]>({
|
||||
url: `/api/admin/v1/stores/${storeId}/delivery-zones`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建配送区域
|
||||
*/
|
||||
export function fetchCreateStoreDeliveryZone(
|
||||
storeId: string,
|
||||
data: Api.Store.CreateStoreDeliveryZoneRequest
|
||||
) {
|
||||
return api.post<Api.Store.StoreDeliveryZoneDto>({
|
||||
url: `/api/admin/v1/stores/${storeId}/delivery-zones`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配送区域
|
||||
*/
|
||||
export function fetchUpdateStoreDeliveryZone(
|
||||
storeId: string,
|
||||
deliveryZoneId: string,
|
||||
data: Api.Store.UpdateStoreDeliveryZoneRequest
|
||||
) {
|
||||
return api.put<Api.Store.StoreDeliveryZoneDto>({
|
||||
url: `/api/admin/v1/stores/${storeId}/delivery-zones/${deliveryZoneId}`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除配送区域
|
||||
*/
|
||||
export function fetchDeleteStoreDeliveryZone(storeId: string, deliveryZoneId: string) {
|
||||
return api.del<void>({
|
||||
url: `/api/admin/v1/stores/${storeId}/delivery-zones/${deliveryZoneId}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 配送范围检测
|
||||
*/
|
||||
export function fetchDeliveryZoneCheck(storeId: string, data: Api.Store.StoreDeliveryCheckRequest) {
|
||||
return api.post<Api.Store.StoreDeliveryCheckResultDto>({
|
||||
url: `/api/admin/v1/stores/${storeId}/delivery-check`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取门店费用配置
|
||||
*/
|
||||
export function fetchStoreFee(storeId: string) {
|
||||
return api.get<Api.Store.StoreFeeDto>({
|
||||
url: `/api/admin/v1/stores/${storeId}/fee`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新门店费用配置
|
||||
*/
|
||||
export function fetchUpdateStoreFee(storeId: string, data: Api.Store.UpdateStoreFeeRequest) {
|
||||
return api.put<Api.Store.StoreFeeDto>({
|
||||
url: `/api/admin/v1/stores/${storeId}/fee`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店费用预览
|
||||
*/
|
||||
export function fetchCalculateStoreFee(storeId: string, data: Api.Store.CalculateStoreFeeRequest) {
|
||||
return api.post<Api.Store.StoreFeeCalculationResultDto>({
|
||||
url: `/api/admin/v1/stores/${storeId}/fee/calculate`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 临时时段 API ====================
|
||||
|
||||
/**
|
||||
* 获取门店临时时段列表
|
||||
*/
|
||||
export function fetchStoreHolidays(storeId: string) {
|
||||
return api.get<Api.Store.StoreHolidayDto[]>({
|
||||
url: `/api/admin/v1/stores/${storeId}/holidays`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建临时时段
|
||||
*/
|
||||
export function fetchCreateStoreHoliday(
|
||||
storeId: string,
|
||||
data: Api.Store.CreateStoreHolidayRequest
|
||||
) {
|
||||
return api.post<Api.Store.StoreHolidayDto>({
|
||||
url: `/api/admin/v1/stores/${storeId}/holidays`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新临时时段
|
||||
*/
|
||||
export function fetchUpdateStoreHoliday(
|
||||
storeId: string,
|
||||
holidayId: string,
|
||||
data: Api.Store.UpdateStoreHolidayRequest
|
||||
) {
|
||||
return api.put<Api.Store.StoreHolidayDto>({
|
||||
url: `/api/admin/v1/stores/${storeId}/holidays/${holidayId}`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除临时时段
|
||||
*/
|
||||
export function fetchDeleteStoreHoliday(storeId: string, holidayId: string) {
|
||||
return api.del<boolean>({
|
||||
url: `/api/admin/v1/stores/${storeId}/holidays/${holidayId}`
|
||||
})
|
||||
}
|
||||
103
src/api/storeAudit.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import api from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 获取待审核门店列表
|
||||
*/
|
||||
export function fetchPendingStoreAudits(
|
||||
params?: Partial<Api.StoreAudit.ListPendingStoreAuditsParams>
|
||||
) {
|
||||
return api.get<Api.StoreAudit.PendingStoreAuditResponse>({
|
||||
url: '/api/admin/v1/platform/store-audits/pending',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审核统计
|
||||
*/
|
||||
export function fetchStoreAuditStatistics(params?: Api.StoreAudit.StoreAuditStatisticsParams) {
|
||||
return api.get<Api.StoreAudit.StoreAuditStatisticsDto>({
|
||||
url: '/api/admin/v1/platform/store-audits/statistics',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取门店审核详情
|
||||
*/
|
||||
export function fetchStoreAuditDetail(storeId: string) {
|
||||
return api.get<Api.StoreAudit.StoreAuditDetailDto>({
|
||||
url: `/api/admin/v1/platform/store-audits/${storeId}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审核记录
|
||||
*/
|
||||
export function fetchStoreAuditRecords(
|
||||
storeId: string,
|
||||
params?: Api.StoreAudit.ListStoreAuditRecordsParams
|
||||
) {
|
||||
return api.get<Api.Common.PageResult<Api.StoreAudit.StoreAuditRecordDto>>({
|
||||
url: `/api/admin/v1/platform/store-audits/${storeId}/records`,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过
|
||||
*/
|
||||
export function fetchApproveStoreAudit(
|
||||
storeId: string,
|
||||
data: Api.StoreAudit.ApproveStoreAuditRequest
|
||||
) {
|
||||
return api.post<Api.StoreAudit.StoreAuditActionResultDto>({
|
||||
url: `/api/admin/v1/platform/store-audits/${storeId}/approve`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核驳回
|
||||
*/
|
||||
export function fetchRejectStoreAudit(
|
||||
storeId: string,
|
||||
data: Api.StoreAudit.RejectStoreAuditRequest
|
||||
) {
|
||||
return api.post<Api.StoreAudit.StoreAuditActionResultDto>({
|
||||
url: `/api/admin/v1/platform/store-audits/${storeId}/reject`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制关闭门店
|
||||
*/
|
||||
export function fetchForceCloseStore(storeId: string, data: Api.StoreAudit.ForceCloseStoreRequest) {
|
||||
return api.post<Api.StoreAudit.StoreAuditActionResultDto>({
|
||||
url: `/api/admin/v1/platform/store-audits/${storeId}/force-close`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除强制关闭
|
||||
*/
|
||||
export function fetchReopenStore(storeId: string, data: Api.StoreAudit.ReopenStoreRequest) {
|
||||
return api.post<Api.StoreAudit.StoreAuditActionResultDto>({
|
||||
url: `/api/admin/v1/platform/store-audits/${storeId}/reopen`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资质预警列表(平台)
|
||||
*/
|
||||
export function fetchQualificationAlerts(
|
||||
params?: Partial<Api.Store.StoreQualificationAlertQueryParams>
|
||||
) {
|
||||
return api.get<Api.Store.StoreQualificationAlertResultDto>({
|
||||
url: '/api/admin/v1/platform/store-qualifications/expiring',
|
||||
params
|
||||
})
|
||||
}
|
||||
134
src/api/subscription.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 订阅管理 API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取订阅列表
|
||||
* @param params 查询参数
|
||||
*/
|
||||
export function getSubscriptionList(params?: Api.Subscription.SubscriptionListParams) {
|
||||
return request.get<Api.Subscription.SubscriptionListResponse>({
|
||||
url: '/api/admin/v1/subscriptions',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订阅详情
|
||||
* @param id 订阅ID
|
||||
*/
|
||||
export function getSubscriptionDetail(id: string) {
|
||||
return request.get<Api.Subscription.SubscriptionDetailDto>({
|
||||
url: `/api/admin/v1/subscriptions/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新订阅
|
||||
* @param id 订阅ID
|
||||
* @param data 更新数据
|
||||
*/
|
||||
export function updateSubscription(
|
||||
id: string,
|
||||
data: Omit<Api.Subscription.UpdateSubscriptionCommand, 'subscriptionId'>
|
||||
) {
|
||||
return request.put<Api.Subscription.SubscriptionDto>({
|
||||
url: `/api/admin/v1/subscriptions/${id}`,
|
||||
data: {
|
||||
...data,
|
||||
subscriptionId: id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 延期订阅
|
||||
* @param id 订阅ID
|
||||
* @param data 延期数据
|
||||
*/
|
||||
export function extendSubscription(
|
||||
id: string,
|
||||
data: Omit<Api.Subscription.ExtendSubscriptionCommand, 'subscriptionId'>
|
||||
) {
|
||||
return request.post<Api.Subscription.SubscriptionDto>({
|
||||
url: `/api/admin/v1/subscriptions/${id}/extend`,
|
||||
data: {
|
||||
...data,
|
||||
subscriptionId: id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 变更套餐
|
||||
* @param id 订阅ID
|
||||
* @param data 变更数据
|
||||
*/
|
||||
export function changeSubscriptionPlan(
|
||||
id: string,
|
||||
data: Omit<Api.Subscription.ChangePlanCommand, 'subscriptionId'>
|
||||
) {
|
||||
return request.post<Api.Subscription.SubscriptionDto>({
|
||||
url: `/api/admin/v1/subscriptions/${id}/change-plan`,
|
||||
data: {
|
||||
...data,
|
||||
subscriptionId: id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 变更订阅状态
|
||||
* @param id 订阅ID
|
||||
* @param data 状态数据
|
||||
*/
|
||||
export function updateSubscriptionStatus(
|
||||
id: string,
|
||||
data: Omit<Api.Subscription.UpdateStatusCommand, 'subscriptionId'>
|
||||
) {
|
||||
return request.post<Api.Subscription.SubscriptionDto>({
|
||||
url: `/api/admin/v1/subscriptions/${id}/status`,
|
||||
data: {
|
||||
...data,
|
||||
subscriptionId: id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量延期订阅
|
||||
* @param data 批量延期数据
|
||||
*/
|
||||
export function batchExtendSubscriptions(data: {
|
||||
subscriptionIds: string[]
|
||||
durationDays: number
|
||||
notes?: string
|
||||
}) {
|
||||
return request.post<{
|
||||
successCount: number
|
||||
failureCount: number
|
||||
failures: Array<{ subscriptionId: string; reason: string }>
|
||||
}>({
|
||||
url: '/api/admin/v1/subscriptions/batch-extend',
|
||||
data,
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量发送提醒
|
||||
* @param data 批量提醒数据
|
||||
*/
|
||||
export function batchSendReminders(data: { subscriptionIds: string[]; reminderContent: string }) {
|
||||
return request.post<{
|
||||
successCount: number
|
||||
failureCount: number
|
||||
failures: Array<{ subscriptionId: string; reason: string }>
|
||||
}>({
|
||||
url: '/api/admin/v1/subscriptions/batch-remind',
|
||||
data,
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
86
src/api/system-manage.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import api from '@/utils/http'
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
|
||||
// 1. 获取用户列表
|
||||
export function fetchGetUserList(params: Api.SystemManage.UserSearchParams) {
|
||||
return api.get<Api.SystemManage.UserListResponse>({
|
||||
url: '/api/admin/v1/users',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 获取用户详情
|
||||
export function fetchGetUserDetail(userId: string, includeDeleted?: boolean) {
|
||||
return api.get<Api.SystemManage.UserDetailDto>({
|
||||
url: `/api/admin/v1/users/${userId}`,
|
||||
params: includeDeleted ? { includeDeleted } : undefined
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 创建用户
|
||||
export function fetchCreateUser(command: Api.SystemManage.CreateIdentityUserCommand) {
|
||||
return api.post<Api.SystemManage.UserDetailDto>({
|
||||
url: '/api/admin/v1/users',
|
||||
data: command
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 更新用户
|
||||
export function fetchUpdateUser(
|
||||
userId: string,
|
||||
command: Api.SystemManage.UpdateIdentityUserCommand
|
||||
) {
|
||||
return api.put<Api.SystemManage.UserDetailDto>({
|
||||
url: `/api/admin/v1/users/${userId}`,
|
||||
data: command
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 删除用户
|
||||
export function fetchDeleteUser(userId: string) {
|
||||
return api.del<boolean>({
|
||||
url: `/api/admin/v1/users/${userId}`
|
||||
})
|
||||
}
|
||||
|
||||
// 6. 恢复用户
|
||||
export function fetchRestoreUser(userId: string) {
|
||||
return api.post<boolean>({
|
||||
url: `/api/admin/v1/users/${userId}/restore`
|
||||
})
|
||||
}
|
||||
|
||||
// 7. 更新用户状态
|
||||
export function fetchChangeUserStatus(
|
||||
userId: string,
|
||||
command: Api.SystemManage.ChangeIdentityUserStatusCommand
|
||||
) {
|
||||
return api.put<boolean>({
|
||||
url: `/api/admin/v1/users/${userId}/status`,
|
||||
data: command
|
||||
})
|
||||
}
|
||||
|
||||
// 8. 重置用户密码
|
||||
export function fetchResetUserPassword(userId: string) {
|
||||
return api.post<Api.SystemManage.ResetIdentityUserPasswordResult>({
|
||||
url: `/api/admin/v1/users/${userId}/password-reset`
|
||||
})
|
||||
}
|
||||
|
||||
// 9. 批量用户操作
|
||||
export function fetchBatchUserOperation(
|
||||
command: Api.SystemManage.BatchIdentityUserOperationCommand
|
||||
) {
|
||||
return api.post<Api.SystemManage.BatchIdentityUserOperationResult>({
|
||||
url: '/api/admin/v1/users/batch',
|
||||
data: command
|
||||
})
|
||||
}
|
||||
|
||||
// 10. 获取菜单列表
|
||||
export function fetchGetMenuList() {
|
||||
return api.get<AppRouteRecord[]>({
|
||||
url: '/api/v3/system/menus/simple'
|
||||
})
|
||||
}
|
||||
99
src/api/tenant-onboarding.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 自助注册租户
|
||||
* @param data 自助注册参数
|
||||
*/
|
||||
export function fetchSelfRegisterTenant(data: Api.Tenant.SelfRegisterTenantCommand) {
|
||||
return request.post<Api.Tenant.SelfRegisterResult>({
|
||||
url: '/api/public/v1/tenants/self-register',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询租户入驻进度
|
||||
* @param tenantId 租户ID
|
||||
*/
|
||||
export function fetchTenantProgress(tenantId: string) {
|
||||
return request.get<Api.Tenant.TenantProgress>({
|
||||
url: `/api/public/v1/tenants/${tenantId}/status`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交或更新租户实名信息
|
||||
* @param tenantId 租户ID
|
||||
* @param data 实名资料
|
||||
*/
|
||||
export function submitTenantVerification(
|
||||
tenantId: string,
|
||||
data: Api.Tenant.SubmitTenantVerificationCommand
|
||||
) {
|
||||
return request.post<Api.Tenant.TenantVerificationDto>({
|
||||
url: `/api/public/v1/tenants/${tenantId}/verification`,
|
||||
data: { ...data, tenantId }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交或更新租户实名信息(管理端)
|
||||
* @param tenantId 租户ID
|
||||
* @param data 实名资料
|
||||
*/
|
||||
export function submitTenantVerificationAdmin(
|
||||
tenantId: string,
|
||||
data: Api.Tenant.SubmitTenantVerificationCommand
|
||||
) {
|
||||
return request.post<Api.Tenant.TenantVerificationDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/verification`,
|
||||
data: { ...data, tenantId }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建租户订阅
|
||||
* @param tenantId 租户ID
|
||||
* @param data 订阅参数
|
||||
*/
|
||||
export function createTenantSubscription(
|
||||
tenantId: string,
|
||||
data: Api.Tenant.CreateTenantSubscriptionCommand
|
||||
) {
|
||||
return request.post<Api.Tenant.TenantSubscriptionDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/subscriptions`,
|
||||
data: { ...data, tenantId }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 初次绑定租户订阅(自助入驻)
|
||||
* @param tenantId 租户ID
|
||||
* @param data 初次绑定参数
|
||||
*/
|
||||
export function bindInitialTenantSubscription(
|
||||
tenantId: string,
|
||||
data: Api.Tenant.BindInitialTenantSubscriptionCommand
|
||||
) {
|
||||
return request.post<Api.Tenant.TenantSubscriptionDto>({
|
||||
url: `/api/public/v1/tenants/${tenantId}/subscriptions/initial`,
|
||||
data: { ...data, tenantId }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 升降配租户套餐
|
||||
* @param tenantId 租户ID
|
||||
* @param subscriptionId 订阅ID
|
||||
* @param data 升降配参数
|
||||
*/
|
||||
export function changeTenantSubscriptionPlan(
|
||||
tenantId: string,
|
||||
subscriptionId: string,
|
||||
data: Api.Tenant.ChangeTenantSubscriptionPlanCommand
|
||||
) {
|
||||
return request.put<Api.Tenant.TenantSubscriptionDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/subscriptions/${subscriptionId}/plan`,
|
||||
data: { ...data, tenantId, tenantSubscriptionId: subscriptionId }
|
||||
})
|
||||
}
|
||||
103
src/api/tenant-package.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 租户套餐管理 API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取租户套餐分页列表
|
||||
*/
|
||||
export function fetchTenantPackageList(params?: Api.Tenant.TenantPackageQueryParams) {
|
||||
return request.get<Api.Tenant.TenantPackageListResponse>({
|
||||
url: '/api/admin/v1/tenant-packages',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共租户套餐列表(匿名可访问)
|
||||
*/
|
||||
export function fetchPublicTenantPackageList(params?: Api.Tenant.TenantPackageQueryParams) {
|
||||
return request.get<Api.Tenant.PublicTenantPackageListResponse>({
|
||||
url: '/api/public/v1/tenant-packages',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户套餐详情
|
||||
*/
|
||||
export function fetchTenantPackageDetail(tenantPackageId: string) {
|
||||
return request.get<Api.Tenant.TenantPackageDto>({
|
||||
url: `/api/admin/v1/tenant-packages/${tenantPackageId}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建租户套餐
|
||||
*/
|
||||
export function fetchCreateTenantPackage(data: Api.Tenant.CreateTenantPackageCommand) {
|
||||
return request.post<Api.Tenant.TenantPackageDto>({
|
||||
url: '/api/admin/v1/tenant-packages',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新租户套餐
|
||||
*/
|
||||
export function fetchUpdateTenantPackage(
|
||||
tenantPackageId: string,
|
||||
data: Api.Tenant.UpdateTenantPackageCommand
|
||||
) {
|
||||
return request.put<Api.Tenant.TenantPackageDto>({
|
||||
url: `/api/admin/v1/tenant-packages/${tenantPackageId}`,
|
||||
data: {
|
||||
...data,
|
||||
tenantPackageId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除租户套餐(软删)
|
||||
*/
|
||||
export function fetchDeleteTenantPackage(tenantPackageId: string) {
|
||||
return request.del<boolean>({
|
||||
url: `/api/admin/v1/tenant-packages/${tenantPackageId}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询套餐使用统计(订阅关联数量、使用租户数量)
|
||||
*/
|
||||
export function fetchTenantPackageUsages(tenantPackageIds?: string[]) {
|
||||
const params = new URLSearchParams()
|
||||
if (tenantPackageIds?.length) {
|
||||
tenantPackageIds.forEach((id) => params.append('tenantPackageIds', id))
|
||||
}
|
||||
|
||||
return request.get<Api.Tenant.TenantPackageUsageDto[]>({
|
||||
url: '/api/admin/v1/tenant-packages/usages',
|
||||
params,
|
||||
showErrorMessage: false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询套餐当前使用租户列表(按有效订阅口径)
|
||||
*/
|
||||
export function fetchTenantPackageTenants(
|
||||
tenantPackageId: string,
|
||||
params: { keyword?: string; page?: number; pageSize?: number; expiringWithinDays?: number } = {}
|
||||
) {
|
||||
return request.get<Api.Common.PageResult<Api.Tenant.TenantPackageTenantDto>>({
|
||||
url: `/api/admin/v1/tenant-packages/${tenantPackageId}/tenants`,
|
||||
params: {
|
||||
keyword: params.keyword || undefined,
|
||||
expiringWithinDays: params.expiringWithinDays ?? undefined,
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 20
|
||||
}
|
||||
})
|
||||
}
|
||||
98
src/api/tenant-role.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 获取租户角色列表
|
||||
* @param tenantId 租户ID
|
||||
* @param params 查询参数
|
||||
*/
|
||||
export function fetchGetTenantRoles(
|
||||
tenantId: number | string,
|
||||
params?: Api.TenantRole.RoleQueryParams
|
||||
) {
|
||||
return request.get<Api.TenantRole.RoleDtoPagedResult>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/roles`,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户角色详情
|
||||
* @param tenantId 租户ID
|
||||
* @param roleId 角色ID
|
||||
*/
|
||||
export function fetchGetTenantRoleDetail(tenantId: number | string, roleId: number | string) {
|
||||
return request.get<Api.TenantRole.RoleDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/roles/${roleId}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建租户角色
|
||||
* @param tenantId 租户ID
|
||||
* @param data 创建参数
|
||||
*/
|
||||
export function fetchCreateTenantRole(
|
||||
tenantId: number | string,
|
||||
data: Api.TenantRole.CreateRoleCommand
|
||||
) {
|
||||
return request.post<Api.TenantRole.RoleDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/roles`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新租户角色
|
||||
* @param tenantId 租户ID
|
||||
* @param roleId 角色ID
|
||||
* @param data 更新参数
|
||||
*/
|
||||
export function fetchUpdateTenantRole(
|
||||
tenantId: number | string,
|
||||
roleId: number | string,
|
||||
data: Api.TenantRole.UpdateRoleCommand
|
||||
) {
|
||||
return request.put<Api.TenantRole.RoleDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/roles/${roleId}`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除租户角色
|
||||
* @param tenantId 租户ID
|
||||
* @param roleId 角色ID
|
||||
*/
|
||||
export function fetchDeleteTenantRole(tenantId: number | string, roleId: number | string) {
|
||||
return request.del<boolean>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/roles/${roleId}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户角色权限
|
||||
* @param tenantId 租户ID
|
||||
* @param roleId 角色ID
|
||||
*/
|
||||
export function fetchGetTenantRolePermissions(tenantId: number | string, roleId: number | string) {
|
||||
return request.get<string[]>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/roles/${roleId}/permissions`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新租户角色权限
|
||||
* @param tenantId 租户ID
|
||||
* @param roleId 角色ID
|
||||
* @param permissions 权限代码列表
|
||||
*/
|
||||
export function fetchUpdateTenantRolePermissions(
|
||||
tenantId: number | string,
|
||||
roleId: number | string,
|
||||
permissionIds: string[]
|
||||
) {
|
||||
return request.put<boolean>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/roles/${roleId}/permissions`,
|
||||
data: { permissionIds }
|
||||
})
|
||||
}
|
||||
263
src/api/tenant.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import api from '@/utils/http'
|
||||
import { fetchTenantPackageList } from './tenant-package'
|
||||
import type { QuotaUsageHistoryDto, UpdateTenantCommand } from '@/types/tenant'
|
||||
|
||||
/**
|
||||
* 获取租户列表
|
||||
* @param params 搜索参数
|
||||
*/
|
||||
export function fetchGetTenantList(params?: Partial<Api.Tenant.TenantListParams>) {
|
||||
return api.get<Api.Tenant.TenantListResponse>({
|
||||
url: '/api/admin/v1/tenants',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册租户
|
||||
*/
|
||||
export function fetchRegisterTenant(data: Api.Tenant.RegisterTenantCommand) {
|
||||
return api.post<Api.Tenant.TenantDto>({
|
||||
url: '/api/admin/v1/tenants',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台手动新增租户并直接入驻(创建租户 + 认证 + 订阅 + 管理员账号)
|
||||
*/
|
||||
export function fetchCreateTenantManually(data: Api.Tenant.CreateTenantManuallyCommand) {
|
||||
return api.post<Api.Tenant.TenantDetailDto>({
|
||||
url: '/api/admin/v1/tenants/manual',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户详情(包含认证信息、订阅信息)
|
||||
*/
|
||||
export function fetchGetTenantDetail(tenantId: string, options?: { showErrorMessage?: boolean }) {
|
||||
return api.get<Api.Tenant.TenantDetailDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}`,
|
||||
showErrorMessage: options?.showErrorMessage
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户配额使用情况
|
||||
*/
|
||||
export function fetchGetTenantQuotaUsage(
|
||||
tenantId: string,
|
||||
options?: { showErrorMessage?: boolean }
|
||||
) {
|
||||
return api.get<Api.QuotaPackage.TenantQuotaUsageDto[]>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/quota-usage`,
|
||||
showErrorMessage: options?.showErrorMessage
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户配额使用历史
|
||||
* @param tenantId 租户ID
|
||||
* @param params 分页参数
|
||||
*/
|
||||
export function fetchGetTenantQuotaUsageHistory(
|
||||
tenantId: string,
|
||||
params?: { Page?: number; PageSize?: number }
|
||||
) {
|
||||
return api.get<Api.Common.PageResult<QuotaUsageHistoryDto>>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/quota-usage-history`,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订阅信息(注意:该接口为 POST)
|
||||
*/
|
||||
export function fetchGetTenantSubscriptions(
|
||||
tenantId: string,
|
||||
options?: { showErrorMessage?: boolean }
|
||||
) {
|
||||
return api.post<Api.Tenant.TenantSubscriptionDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/subscriptions`,
|
||||
data: {},
|
||||
showErrorMessage: options?.showErrorMessage
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账单信息(分页,最近10条可传 Page=1 PageSize=10)
|
||||
*/
|
||||
export function fetchGetTenantBillings(
|
||||
tenantId: string,
|
||||
params: { Page?: number; PageSize?: number } = {},
|
||||
options?: { showErrorMessage?: boolean }
|
||||
) {
|
||||
return api.get<Api.Common.PageResult<Api.Billing.BillingListDto>>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/billings`,
|
||||
params,
|
||||
showErrorMessage: options?.showErrorMessage
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新租户信息(TD-001:待后端接口补充)
|
||||
* @param tenantId 租户ID(Snowflake long → string)
|
||||
* @param data 更新数据(tenantId 会以入参为准覆盖)
|
||||
* @returns void(后端 BaseResponse<void> 的 data 字段)
|
||||
*
|
||||
* 后端接口:PUT /api/admin/v1/tenants/{tenantId}
|
||||
* 预期响应:BaseResponse<void>
|
||||
* 预期错误码:400(参数错误)、404(租户不存在)、409(code 冲突)
|
||||
* TODO(TD-001): Swagger 中缺失该端点(当前可能返回 404/405),待后端补充后再联调验证
|
||||
*/
|
||||
export function fetchUpdateTenant(tenantId: string, data: UpdateTenantCommand) {
|
||||
return api.put<void>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}`,
|
||||
data: { ...data, tenantId },
|
||||
showErrorMessage: false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户套餐列表(兼容旧调用)
|
||||
*/
|
||||
export const fetchGetTenantPackages = fetchTenantPackageList
|
||||
|
||||
/**
|
||||
* 审核租户
|
||||
*/
|
||||
export function fetchReviewTenant(
|
||||
tenantId: string,
|
||||
data: Omit<Api.Tenant.ReviewTenantCommand, 'tenantId'>
|
||||
) {
|
||||
return api.post<Api.Tenant.TenantDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/review`,
|
||||
data: { ...data, tenantId }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户审核领取信息(未领取返回 null)
|
||||
*/
|
||||
export function fetchGetTenantReviewClaim(tenantId: string) {
|
||||
return api.get<Api.Tenant.TenantReviewClaimDto | null>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/review/claim`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取租户审核
|
||||
*/
|
||||
export function fetchClaimTenantReview(tenantId: string) {
|
||||
return api.post<Api.Tenant.TenantReviewClaimDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/review/claim`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制接管租户审核(仅超级管理员)
|
||||
*/
|
||||
export function fetchForceClaimTenantReview(tenantId: string) {
|
||||
return api.post<Api.Tenant.TenantReviewClaimDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/review/force-claim`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放租户审核领取(仅领取人)
|
||||
*/
|
||||
export function fetchReleaseTenantReviewClaim(tenantId: string) {
|
||||
return api.post<Api.Tenant.TenantReviewClaimDto | null>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/review/release`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询租户审核日志
|
||||
*/
|
||||
export function fetchGetTenantAuditLogs(
|
||||
tenantId: string,
|
||||
params: { page?: number; pageSize?: number } = {}
|
||||
) {
|
||||
return api.get<Api.Tenant.TenantAuditLogListResponse>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/audits`,
|
||||
params: {
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 20
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 冻结租户(暂停服务)
|
||||
*/
|
||||
export function fetchFreezeTenant(
|
||||
tenantId: string,
|
||||
data: Omit<Api.Tenant.FreezeTenantCommand, 'tenantId'>
|
||||
) {
|
||||
return api.post<Api.Tenant.TenantDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/freeze`,
|
||||
data: { ...data, tenantId }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 解冻租户(恢复服务)
|
||||
*/
|
||||
export function fetchUnfreezeTenant(
|
||||
tenantId: string,
|
||||
data: Omit<Api.Tenant.UnfreezeTenantCommand, 'tenantId'> = {}
|
||||
) {
|
||||
return api.post<Api.Tenant.TenantDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/unfreeze`,
|
||||
data: { ...data, tenantId }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 延期/赠送租户订阅时长(按当前订阅套餐续费)
|
||||
*/
|
||||
export function fetchExtendTenantSubscription(
|
||||
tenantId: string,
|
||||
data: Omit<Api.Tenant.ExtendTenantSubscriptionCommand, 'tenantId'>
|
||||
) {
|
||||
return api.post<Api.Tenant.TenantSubscriptionDto>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/subscriptions/extend`,
|
||||
data: { ...data, tenantId }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 伪装登录租户(返回租户主管理员的 Token)
|
||||
*/
|
||||
export function fetchImpersonateTenant(tenantId: string) {
|
||||
return api.post<Api.Auth.TokenResponse>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/impersonate`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成租户主管理员重置密码链接(仅展示一次)
|
||||
*/
|
||||
export function fetchCreateTenantAdminResetLink(tenantId: string) {
|
||||
return api.post<string>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/admin/reset-link`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户配额购买记录(分页)
|
||||
*/
|
||||
export function fetchGetTenantQuotaPurchases(
|
||||
tenantId: string,
|
||||
params: { Page?: number; PageSize?: number } = {}
|
||||
) {
|
||||
return api.get<Api.Common.PageResult<Api.QuotaPackage.TenantQuotaPurchaseDto>>({
|
||||
url: `/api/admin/v1/tenants/${tenantId}/quota-purchases`,
|
||||
params: {
|
||||
Page: params.Page ?? 1,
|
||||
PageSize: params.PageSize ?? 10
|
||||
}
|
||||
})
|
||||
}
|
||||
BIN
src/assets/images/avatar/avatar.webp
Normal file
|
After Width: | Height: | Size: 954 B |
BIN
src/assets/images/avatar/avatar1.webp
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src/assets/images/avatar/avatar10.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/assets/images/avatar/avatar2.webp
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/images/avatar/avatar3.webp
Normal file
|
After Width: | Height: | Size: 726 B |
BIN
src/assets/images/avatar/avatar4.webp
Normal file
|
After Width: | Height: | Size: 944 B |
BIN
src/assets/images/avatar/avatar5.webp
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src/assets/images/avatar/avatar6.webp
Normal file
|
After Width: | Height: | Size: 810 B |
BIN
src/assets/images/avatar/avatar7.webp
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src/assets/images/avatar/avatar8.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src/assets/images/avatar/avatar9.webp
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/assets/images/ceremony/hb.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src/assets/images/ceremony/sd.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
src/assets/images/ceremony/xc.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
src/assets/images/ceremony/yd.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
src/assets/images/common/logo.webp
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/assets/images/draw/draw1.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src/assets/images/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src/assets/images/lock/bg_dark.webp
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
src/assets/images/lock/bg_light.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
src/assets/images/login/lf_icon2.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src/assets/images/settings/menu_layouts/dual_column.png
Normal file
|
After Width: | Height: | Size: 514 B |
BIN
src/assets/images/settings/menu_layouts/horizontal.png
Normal file
|
After Width: | Height: | Size: 409 B |
BIN
src/assets/images/settings/menu_layouts/mixed.png
Normal file
|
After Width: | Height: | Size: 431 B |
BIN
src/assets/images/settings/menu_layouts/vertical.png
Normal file
|
After Width: | Height: | Size: 439 B |
BIN
src/assets/images/settings/menu_styles/dark.png
Normal file
|
After Width: | Height: | Size: 292 B |
BIN
src/assets/images/settings/menu_styles/design.png
Normal file
|
After Width: | Height: | Size: 286 B |
BIN
src/assets/images/settings/menu_styles/light.png
Normal file
|
After Width: | Height: | Size: 293 B |
BIN
src/assets/images/settings/theme_styles/dark.png
Normal file
|
After Width: | Height: | Size: 448 B |
BIN
src/assets/images/settings/theme_styles/light.png
Normal file
|
After Width: | Height: | Size: 416 B |
BIN
src/assets/images/settings/theme_styles/system.png
Normal file
|
After Width: | Height: | Size: 509 B |
1
src/assets/images/svg/403.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="94" y="34" width="212" height="233"><path d="M306 34H94v233h212V34Z" fill="#fff"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M234.427 155.64h38.36V69.6h-38.36v86.04ZM113.326 155.64h121.1V69.6h-121.1v86.04Z" fill="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M130.126 155.354h104.2v-72.95h-104.2v72.95ZM236.369 71.05s0 3.3 1.65 5.05c2.33 2.52 7.38-.2 7.38-.2s-1.75 5.15-1.55 10.19c.29 8.24 6.99 9.51 10 4.75 4.56 4.85 8.94-.29 9.52-2.62 4.27 4.76 9.32-.87 9.32-.87v-6.3l-23.99-12.13-12.33 2.13Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M234.429 155.641h-121.1l-15.93 32.11h121.1l15.93-32.11Z" fill="#fff"/><path d="M234.427 69.6h38.46v86.04M113.326 146.52V69.6h121.1M234.429 155.641l-15.93 32.11h-121.1l15.93-32.11h111.39" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M226.37 159.715H116.82l-12.04 23.86H215l11.37-23.86Z" fill="#006EFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="m288.807 187.751-15.92-32.11h-38.46l16.02 32.11h38.36Z" fill="#fff"/><path d="m238.607 163.981 11.84 23.77h38.36l-15.92-32.11h-38.46" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M207.336 223.734c-3.69-13.77-15.44-23.86-29.33-23.86h-8.65s-27.09 14.94-27.09 33.27c0 18.34 25.44 33.18 25.44 33.18h10.4c13.79-.1 25.44-10.19 29.13-23.87 1.75-12.51 0-18.62.1-18.72Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M243.459 240.421c3.98 0 7.28-3.3 7.28-7.27 0-3.98-3.3-7.28-7.28-7.28h-31.08c-3.98 0-7.28 3.3-7.28 7.28 0 3.97 3.3 7.27 7.28 7.27h31.08Z" fill="#C7DEFF"/><path d="M210.342 223.737c-4.08-13.87-16.9-23.96-32.05-23.96H168.972s-29.62 14.94-29.62 33.37 27.87 33.37 27.87 33.37h11.27c15.05-.1 27.77-10.19 31.75-23.96" stroke="#071F4D"/><path d="M212.379 240.421c-3.98 0-7.28-3.3-7.28-7.27m0 0c0-3.98 3.3-7.28 7.28-7.28" stroke="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M168.781 199.777c-18.45 0-33.41 14.94-33.41 33.37s14.96 33.37 33.41 33.37c18.45 0 33.4-14.94 33.4-33.37s-14.95-33.37-33.4-33.37Z" fill="#006EFF"/><path d="M168.781 199.777c-18.45 0-33.41 14.94-33.41 33.37s14.96 33.37 33.41 33.37c18.45 0 33.4-14.94 33.4-33.37s-14.95-33.37-33.4-33.37Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M168.775 209.38c-13.14 0-23.79 10.64-23.79 23.77 0 13.12 10.65 23.76 23.79 23.76 13.14 0 23.8-10.64 23.8-23.76 0-13.13-10.66-23.77-23.8-23.77Z" fill="#00E4E5"/><path d="M162.174 223.736a17.48 17.48 0 0 1 14.76-8.05M159.455 231.982c.1-1.36.29-2.62.68-3.88" stroke="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M173.535 209.87c-1.55-.3-3.11-.49-4.76-.49-13.11 0-23.79 10.67-23.79 23.77 0 13.09 10.68 23.76 23.79 23.76 1.65 0 3.21-.19 4.76-.48-10.88-2.23-19.03-11.84-19.03-23.28 0-11.45 8.15-21.05 19.03-23.28Z" fill="#071F4D"/><path d="M219.957 225.774h23.6c4.08 0 7.38 3.3 7.38 7.37m0 0c0 4.08-3.3 7.37-7.38 7.37h-20.1M212.091 225.774h3.3" stroke="#071F4D"/><path d="m248.894 34.485-.19 18.24c0 4.07-.39 5.23-2.14 6.79-8.15 6.88-10.97 9.02-9.22 12.9 1.45 3.2 6.79 2.23 9.61-1.55-.39 4.56-5.24 15.32-.58 18.04 4.37 2.52 6.89-3.49 6.89-3.49s.49 3.49 4.47 3.49c3.69 0 5.24-4.75 5.24-4.75s2.14 3.49 6.22 1.35c3.11-1.55 5.44-7.08 5.44-26.67v-24.35" fill="#fff"/><path d="m248.894 34.485-.19 18.24c0 4.07-.39 5.23-2.14 6.79-8.15 6.88-10.97 9.02-9.22 12.9 1.45 3.2 6.79 2.23 9.61-1.55-.39 4.56-5.24 15.32-.58 18.04 4.37 2.52 6.89-3.49 6.89-3.49s.49 3.49 4.47 3.49c3.69 0 5.24-4.75 5.24-4.75s2.14 3.49 6.22 1.35c3.11-1.55 5.44-7.08 5.44-26.67v-24.35" stroke="#071F4D"/><path d="M255.307 75.71s-.39 5.43-2.04 9.6l2.04-9.6Z" fill="#fff"/><path d="M255.307 75.71s-.39 5.43-2.04 9.6" stroke="#071F4D"/><path d="M264.921 75.323s-.68 5.24-2.04 8.63l2.04-8.63Z" fill="#fff"/><path d="M264.921 75.323s-.68 5.24-2.04 8.63M147.801 34.485v34.92M121.775 34.485v34.92M102.546 204.724v13.97M102.546 222.379v.87M102.546 197.934v3.49M115.268 206.955v26.29M115.268 239.451v5.34M244.43 197.643v11.93M244.43 213.939v3.49M270.359 201.232v33.76M115.369 47.774h-13.6M94.486 47.774h3.4M241.516 47.774h-84.1M280.168 47.774h25.35" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m282.497 183.575-12.04-23.86h-27.29l11.36 23.86h27.97Z" fill="#00E4E5"/><path d="M234.427 134.88V69.6M234.427 140.412v7.66" stroke="#071F4D"/><path d="M220.831 228.684h16.99M240.934 228.684h2.43" stroke="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="m223.842 187.462 21.46-.2-10.97-20.66-10.49 20.86Z" fill="#071F4D"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
1
src/assets/images/svg/404.svg
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
5
src/assets/images/svg/500.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg
|
||||
viewBox="0 0 400 300"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="47" y="38" width="307" height="224"><path d="M353.3 38H47.5v223.8h305.8V38Z" fill="#fff"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M299.2 200.6H61.6v5.1h240.3l-2.7-5.1Z" fill="#C7DEFF"/><path d="m308.9 185.8-6.5 20H183.7M332.3 127.6h10.6l-5 16.7-14.8-.1-7.2 21.1M328.8 127.4l13.6-39.6M307.6 166 337 84.7H180.6l-9.8 26.9h-10.5M296.6 196l4.3-11.8M157.2 149.2l6.4-17.7" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M324.8 93.1H188.5l-34.8 95.8h136.4l34.7-95.8ZM169.9 166.2l5-13.6-5 13.6Z" fill="#fff"/><path d="m169.9 166.2 5-13.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M324.8 93.1H188.5l-4 11.7h135.8l4.5-11.7Z" fill="#006EFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M102.6 159.5h38.3l2.7 36.6h-38.4c-10.1 0-20.9-8.2-20.9-18.3 0-10.1 8.2-18.3 18.3-18.3Z" fill="#DEEBFC"/><path fill-rule="evenodd" clip-rule="evenodd" d="M84.3 174.102c2.5 3.4 10 5 17.9 2.8 16.6-6.5 23.8-3.9 23.8-3.9s.5-3.4 1.3-5c-5.8-3-15.4.3-26.1 3.1-10.7 2.8-15.8-2.5-15.8-2.5-.4 0-1.1 2.8-1.1 5.5Z" fill="#fff"/><path d="M96.5 194.2c-7.2-3.3-12.2-10.5-12.2-19m0 0c0-11.5 9.3-20.8 20.8-20.8h29.4" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M140.3 195.1c-8.4-2.7-14.5-10.6-14.5-19.8l14.5 19.8Zm-14.5-19.8c0-11.5 9.3-20.8 20.8-20.8l-20.8 20.8Zm20.8-20.8c11.5 0 20.8 9.3 20.8 20.8l-20.8-20.8Zm20.8 20.8c0 8.4-5 15.6-12.1 18.9l12.1-18.9Z" fill="#fff"/><path d="M140.3 195.1c-8.4-2.7-14.5-10.6-14.5-19.8m0 0c0-11.5 9.3-20.8 20.8-20.8m0 0c11.5 0 20.8 9.3 20.8 20.8m0 0c0 8.4-5 15.6-12.1 18.9" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M161.5 177.2c0-7.7-6.3-14-14-14s-14 6.3-14 14c0 5.8 3.5 10.8 8.6 12.9.1 0 5.8 1.6 10.7 0 5.3-1.7 8.7-7.1 8.7-12.9Z" fill="#00E4E5"/><path d="M140.5 190.1c-5.8-2.4-9.9-8.2-9.9-14.9 0-8.9 7.2-16.1 16.1-16.1 8.9 0 16.1 7.2 16.1 16.1 0 6.8-4.2 12.5-10.1 14.9M88.4 170.604c2.9 1.3 7.7 2.6 13.6.3 14.7-5.7 22.3-4.3 24.6-3.5M84.5 174.599s5.9 6.5 19 1.7c9.2-3.4 15.3-3.9 18.8-3.8" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M340.6 112.3h-55.2l-2.7 6.2H338l2.6-6.2Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M236.8 117.9c-16.13 0-29.2 13.07-29.2 29.2s13.07 29.2 29.2 29.2 29.2-13.07 29.2-29.2-13.07-29.2-29.2-29.2Z" fill="#00E4E5"/><path d="M265 123.3c13.1 13.1 13.1 34.4 0 47.6M306 205.9h19.2M61.7 205.9h32.9M181.2 196.2h115.2M47.5 205.9h10v-9.7h73.8" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M146.7 179.2c-2.49 0-4.5 2.01-4.5 4.5s2.01 4.5 4.5 4.5 4.5-2.01 4.5-4.5-2.01-4.5-4.5-4.5Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M169.5 196.2c3.9 0 7.1 3.2 7.1 7.1 0 3.9-3.2 7.1-7.1 7.1H144c-2.1 0-3.9 1.7-3.9 3.9v1c0 2.1 1.7 3.9 3.9 3.9h48c5.1 0 9.2 4.1 9.2 9.2s-4.1 9.3-9.2 9.2h-33.8c-2.3 0-4.1 1.8-4.1 4.1s1.8 4.1 4.1 4.1h4.2c4.4 0 8 3.6 8 8s-3.6 8-8 8H111c-3.7 0-6.8-3-6.8-6.8 0-3.7 3-6.8 6.8-6.8h.3c2.3 0 4.1-1.8 4.1-4.1s-1.8-4.1-4.1-4.1H79c-4.5 0-8.1-3.6-8.1-8.1s3.6-8.1 8.1-8.1h37.7c2.1 0 3.9-1.7 3.9-3.9 0-2.1-1.7-3.9-3.9-3.9h-7.9c-4.4 0-7.9-3.5-7.9-7.9s3.5-7.9 7.9-7.9h30.4c2.2 0 3.9-1.8 3.9-3.9V187c0-1.9 1.6-3.5 3.5-3.5s3.5 1.6 3.5 3.5v5.3c0 2.2 1.8 3.9 3.9 3.9h15.5Z" fill="#006EFF"/><path d="m227.8 138.5 18.7 18.7M227.8 157.2l18.7-18.7" stroke="#fff" stroke-width="6"/><path fill-rule="evenodd" clip-rule="evenodd" d="M194.8 96.9c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8ZM202.9 96.9c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8Z" fill="#fff"/><path d="m291.7 184.3-1.6 4.6h-121M298.1 166.7l22.5-61.9" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m193 134.1 2.2-5.1h-19.4l-2.3 5.1H193ZM313.2 123.5l2.2-5.1h-24.5l-2.3 5.1h24.6Z" fill="#DEEBFC"/><path d="m164.5 159.2 19.8-54.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M199.6 119.8h-53.2l-4.4 9.3h53.2l4.4-9.3Z" fill="#00E4E5"/><path d="M151.3 129.1H142l4.4-9.3h16.9" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M353.3 169.4h-67.4l-4.8 12.2h67.3l4.9-12.2Z" fill="#006EFF"/><path d="M332.4 169.4h20.9l-4.9 12.2h-39.7M242.7 235.5v-4.8c0-3.8 3.1-7 7-7h20.2c3.8 0 7 3.1 7 7" stroke="#071F4D"/><path d="M261.1 235.5v-4.8c0-3.8 3.1-7 7-7h13.7c3.8 0 7 3.1 7 7v4.8M242.6 230.7h13.7M235.2 237.7h63.3M224 237.7h6.7" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M324.1 141.3H335l3.3-10.7h-10.2l-4 10.7Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M288.3 230.4c0-3.6-2.9-6.5-6.5-6.5h-14.2c-3.6 0-6.5 2.9-6.5 6.5v5.3h27.2v-5.3Z" fill="#071F4D"/><path d="M80.4 228.5H83M87.7 228.5h19.2M146.3 195.8v2c0 3.6-2.9 6.6-6.6 6.6H138M133.4 204.3h1.5M154 249.9h9.4" stroke="#DEEBFC"/><path d="m299.4 141.9 5.1-13.9" stroke="#071F4D"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
1
src/assets/images/svg/login_icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="44" y="42" width="312" height="217"><path d="M355.3 42H44v216.9h311.3V42Z" fill="#fff"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M288.2 248.4h25.1v-30h-25.1v30Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M304.498 238.199c-1.5-3.9-5.9-15.4-4-21.6-2.9.8-3.3.1-5-.1-1.7-.1 0 10.7 2.2 16.4 1.7 4.5 2.1 11.1 2.1 13.6h5.4c.2-1.9.3-5.5-.7-8.3Z" fill="#fff"/><path d="M311.5 214.7v-1.6c0-.7-.6-1.3-1.3-1.3h-22.8c-.7 0-1.3.6-1.3 1.3v1.6" fill="#fff"/><path d="M311.5 214.7v-1.6c0-.7-.6-1.3-1.3-1.3h-22.8c-.7 0-1.3.6-1.3 1.3v1.6M290.2 214.7h21.4c1 0 1.8.8 1.8 1.8v29" stroke="#071F4D" stroke-width="1.096"/><path d="M284.3 245.6v-29c0-1 .8-1.8 1.8-1.8h1.6" fill="#fff"/><path d="M284.3 245.6v-29c0-1 .8-1.8 1.8-1.8h1.6" stroke="#071F4D" stroke-width="1.096"/><path d="M295.402 216.5c-.9 4.2-.4 9.7 2.8 17.5 2.4 5.9 1.9 10.2 1.8 12.3M300.502 216.5c-.9 4.2-.4 9.7 2.8 17.5 2.4 5.9 1.9 10.2 1.8 12.3" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m331 258.4-.3-5.2H88.5l-1.2 5.2H331Z" fill="#C7DEFF"/><path d="M252.9 248.7H331M216.6 258.4H331M47.1 139.3l-2.6 1.5 42.7 117.6h129.2v-6.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m247.2 248.6-40.4-111.3H50.5l40.3 111.3h156.4Z" fill="#fff"/><path d="m247.2 248.6-40.4-111.3H50.5l40.3 111.3h156.4Z" stroke="#071F4D"/><path d="m203.2 153.2 32.2 88.7H97.8l-32.3-88.7" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M72.2 146.9c-.77 0-1.4.63-1.4 1.4 0 .77.63 1.4 1.4 1.4.77 0 1.4-.63 1.4-1.4 0-.77-.63-1.4-1.4-1.4ZM79.3 146.9c-.77 0-1.4.63-1.4 1.4 0 .77.63 1.4 1.4 1.4.77 0 1.4-.63 1.4-1.4 0-.77-.63-1.4-1.4-1.4Z" fill="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M263.5 171.2h80.3v-63.7h-80.3v63.7Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M290 143.9h-45.6l12.5 51.3H290v-51.3Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M286 117.4h-29.3v77.8h92.9v-67.6l-55.9.6-7.7-10.8Z" fill="#00E4E5"/><path d="m332.6 127.6-38.9.6-7.7-10.8h-11.7M308.9 195.2h45.9M250.3 195.2h28.5M287.3 195.2h12.3" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M130.5 211.4H186v-44h-55.5v44Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M148.7 192.5h-31.6l8.7 35.5h22.9v-35.5Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M145.9 174.2h-20.2V228h64.1v-46.7l-38.6.4-5.3-7.5Z" fill="#006EFF"/><path d="m179 181.3-27.8.4-5.3-7.5h-7.7M176.2 201.7h19.2M163.2 210.7H195M172.1 228h-54.2M184.8 228h8.1M174.9 228h5.4" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m293.2 155.7-6.4 6.3 15.3 15.3 22.7-22.6-6.4-6.4-16.3 16.3-8.9-8.9Z" fill="#fff"/><path d="M57.2 258.4h283.6M345.9 258.4h8.1M55.4 258.4h220.5M160.1 118.8l-1.2 2.7M156.7 127c-.3.8-.7 1.8-1.1 2.8M222 68.5c-1 .2-1.9.5-2.9.8M214.1 70.7c-5.8 1.9-11.3 4.4-16.5 7.4M195.4 79.5c-.9.5-1.7 1.1-2.5 1.6M314.2 98.5c-.6-.8-1.3-1.5-2-2.3M308.9 92.8c-4-4-8.3-7.6-13-10.8M293.9 80.7c-.8-.5-1.7-1.1-2.5-1.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M251.296 71.203c-3.6-1.5-18.5-2.9-21.8-1.9-1 5.8 4.9 13.5 4.9 13.5s6-9.9 16.9-11.6Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M251.3 42.704c-6.5 6.7-7.8 13-8.8 19.3 24.4-1.1 36.3 13 42.8 20 3.2-9.1 7.8-23 7.2-29-7.1-6.4-20-11.7-41.2-10.3Z" fill="#C7DEFF"/><path d="M230 69.3c36.2-3.8 52 21.1 52 21.1s11.4-28.2 10.5-37.4c-7.3-6.5-23.3-12-45.6-10.1-9 6.3-15.6 18.7-16.9 26.4Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M161.604 70.7c-6 8.4-9.9 21.9-8.8 33.8 8.4 5.3 32.3 10.5 43.6 11.5 6.1-7.9 15.9-26 15.9-26s-32-4.8-50.7-19.3Z" fill="#C7DEFF"/><path d="M193.103 119.5c4.8-2.7 19.2-29.5 19.2-29.5s-35.8-5.4-53.7-21.8c-9.3 6.1-16.4 24.3-15 40.1 10.6 6.7 45.8 13.3 49.5 11.2Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M189.5 111.6c-3 5.2-5.7 7.2-9.8 6.6 12.2 2.6 13.5 1.2 15.6-1.1 2.2-2.4 4.2-6.6 4.2-6.6s-3.1 2.5-10 1.1Z" fill="#071F4D"/><path d="M331 251.8v6.6M77 165.4l-2.7-6.7h7.8M222.8 228.9l2.8 6.6h-7.9" stroke="#071F4D"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src/assets/images/user/avatar.webp
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/assets/images/user/bg.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
71
src/assets/styles/components/_action-btn.scss
Normal file
@@ -0,0 +1,71 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
/**
|
||||
* 统一的操作按钮样式
|
||||
* 用于表格操作列等场景
|
||||
*/
|
||||
|
||||
.art-action-btn {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center; /* 增加居中 */
|
||||
min-width: 64px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: var(--el-text-color-primary);
|
||||
cursor: pointer;
|
||||
background: var(--el-fill-color-light);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 10px;
|
||||
transition: all 0.15s ease; /* 移动 transition 到这里 */
|
||||
|
||||
.art-action-icon {
|
||||
display: flex;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.97);
|
||||
}
|
||||
|
||||
/* 样式变体 */
|
||||
&.primary {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: var(--el-color-success);
|
||||
background: var(--el-color-success-light-9);
|
||||
border-color: var(--el-color-success-light-8);
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: var(--el-color-warning);
|
||||
background: var(--el-color-warning-light-9);
|
||||
border-color: var(--el-color-warning-light-8);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--el-color-danger);
|
||||
background: var(--el-color-danger-light-9);
|
||||
border-color: var(--el-color-danger-light-8);
|
||||
}
|
||||
|
||||
&.info {
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-color: var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
/* 操作按钮容器 */
|
||||
.action-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
292
src/assets/styles/core/app.scss
Normal file
@@ -0,0 +1,292 @@
|
||||
// 全局样式
|
||||
// 顶部进度条颜色
|
||||
#nprogress .bar {
|
||||
z-index: 2400;
|
||||
background-color: color-mix(in srgb, var(--theme-color) 70%, white);
|
||||
}
|
||||
|
||||
#nprogress .peg {
|
||||
box-shadow:
|
||||
0 0 10px var(--theme-color),
|
||||
0 0 5px var(--theme-color) !important;
|
||||
}
|
||||
|
||||
#nprogress .spinner-icon {
|
||||
border-top-color: var(--theme-color) !important;
|
||||
border-left-color: var(--theme-color) !important;
|
||||
}
|
||||
|
||||
// 处理移动端组件兼容性
|
||||
@media screen and (max-width: 640px) {
|
||||
* {
|
||||
cursor: default !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 背景滤镜
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
--tw-backdrop-blur: ;
|
||||
--tw-backdrop-brightness: ;
|
||||
--tw-backdrop-contrast: ;
|
||||
--tw-backdrop-grayscale: ;
|
||||
--tw-backdrop-hue-rotate: ;
|
||||
--tw-backdrop-invert: ;
|
||||
--tw-backdrop-opacity: ;
|
||||
--tw-backdrop-saturate: ;
|
||||
--tw-backdrop-sepia: ;
|
||||
}
|
||||
|
||||
// 色弱模式
|
||||
.color-weak {
|
||||
filter: invert(80%);
|
||||
-webkit-filter: invert(80%);
|
||||
}
|
||||
|
||||
#noop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// 语言切换选中样式
|
||||
.langDropDownStyle {
|
||||
// 选中项背景颜色
|
||||
.is-selected {
|
||||
background-color: var(--art-el-active-color) !important;
|
||||
}
|
||||
|
||||
// 语言切换按钮菜单样式优化
|
||||
.lang-btn-item {
|
||||
.el-dropdown-menu__item {
|
||||
padding-left: 13px !important;
|
||||
padding-right: 6px !important;
|
||||
margin-bottom: 3px !important;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
.el-dropdown-menu__item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-txt {
|
||||
min-width: 60px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 10px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 盒子默认边框
|
||||
.page-content {
|
||||
border: 1px solid var(--art-card-border) !important;
|
||||
}
|
||||
|
||||
@mixin art-card-base($border-color, $shadow: none, $radius-diff: 4px) {
|
||||
background: var(--default-box-color);
|
||||
border: 1px solid #{$border-color} !important;
|
||||
border-radius: calc(var(--custom-radius) + #{$radius-diff}) !important;
|
||||
box-shadow: #{$shadow} !important;
|
||||
|
||||
--el-card-border-color: var(--default-border) !important;
|
||||
}
|
||||
|
||||
.art-card,
|
||||
.art-card-sm,
|
||||
.art-card-xs {
|
||||
border: 1px solid var(--art-card-border);
|
||||
}
|
||||
|
||||
// 盒子边框
|
||||
[data-box-mode='border-mode'] {
|
||||
.page-content,
|
||||
.art-table-card {
|
||||
border: 1px solid var(--art-card-border) !important;
|
||||
}
|
||||
|
||||
.art-card {
|
||||
@include art-card-base(var(--art-card-border), none, 4px);
|
||||
}
|
||||
|
||||
.art-card-sm {
|
||||
@include art-card-base(var(--art-card-border), none, 0px);
|
||||
}
|
||||
|
||||
.art-card-xs {
|
||||
@include art-card-base(var(--art-card-border), none, -4px);
|
||||
}
|
||||
}
|
||||
|
||||
// 盒子阴影
|
||||
[data-box-mode='shadow-mode'] {
|
||||
.page-content,
|
||||
.art-table-card {
|
||||
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important;
|
||||
border: 1px solid var(--art-gray-200) !important;
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
border-right: 1px solid var(--art-card-border) !important;
|
||||
}
|
||||
|
||||
.art-card {
|
||||
@include art-card-base(
|
||||
var(--art-gray-200),
|
||||
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
|
||||
4px
|
||||
);
|
||||
}
|
||||
|
||||
.art-card-sm {
|
||||
@include art-card-base(
|
||||
var(--art-gray-200),
|
||||
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
|
||||
2px
|
||||
);
|
||||
}
|
||||
|
||||
.art-card-xs {
|
||||
@include art-card-base(
|
||||
var(--art-gray-200),
|
||||
(0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 1px -1px rgba(0, 0, 0, 0.08)),
|
||||
-4px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 元素全屏
|
||||
.el-full-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100vw !important;
|
||||
height: 100% !important;
|
||||
z-index: 2300;
|
||||
margin-top: 0;
|
||||
padding: 15px;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--default-box-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// 表格卡片
|
||||
.art-table-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 12px;
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
|
||||
.el-card__body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
// 容器全高
|
||||
.art-full-height {
|
||||
height: var(--art-full-height);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// 徽章样式
|
||||
.art-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 20px;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: auto;
|
||||
background: #ff3860;
|
||||
border-radius: 50%;
|
||||
animation: breathe 1.5s ease-in-out infinite;
|
||||
|
||||
&.art-badge-horizontal {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&.art-badge-mixed {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&.art-badge-dual {
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
bottom: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// 文字徽章样式
|
||||
.art-text-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 12px;
|
||||
bottom: 0;
|
||||
min-width: 20px;
|
||||
height: 18px;
|
||||
line-height: 17px;
|
||||
padding: 0 5px;
|
||||
margin: auto;
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
background: #fd4e4e;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 修复老机型 loading 定位问题
|
||||
.art-loading-fix {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.art-loading-fix .el-loading-spinner {
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
// 去除移动端点击背景色
|
||||
@media screen and (max-width: 1180px) {
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
93
src/assets/styles/core/dark.scss
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 深色主题
|
||||
* 单页面移除深色主题 document.getElementsByTagName("html")[0].removeAttribute('class')
|
||||
*/
|
||||
|
||||
$font-color: rgba(#ffffff, 0.85);
|
||||
|
||||
/* 覆盖element-plus默认深色背景色 */
|
||||
html.dark {
|
||||
// element-plus
|
||||
--el-bg-color: var(--default-box-color);
|
||||
--el-text-color-regular: #{$font-color};
|
||||
|
||||
// 富文本编辑器
|
||||
// 工具栏背景颜色
|
||||
--w-e-toolbar-bg-color: #18191c;
|
||||
// 输入区域背景颜色
|
||||
--w-e-textarea-bg-color: #090909;
|
||||
// 工具栏文字颜色
|
||||
--w-e-toolbar-color: var(--art-gray-600);
|
||||
// 选中菜单颜色
|
||||
--w-e-toolbar-active-bg-color: #25262b;
|
||||
// 弹窗边框颜色
|
||||
--w-e-toolbar-border-color: var(--default-border-dashed);
|
||||
// 分割线颜色
|
||||
--w-e-textarea-border-color: var(--default-border-dashed);
|
||||
// 链接输入框边框颜色
|
||||
--w-e-modal-button-border-color: var(--default-border-dashed);
|
||||
// 表格头颜色
|
||||
--w-e-textarea-slight-bg-color: #090909;
|
||||
// 按钮背景颜色
|
||||
--w-e-modal-button-bg-color: #090909;
|
||||
// hover toolbar 背景颜色
|
||||
--w-e-toolbar-active-color: var(--art-gray-800);
|
||||
}
|
||||
|
||||
.dark {
|
||||
.page-content .article-list .item .left .outer > div {
|
||||
border-right-color: var(--dark-border-color) !important;
|
||||
}
|
||||
|
||||
// 富文本编辑器
|
||||
.editor-wrapper {
|
||||
*:not(pre code *) {
|
||||
color: inherit !important;
|
||||
}
|
||||
}
|
||||
// 分隔线
|
||||
.w-e-bar-divider {
|
||||
background-color: var(--art-gray-300) !important;
|
||||
}
|
||||
|
||||
.w-e-select-list,
|
||||
.w-e-drop-panel,
|
||||
.w-e-bar-item-group .w-e-bar-item-menus-container,
|
||||
.w-e-text-container [data-slate-editor] pre > code {
|
||||
border: 1px solid var(--default-border) !important;
|
||||
}
|
||||
|
||||
// 下拉选择框
|
||||
.w-e-select-list {
|
||||
background-color: var(--default-box-color) !important;
|
||||
}
|
||||
|
||||
/* 下拉选择框 hover 样式调整 */
|
||||
.w-e-select-list ul li:hover,
|
||||
/* 工具栏 hover 按钮背景颜色 */
|
||||
.w-e-bar-item button:hover {
|
||||
background-color: #090909 !important;
|
||||
}
|
||||
|
||||
/* 代码块 */
|
||||
.w-e-text-container [data-slate-editor] pre > code {
|
||||
background-color: #25262b !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 引用 */
|
||||
.w-e-text-container [data-slate-editor] blockquote {
|
||||
border-left: 4px solid var(--default-border-dashed) !important;
|
||||
background-color: var(--art-color);
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
|
||||
border-right: 1px solid var(--default-border-dashed) !important;
|
||||
}
|
||||
|
||||
.w-e-modal {
|
||||
background-color: var(--art-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src/assets/styles/core/el-dark.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
// 导入暗黑主题
|
||||
@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *;
|
||||
34
src/assets/styles/core/el-light.scss
Normal file
@@ -0,0 +1,34 @@
|
||||
// https://github.com/element-plus/element-plus/blob/dev/packages/theme-chalk/src/common/var.scss
|
||||
// 自定义Element 亮色主题
|
||||
|
||||
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
|
||||
$colors: (
|
||||
'white': #ffffff,
|
||||
'black': #000000,
|
||||
'success': (
|
||||
'base': #13deb9
|
||||
),
|
||||
'warning': (
|
||||
'base': #ffae1f
|
||||
),
|
||||
'danger': (
|
||||
'base': #ff4d4f
|
||||
),
|
||||
'error': (
|
||||
'base': #fa896b
|
||||
)
|
||||
),
|
||||
$button: (
|
||||
'hover-bg-color': var(--el-color-primary-light-9),
|
||||
'hover-border-color': var(--el-color-primary),
|
||||
'border-color': var(--el-color-primary),
|
||||
'text-color': var(--el-color-primary)
|
||||
),
|
||||
$messagebox: (
|
||||
'border-radius': '12px'
|
||||
),
|
||||
$popover: (
|
||||
'padding': '14px',
|
||||
'border-radius': '10px'
|
||||
)
|
||||
);
|
||||
526
src/assets/styles/core/el-ui.scss
Normal file
@@ -0,0 +1,526 @@
|
||||
// 优化 Element Plus 组件库默认样式
|
||||
|
||||
:root {
|
||||
// 系统主色
|
||||
--main-color: var(--el-color-primary);
|
||||
--el-color-white: white !important;
|
||||
--el-color-black: white !important;
|
||||
// 输入框边框颜色
|
||||
// --el-border-color: #E4E4E7 !important; // DCDFE6
|
||||
// 按钮粗度
|
||||
--el-font-weight-primary: 400 !important;
|
||||
|
||||
--el-component-custom-height: 36px !important;
|
||||
|
||||
--el-component-size: var(--el-component-custom-height) !important;
|
||||
|
||||
// 边框、按钮圆角...
|
||||
--el-border-radius-base: calc(var(--custom-radius) / 3 + 2px) !important;
|
||||
|
||||
--el-border-radius-small: calc(var(--custom-radius) / 3 + 4px) !important;
|
||||
--el-messagebox-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
|
||||
--el-popover-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
|
||||
|
||||
.region .el-radio-button__original-radio:checked + .el-radio-button__inner {
|
||||
color: var(--theme-color);
|
||||
}
|
||||
}
|
||||
|
||||
// 优化 el-form-item 标签高度
|
||||
.el-form-item__label {
|
||||
height: var(--el-component-custom-height) !important;
|
||||
line-height: var(--el-component-custom-height) !important;
|
||||
}
|
||||
|
||||
// 日期选择器
|
||||
.el-date-range-picker {
|
||||
--el-datepicker-inrange-bg-color: var(--art-gray-200) !important;
|
||||
}
|
||||
|
||||
// el-card 背景色跟系统背景色保持一致
|
||||
html.dark .el-card {
|
||||
--el-card-bg-color: var(--default-box-color) !important;
|
||||
}
|
||||
|
||||
// 修改 el-pagination 大小
|
||||
.el-pagination--default {
|
||||
& {
|
||||
--el-pagination-button-width: 32px !important;
|
||||
--el-pagination-button-height: var(--el-pagination-button-width) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
& {
|
||||
--el-pagination-button-width: 28px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-select--default .el-select__wrapper {
|
||||
min-height: var(--el-pagination-button-width) !important;
|
||||
}
|
||||
|
||||
.el-pagination__jump .el-input {
|
||||
height: var(--el-pagination-button-width) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-pager li {
|
||||
padding: 0 10px !important;
|
||||
// border: 1px solid red !important;
|
||||
}
|
||||
|
||||
// 优化菜单折叠展开动画(提升动画流畅度)
|
||||
.el-menu.el-menu--inline {
|
||||
transition: max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
}
|
||||
|
||||
// 优化菜单 item hover 动画(提升鼠标跟手感)
|
||||
.el-sub-menu__title,
|
||||
.el-menu-item {
|
||||
transition: background-color 0s !important;
|
||||
}
|
||||
|
||||
// -------------------------------- 修改 el-size=default 组件默认高度 start --------------------------------
|
||||
// 修改 el-button 高度
|
||||
.el-button--default {
|
||||
height: var(--el-component-custom-height) !important;
|
||||
}
|
||||
|
||||
// circle 按钮宽度优化
|
||||
.el-button--default.is-circle {
|
||||
width: var(--el-component-custom-height) !important;
|
||||
}
|
||||
|
||||
// 修改 el-select 高度
|
||||
.el-select--default {
|
||||
.el-select__wrapper {
|
||||
min-height: var(--el-component-custom-height) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 修改 el-checkbox-button 高度
|
||||
.el-checkbox-button--default .el-checkbox-button__inner,
|
||||
// 修改 el-radio-button 高度
|
||||
.el-radio-button--default .el-radio-button__inner {
|
||||
padding: 10px 15px !important;
|
||||
}
|
||||
// -------------------------------- 修改 el-size=default 组件默认高度 end --------------------------------
|
||||
|
||||
.el-pagination.is-background .btn-next,
|
||||
.el-pagination.is-background .btn-prev,
|
||||
.el-pagination.is-background .el-pager li {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.el-popover {
|
||||
min-width: 80px;
|
||||
border-radius: var(--el-border-radius-small) !important;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: 100px !important;
|
||||
border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
.el-dialog__title {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 25px 0 !important;
|
||||
position: relative; // 为了兼容 el-pagination 样式,需要设置 relative,不然会影响 el-pagination 的样式,比如 el-pagination__jump--small 会被影响,导致 el-pagination__jump--small 按钮无法点击,详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275;
|
||||
}
|
||||
|
||||
.el-dialog.el-dialog-border {
|
||||
.el-dialog__body {
|
||||
// 上边框
|
||||
&::before,
|
||||
// 下边框
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -16px;
|
||||
width: calc(100% + 32px);
|
||||
height: 1px;
|
||||
background-color: var(--art-gray-300);
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// el-message 样式优化
|
||||
.el-message {
|
||||
background-color: var(--default-box-color) !important;
|
||||
border: 0 !important;
|
||||
box-shadow:
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05) !important;
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
// 修改 el-dropdown 样式
|
||||
.el-dropdown-menu {
|
||||
padding: 6px !important;
|
||||
border-radius: 10px !important;
|
||||
border: none !important;
|
||||
|
||||
.el-dropdown-menu__item {
|
||||
padding: 6px 16px !important;
|
||||
border-radius: 6px !important;
|
||||
|
||||
&:hover:not(.is-disabled) {
|
||||
color: var(--art-gray-900) !important;
|
||||
background-color: var(--art-el-active-color) !important;
|
||||
}
|
||||
|
||||
&:focus:not(.is-disabled) {
|
||||
color: var(--art-gray-900) !important;
|
||||
background-color: var(--art-gray-200) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏 select、dropdown 的三角
|
||||
.el-select__popper,
|
||||
.el-dropdown__popper {
|
||||
margin-top: -6px !important;
|
||||
|
||||
.el-popper__arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.el-dropdown-selfdefine:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
// 处理移动端组件兼容性
|
||||
@media screen and (max-width: 640px) {
|
||||
.el-message-box,
|
||||
.el-dialog {
|
||||
width: calc(100% - 24px) !important;
|
||||
}
|
||||
|
||||
.el-date-picker.has-sidebar.has-time {
|
||||
width: calc(100% - 24px);
|
||||
left: 12px !important;
|
||||
}
|
||||
|
||||
.el-picker-panel *[slot='sidebar'],
|
||||
.el-picker-panel__sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-picker-panel *[slot='sidebar'] + .el-picker-panel__body,
|
||||
.el-picker-panel__sidebar + .el-picker-panel__body {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 修改el-button样式
|
||||
.el-button {
|
||||
&.el-button--text {
|
||||
background-color: transparent !important;
|
||||
padding: 0 !important;
|
||||
|
||||
span {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 修改el-tag样式
|
||||
.el-tag {
|
||||
font-weight: 500;
|
||||
transition: all 0s !important;
|
||||
|
||||
&.el-tag--default {
|
||||
height: 26px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-checkbox-group {
|
||||
&.el-table-filter__checkbox-group label.el-checkbox {
|
||||
height: 17px !important;
|
||||
|
||||
.el-checkbox__label {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-radio--default {
|
||||
// 优化单选按钮大小
|
||||
.el-radio__input {
|
||||
.el-radio__inner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
&::after {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-checkbox {
|
||||
.el-checkbox__inner {
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 优化复选框样式
|
||||
.el-checkbox--default {
|
||||
.el-checkbox__inner {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
border-radius: 4px !important;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
height: 4px !important;
|
||||
top: 5px !important;
|
||||
background-color: #fff !important;
|
||||
transform: scale(0.6) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.is-checked {
|
||||
.el-checkbox__inner {
|
||||
&::after {
|
||||
width: 3px;
|
||||
height: 8px;
|
||||
margin: auto;
|
||||
border: 2px solid var(--el-checkbox-checked-icon-color);
|
||||
border-left: 0;
|
||||
border-top: 0;
|
||||
transform: translate(-45%, -60%) rotate(45deg) scale(0.86) !important;
|
||||
transform-origin: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-notification .el-notification__icon {
|
||||
font-size: 22px !important;
|
||||
}
|
||||
|
||||
// 修改 el-message-box 样式
|
||||
.el-message-box__headerbtn .el-message-box__close,
|
||||
.el-dialog__headerbtn .el-dialog__close {
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-hover-color) !important;
|
||||
color: var(--art-gray-900) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-message-box {
|
||||
padding: 25px 20px !important;
|
||||
}
|
||||
|
||||
.el-message-box__title {
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.el-table__column-filter-trigger i {
|
||||
color: var(--theme-color) !important;
|
||||
margin: -3px 0 0 2px;
|
||||
}
|
||||
|
||||
// 去除 el-dropdown 鼠标放上去出现的边框
|
||||
.el-tooltip__trigger:focus-visible {
|
||||
outline: unset;
|
||||
}
|
||||
|
||||
// ipad 表单右侧按钮优化
|
||||
@media screen and (max-width: 1180px) {
|
||||
.el-table-fixed-column--right {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.login-out-dialog {
|
||||
padding: 30px 20px !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
// 修改 dialog 动画
|
||||
.dialog-fade-enter-active {
|
||||
.el-dialog:not(.is-draggable) {
|
||||
animation: dialog-open 0.3s cubic-bezier(0.32, 0.14, 0.15, 0.86);
|
||||
|
||||
// 修复 el-dialog 动画后宽度不自适应问题
|
||||
.el-select__selected-item {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-fade-leave-active {
|
||||
animation: fade-out 0.2s linear;
|
||||
|
||||
.el-dialog:not(.is-draggable) {
|
||||
animation: dialog-close 0.5s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialog-open {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.2);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialog-close {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// 遮罩层动画
|
||||
@keyframes fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 修改 el-select 样式
|
||||
.el-select__popper:not(.el-tree-select__popper) {
|
||||
.el-select-dropdown__list {
|
||||
padding: 5px !important;
|
||||
|
||||
.el-select-dropdown__item {
|
||||
height: 34px !important;
|
||||
line-height: 34px !important;
|
||||
border-radius: 6px !important;
|
||||
|
||||
&.is-selected {
|
||||
color: var(--art-gray-900) !important;
|
||||
font-weight: 400 !important;
|
||||
background-color: var(--art-el-active-color) !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-hover-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-select-dropdown__item:hover ~ .is-selected,
|
||||
.el-select-dropdown__item.is-selected:has(~ .el-select-dropdown__item:hover) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 修改 el-tree-select 样式
|
||||
.el-tree-select__popper {
|
||||
.el-select-dropdown__list {
|
||||
padding: 5px !important;
|
||||
|
||||
.el-tree-node {
|
||||
.el-tree-node__content {
|
||||
height: 36px !important;
|
||||
border-radius: 6px !important;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-gray-200) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 实现水波纹在文字下面效果
|
||||
.el-button > span {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
// 优化颜色选择器圆角
|
||||
.el-color-picker__color {
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
|
||||
// 优化日期时间选择器底部圆角
|
||||
.el-picker-panel {
|
||||
.el-picker-panel__footer {
|
||||
border-radius: 0 0 var(--el-border-radius-base) var(--el-border-radius-base);
|
||||
}
|
||||
}
|
||||
|
||||
// 优化树型菜单样式
|
||||
.el-tree-node__content {
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
padding: 1px 0;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-hover-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
|
||||
background-color: var(--art-gray-300) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏折叠菜单弹窗 hover 出现的边框
|
||||
.menu-left-popper:focus-within,
|
||||
.horizontal-menu-popper:focus-within {
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
// 数字输入组件右侧按钮高度跟随自定义组件高度
|
||||
.el-input-number--default.is-controls-right {
|
||||
.el-input-number__decrease,
|
||||
.el-input-number__increase {
|
||||
height: calc((var(--el-component-size) / 2)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 全局输入框文本左对齐(统一表单输入体验)
|
||||
.el-input__inner,
|
||||
.el-textarea__inner,
|
||||
.el-input-number .el-input__inner {
|
||||
text-align: left !important;
|
||||
}
|
||||
1036
src/assets/styles/core/md.scss
Normal file
157
src/assets/styles/core/mixin.scss
Normal file
@@ -0,0 +1,157 @@
|
||||
// sass 混合宏(函数)
|
||||
|
||||
/**
|
||||
* 溢出省略号
|
||||
* @param {Number} 行数
|
||||
*/
|
||||
@mixin ellipsis($rowCount: 1) {
|
||||
@if $rowCount <=1 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
} @else {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: $rowCount;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 控制用户能否选中文本
|
||||
* @param {String} 类型
|
||||
*/
|
||||
@mixin userSelect($value: none) {
|
||||
user-select: $value;
|
||||
-moz-user-select: $value;
|
||||
-ms-user-select: $value;
|
||||
-webkit-user-select: $value;
|
||||
}
|
||||
|
||||
// 绝对定位居中
|
||||
@mixin absoluteCenter() {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* css3动画
|
||||
*
|
||||
*/
|
||||
@mixin animation(
|
||||
$from: (
|
||||
width: 0px
|
||||
),
|
||||
$to: (
|
||||
width: 100px
|
||||
),
|
||||
$name: mymove,
|
||||
$animate: mymove 2s 1 linear infinite
|
||||
) {
|
||||
-webkit-animation: $animate;
|
||||
-o-animation: $animate;
|
||||
animation: $animate;
|
||||
|
||||
@keyframes #{$name} {
|
||||
from {
|
||||
@each $key, $value in $from {
|
||||
#{$key}: #{$value};
|
||||
}
|
||||
}
|
||||
|
||||
to {
|
||||
@each $key, $value in $to {
|
||||
#{$key}: #{$value};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes #{$name} {
|
||||
from {
|
||||
@each $key, $value in $from {
|
||||
$key: $value;
|
||||
}
|
||||
}
|
||||
|
||||
to {
|
||||
@each $key, $value in $to {
|
||||
$key: $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 圆形盒子
|
||||
@mixin circle($size: 11px, $bg: #fff) {
|
||||
border-radius: 50%;
|
||||
width: $size;
|
||||
height: $size;
|
||||
line-height: $size;
|
||||
text-align: center;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
// placeholder
|
||||
@mixin placeholder($color: #bbb) {
|
||||
// Firefox
|
||||
&::-moz-placeholder {
|
||||
color: $color;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// Internet Explorer 10+
|
||||
&:-ms-input-placeholder {
|
||||
color: $color;
|
||||
}
|
||||
|
||||
// Safari and Chrome
|
||||
&::-webkit-input-placeholder {
|
||||
color: $color;
|
||||
}
|
||||
|
||||
&:placeholder-shown {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
//背景透明,文字不透明。兼容IE8
|
||||
@mixin betterTransparentize($color, $alpha) {
|
||||
$c: rgba($color, $alpha);
|
||||
$ie_c: ie_hex_str($c);
|
||||
background: rgba($color, 1);
|
||||
background: $c;
|
||||
background: transparent \9;
|
||||
zoom: 1;
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c});
|
||||
-ms-filter: 'progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c})';
|
||||
}
|
||||
|
||||
//添加浏览器前缀
|
||||
@mixin browserPrefix($propertyName, $value) {
|
||||
@each $prefix in -webkit-, -moz-, -ms-, -o-, '' {
|
||||
#{$prefix}#{$propertyName}: $value;
|
||||
}
|
||||
}
|
||||
|
||||
// 边框
|
||||
@mixin border($color: red) {
|
||||
border: 1px solid $color;
|
||||
}
|
||||
|
||||
// 背景滤镜
|
||||
@mixin backdropBlur() {
|
||||
--tw-backdrop-blur: blur(30px);
|
||||
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness)
|
||||
var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate)
|
||||
var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate)
|
||||
var(--tw-backdrop-sepia);
|
||||
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast)
|
||||
var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert)
|
||||
var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
|
||||
}
|
||||
41
src/assets/styles/core/reset.scss
Normal file
@@ -0,0 +1,41 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
/*滚动条*/
|
||||
/*滚动条整体部分,必须要设置*/
|
||||
::-webkit-scrollbar {
|
||||
width: 8px !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
/*滚动条的轨道*/
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
|
||||
/*滚动条的滑块按钮*/
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 5px;
|
||||
background-color: #cccccc !important;
|
||||
transition: all 0.2s;
|
||||
-webkit-transition: all 0.2s;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #b0abab !important;
|
||||
}
|
||||
|
||||
/*滚动条的上下两端的按钮*/
|
||||
::-webkit-scrollbar-button {
|
||||
height: 0px;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.dark {
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: var(--default-bg-color);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--art-gray-300) !important;
|
||||
}
|
||||
}
|
||||
104
src/assets/styles/core/router-transition.scss
Normal file
@@ -0,0 +1,104 @@
|
||||
@use 'sass:map';
|
||||
|
||||
// === 变量区域 ===
|
||||
$transition: (
|
||||
// 动画持续时间
|
||||
duration: 0.25s,
|
||||
// 滑动动画的移动距离
|
||||
distance: 15px,
|
||||
// 默认缓动函数
|
||||
easing: cubic-bezier(0.25, 0.1, 0.25, 1),
|
||||
// 淡入淡出专用的缓动函数
|
||||
fade-easing: cubic-bezier(0.4, 0, 0.6, 1)
|
||||
);
|
||||
|
||||
// 抽取配置值函数,提高可复用性
|
||||
@function transition-config($key) {
|
||||
@return map.get($transition, $key);
|
||||
}
|
||||
|
||||
// 变量简写
|
||||
$duration: transition-config('duration');
|
||||
$distance: transition-config('distance');
|
||||
$easing: transition-config('easing');
|
||||
$fade-easing: transition-config('fade-easing');
|
||||
|
||||
// === 动画类 ===
|
||||
|
||||
// 淡入淡出动画
|
||||
.fade {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: opacity $duration $fade-easing;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&-enter-to,
|
||||
&-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 滑动动画通用样式
|
||||
@mixin slide-transition($direction) {
|
||||
$distance-x: 0;
|
||||
$distance-y: 0;
|
||||
|
||||
@if $direction == 'left' {
|
||||
$distance-x: -$distance;
|
||||
} @else if $direction == 'right' {
|
||||
$distance-x: $distance;
|
||||
} @else if $direction == 'top' {
|
||||
$distance-y: -$distance;
|
||||
} @else if $direction == 'bottom' {
|
||||
$distance-y: $distance;
|
||||
}
|
||||
|
||||
&-enter-active {
|
||||
transition:
|
||||
opacity $duration $easing,
|
||||
transform $duration $easing;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
&-leave-active {
|
||||
transition:
|
||||
opacity calc($duration * 0.7) $easing,
|
||||
transform calc($duration * 0.7) $easing;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
&-enter-from {
|
||||
opacity: 0;
|
||||
transform: translate3d($distance-x, $distance-y, 0);
|
||||
}
|
||||
|
||||
&-enter-to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
transform: translate3d(-$distance-x, -$distance-y, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 滑动动画方向类
|
||||
.slide-left {
|
||||
@include slide-transition('left');
|
||||
}
|
||||
.slide-right {
|
||||
@include slide-transition('right');
|
||||
}
|
||||
.slide-top {
|
||||
@include slide-transition('top');
|
||||
}
|
||||
.slide-bottom {
|
||||
@include slide-transition('bottom');
|
||||
}
|
||||
208
src/assets/styles/core/tailwind.css
Normal file
@@ -0,0 +1,208 @@
|
||||
@import 'tailwindcss';
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* ==================== Light Mode Variables ==================== */
|
||||
:root {
|
||||
/* Base Colors */
|
||||
--art-color: #ffffff;
|
||||
--theme-color: var(--main-color);
|
||||
|
||||
/* Theme Colors - OKLCH Format */
|
||||
--art-primary: oklch(0.7 0.23 260);
|
||||
--art-secondary: oklch(0.72 0.19 231.6);
|
||||
--art-error: oklch(0.73 0.15 25.3);
|
||||
--art-info: oklch(0.58 0.03 254.1);
|
||||
--art-success: oklch(0.78 0.17 166.1);
|
||||
--art-warning: oklch(0.78 0.14 75.5);
|
||||
--art-danger: oklch(0.68 0.22 25.3);
|
||||
|
||||
/* Gray Scale - Light Mode */
|
||||
--art-gray-100: #f9fafb;
|
||||
--art-gray-200: #f2f4f5;
|
||||
--art-gray-300: #e6eaeb;
|
||||
--art-gray-400: #dbdfe1;
|
||||
--art-gray-500: #949eb7;
|
||||
--art-gray-600: #7987a1;
|
||||
--art-gray-700: #4d5875;
|
||||
--art-gray-800: #383853;
|
||||
--art-gray-900: #323251;
|
||||
|
||||
/* Border Colors */
|
||||
--art-card-border: rgba(0, 0, 0, 0.08);
|
||||
|
||||
--default-border: #e2e8ee;
|
||||
--default-border-dashed: #dbdfe9;
|
||||
|
||||
/* Background Colors */
|
||||
--default-bg-color: #fafbfc;
|
||||
--default-box-color: #ffffff;
|
||||
|
||||
/* Hover Color */
|
||||
--art-hover-color: #edeff0;
|
||||
|
||||
/* Active Color */
|
||||
--art-active-color: #f2f4f5;
|
||||
|
||||
/* Element Component Active Color */
|
||||
--art-el-active-color: #f2f4f5;
|
||||
}
|
||||
|
||||
/* ==================== Dark Mode Variables ==================== */
|
||||
.dark {
|
||||
/* Base Colors */
|
||||
--art-color: #000000;
|
||||
|
||||
/* Gray Scale - Dark Mode */
|
||||
--art-gray-100: #110f0f;
|
||||
--art-gray-200: #17171c;
|
||||
--art-gray-300: #393946;
|
||||
--art-gray-400: #505062;
|
||||
--art-gray-500: #73738c;
|
||||
--art-gray-600: #8f8fa3;
|
||||
--art-gray-700: #ababba;
|
||||
--art-gray-800: #c7c7d1;
|
||||
--art-gray-900: #e3e3e8;
|
||||
|
||||
/* Border Colors */
|
||||
--art-card-border: rgba(255, 255, 255, 0.08);
|
||||
|
||||
--default-border: rgba(255, 255, 255, 0.1);
|
||||
--default-border-dashed: #363843;
|
||||
|
||||
/* Background Colors */
|
||||
--default-bg-color: #070707;
|
||||
--default-box-color: #161618;
|
||||
|
||||
/* Hover Color */
|
||||
--art-hover-color: #252530;
|
||||
|
||||
/* Active Color */
|
||||
--art-active-color: #202226;
|
||||
|
||||
/* Element Component Active Color */
|
||||
--art-el-active-color: #2e2e38;
|
||||
}
|
||||
|
||||
/* ==================== Tailwind Theme Configuration ==================== */
|
||||
@theme {
|
||||
/* Box Color (Light: white / Dark: black) */
|
||||
--color-box: var(--default-box-color);
|
||||
|
||||
/* System Theme Color */
|
||||
--color-theme: var(--theme-color);
|
||||
|
||||
/* Hover Color */
|
||||
--color-hover-color: var(--art-hover-color);
|
||||
|
||||
/* Active Color */
|
||||
--color-active-color: var(--art-active-color);
|
||||
|
||||
/* Active Color */
|
||||
--color-el-active-color: var(--art-active-color);
|
||||
|
||||
/* ElementPlus Theme Colors */
|
||||
--color-primary: var(--art-primary);
|
||||
--color-secondary: var(--art-secondary);
|
||||
--color-error: var(--art-error);
|
||||
--color-info: var(--art-info);
|
||||
--color-success: var(--art-success);
|
||||
--color-warning: var(--art-warning);
|
||||
--color-danger: var(--art-danger);
|
||||
|
||||
/* Gray Scale Colors (Auto-adapts to dark mode) */
|
||||
--color-g-100: var(--art-gray-100);
|
||||
--color-g-200: var(--art-gray-200);
|
||||
--color-g-300: var(--art-gray-300);
|
||||
--color-g-400: var(--art-gray-400);
|
||||
--color-g-500: var(--art-gray-500);
|
||||
--color-g-600: var(--art-gray-600);
|
||||
--color-g-700: var(--art-gray-700);
|
||||
--color-g-800: var(--art-gray-800);
|
||||
--color-g-900: var(--art-gray-900);
|
||||
}
|
||||
|
||||
/* ==================== Custom Border Radius Utilities ==================== */
|
||||
@utility rounded-custom-xs {
|
||||
border-radius: calc(var(--custom-radius) / 2);
|
||||
}
|
||||
|
||||
@utility rounded-custom-sm {
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px);
|
||||
}
|
||||
|
||||
/* ==================== Custom Utility Classes ==================== */
|
||||
@layer utilities {
|
||||
/* Flexbox Layout Utilities */
|
||||
.flex-c {
|
||||
@apply flex items-center;
|
||||
}
|
||||
|
||||
.flex-b {
|
||||
@apply flex justify-between;
|
||||
}
|
||||
|
||||
.flex-cc {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
|
||||
.flex-cb {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
|
||||
/* Transition Utilities */
|
||||
.tad-200 {
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.tad-300 {
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
|
||||
/* Border Utilities */
|
||||
.border-full-d {
|
||||
@apply border border-[var(--default-border)];
|
||||
}
|
||||
|
||||
.border-b-d {
|
||||
@apply border-b border-[var(--default-border)];
|
||||
}
|
||||
|
||||
.border-t-d {
|
||||
@apply border-t border-[var(--default-border)];
|
||||
}
|
||||
|
||||
.border-l-d {
|
||||
@apply border-l border-[var(--default-border)];
|
||||
}
|
||||
|
||||
.border-r-d {
|
||||
@apply border-r border-[var(--default-border)];
|
||||
}
|
||||
|
||||
/* Cursor Utilities */
|
||||
.c-p {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== Custom Component Classes ==================== */
|
||||
@layer components {
|
||||
/* Art Card Header Component */
|
||||
.art-card-header {
|
||||
@apply flex justify-between pr-6 pb-1;
|
||||
|
||||
.title {
|
||||
h4 {
|
||||
@apply text-lg font-medium text-g-900;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply mt-1 text-sm text-g-600;
|
||||
|
||||
span {
|
||||
@apply ml-2 font-medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/assets/styles/core/theme-animation.scss
Normal file
@@ -0,0 +1,63 @@
|
||||
// 定义基础变量
|
||||
$bg-animation-color-light: #000;
|
||||
$bg-animation-color-dark: #fff;
|
||||
$bg-animation-duration: 0.5s;
|
||||
|
||||
html {
|
||||
--bg-animation-color: $bg-animation-color-light;
|
||||
|
||||
&.dark {
|
||||
--bg-animation-color: $bg-animation-color-dark;
|
||||
}
|
||||
|
||||
// View transition styles
|
||||
&::view-transition-old(*) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
&::view-transition-new(*) {
|
||||
animation: clip $bg-animation-duration ease-in both;
|
||||
}
|
||||
|
||||
&::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&::view-transition-new(root) {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
&.dark {
|
||||
&::view-transition-old(*) {
|
||||
animation: clip $bg-animation-duration ease-in reverse both;
|
||||
}
|
||||
|
||||
&::view-transition-new(*) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
&::view-transition-old(root) {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
&::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 定义动画
|
||||
@keyframes clip {
|
||||
from {
|
||||
clip-path: circle(0% at var(--x) var(--y));
|
||||
}
|
||||
|
||||
to {
|
||||
clip-path: circle(var(--r) at var(--x) var(--y));
|
||||
}
|
||||
}
|
||||
|
||||
// body 相关样式
|
||||
body {
|
||||
background-color: var(--bg-animation-color);
|
||||
}
|
||||
11
src/assets/styles/core/theme-change.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
// 主题切换过渡优化,优化除视觉上的不适感
|
||||
.theme-change {
|
||||
* {
|
||||
transition: 0s !important;
|
||||
}
|
||||
|
||||
.el-switch__core,
|
||||
.el-switch__action {
|
||||
transition: all 0.3s !important;
|
||||
}
|
||||
}
|
||||
98
src/assets/styles/custom/one-dark-pro.scss
Normal file
@@ -0,0 +1,98 @@
|
||||
.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 0.5em;
|
||||
|
||||
color: #a6accd;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-section,
|
||||
.hljs-selector-class,
|
||||
.hljs-template-variable,
|
||||
.hljs-deletion {
|
||||
color: #aed07e !important;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #6f747d;
|
||||
}
|
||||
|
||||
.hljs-doctag,
|
||||
.hljs-keyword,
|
||||
.hljs-formula {
|
||||
color: #c792ea;
|
||||
}
|
||||
|
||||
.hljs-section,
|
||||
.hljs-name,
|
||||
.hljs-selector-tag,
|
||||
.hljs-deletion,
|
||||
.hljs-subst {
|
||||
color: #c86068;
|
||||
}
|
||||
|
||||
.hljs-literal {
|
||||
color: #56b6c2;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-regexp,
|
||||
.hljs-addition,
|
||||
.hljs-attribute,
|
||||
.hljs-meta-string {
|
||||
color: #abb2bf;
|
||||
}
|
||||
|
||||
.hljs-attribute {
|
||||
color: #c792ea;
|
||||
}
|
||||
|
||||
.hljs-function {
|
||||
color: #c792ea;
|
||||
}
|
||||
|
||||
.hljs-type {
|
||||
color: #f07178;
|
||||
}
|
||||
|
||||
.hljs-title {
|
||||
color: #82aaff !important;
|
||||
}
|
||||
|
||||
.hljs-built_in,
|
||||
.hljs-class {
|
||||
color: #82aaff;
|
||||
}
|
||||
|
||||
// 括号
|
||||
.hljs-params {
|
||||
color: #a6accd;
|
||||
}
|
||||
|
||||
.hljs-attr,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-selector-class,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-number {
|
||||
color: #de7e61;
|
||||
}
|
||||
|
||||
.hljs-symbol,
|
||||
.hljs-bullet,
|
||||
.hljs-link,
|
||||
.hljs-meta,
|
||||
.hljs-selector-id {
|
||||
color: #61aeee;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hljs-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
26
src/assets/styles/index.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
// 重置默认样式
|
||||
@use './core/reset.scss';
|
||||
|
||||
// 应用全局样式
|
||||
@use './core/app.scss';
|
||||
|
||||
// Element Plus 样式优化
|
||||
@use './core/el-ui.scss';
|
||||
|
||||
// Element Plus 暗黑主题
|
||||
@use './core/el-dark.scss';
|
||||
|
||||
// 暗黑主题样式优化
|
||||
@use './core/dark.scss';
|
||||
|
||||
// 路由切换动画
|
||||
@use './core/router-transition';
|
||||
|
||||
// 主题切换过渡优化
|
||||
@use './core/theme-change.scss';
|
||||
|
||||
// 主题切换圆形扩散动画
|
||||
@use './core/theme-animation.scss';
|
||||
|
||||
// 统一操作按钮组件
|
||||
@use './components/action-btn.scss';
|
||||
32
src/assets/svg/loading.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// 自定义四点旋转SVG
|
||||
export const fourDotsSpinnerSvg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
|
||||
<style>
|
||||
.spinner {
|
||||
transform-origin: 20px 20px;
|
||||
animation: rotate 1.6s linear infinite;
|
||||
}
|
||||
.dot {
|
||||
fill: var(--theme-color);
|
||||
animation: fade 1.6s infinite;
|
||||
}
|
||||
.dot:nth-child(1) { animation-delay: 0s; }
|
||||
.dot:nth-child(2) { animation-delay: 0.5s; }
|
||||
.dot:nth-child(3) { animation-delay: 1s; }
|
||||
.dot:nth-child(4) { animation-delay: 1.5s; }
|
||||
@keyframes rotate {
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes fade {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
<g class="spinner">
|
||||
<circle class="dot" cx="20" cy="8" r="4"/>
|
||||
<circle class="dot" cx="32" cy="20" r="4"/>
|
||||
<circle class="dot" cx="20" cy="32" r="4"/>
|
||||
<circle class="dot" cx="8" cy="20" r="4"/>
|
||||
</g>
|
||||
</svg>
|
||||
`
|
||||
413
src/components/announcement/AnnouncementPreview.vue
Normal file
@@ -0,0 +1,413 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="announcement-preview h-full w-full"
|
||||
:class="{ 'is-resizing': isResizing }"
|
||||
>
|
||||
<template v-if="isSplitMode">
|
||||
<ElContainer class="h-full w-full overflow-hidden">
|
||||
<ElMain class="h-full overflow-auto pr-4">
|
||||
<slot name="editor" />
|
||||
</ElMain>
|
||||
|
||||
<div
|
||||
class="preview-resizer"
|
||||
role="separator"
|
||||
:aria-label="t('announcement.preview.resizeHandle')"
|
||||
@pointerdown="handleResizeStart"
|
||||
></div>
|
||||
|
||||
<ElAside
|
||||
class="preview-aside flex h-full items-center justify-center border-l border-[var(--el-border-color)] bg-[var(--el-fill-color-blank)]"
|
||||
>
|
||||
<div class="flex h-full w-full items-center justify-center p-4">
|
||||
<div
|
||||
class="preview-phone flex h-full max-h-[720px] w-full max-w-[380px] flex-col overflow-hidden rounded-[32px] border border-[var(--el-border-color)] bg-[var(--el-bg-color)] shadow-xl"
|
||||
>
|
||||
<div class="border-b border-[var(--el-border-color)] px-4 py-3">
|
||||
<div class="text-base font-semibold text-[var(--el-text-color-primary)]">
|
||||
{{ titleText }}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<ElTag size="small" :type="typeTagType">{{ typeLabel }}</ElTag>
|
||||
<ElTag size="small" :type="priorityTagType">{{ priorityText }}</ElTag>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-[var(--el-text-color-secondary)]">
|
||||
<span class="mr-1">{{ $t('announcement.preview.publishAt') }}</span>
|
||||
<span>{{ publishTimeText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElScrollbar class="flex-1">
|
||||
<div
|
||||
class="announcement-preview__content px-4 py-4 text-sm leading-6 text-[var(--el-text-color-regular)]"
|
||||
>
|
||||
<div v-if="hasContent" v-html="sanitizedContent"></div>
|
||||
<div v-else class="text-sm text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('announcement.preview.emptyContent') }}
|
||||
</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</ElAside>
|
||||
</ElContainer>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="h-full w-full">
|
||||
<slot name="editor" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="announcement-preview__action-bar sticky bottom-0 z-20 border-t border-[var(--el-border-color)] bg-[var(--el-bg-color)] px-4 py-3"
|
||||
>
|
||||
<ElButton type="primary" class="w-full" @click="drawerVisible = true">
|
||||
{{ $t('announcement.preview.openPreview') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElDrawer
|
||||
v-model="drawerVisible"
|
||||
:title="t('announcement.preview.drawerTitle')"
|
||||
direction="btt"
|
||||
:size="drawerSize"
|
||||
append-to-body
|
||||
>
|
||||
<div class="flex w-full justify-center px-4 pb-4">
|
||||
<div
|
||||
class="preview-phone flex h-full max-h-[720px] w-full max-w-[380px] flex-col overflow-hidden rounded-[32px] border border-[var(--el-border-color)] bg-[var(--el-bg-color)] shadow-xl"
|
||||
>
|
||||
<div class="border-b border-[var(--el-border-color)] px-4 py-3">
|
||||
<div class="text-base font-semibold text-[var(--el-text-color-primary)]">
|
||||
{{ titleText }}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<ElTag size="small" :type="typeTagType">{{ typeLabel }}</ElTag>
|
||||
<ElTag size="small" :type="priorityTagType">{{ priorityText }}</ElTag>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-[var(--el-text-color-secondary)]">
|
||||
<span class="mr-1">{{ $t('announcement.preview.publishAt') }}</span>
|
||||
<span>{{ publishTimeText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElScrollbar class="flex-1">
|
||||
<div
|
||||
class="announcement-preview__content px-4 py-4 text-sm leading-6 text-[var(--el-text-color-regular)]"
|
||||
>
|
||||
<div v-if="hasContent" v-html="sanitizedContent"></div>
|
||||
<div v-else class="text-sm text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('announcement.preview.emptyContent') }}
|
||||
</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import DOMPurify from 'dompurify'
|
||||
import {
|
||||
ElAside,
|
||||
ElButton,
|
||||
ElContainer,
|
||||
ElDrawer,
|
||||
ElMain,
|
||||
ElScrollbar,
|
||||
ElTag
|
||||
} from 'element-plus'
|
||||
import type { AnnouncementFormData, TenantAnnouncementDto } from '@/types/announcement'
|
||||
import { TenantAnnouncementType } from '@/types/announcement'
|
||||
|
||||
defineOptions({ name: 'AnnouncementPreview' })
|
||||
|
||||
interface Props {
|
||||
announcement: Partial<AnnouncementFormData> | TenantAnnouncementDto
|
||||
mode?: 'split' | 'drawer'
|
||||
}
|
||||
|
||||
// 1. 解析 Props 与基础依赖
|
||||
const { announcement, mode = 'split' } = defineProps<Props>()
|
||||
const { t, locale } = useI18n()
|
||||
const { width } = useWindowSize()
|
||||
|
||||
// 2. 拖拽与布局状态
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const isResizing = ref(false)
|
||||
const drawerVisible = ref(false)
|
||||
|
||||
const PREVIEW_MIN_WIDTH = 320
|
||||
const PREVIEW_MAX_WIDTH = 520
|
||||
const PREVIEW_DEFAULT_WIDTH = 380
|
||||
const EDITOR_MIN_WIDTH = 360
|
||||
const DRAWER_BREAKPOINT = 1024
|
||||
|
||||
const previewWidth = ref(PREVIEW_DEFAULT_WIDTH)
|
||||
|
||||
// 3. 模式判定(桌面分栏 / 移动抽屉)
|
||||
const isDrawerMode = computed(() => mode === 'drawer' || width.value < DRAWER_BREAKPOINT)
|
||||
const isSplitMode = computed(() => !isDrawerMode.value)
|
||||
const drawerSize = computed(() => (width.value < 640 ? '90%' : '85%'))
|
||||
|
||||
// 4. 预览数据统一读取
|
||||
const announcementData = computed(
|
||||
() => announcement as Partial<TenantAnnouncementDto> & Partial<AnnouncementFormData>
|
||||
)
|
||||
|
||||
const titleText = computed(() => {
|
||||
// 1. 取标题或占位文案
|
||||
const title = announcementData.value.title?.trim()
|
||||
return title && title.length > 0 ? title : t('announcement.preview.titlePlaceholder')
|
||||
})
|
||||
|
||||
const rawContent = computed(() => {
|
||||
// 1. 取原始内容
|
||||
return announcementData.value.content?.trim() || ''
|
||||
})
|
||||
|
||||
const sanitizedContent = computed(() => {
|
||||
// 1. 使用 DOMPurify 清洗富文本
|
||||
return rawContent.value ? DOMPurify.sanitize(rawContent.value) : ''
|
||||
})
|
||||
|
||||
const hasContent = computed(() => sanitizedContent.value.length > 0)
|
||||
|
||||
const publishTimeText = computed(() => {
|
||||
// 1. 选择发布时间字段
|
||||
const candidate =
|
||||
announcementData.value.publishedAt ||
|
||||
announcementData.value.scheduledPublishAt ||
|
||||
announcementData.value.effectiveFrom ||
|
||||
null
|
||||
|
||||
// 2. 格式化时间
|
||||
if (!candidate) {
|
||||
return t('announcement.preview.publishAtUnknown')
|
||||
}
|
||||
|
||||
const date = new Date(candidate)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return t('announcement.preview.publishAtUnknown')
|
||||
}
|
||||
|
||||
return date.toLocaleString(locale.value)
|
||||
})
|
||||
|
||||
const typeLabel = computed(() => {
|
||||
// 1. 根据枚举映射公告类型
|
||||
switch (announcementData.value.announcementType) {
|
||||
case TenantAnnouncementType.System:
|
||||
return t('announcement.type.system')
|
||||
case TenantAnnouncementType.Billing:
|
||||
return t('announcement.type.billing')
|
||||
case TenantAnnouncementType.Operation:
|
||||
return t('announcement.type.operation')
|
||||
case TenantAnnouncementType.SYSTEM_PLATFORM_UPDATE:
|
||||
return t('announcement.type.systemPlatformUpdate')
|
||||
case TenantAnnouncementType.SYSTEM_SECURITY_NOTICE:
|
||||
return t('announcement.type.systemSecurityNotice')
|
||||
case TenantAnnouncementType.SYSTEM_COMPLIANCE:
|
||||
return t('announcement.type.systemCompliance')
|
||||
case TenantAnnouncementType.TENANT_INTERNAL:
|
||||
return t('announcement.type.tenantInternal')
|
||||
case TenantAnnouncementType.TENANT_FINANCE:
|
||||
return t('announcement.type.tenantFinance')
|
||||
case TenantAnnouncementType.TENANT_OPERATION:
|
||||
return t('announcement.type.tenantOperation')
|
||||
default:
|
||||
return t('announcement.type.unknown')
|
||||
}
|
||||
})
|
||||
|
||||
const typeTagType = computed(() => {
|
||||
// 1. 映射类型标签颜色
|
||||
switch (announcementData.value.announcementType) {
|
||||
case TenantAnnouncementType.System:
|
||||
case TenantAnnouncementType.SYSTEM_SECURITY_NOTICE:
|
||||
case TenantAnnouncementType.SYSTEM_COMPLIANCE:
|
||||
return 'danger'
|
||||
case TenantAnnouncementType.Billing:
|
||||
return 'warning'
|
||||
case TenantAnnouncementType.TENANT_FINANCE:
|
||||
return 'warning'
|
||||
case TenantAnnouncementType.Operation:
|
||||
case TenantAnnouncementType.SYSTEM_PLATFORM_UPDATE:
|
||||
return 'info'
|
||||
case TenantAnnouncementType.TENANT_INTERNAL:
|
||||
case TenantAnnouncementType.TENANT_OPERATION:
|
||||
return 'success'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
})
|
||||
|
||||
const priorityValue = computed(() => {
|
||||
// 1. 解析优先级数值
|
||||
const value = Number(announcementData.value.priority)
|
||||
return Number.isFinite(value) ? value : null
|
||||
})
|
||||
|
||||
const priorityText = computed(() => {
|
||||
// 1. 输出优先级文案
|
||||
if (priorityValue.value === null) {
|
||||
return t('announcement.preview.priorityUnknown')
|
||||
}
|
||||
|
||||
return t('announcement.preview.priorityTag', { level: priorityValue.value })
|
||||
})
|
||||
|
||||
const priorityTagType = computed(() => {
|
||||
// 1. 根据数值区间标识优先级
|
||||
if (priorityValue.value === null) {
|
||||
return 'info'
|
||||
}
|
||||
|
||||
if (priorityValue.value >= 8) {
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
if (priorityValue.value >= 5) {
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
if (priorityValue.value >= 3) {
|
||||
return 'info'
|
||||
}
|
||||
|
||||
return 'success'
|
||||
})
|
||||
|
||||
// 5. 预览宽度计算与写入
|
||||
const resolveMaxWidth = () => {
|
||||
// 1. 读取容器宽度并扣除编辑区最小值
|
||||
const rect = rootRef.value?.getBoundingClientRect()
|
||||
if (!rect) {
|
||||
return PREVIEW_MAX_WIDTH
|
||||
}
|
||||
|
||||
const maxByContainer = rect.width - EDITOR_MIN_WIDTH
|
||||
return Math.min(PREVIEW_MAX_WIDTH, Math.max(PREVIEW_MIN_WIDTH, maxByContainer))
|
||||
}
|
||||
|
||||
const applyPreviewWidth = (value: number) => {
|
||||
// 1. 计算限制后的宽度
|
||||
const maxWidth = resolveMaxWidth()
|
||||
const nextWidth = Math.min(Math.max(value, PREVIEW_MIN_WIDTH), maxWidth)
|
||||
|
||||
// 2. 写入 CSS 变量并同步缓存
|
||||
previewWidth.value = nextWidth
|
||||
rootRef.value?.style.setProperty('--preview-width', `${nextWidth}px`)
|
||||
}
|
||||
|
||||
// 6. 拖拽调整宽度
|
||||
const handleResizeStart = (event: PointerEvent) => {
|
||||
// 1. 仅在分栏模式启用拖拽
|
||||
if (!isSplitMode.value) return
|
||||
|
||||
// 2. 阻止选中文本
|
||||
event.preventDefault()
|
||||
|
||||
// 3. 初始化拖拽状态
|
||||
isResizing.value = true
|
||||
const startX = event.clientX
|
||||
const startWidth = previewWidth.value
|
||||
|
||||
// 4. 绑定全局拖拽监听
|
||||
const handleResizeMove = (moveEvent: PointerEvent) => {
|
||||
// 1. 计算拖拽偏移量
|
||||
const deltaX = startX - moveEvent.clientX
|
||||
const nextWidth = startWidth + deltaX
|
||||
|
||||
// 2. 更新预览宽度
|
||||
applyPreviewWidth(nextWidth)
|
||||
}
|
||||
|
||||
const handleResizeEnd = () => {
|
||||
// 1. 清理拖拽状态与监听
|
||||
isResizing.value = false
|
||||
window.removeEventListener('pointermove', handleResizeMove)
|
||||
window.removeEventListener('pointerup', handleResizeEnd)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handleResizeMove)
|
||||
window.addEventListener('pointerup', handleResizeEnd)
|
||||
}
|
||||
|
||||
// 7. 响应式监听与清理
|
||||
watch(isSplitMode, (value) => {
|
||||
// 1. 切回分栏时重置预览宽度
|
||||
if (value) {
|
||||
applyPreviewWidth(previewWidth.value)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 切换到抽屉时关闭预览
|
||||
drawerVisible.value = false
|
||||
})
|
||||
|
||||
watch(width, () => {
|
||||
// 1. 窗口变化时校正预览宽度
|
||||
if (isSplitMode.value) {
|
||||
applyPreviewWidth(previewWidth.value)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 1. 初始化默认宽度
|
||||
applyPreviewWidth(previewWidth.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 1. 清理拖拽状态
|
||||
isResizing.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.announcement-preview {
|
||||
--preview-width: 380px;
|
||||
}
|
||||
|
||||
.preview-aside {
|
||||
width: var(--preview-width);
|
||||
}
|
||||
|
||||
.preview-resizer {
|
||||
@apply relative w-2 shrink-0 cursor-col-resize;
|
||||
}
|
||||
|
||||
.preview-resizer::before {
|
||||
content: '';
|
||||
|
||||
@apply absolute left-1/2 top-2 h-[calc(100%-16px)] w-px -translate-x-1/2 bg-[var(--el-border-color)];
|
||||
}
|
||||
|
||||
.announcement-preview.is-resizing {
|
||||
@apply cursor-col-resize select-none;
|
||||
}
|
||||
|
||||
.announcement-preview__content :deep(p) {
|
||||
@apply mb-3 last:mb-0;
|
||||
}
|
||||
|
||||
.announcement-preview__content :deep(ul) {
|
||||
@apply mb-3 list-inside list-disc;
|
||||
}
|
||||
|
||||
.announcement-preview__content :deep(ol) {
|
||||
@apply mb-3 list-inside list-decimal;
|
||||
}
|
||||
|
||||
.announcement-preview__content :deep(h1),
|
||||
.announcement-preview__content :deep(h2),
|
||||
.announcement-preview__content :deep(h3) {
|
||||
@apply mb-2 text-base font-semibold text-[var(--el-text-color-primary)];
|
||||
}
|
||||
</style>
|
||||
766
src/components/announcement/AudienceSelector.vue
Normal file
@@ -0,0 +1,766 @@
|
||||
<template>
|
||||
<section class="w-full space-y-6">
|
||||
<ElCard shadow="never" class="w-full">
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-base font-semibold text-[var(--el-text-color-primary)]">
|
||||
{{ t('announcement.audience.title') }}
|
||||
</div>
|
||||
<div class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ t('announcement.audience.targetTypeHint') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium text-[var(--el-text-color-primary)]">
|
||||
{{ t('announcement.audience.targetType') }}
|
||||
</div>
|
||||
<ElRadioGroup v-model="localState.targetType" class="flex flex-wrap gap-4">
|
||||
<ElRadio v-for="item in targetTypeOptions" :key="item.value" :label="item.value">
|
||||
{{ t(item.labelKey) }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
<div class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ targetTypeHelper }}
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard v-if="isRoleMode" shadow="never" class="w-full">
|
||||
<template #header>
|
||||
<div class="text-sm font-semibold text-[var(--el-text-color-primary)]">
|
||||
{{ t('announcement.audience.type.roles') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-[var(--el-text-color-primary)]">
|
||||
{{ t('announcement.audience.rules.roles') }}
|
||||
</div>
|
||||
|
||||
<div v-loading="roleLoading" class="min-h-[48px]">
|
||||
<ElCheckboxGroup v-model="localState.rules.roles" class="flex flex-wrap gap-3">
|
||||
<ElCheckbox
|
||||
v-for="role in roleOptions"
|
||||
:key="role.id"
|
||||
:label="role.id"
|
||||
:disabled="role.disabled"
|
||||
>
|
||||
{{ role.label }}
|
||||
</ElCheckbox>
|
||||
</ElCheckboxGroup>
|
||||
</div>
|
||||
|
||||
<ElEmpty
|
||||
v-if="!roleLoading && roleOptions.length === 0"
|
||||
:description="t('announcement.audience.rules.emptyRoles')"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 text-xs text-[var(--el-text-color-secondary)]"
|
||||
>
|
||||
<span>{{ t('announcement.audience.rules.estimateLabel') }}</span>
|
||||
<ElTag type="info" effect="light">
|
||||
<span v-if="estimateLoading">{{
|
||||
t('announcement.audience.rules.estimateLoading')
|
||||
}}</span>
|
||||
<span v-else>{{ estimateState.count }}</span>
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard v-if="isRulesMode" shadow="never" class="w-full">
|
||||
<template #header>
|
||||
<div class="text-sm font-semibold text-[var(--el-text-color-primary)]">
|
||||
{{ t('announcement.audience.type.rules') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-[var(--el-text-color-primary)]">
|
||||
{{ t('announcement.audience.rules.departments') }}
|
||||
</div>
|
||||
<ElTreeSelect
|
||||
v-model="localState.rules.departments"
|
||||
:data="departmentOptions"
|
||||
multiple
|
||||
show-checkbox
|
||||
node-key="value"
|
||||
class="w-full"
|
||||
:placeholder="t('announcement.audience.rules.departmentsPlaceholder')"
|
||||
:loading="departmentLoading"
|
||||
clearable
|
||||
/>
|
||||
<ElEmpty
|
||||
v-if="!departmentLoading && departmentOptions.length === 0"
|
||||
:description="t('announcement.audience.rules.emptyDepartments')"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-[var(--el-text-color-primary)]">
|
||||
{{ t('announcement.audience.rules.roles') }}
|
||||
</div>
|
||||
<div v-loading="roleLoading" class="min-h-[48px]">
|
||||
<ElCheckboxGroup v-model="localState.rules.roles" class="flex flex-wrap gap-3">
|
||||
<ElCheckbox
|
||||
v-for="role in roleOptions"
|
||||
:key="role.id"
|
||||
:label="role.id"
|
||||
:disabled="role.disabled"
|
||||
>
|
||||
{{ role.label }}
|
||||
</ElCheckbox>
|
||||
</ElCheckboxGroup>
|
||||
</div>
|
||||
<ElEmpty
|
||||
v-if="!roleLoading && roleOptions.length === 0"
|
||||
:description="t('announcement.audience.rules.emptyRoles')"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-[var(--el-text-color-primary)]">
|
||||
{{ t('announcement.audience.rules.tags') }}
|
||||
</div>
|
||||
<ElSelect
|
||||
v-model="localState.rules.tags"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
collapse-tags
|
||||
class="w-full"
|
||||
:placeholder="t('announcement.audience.rules.tagsPlaceholder')"
|
||||
>
|
||||
<ElOption
|
||||
v-for="tag in tagOptions"
|
||||
:key="tag.value"
|
||||
:label="tag.label"
|
||||
:value="tag.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 text-xs text-[var(--el-text-color-secondary)]"
|
||||
>
|
||||
<span>{{ t('announcement.audience.rules.estimateLabel') }}</span>
|
||||
<ElTag type="info" effect="light">
|
||||
<span v-if="estimateLoading">{{
|
||||
t('announcement.audience.rules.estimateLoading')
|
||||
}}</span>
|
||||
<span v-else>{{ estimateState.count }}</span>
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard v-if="isUsersMode" shadow="never" class="w-full">
|
||||
<template #header>
|
||||
<div class="text-sm font-semibold text-[var(--el-text-color-primary)]">
|
||||
{{ t('announcement.audience.type.users') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<ElSelect
|
||||
v-model="localState.userIds"
|
||||
multiple
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
collapse-tags
|
||||
class="w-full"
|
||||
:remote-method="handleUserRemoteSearch"
|
||||
:loading="userLoading"
|
||||
:placeholder="t('announcement.audience.users.placeholder')"
|
||||
>
|
||||
<ElOption
|
||||
v-for="user in userOptions"
|
||||
:key="user.id"
|
||||
:label="user.label"
|
||||
:value="user.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 text-xs text-[var(--el-text-color-secondary)]"
|
||||
>
|
||||
<span>{{ t('announcement.audience.manual.estimateLabel') }}</span>
|
||||
<ElTag type="info" effect="light">{{ selectedUserCount }}</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard v-if="isManualMode" shadow="never" class="w-full">
|
||||
<template #header>
|
||||
<div class="text-sm font-semibold text-[var(--el-text-color-primary)]">
|
||||
{{ t('announcement.audience.type.manual') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<ElAutocomplete
|
||||
v-model="manualQuery"
|
||||
class="w-full"
|
||||
:fetch-suggestions="handleUserSuggestions"
|
||||
:placeholder="t('announcement.audience.manual.searchPlaceholder')"
|
||||
:trigger-on-focus="false"
|
||||
:loading="userLoading"
|
||||
clearable
|
||||
@select="handleUserSelect"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm text-[var(--el-text-color-primary)]">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ item.option?.email || item.option?.phone || item.option?.id }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElAutocomplete>
|
||||
|
||||
<ElTransfer
|
||||
v-model="localState.userIds"
|
||||
:data="manualTransferData"
|
||||
:titles="manualTransferTitles"
|
||||
filterable
|
||||
:filter-placeholder="t('announcement.audience.manual.filterPlaceholder')"
|
||||
class="w-full"
|
||||
>
|
||||
<template #default="{ option }">
|
||||
<span class="text-sm">{{ option.label }}</span>
|
||||
</template>
|
||||
</ElTransfer>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 text-xs text-[var(--el-text-color-secondary)]"
|
||||
>
|
||||
<span>{{ t('announcement.audience.manual.estimateLabel') }}</span>
|
||||
<ElTag type="info" effect="light">{{ selectedUserCount }}</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { fetchGetTenantRoles } from '@/api/tenant-role'
|
||||
import { fetchGetUserList } from '@/api/system-manage'
|
||||
import { useAnnouncementStore } from '@/store/modules/announcement'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import type { AnnouncementTargetType, AudienceEstimate, TargetRules } from '@/types/announcement'
|
||||
|
||||
defineOptions({ name: 'AudienceSelector' })
|
||||
|
||||
interface Props {
|
||||
modelValue: {
|
||||
targetType: AnnouncementTargetType
|
||||
targetRules?: TargetRules
|
||||
targetUserIds?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: Props['modelValue']): void
|
||||
(e: 'estimate-change', value: AudienceEstimate): void
|
||||
}
|
||||
|
||||
const { modelValue } = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const announcementStore = useAnnouncementStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 1. 选项数据结构定义
|
||||
interface DepartmentOption {
|
||||
value: string
|
||||
label: string
|
||||
children?: DepartmentOption[]
|
||||
}
|
||||
|
||||
interface RoleOption {
|
||||
id: string
|
||||
label: string
|
||||
code: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
interface TagOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
id: string
|
||||
label: string
|
||||
name?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
interface UserSuggestion {
|
||||
value: string
|
||||
label: string
|
||||
id: string
|
||||
option: UserOption
|
||||
}
|
||||
|
||||
interface TransferItem {
|
||||
key: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const targetTypeOptions: Array<{ value: AnnouncementTargetType; labelKey: string }> = [
|
||||
{ value: 'all', labelKey: 'announcement.audience.type.all' },
|
||||
{ value: 'roles', labelKey: 'announcement.audience.type.roles' },
|
||||
{ value: 'users', labelKey: 'announcement.audience.type.users' },
|
||||
{ value: 'rules', labelKey: 'announcement.audience.type.rules' },
|
||||
{ value: 'manual', labelKey: 'announcement.audience.type.manual' }
|
||||
]
|
||||
|
||||
const targetTypeHelperMap: Record<AnnouncementTargetType, string> = {
|
||||
all: 'announcement.audience.helper.all',
|
||||
roles: 'announcement.audience.helper.roles',
|
||||
users: 'announcement.audience.helper.users',
|
||||
rules: 'announcement.audience.helper.rules',
|
||||
manual: 'announcement.audience.helper.manual'
|
||||
}
|
||||
|
||||
// 2. 本地状态与加载状态
|
||||
const currentTenantId = computed(() => String(userStore.info?.tenantId || ''))
|
||||
const localState = reactive({
|
||||
targetType: 'all' as AnnouncementTargetType,
|
||||
rules: {
|
||||
departments: [] as string[],
|
||||
roles: [] as string[],
|
||||
tags: [] as string[]
|
||||
},
|
||||
userIds: [] as string[]
|
||||
})
|
||||
|
||||
const roleOptions = ref<RoleOption[]>([])
|
||||
const departmentOptions = ref<DepartmentOption[]>([])
|
||||
const tagOptions = ref<TagOption[]>([])
|
||||
const userOptions = ref<UserOption[]>([])
|
||||
|
||||
const roleLoading = ref(false)
|
||||
const departmentLoading = ref(false)
|
||||
const userLoading = ref(false)
|
||||
const estimateLoading = ref(false)
|
||||
|
||||
const estimateState = ref<AudienceEstimate>({ count: 0, preview: [] })
|
||||
const manualQuery = ref('')
|
||||
const isSyncing = ref(false)
|
||||
|
||||
// 3. 计算属性
|
||||
const isRoleMode = computed(() => localState.targetType === 'roles')
|
||||
const isUsersMode = computed(() => localState.targetType === 'users')
|
||||
const isRulesMode = computed(() => localState.targetType === 'rules')
|
||||
const isManualMode = computed(() => localState.targetType === 'manual')
|
||||
const isRulesOrRoles = computed(() => isRulesMode.value || isRoleMode.value)
|
||||
|
||||
const selectedUserCount = computed(() => localState.userIds.length)
|
||||
|
||||
const targetTypeHelper = computed(() => {
|
||||
return t(targetTypeHelperMap[localState.targetType])
|
||||
})
|
||||
|
||||
const manualTransferData = computed<TransferItem[]>(() => {
|
||||
return userOptions.value.map((user) => ({
|
||||
key: user.id,
|
||||
label: user.label
|
||||
}))
|
||||
})
|
||||
|
||||
const manualTransferTitles = computed(() => [
|
||||
t('announcement.audience.manual.transferLeft'),
|
||||
t('announcement.audience.manual.transferRight')
|
||||
])
|
||||
|
||||
// 4. 数据清洗与模型构建
|
||||
const normalizeArray = (value?: string[]) => {
|
||||
// 1. 空值回退
|
||||
const list = Array.isArray(value) ? value : []
|
||||
|
||||
// 2. 去重并过滤空白
|
||||
return Array.from(
|
||||
new Set(list.map((item) => String(item)).filter((item) => item.trim().length > 0))
|
||||
)
|
||||
}
|
||||
|
||||
const normalizeRules = (rules?: TargetRules) => {
|
||||
// 1. 统一格式化三类规则
|
||||
return {
|
||||
departments: normalizeArray(rules?.departments),
|
||||
roles: normalizeArray(rules?.roles),
|
||||
tags: normalizeArray(rules?.tags)
|
||||
}
|
||||
}
|
||||
|
||||
const buildRulesPayload = (mode: 'rules' | 'roles'): TargetRules => {
|
||||
// 1. 获取清洗后的规则数据
|
||||
const cleaned = normalizeRules(localState.rules)
|
||||
|
||||
// 2. 角色模式仅返回角色规则
|
||||
if (mode === 'roles') {
|
||||
return {
|
||||
roles: cleaned.roles
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 规则模式返回全部规则
|
||||
return cleaned
|
||||
}
|
||||
|
||||
const buildModelValue = (): Props['modelValue'] => {
|
||||
// 1. 初始化基础结构
|
||||
const targetType = localState.targetType
|
||||
|
||||
// 2. 角色与规则模式
|
||||
if (targetType === 'roles' || targetType === 'rules') {
|
||||
const rules = buildRulesPayload(targetType === 'roles' ? 'roles' : 'rules')
|
||||
const hasRules =
|
||||
(rules.departments && rules.departments.length > 0) ||
|
||||
(rules.roles && rules.roles.length > 0) ||
|
||||
(rules.tags && rules.tags.length > 0)
|
||||
|
||||
return {
|
||||
targetType,
|
||||
...(hasRules ? { targetRules: rules } : {})
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 指定用户与手动选择
|
||||
if (targetType === 'users' || targetType === 'manual') {
|
||||
const userIds = normalizeArray(localState.userIds)
|
||||
|
||||
return {
|
||||
targetType,
|
||||
...(userIds.length > 0 ? { targetUserIds: userIds } : {})
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 全部用户
|
||||
return { targetType }
|
||||
}
|
||||
|
||||
const emitModelValue = () => {
|
||||
// 1. 避免同步阶段触发外部更新
|
||||
if (isSyncing.value) return
|
||||
|
||||
// 2. 对外发送最新模型
|
||||
emit('update:modelValue', buildModelValue())
|
||||
}
|
||||
|
||||
// 5. 受众预估处理
|
||||
const emitEstimate = (estimate: AudienceEstimate) => {
|
||||
// 1. 更新本地缓存
|
||||
estimateState.value = estimate
|
||||
|
||||
// 2. 同步到外部监听
|
||||
emit('estimate-change', estimate)
|
||||
}
|
||||
|
||||
const updateUserEstimate = () => {
|
||||
// 1. 构建用户预览
|
||||
const userIds = normalizeArray(localState.userIds)
|
||||
|
||||
// 2. 发送预估结果
|
||||
emitEstimate({
|
||||
count: userIds.length,
|
||||
preview: userIds.slice(0, 10)
|
||||
})
|
||||
}
|
||||
|
||||
const triggerRuleEstimate = useDebounceFn(async () => {
|
||||
// 1. 仅规则/角色模式触发
|
||||
if (!isRulesOrRoles.value) return
|
||||
|
||||
// 2. 发起预估请求
|
||||
estimateLoading.value = true
|
||||
const rules = buildRulesPayload(localState.targetType === 'roles' ? 'roles' : 'rules')
|
||||
const estimate = await announcementStore.estimateAudience(rules)
|
||||
|
||||
// 3. 更新预估结果
|
||||
estimateLoading.value = false
|
||||
emitEstimate(estimate)
|
||||
}, 400)
|
||||
|
||||
const refreshEstimateByType = () => {
|
||||
// 1. 规则/角色模式走实时预估
|
||||
if (isRulesOrRoles.value) {
|
||||
triggerRuleEstimate()
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 用户模式直接统计
|
||||
if (isUsersMode.value || isManualMode.value) {
|
||||
updateUserEstimate()
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 角色数据加载
|
||||
const loadRoles = async () => {
|
||||
// 1. 标记加载状态
|
||||
roleLoading.value = true
|
||||
|
||||
try {
|
||||
// 2. 拉取角色列表
|
||||
const tenantId = currentTenantId.value
|
||||
if (!tenantId) {
|
||||
roleOptions.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 执行查询
|
||||
const result = await fetchGetTenantRoles(tenantId, { Page: 1, PageSize: 200 })
|
||||
const records = Array.isArray(result?.items) ? result.items : []
|
||||
|
||||
// 4. 映射为选择项
|
||||
roleOptions.value = records.map((role) => ({
|
||||
id: String(role.id),
|
||||
label: role.name || role.code,
|
||||
code: role.code,
|
||||
disabled: false
|
||||
}))
|
||||
} catch {
|
||||
// 1. 失败提示
|
||||
ElMessage.error(t('announcement.audience.errors.loadRolesFailed'))
|
||||
roleOptions.value = []
|
||||
} finally {
|
||||
// 2. 关闭加载状态
|
||||
roleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 用户搜索与候选缓存
|
||||
type UserListItem = Api.SystemManage.UserListItem
|
||||
type UserSearchParams = Api.SystemManage.UserSearchParams
|
||||
|
||||
const resolveUserLabel = (user: UserListItem) => {
|
||||
// 1. 优先展示昵称或账号
|
||||
if (user.displayName) return user.displayName
|
||||
if (user.account) return user.account
|
||||
|
||||
// 2. 回退手机号/邮箱
|
||||
if (user.phone) return user.phone
|
||||
if (user.email) return user.email
|
||||
|
||||
// 3. 最后回退ID
|
||||
return String(user.userId)
|
||||
}
|
||||
|
||||
const buildUserOption = (user: UserListItem): UserOption => {
|
||||
// 1. 生成基础字段
|
||||
return {
|
||||
id: String(user.userId),
|
||||
label: resolveUserLabel(user),
|
||||
name: user.displayName,
|
||||
phone: user.phone,
|
||||
email: user.email
|
||||
}
|
||||
}
|
||||
|
||||
const upsertUserOption = (option: UserOption) => {
|
||||
// 1. 查找已有项
|
||||
const index = userOptions.value.findIndex((item) => item.id === option.id)
|
||||
|
||||
// 2. 覆盖或追加
|
||||
if (index >= 0) {
|
||||
userOptions.value.splice(index, 1, option)
|
||||
} else {
|
||||
userOptions.value.push(option)
|
||||
}
|
||||
}
|
||||
|
||||
const ensureUserOptions = (ids: string[]) => {
|
||||
// 1. 生成已有ID集合
|
||||
const existingIds = new Set(userOptions.value.map((item) => item.id))
|
||||
|
||||
// 2. 补齐未知用户占位
|
||||
ids.forEach((id) => {
|
||||
if (!existingIds.has(id)) {
|
||||
userOptions.value.push({
|
||||
id,
|
||||
label: t('announcement.audience.users.unknownUser', { id })
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const buildUserSearchParams = (keyword: string): UserSearchParams => {
|
||||
// 1. 默认分页参数
|
||||
const params: UserSearchParams = { Page: 1, PageSize: 20 }
|
||||
|
||||
// 2. 使用统一关键词检索
|
||||
return {
|
||||
...params,
|
||||
Keyword: keyword
|
||||
}
|
||||
}
|
||||
|
||||
const searchUsers = async (keyword: string) => {
|
||||
// 1. 清洗关键词
|
||||
const query = keyword.trim()
|
||||
if (!query) return [] as UserOption[]
|
||||
|
||||
userLoading.value = true
|
||||
|
||||
try {
|
||||
// 2. 发起搜索请求
|
||||
const params = buildUserSearchParams(query)
|
||||
const result = await fetchGetUserList(params)
|
||||
const records = Array.isArray(result?.items) ? result.items : []
|
||||
|
||||
// 3. 转为候选选项
|
||||
const options = records.map((user) => buildUserOption(user))
|
||||
options.forEach((option) => upsertUserOption(option))
|
||||
|
||||
return options
|
||||
} catch {
|
||||
// 1. 失败提示
|
||||
ElMessage.error(t('announcement.audience.errors.loadUsersFailed'))
|
||||
return [] as UserOption[]
|
||||
} finally {
|
||||
// 2. 关闭加载状态
|
||||
userLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserSuggestions = useDebounceFn(
|
||||
async (queryString: string, callback: (results: UserSuggestion[]) => void) => {
|
||||
// 1. 拉取候选用户
|
||||
const options = await searchUsers(queryString)
|
||||
|
||||
// 2. 转换为自动完成格式
|
||||
const suggestions = options.map((option) => ({
|
||||
value: option.label,
|
||||
label: option.label,
|
||||
id: option.id,
|
||||
option
|
||||
}))
|
||||
|
||||
callback(suggestions)
|
||||
},
|
||||
300
|
||||
)
|
||||
|
||||
const handleUserSelect = (suggestion: UserSuggestion) => {
|
||||
// 1. 写入候选列表
|
||||
upsertUserOption(suggestion.option)
|
||||
|
||||
// 2. 追加到已选列表
|
||||
if (!localState.userIds.includes(suggestion.id)) {
|
||||
localState.userIds = [...localState.userIds, suggestion.id]
|
||||
}
|
||||
|
||||
// 3. 清空搜索框
|
||||
manualQuery.value = ''
|
||||
}
|
||||
|
||||
const handleUserRemoteSearch = (query: string) => {
|
||||
// 1. 指定用户模式触发搜索
|
||||
if (!query.trim()) return
|
||||
|
||||
searchUsers(query)
|
||||
}
|
||||
|
||||
// 8. Props 同步处理
|
||||
const syncFromProps = (value: Props['modelValue']) => {
|
||||
// 1. 标记同步中
|
||||
isSyncing.value = true
|
||||
|
||||
// 2. 同步目标类型
|
||||
localState.targetType = value?.targetType ?? 'all'
|
||||
|
||||
// 3. 同步规则与用户列表
|
||||
const normalizedRules = normalizeRules(value?.targetRules)
|
||||
localState.rules.departments = normalizedRules.departments
|
||||
localState.rules.roles = normalizedRules.roles
|
||||
localState.rules.tags = normalizedRules.tags
|
||||
localState.userIds = normalizeArray(value?.targetUserIds)
|
||||
|
||||
// 4. 补齐未知用户占位
|
||||
ensureUserOptions(localState.userIds)
|
||||
|
||||
nextTick(() => {
|
||||
// 1. 完成同步并刷新预估
|
||||
isSyncing.value = false
|
||||
refreshEstimateByType()
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => modelValue,
|
||||
(value) => syncFromProps(value),
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
// 9. 监听本地变更并向外同步
|
||||
watch(
|
||||
() => localState.targetType,
|
||||
() => {
|
||||
if (isSyncing.value) return
|
||||
emitModelValue()
|
||||
refreshEstimateByType()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => localState.rules,
|
||||
() => {
|
||||
if (isSyncing.value) return
|
||||
emitModelValue()
|
||||
|
||||
if (isRulesOrRoles.value) {
|
||||
triggerRuleEstimate()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => localState.userIds,
|
||||
() => {
|
||||
if (isSyncing.value) return
|
||||
emitModelValue()
|
||||
|
||||
if (isUsersMode.value || isManualMode.value) {
|
||||
updateUserEstimate()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 10. 初始化动作
|
||||
onMounted(() => {
|
||||
loadRoles()
|
||||
})
|
||||
|
||||
// 11. 监听租户变化
|
||||
watch(
|
||||
() => currentTenantId.value,
|
||||
(value) => {
|
||||
if (!value) return
|
||||
loadRoles()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
51
src/components/billing/BillingAmountDisplay.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<span :class="wrapClass">
|
||||
{{ formatted }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineOptions({ name: 'BillingAmountDisplay' })
|
||||
|
||||
interface Props {
|
||||
/** 金额 */
|
||||
amount: number
|
||||
/** 货币(默认 CNY) */
|
||||
currency?: string
|
||||
/** 小数位(默认 2) */
|
||||
precision?: number
|
||||
/** 是否显示货币符号(默认 true) */
|
||||
showSymbol?: boolean
|
||||
/** 字体强调(默认 false) */
|
||||
bold?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
currency: 'CNY',
|
||||
precision: 2,
|
||||
showSymbol: true,
|
||||
bold: false
|
||||
})
|
||||
|
||||
const symbol = computed(() => {
|
||||
if (!props.showSymbol) return ''
|
||||
if (props.currency === 'CNY') return '¥'
|
||||
if (props.currency === 'USD') return '$'
|
||||
return ''
|
||||
})
|
||||
|
||||
const formatted = computed(() => {
|
||||
const value = Number.isFinite(props.amount) ? props.amount : 0
|
||||
const text = value.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: props.precision,
|
||||
maximumFractionDigits: props.precision
|
||||
})
|
||||
return `${symbol.value}${text}`
|
||||
})
|
||||
|
||||
const wrapClass = computed(() => {
|
||||
return props.bold ? 'font-semibold' : ''
|
||||
})
|
||||
</script>
|
||||
30
src/components/billing/BillingStatusTag.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<ElTag :type="tagType" :size="size" :effect="effect">
|
||||
{{ label }}
|
||||
</ElTag>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ElTag } from 'element-plus'
|
||||
import { formatBillingStatus, getBillingStatusType } from '@/utils/billing'
|
||||
|
||||
defineOptions({ name: 'BillingStatusTag' })
|
||||
|
||||
interface Props {
|
||||
/** 账单状态 */
|
||||
status: Api.Billing.TenantBillingStatus
|
||||
/** Tag 尺寸 */
|
||||
size?: 'default' | 'small' | 'large'
|
||||
/** Tag 风格 */
|
||||
effect?: 'dark' | 'light' | 'plain'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'default',
|
||||
effect: 'light'
|
||||
})
|
||||
|
||||
const label = computed(() => formatBillingStatus(props.status))
|
||||
const tagType = computed(() => getBillingStatusType(props.status))
|
||||
</script>
|
||||
132
src/components/billing/BillingTimeline.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<ElTimeline>
|
||||
<ElTimelineItem
|
||||
v-for="item in timelineItems"
|
||||
:key="item.key"
|
||||
:timestamp="item.timestamp"
|
||||
:type="item.type"
|
||||
placement="top"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="font-medium">{{ item.title }}</div>
|
||||
<div v-if="item.description" class="text-sm text-[var(--el-text-color-secondary)]">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</div>
|
||||
</ElTimelineItem>
|
||||
</ElTimeline>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElTimeline, ElTimelineItem } from 'element-plus'
|
||||
import { TenantBillingStatus, TenantPaymentStatus } from '@/enums/Billing'
|
||||
import { calculateDaysOverdue, formatBillingStatus, formatPaymentMethod } from '@/utils/billing'
|
||||
|
||||
defineOptions({ name: 'BillingTimeline' })
|
||||
|
||||
type TimelineType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
|
||||
|
||||
interface Props {
|
||||
/** 账单(列表或详情均可) */
|
||||
billing: Api.Billing.BillingListDto | Api.Billing.BillingDetailDto
|
||||
/** 支付记录(可选,默认从 billing.payments 读取) */
|
||||
payments?: Api.Billing.PaymentRecordDto[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const formatDateTime = (date?: string) => {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
if (Number.isNaN(d.getTime())) return '-'
|
||||
return d.toLocaleString(locale.value)
|
||||
}
|
||||
|
||||
const toTime = (date?: string) => {
|
||||
if (!date) return 0
|
||||
const d = new Date(date)
|
||||
return Number.isNaN(d.getTime()) ? 0 : d.getTime()
|
||||
}
|
||||
|
||||
const timelineItems = computed(() => {
|
||||
const items: Array<{
|
||||
key: string
|
||||
timestamp: string
|
||||
title: string
|
||||
description?: string
|
||||
type: TimelineType
|
||||
timeValue: number
|
||||
}> = []
|
||||
|
||||
// 1. 创建事件
|
||||
items.push({
|
||||
key: `created-${props.billing.id}`,
|
||||
timestamp: formatDateTime(props.billing.createdAt),
|
||||
title: t('billing.timeline.created'),
|
||||
type: 'info',
|
||||
timeValue: toTime(props.billing.createdAt)
|
||||
})
|
||||
|
||||
// 2. 到期事件(带逾期天数)
|
||||
const days = calculateDaysOverdue(props.billing.dueDate)
|
||||
const isOverdue = days > 0
|
||||
items.push({
|
||||
key: `due-${props.billing.id}`,
|
||||
timestamp: formatDateTime(props.billing.dueDate),
|
||||
title: t('billing.timeline.dueDate'),
|
||||
description: isOverdue ? t('billing.timeline.overdueByDays', { days }) : undefined,
|
||||
type: isOverdue ? 'danger' : 'warning',
|
||||
timeValue: toTime(props.billing.dueDate)
|
||||
})
|
||||
|
||||
// 3. 支付记录事件
|
||||
const payments =
|
||||
props.payments || (props.billing as Api.Billing.BillingDetailDto).payments || []
|
||||
payments.forEach((p) => {
|
||||
const at = p.paidAt || p.createdAt
|
||||
const isSuccess = p.status === TenantPaymentStatus.Success
|
||||
const type: TimelineType = isSuccess
|
||||
? 'success'
|
||||
: p.status === TenantPaymentStatus.Failed
|
||||
? 'danger'
|
||||
: 'warning'
|
||||
|
||||
items.push({
|
||||
key: `payment-${p.id}`,
|
||||
timestamp: formatDateTime(at),
|
||||
title: t('billing.timeline.payment'),
|
||||
description: t('billing.timeline.paymentDesc', {
|
||||
amount: Number.isFinite(p.amount) ? p.amount.toFixed(2) : '0.00',
|
||||
method: formatPaymentMethod(p.method)
|
||||
}),
|
||||
type,
|
||||
timeValue: toTime(at)
|
||||
})
|
||||
})
|
||||
|
||||
// 4. 当前状态事件(仅展示一次,避免与 payment 重复过多)
|
||||
const statusTypeMap: Record<number, TimelineType> = {
|
||||
[TenantBillingStatus.Pending]: 'warning',
|
||||
[TenantBillingStatus.Paid]: 'success',
|
||||
[TenantBillingStatus.Overdue]: 'danger',
|
||||
[TenantBillingStatus.Cancelled]: 'info'
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: `status-${props.billing.id}`,
|
||||
timestamp: formatDateTime(props.billing.updatedAt || props.billing.createdAt),
|
||||
title: t('billing.timeline.currentStatus', {
|
||||
status: formatBillingStatus(props.billing.status)
|
||||
}),
|
||||
description: t('billing.timeline.currentStatusDesc'),
|
||||
type: statusTypeMap[props.billing.status] || 'info',
|
||||
timeValue: toTime(props.billing.updatedAt || props.billing.createdAt)
|
||||
})
|
||||
|
||||
// 5. 按时间排序
|
||||
return items.sort((a, b) => a.timeValue - b.timeValue)
|
||||
})
|
||||
</script>
|
||||
42
src/components/billing/PaymentMethodIcon.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<ArtSvgIcon :icon="icon" :size="size" />
|
||||
<span v-if="showLabel" class="text-sm">
|
||||
{{ label }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import ArtSvgIcon from '@/components/core/base/art-svg-icon/index.vue'
|
||||
import { TenantPaymentMethod } from '@/enums/Billing'
|
||||
import { formatPaymentMethod } from '@/utils/billing'
|
||||
|
||||
defineOptions({ name: 'PaymentMethodIcon' })
|
||||
|
||||
interface Props {
|
||||
/** 支付方式 */
|
||||
method: Api.Billing.TenantPaymentMethod
|
||||
/** 图标大小 */
|
||||
size?: number
|
||||
/** 是否显示文本 */
|
||||
showLabel?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 16,
|
||||
showLabel: false
|
||||
})
|
||||
|
||||
const icon = computed(() => {
|
||||
const map: Record<number, string> = {
|
||||
[TenantPaymentMethod.Online]: 'ri:credit-card-line',
|
||||
[TenantPaymentMethod.BankTransfer]: 'ri:bank-line',
|
||||
[TenantPaymentMethod.Other]: 'ri:money-cny-circle-line'
|
||||
}
|
||||
return map[props.method] ?? 'ri:money-cny-circle-line'
|
||||
})
|
||||
|
||||
const label = computed(() => formatPaymentMethod(props.method))
|
||||
</script>
|
||||
186
src/components/business/contact_modal/ContactModal.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 定义 Props,控制显示状态
|
||||
const { modelValue = false } = defineProps<{
|
||||
modelValue?: boolean // 用于 v-model 控制显隐
|
||||
}>()
|
||||
|
||||
// 联系方式配置
|
||||
const wechatQrImageUrl = 'https://image-admin.laosankeji.com/assets/wx-qr/m1352251.jpg'
|
||||
const phoneNumber = '+8613522515407'
|
||||
const qqUin = '2039814060'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
// 关闭弹窗逻辑
|
||||
const closeModal = () => {
|
||||
emit('update:modelValue', false)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 复制功能逻辑
|
||||
const copySuccess = ref(false)
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copySuccess.value = true
|
||||
setTimeout(() => {
|
||||
copySuccess.value = false
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 唤起腾讯 QQ(失败则复制 QQ 号)
|
||||
const qqOpenFailed = ref(false)
|
||||
const openTencentQq = async () => {
|
||||
// 1. 尝试唤起客户端
|
||||
const schemeUrl = `tencent://message/?uin=${qqUin}&Site=&Menu=yes`
|
||||
const opened = window.open(schemeUrl, '_blank')
|
||||
|
||||
// 2. 浏览器阻止或不支持 scheme 时,降级为复制 QQ 号
|
||||
if (!opened) {
|
||||
qqOpenFailed.value = true
|
||||
await copyToClipboard(qqUin)
|
||||
setTimeout(() => {
|
||||
qqOpenFailed.value = false
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Teleport 确保弹窗挂载到 body,避免被父级 overflow:hidden 截断 -->
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<!-- 遮罩层 & 容器 -->
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-[100] overflow-y-auto"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<!-- 背景遮罩 -->
|
||||
<div
|
||||
class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm transition-opacity"
|
||||
@click="closeModal"
|
||||
></div>
|
||||
|
||||
<div class="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out transform"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-4"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition duration-200 ease-in transform"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-95 translate-y-4"
|
||||
>
|
||||
<!-- 弹窗主体 -->
|
||||
<!-- 修复:添加 v-if="modelValue" 解决 ESLint 报错,并确保动画触发 -->
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="relative w-full max-w-3xl transform overflow-hidden rounded-2xl bg-white text-left shadow-2xl transition-all border border-indigo-50"
|
||||
>
|
||||
<!-- 关闭按钮 -->
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="absolute top-4 right-4 text-slate-400 hover:text-slate-600 transition-colors z-20"
|
||||
>
|
||||
<!-- 这里的 icon 可以换成你项目中使用的组件,如 <IconClose /> -->
|
||||
<i class="fa-solid fa-times text-xl">X</i>
|
||||
</button>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2">
|
||||
<!-- 左侧:微信引导 -->
|
||||
<div
|
||||
class="bg-indigo-600 p-8 text-white flex flex-col justify-center items-center text-center relative overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-full bg-indigo-700 opacity-10 rotate-12 scale-150 transform pointer-events-none"
|
||||
></div>
|
||||
|
||||
<h3 class="text-xl font-bold mb-2 relative z-10">添加客户经理</h3>
|
||||
<p class="text-indigo-100 text-sm mb-6 relative z-10"
|
||||
>1对1 解答疑问 · 获取专属优惠</p
|
||||
>
|
||||
|
||||
<div
|
||||
class="bg-white p-2 rounded-lg shadow-lg mb-4 relative z-10 transform hover:scale-105 transition-transform duration-300"
|
||||
>
|
||||
<img
|
||||
:src="wechatQrImageUrl"
|
||||
alt="微信二维码"
|
||||
class="w-32 h-32 object-cover rounded-md"
|
||||
referrerpolicy="no-referrer"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-indigo-200 relative z-10 flex items-center">
|
||||
<span class="mr-1">🏷️</span> 微信扫一扫,极速响应
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:其他方式 -->
|
||||
<div class="p-8 bg-white flex flex-col justify-center">
|
||||
<h3 class="text-lg font-bold text-slate-800 mb-6">其他联系方式</h3>
|
||||
|
||||
<!-- 电话 -->
|
||||
<div class="mb-6 group cursor-pointer" @click="copyToClipboard(phoneNumber)">
|
||||
<div class="flex items-center mb-1">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-indigo-50 text-indigo-600 flex items-center justify-center mr-3 group-hover:bg-indigo-600 group-hover:text-white transition-colors"
|
||||
>
|
||||
📞
|
||||
</div>
|
||||
<span class="text-sm font-bold text-slate-700">电话咨询</span>
|
||||
</div>
|
||||
<div class="pl-11">
|
||||
<div
|
||||
class="text-xl font-mono text-slate-900 font-semibold group-hover:text-indigo-600"
|
||||
>{{ phoneNumber }}</div
|
||||
>
|
||||
<p class="text-xs text-slate-400 mt-1">工作日 9:00 - 18:00</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QQ -->
|
||||
<div class="mb-6 group cursor-pointer" @click="openTencentQq">
|
||||
<div class="flex items-center mb-1">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-blue-50 text-blue-500 flex items-center justify-center mr-3 group-hover:bg-blue-500 group-hover:text-white transition-colors"
|
||||
>
|
||||
🐧
|
||||
</div>
|
||||
<span class="text-sm font-bold text-slate-700">在线客服 (QQ)</span>
|
||||
</div>
|
||||
<div class="pl-11 flex items-center">
|
||||
<span class="text-slate-600 mr-2 font-mono">{{ qqUin }}</span>
|
||||
<span
|
||||
class="text-xs bg-slate-100 text-slate-500 px-2 py-0.5 rounded hover:bg-blue-100 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
{{ qqOpenFailed ? '未检测到 QQ,已复制' : '点击打开 QQ' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
82
src/components/common/CategorySelect.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<ElSelect
|
||||
v-model="internalValue"
|
||||
filterable
|
||||
clearable
|
||||
:loading="loading"
|
||||
:placeholder="placeholder || t('store.form.categoryIdPlaceholder')"
|
||||
class="w-full"
|
||||
@change="handleChange"
|
||||
>
|
||||
<ElOption v-for="item in options" :key="item.id" :label="getLabel(item)" :value="item.id">
|
||||
<span>{{ getLabel(item) }}</span>
|
||||
<span class="ml-2 text-xs text-[var(--el-text-color-secondary)]">[{{ item.key }}]</span>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElSelect, ElOption } from 'element-plus'
|
||||
import { getDictionary } from '@/api/dictionary/query'
|
||||
|
||||
defineOptions({ name: 'CategorySelect' })
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | null
|
||||
placeholder?: string
|
||||
dictCode?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
dictCode: 'STORE_CATEGORY'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
(e: 'change', value: string | null, item?: Api.Dictionary.DictionaryItemDto): void
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const loading = ref(false)
|
||||
const internalValue = ref<string | null>(null)
|
||||
const options = ref<Api.Dictionary.DictionaryItemDto[]>([])
|
||||
|
||||
/** 获取字典多语言标签 */
|
||||
const getLabel = (item: Api.Dictionary.DictionaryItemDto) => {
|
||||
// 优先匹配当前语言,匹配不到则取第一个
|
||||
const labels = item.value || {}
|
||||
const currentLocale = locale.value
|
||||
return labels[currentLocale] || labels['zh-CN'] || Object.values(labels)[0] || item.key
|
||||
}
|
||||
|
||||
/** 加载字典数据 */
|
||||
const loadOptions = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
options.value = await getDictionary(props.dictCode)
|
||||
} catch (error) {
|
||||
console.warn(`Fetch Dictionary [${props.dictCode}] Error:`, error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理值变更 */
|
||||
const handleChange = (val: string | null) => {
|
||||
const selected = options.value.find((item) => item.id === val)
|
||||
emit('update:modelValue', val)
|
||||
emit('change', val, selected)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
internalValue.value = newVal || null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(loadOptions)
|
||||
</script>
|
||||
147
src/components/common/ImageUpload.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="image-upload-container">
|
||||
<div
|
||||
class="upload-box relative flex items-center justify-center overflow-hidden rounded-lg border border-dashed border-[var(--el-border-color)] bg-[var(--el-fill-color-lighter)] transition-all hover:border-[var(--el-color-primary)]"
|
||||
:style="{ width: boxWidth, height: boxHeight }"
|
||||
>
|
||||
<!-- 1. 已有图片展示 -->
|
||||
<template v-if="modelValue">
|
||||
<ElImage
|
||||
:src="modelValue"
|
||||
fit="cover"
|
||||
class="h-full w-full"
|
||||
:preview-src-list="[modelValue]"
|
||||
preview-teleported
|
||||
/>
|
||||
<!-- 遮罩操作层 -->
|
||||
<div
|
||||
class="action-mask absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<ElButton circle type="primary" :icon="Edit" @click="handleTriggerUpload" />
|
||||
<ElButton circle type="danger" :icon="Delete" @click="handleRemove" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 2. 未上传状态 -->
|
||||
<div
|
||||
v-else
|
||||
class="flex cursor-pointer flex-col items-center justify-center gap-2 p-4 text-[var(--el-text-color-secondary)] hover:text-[var(--el-color-primary)]"
|
||||
@click="handleTriggerUpload"
|
||||
>
|
||||
<ElIcon :size="24" v-loading="isUploading">
|
||||
<Plus v-if="!isUploading" />
|
||||
</ElIcon>
|
||||
<span class="text-xs">{{ placeholder || t('common.uploadImage') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 提示信息 -->
|
||||
<div v-if="tip" class="mt-2 text-xs text-[var(--el-text-color-placeholder)]">
|
||||
{{ tip }}
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的原始 Input -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
class="hidden"
|
||||
:accept="accept"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElImage, ElButton, ElIcon, ElMessage } from 'element-plus'
|
||||
import { Plus, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { fetchUploadFile } from '@/api/files'
|
||||
|
||||
defineOptions({ name: 'ImageUpload' })
|
||||
|
||||
// 1. 定义 Props
|
||||
interface Props {
|
||||
modelValue?: string // 图片 URL
|
||||
uploadType?: Api.Files.UploadFileType // 后端要求的文件分类
|
||||
accept?: string // 接受的文件类型
|
||||
boxWidth?: string // 容器宽度
|
||||
boxHeight?: string // 容器高度
|
||||
placeholder?: string // 未上传时的占位文字
|
||||
tip?: string // 下方的提示文字
|
||||
maxSize?: number // 最大体积 (MB),默认 2MB
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
uploadType: 'other',
|
||||
accept: 'image/jpeg,image/png,image/webp',
|
||||
boxWidth: '120px',
|
||||
boxHeight: '120px',
|
||||
maxSize: 2
|
||||
})
|
||||
|
||||
// 2. 定义 Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'success', value: Api.Files.FileUploadResponse): void
|
||||
(e: 'change', value: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 3. 内部状态
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const isUploading = ref(false)
|
||||
|
||||
/** 触发文件选择 */
|
||||
const handleTriggerUpload = () => {
|
||||
if (isUploading.value) return
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
/** 处理文件选择变更 */
|
||||
const handleFileChange = async (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 1. 基础校验 (大小)
|
||||
if (file.size > props.maxSize * 1024 * 1024) {
|
||||
ElMessage.warning(t('common.fileTooLarge', { size: props.maxSize }))
|
||||
target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 上传流程
|
||||
isUploading.value = true
|
||||
try {
|
||||
const res = await fetchUploadFile(file, props.uploadType)
|
||||
if (res.url) {
|
||||
emit('update:modelValue', res.url)
|
||||
emit('success', res)
|
||||
emit('change', res.url)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Image Upload Error:', error)
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
target.value = '' // 清空 input 以便下次选择同一张图
|
||||
}
|
||||
}
|
||||
|
||||
/** 移除图片 */
|
||||
const handleRemove = () => {
|
||||
emit('update:modelValue', '')
|
||||
emit('change', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.image-upload-container {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
</style>
|
||||
118
src/components/common/MerchantSelect.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<ElSelect
|
||||
v-model="internalValue"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
:remote-method="handleSearch"
|
||||
:loading="loading"
|
||||
:placeholder="placeholder || '输入商户名搜索...'"
|
||||
class="w-full"
|
||||
@change="handleChange"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Search /></ElIcon>
|
||||
</template>
|
||||
<ElOption v-for="item in options" :key="item.id" :label="item.name" :value="item.id">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>{{ item.name }}</span>
|
||||
<span class="ml-2 text-xs text-[var(--el-text-color-secondary)]">ID: {{ item.id }}</span>
|
||||
</div>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { ElSelect, ElOption, ElIcon } from 'element-plus'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { fetchMerchantList, fetchMerchantDetail } from '@/api/merchant'
|
||||
|
||||
defineOptions({ name: 'MerchantSelect' })
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | number | null
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
(
|
||||
e: 'change',
|
||||
value: string | null,
|
||||
merchant?: Api.Merchant.MerchantListItem | Api.Merchant.MerchantDetail
|
||||
): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const internalValue = ref<string | null>(null)
|
||||
const options = ref<Api.Merchant.MerchantListItem[]>([])
|
||||
|
||||
/** 搜索商户 */
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!query) {
|
||||
options.value = []
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchMerchantList({
|
||||
keyword: query,
|
||||
Page: 1,
|
||||
PageSize: 20
|
||||
})
|
||||
options.value = res.items || []
|
||||
} catch (error) {
|
||||
console.error('Fetch Merchant List Error:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理选中值变更 */
|
||||
const handleChange = (val: string | null) => {
|
||||
const selected = options.value.find((item) => item.id === val)
|
||||
emit('update:modelValue', val)
|
||||
emit('change', val, selected)
|
||||
}
|
||||
|
||||
/** 监听外部值变化 */
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (newVal) => {
|
||||
const stringVal = newVal ? String(newVal) : null
|
||||
if (stringVal === internalValue.value) return
|
||||
|
||||
internalValue.value = stringVal
|
||||
|
||||
// 如果有值但不在当前选项列表中,需要反查详情以显示 Label
|
||||
if (stringVal && !options.value.some((item) => item.id === stringVal)) {
|
||||
try {
|
||||
const detail = await fetchMerchantDetail(stringVal, { showErrorMessage: false })
|
||||
if (detail) {
|
||||
// @ts-expect-error MerchantDetail to ListItem mapping
|
||||
options.value = [detail]
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Fetch Merchant Detail for Select failed', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/** 初始化加载部分数据(可选) */
|
||||
onMounted(async () => {
|
||||
if (!props.modelValue) {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchMerchantList({ Page: 1, PageSize: 10 })
|
||||
options.value = res.items || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
134
src/components/common/RichTextEditor.example.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* RichTextEditor 组件使用示例和验收标准验证
|
||||
*
|
||||
* 任务:FE-ANNOUNCE-07 创建 RichTextEditor 组件
|
||||
*
|
||||
* ✅ 验收标准检查清单:
|
||||
*
|
||||
* 1. ✅ 组件可通过 v-model 绑定 HTML 内容
|
||||
* - 实现:使用 defineModel 和 v-model 实现双向绑定
|
||||
* - 代码:line 59, 85-92, 100-105
|
||||
*
|
||||
* 2. ✅ 工具栏功能正常(加粗、斜体等)
|
||||
* - 实现:配置简化工具栏包含 bold、italic、underline、headerSelect、list、link
|
||||
* - 代码:line 63-76 toolbarConfig
|
||||
*
|
||||
* 3. ✅ disabled 状态下编辑器只读
|
||||
* - 实现:通过 readOnly 配置和动态监听 disabled 变化调用 enable/disable
|
||||
* - 代码:line 80 (readOnly), line 94-103 (watch disabled)
|
||||
*
|
||||
* 4. ✅ 组件销毁时正确清理编辑器实例
|
||||
* - 实现:onBeforeUnmount 钩子中调用 editor.destroy()
|
||||
* - 代码:line 116-121
|
||||
*
|
||||
* 5. ✅ TypeScript 类型完整无 any
|
||||
* - 实现:所有 props、emits、变量都有明确类型定义
|
||||
* - 代码:Props interface (line 17-30), emits (line 34-37),
|
||||
* IDomEditor (line 40), computed types (line 43-48, 63-76, 79-83)
|
||||
*
|
||||
* 📋 实现的额外功能:
|
||||
* - 支持自定义高度(height prop)
|
||||
* - 支持自定义占位符(placeholder prop)
|
||||
* - 支持编辑器模式切换(mode prop: default/simple)
|
||||
* - 暴露编辑器实例方法(getEditor, setHtml, getHtml, clear, focus, disable, enable)
|
||||
* - 正确处理外部 modelValue 变化(避免循环更新)
|
||||
*
|
||||
* 🔧 技术栈:
|
||||
* - @wangeditor/editor 5.1.23
|
||||
* - @wangeditor/editor-for-vue (next)
|
||||
* - Vue 3.5 Composition API (script setup)
|
||||
* - TypeScript 5.6
|
||||
*
|
||||
* 📝 使用示例:
|
||||
*/
|
||||
|
||||
// 示例 1: 基础使用
|
||||
/*
|
||||
<template>
|
||||
<RichTextEditor
|
||||
v-model="content"
|
||||
placeholder="请输入公告内容..."
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import RichTextEditor from '@/components/common/RichTextEditor.vue'
|
||||
|
||||
const content = ref('<p>初始内容</p>')
|
||||
</script>
|
||||
*/
|
||||
|
||||
// 示例 2: 禁用状态
|
||||
/*
|
||||
<template>
|
||||
<RichTextEditor
|
||||
v-model="content"
|
||||
:disabled="isPreview"
|
||||
placeholder="预览模式..."
|
||||
/>
|
||||
<button @click="isPreview = !isPreview">切换预览</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import RichTextEditor from '@/components/common/RichTextEditor.vue'
|
||||
|
||||
const content = ref('')
|
||||
const isPreview = ref(false)
|
||||
</script>
|
||||
*/
|
||||
|
||||
// 示例 3: 自定义高度和监听变化
|
||||
/*
|
||||
<template>
|
||||
<RichTextEditor
|
||||
v-model="content"
|
||||
height="300px"
|
||||
@change="handleContentChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import RichTextEditor from '@/components/common/RichTextEditor.vue'
|
||||
|
||||
const content = ref('')
|
||||
|
||||
const handleContentChange = (html: string) => {
|
||||
console.log('内容已变化:', html)
|
||||
}
|
||||
</script>
|
||||
*/
|
||||
|
||||
// 示例 4: 使用 ref 访问编辑器实例方法
|
||||
/*
|
||||
<template>
|
||||
<RichTextEditor ref="editorRef" v-model="content" />
|
||||
<button @click="clearContent">清空</button>
|
||||
<button @click="focusEditor">聚焦</button>
|
||||
<button @click="logContent">打印内容</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import RichTextEditor from '@/components/common/RichTextEditor.vue'
|
||||
|
||||
const content = ref('')
|
||||
const editorRef = ref<InstanceType<typeof RichTextEditor>>()
|
||||
|
||||
const clearContent = () => {
|
||||
editorRef.value?.clear()
|
||||
}
|
||||
|
||||
const focusEditor = () => {
|
||||
editorRef.value?.focus()
|
||||
}
|
||||
|
||||
const logContent = () => {
|
||||
console.log(editorRef.value?.getHtml())
|
||||
}
|
||||
</script>
|
||||
*/
|
||||
|
||||
export default {}
|
||||
182
src/components/common/RichTextEditor.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<!-- RichTextEditor 富文本编辑器组件 -->
|
||||
<template>
|
||||
<div class="rich-text-editor-wrapper">
|
||||
<Toolbar
|
||||
class="rich-text-toolbar"
|
||||
:editor="editorRef"
|
||||
:defaultConfig="toolbarConfig"
|
||||
:mode="mode"
|
||||
/>
|
||||
<Editor
|
||||
:style="editorStyle"
|
||||
v-model="content"
|
||||
:defaultConfig="editorConfig"
|
||||
:mode="mode"
|
||||
@onCreated="handleEditorCreated"
|
||||
@onChange="handleEditorChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'
|
||||
|
||||
defineOptions({ name: 'RichTextEditor' })
|
||||
|
||||
// 1. Props 定义
|
||||
interface Props {
|
||||
/** 编辑器内容(HTML 字符串) */
|
||||
modelValue?: string
|
||||
/** 编辑器高度 */
|
||||
height?: string
|
||||
/** 占位符文本 */
|
||||
placeholder?: string
|
||||
/** 是否禁用编辑器 */
|
||||
disabled?: boolean
|
||||
/** 编辑器模式 */
|
||||
mode?: 'default' | 'simple'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
height: '400px',
|
||||
placeholder: '请输入内容...',
|
||||
disabled: false,
|
||||
mode: 'default'
|
||||
})
|
||||
|
||||
// 2. Emits 定义
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
change: [value: string]
|
||||
}>()
|
||||
|
||||
// 3. 编辑器实例
|
||||
const editorRef = shallowRef<IDomEditor>()
|
||||
const content = shallowRef<string>(props.modelValue)
|
||||
|
||||
// 4. 计算属性:编辑器样式
|
||||
const editorStyle = computed(() => ({
|
||||
height: props.height,
|
||||
overflowY: 'hidden'
|
||||
}))
|
||||
|
||||
// 5. 工具栏配置(简化工具栏)
|
||||
const toolbarConfig = computed(
|
||||
(): Partial<IToolbarConfig> => ({
|
||||
toolbarKeys: [
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'|',
|
||||
'headerSelect',
|
||||
'|',
|
||||
'bulletedList',
|
||||
'numberedList',
|
||||
'|',
|
||||
'insertLink',
|
||||
'|',
|
||||
'undo',
|
||||
'redo'
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
// 6. 编辑器配置
|
||||
const editorConfig = computed(
|
||||
(): Partial<IEditorConfig> => ({
|
||||
placeholder: props.placeholder,
|
||||
readOnly: props.disabled,
|
||||
MENU_CONF: {}
|
||||
})
|
||||
)
|
||||
|
||||
// 7. 监听外部 modelValue 变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
// 避免重复更新导致死循环
|
||||
if (newValue !== content.value) {
|
||||
content.value = newValue
|
||||
// 如果编辑器已创建,同步内容
|
||||
if (editorRef.value && editorRef.value.getHtml() !== newValue) {
|
||||
editorRef.value.setHtml(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 8. 监听 disabled 状态变化
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(newDisabled) => {
|
||||
if (editorRef.value) {
|
||||
if (newDisabled) {
|
||||
editorRef.value.disable()
|
||||
} else {
|
||||
editorRef.value.enable()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 9. 编辑器创建回调
|
||||
const handleEditorCreated = (editor: IDomEditor) => {
|
||||
editorRef.value = editor
|
||||
|
||||
// 应用初始禁用状态
|
||||
if (props.disabled) {
|
||||
editor.disable()
|
||||
}
|
||||
}
|
||||
|
||||
// 10. 编辑器内容变化回调
|
||||
const handleEditorChange = (editor: IDomEditor) => {
|
||||
const html = editor.getHtml()
|
||||
content.value = html
|
||||
emit('update:modelValue', html)
|
||||
emit('change', html)
|
||||
}
|
||||
|
||||
// 11. 组件销毁时清理编辑器实例
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor) {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
// 12. 暴露编辑器方法
|
||||
defineExpose({
|
||||
/** 获取编辑器实例 */
|
||||
getEditor: () => editorRef.value,
|
||||
/** 设置编辑器内容 */
|
||||
setHtml: (html: string) => editorRef.value?.setHtml(html),
|
||||
/** 获取编辑器内容 */
|
||||
getHtml: () => editorRef.value?.getHtml() || '',
|
||||
/** 清空编辑器 */
|
||||
clear: () => editorRef.value?.clear(),
|
||||
/** 聚焦编辑器 */
|
||||
focus: () => editorRef.value?.focus(),
|
||||
/** 禁用编辑器 */
|
||||
disable: () => editorRef.value?.disable(),
|
||||
/** 启用编辑器 */
|
||||
enable: () => editorRef.value?.enable()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rich-text-editor-wrapper {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
|
||||
.rich-text-toolbar {
|
||||
background-color: var(--el-bg-color);
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
343
src/components/core/banners/art-basic-banner/index.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<!-- 基础横幅组件 -->
|
||||
<template>
|
||||
<div
|
||||
class="art-card basic-banner"
|
||||
:class="[{ 'has-decoration': decoration }, boxStyle]"
|
||||
:style="{ height }"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<!-- 流星效果 -->
|
||||
<div v-if="meteorConfig?.enabled && isDark" class="basic-banner__meteors">
|
||||
<span
|
||||
v-for="(meteor, index) in meteors"
|
||||
:key="index"
|
||||
class="meteor"
|
||||
:style="{
|
||||
top: '-60px',
|
||||
left: `${meteor.x}%`,
|
||||
animationDuration: `${meteor.speed}s`,
|
||||
animationDelay: `${meteor.delay}s`
|
||||
}"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div class="basic-banner__content">
|
||||
<!-- title slot -->
|
||||
<slot name="title">
|
||||
<p v-if="title" class="basic-banner__title" :style="{ color: titleColor }">{{ title }}</p>
|
||||
</slot>
|
||||
|
||||
<!-- subtitle slot -->
|
||||
<slot name="subtitle">
|
||||
<p v-if="subtitle" class="basic-banner__subtitle" :style="{ color: subtitleColor }">{{
|
||||
subtitle
|
||||
}}</p>
|
||||
</slot>
|
||||
|
||||
<!-- button slot -->
|
||||
<slot name="button">
|
||||
<div
|
||||
v-if="buttonConfig?.show"
|
||||
class="basic-banner__button"
|
||||
:style="{
|
||||
backgroundColor: buttonColor,
|
||||
color: buttonTextColor,
|
||||
borderRadius: buttonRadius
|
||||
}"
|
||||
@click.stop="emit('buttonClick')"
|
||||
>
|
||||
{{ buttonConfig?.text }}
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<!-- default slot -->
|
||||
<slot></slot>
|
||||
|
||||
<!-- background image -->
|
||||
<img
|
||||
v-if="imageConfig.src"
|
||||
class="basic-banner__background-image"
|
||||
:src="imageConfig.src"
|
||||
:style="{ width: imageConfig.width, bottom: imageConfig.bottom, right: imageConfig.right }"
|
||||
loading="lazy"
|
||||
alt="背景图片"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
const settingStore = useSettingStore()
|
||||
const { isDark } = storeToRefs(settingStore)
|
||||
|
||||
defineOptions({ name: 'ArtBasicBanner' })
|
||||
|
||||
// 流星对象接口定义
|
||||
interface Meteor {
|
||||
/** 流星的水平位置(百分比) */
|
||||
x: number
|
||||
/** 流星划过的速度 */
|
||||
speed: number
|
||||
/** 流星出现的延迟时间 */
|
||||
delay: number
|
||||
}
|
||||
|
||||
// 按钮配置接口定义
|
||||
interface ButtonConfig {
|
||||
/** 是否启用按钮 */
|
||||
show: boolean
|
||||
/** 按钮文本 */
|
||||
text: string
|
||||
/** 按钮背景色 */
|
||||
color?: string
|
||||
/** 按钮文字颜色 */
|
||||
textColor?: string
|
||||
/** 按钮圆角大小 */
|
||||
radius?: string
|
||||
}
|
||||
|
||||
// 流星效果配置接口定义
|
||||
interface MeteorConfig {
|
||||
/** 是否启用流星效果 */
|
||||
enabled: boolean
|
||||
/** 流星数量 */
|
||||
count?: number
|
||||
}
|
||||
|
||||
// 背景图片配置接口定义
|
||||
interface ImageConfig {
|
||||
/** 图片源地址 */
|
||||
src: string
|
||||
/** 图片宽度 */
|
||||
width?: string
|
||||
/** 距底部距离 */
|
||||
bottom?: string
|
||||
/** 距右侧距离 */
|
||||
right?: string // 距右侧距离
|
||||
}
|
||||
|
||||
// 组件属性接口定义
|
||||
interface Props {
|
||||
/** 横幅高度 */
|
||||
height?: string
|
||||
/** 标题文本 */
|
||||
title?: string
|
||||
/** 副标题文本 */
|
||||
subtitle?: string
|
||||
/** 盒子样式 */
|
||||
boxStyle?: string
|
||||
/** 是否显示装饰效果 */
|
||||
decoration?: boolean
|
||||
/** 按钮配置 */
|
||||
buttonConfig?: ButtonConfig
|
||||
/** 流星配置 */
|
||||
meteorConfig?: MeteorConfig
|
||||
/** 图片配置 */
|
||||
imageConfig?: ImageConfig
|
||||
/** 标题颜色 */
|
||||
titleColor?: string
|
||||
/** 副标题颜色 */
|
||||
subtitleColor?: string
|
||||
}
|
||||
|
||||
// 组件属性默认值设置
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: '11rem',
|
||||
titleColor: 'white',
|
||||
subtitleColor: 'white',
|
||||
boxStyle: '!bg-theme/60',
|
||||
decoration: true,
|
||||
buttonConfig: () => ({
|
||||
show: true,
|
||||
text: '查看',
|
||||
color: '#fff',
|
||||
textColor: '#333',
|
||||
radius: '6px'
|
||||
}),
|
||||
meteorConfig: () => ({ enabled: false, count: 10 }),
|
||||
imageConfig: () => ({ src: '', width: '12rem', bottom: '-3rem', right: '0' })
|
||||
})
|
||||
|
||||
// 定义组件事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void // 整体点击事件
|
||||
(e: 'buttonClick'): void // 按钮点击事件
|
||||
}>()
|
||||
|
||||
// 计算按钮样式属性
|
||||
const buttonColor = computed(() => props.buttonConfig?.color ?? '#fff')
|
||||
const buttonTextColor = computed(() => props.buttonConfig?.textColor ?? '#333')
|
||||
const buttonRadius = computed(() => props.buttonConfig?.radius ?? '6px')
|
||||
|
||||
// 流星数据初始化
|
||||
const meteors = ref<Meteor[]>([])
|
||||
onMounted(() => {
|
||||
if (props.meteorConfig?.enabled) {
|
||||
meteors.value = generateMeteors(props.meteorConfig?.count ?? 10)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 生成流星数据数组
|
||||
* @param count 流星数量
|
||||
* @returns 流星数据数组
|
||||
*/
|
||||
function generateMeteors(count: number): Meteor[] {
|
||||
// 计算每个流星的区域宽度
|
||||
const segmentWidth = 100 / count
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
// 计算流星起始位置
|
||||
const segmentStart = index * segmentWidth
|
||||
// 在区域内随机生成x坐标
|
||||
const x = segmentStart + Math.random() * segmentWidth
|
||||
// 随机决定流星速度快慢
|
||||
const isSlow = Math.random() > 0.5
|
||||
return {
|
||||
x,
|
||||
speed: isSlow ? 5 + Math.random() * 3 : 2 + Math.random() * 2,
|
||||
delay: Math.random() * 5
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.basic-banner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 0 2rem;
|
||||
overflow: hidden;
|
||||
color: white;
|
||||
border-radius: calc(var(--custom-radius) + 2px) !important;
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&__button {
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
min-width: 80px;
|
||||
height: var(--el-component-custom-height);
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
line-height: var(--el-component-custom-height);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&__background-image {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -3rem;
|
||||
z-index: 0;
|
||||
width: 12rem;
|
||||
}
|
||||
|
||||
&.has-decoration::after {
|
||||
position: absolute;
|
||||
right: -10%;
|
||||
bottom: -20%;
|
||||
width: 60%;
|
||||
height: 140%;
|
||||
content: '';
|
||||
background: rgb(255 255 255 / 10%);
|
||||
border-radius: 30%;
|
||||
transform: rotate(-20deg);
|
||||
}
|
||||
|
||||
&__meteors {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
|
||||
.meteor {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 60px;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgb(255 255 255 / 40%),
|
||||
rgb(255 255 255 / 10%),
|
||||
transparent
|
||||
);
|
||||
opacity: 0;
|
||||
transform-origin: top left;
|
||||
animation-name: meteor-fall;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
content: '';
|
||||
background: rgb(255 255 255 / 50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes meteor-fall {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(0, -60px) rotate(-45deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(400px, 340px) rotate(-45deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.basic-banner {
|
||||
box-sizing: border-box;
|
||||
justify-content: flex-start;
|
||||
padding: 16px;
|
||||
|
||||
&__title {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
&__background-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.has-decoration::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
114
src/components/core/banners/art-card-banner/index.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<!-- 卡片横幅组件 -->
|
||||
<template>
|
||||
<div class="art-card-sm flex-c flex-col pb-6" :style="{ height: height }">
|
||||
<div class="flex-c flex-col gap-4 text-center">
|
||||
<div class="w-45">
|
||||
<img :src="image" :alt="title" class="w-full h-full object-contain" />
|
||||
</div>
|
||||
<div class="box-border px-4">
|
||||
<p class="mb-2 text-lg font-semibold text-g-800">{{ title }}</p>
|
||||
<p class="m-0 text-sm text-g-600">{{ description }}</p>
|
||||
</div>
|
||||
<div class="flex-c gap-3">
|
||||
<div
|
||||
v-if="cancelButton?.show"
|
||||
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md border border-g-300"
|
||||
:style="{
|
||||
backgroundColor: cancelButton?.color,
|
||||
color: cancelButton?.textColor
|
||||
}"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ cancelButton?.text }}
|
||||
</div>
|
||||
<div
|
||||
v-if="button?.show"
|
||||
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md"
|
||||
:style="{ backgroundColor: button?.color, color: button?.textColor }"
|
||||
@click="handleClick"
|
||||
>
|
||||
{{ button?.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 导入默认图标
|
||||
import defaultIcon from '@imgs/3d/icon1.webp'
|
||||
|
||||
defineOptions({ name: 'ArtCardBanner' })
|
||||
|
||||
// 定义卡片横幅组件的属性接口
|
||||
interface CardBannerProps {
|
||||
/** 高度 */
|
||||
height?: string
|
||||
/** 图片路径 */
|
||||
image?: string
|
||||
/** 标题文本 */
|
||||
title: string
|
||||
/** 描述文本 */
|
||||
description: string
|
||||
/** 主按钮配置 */
|
||||
button?: {
|
||||
/** 是否显示 */
|
||||
show?: boolean
|
||||
/** 按钮文本 */
|
||||
text?: string
|
||||
/** 背景颜色 */
|
||||
color?: string
|
||||
/** 文字颜色 */
|
||||
textColor?: string
|
||||
}
|
||||
/** 取消按钮配置 */
|
||||
cancelButton?: {
|
||||
/** 是否显示 */
|
||||
show?: boolean
|
||||
/** 按钮文本 */
|
||||
text?: string
|
||||
/** 背景颜色 */
|
||||
color?: string
|
||||
/** 文字颜色 */
|
||||
textColor?: string
|
||||
}
|
||||
}
|
||||
|
||||
// 定义组件属性默认值
|
||||
withDefaults(defineProps<CardBannerProps>(), {
|
||||
height: '24rem',
|
||||
image: defaultIcon,
|
||||
title: '',
|
||||
description: '',
|
||||
// 主按钮默认配置
|
||||
button: () => ({
|
||||
show: true,
|
||||
text: '查看详情',
|
||||
color: 'var(--theme-color)',
|
||||
textColor: '#fff'
|
||||
}),
|
||||
// 取消按钮默认配置
|
||||
cancelButton: () => ({
|
||||
show: false,
|
||||
text: '取消',
|
||||
color: '#f5f5f5',
|
||||
textColor: '#666'
|
||||
})
|
||||
})
|
||||
|
||||
// 定义组件事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void // 主按钮点击事件
|
||||
(e: 'cancel'): void // 取消按钮点击事件
|
||||
}>()
|
||||
|
||||
// 主按钮点击处理函数
|
||||
const handleClick = () => {
|
||||
emit('click')
|
||||
}
|
||||
|
||||
// 取消按钮点击处理函数
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
40
src/components/core/base/art-back-to-top/index.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<!-- 返回顶部按钮 -->
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="tad-300 ease-out"
|
||||
leave-active-class="tad-200 ease-in"
|
||||
enter-from-class="opacity-0 translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-show="showButton"
|
||||
class="fixed right-10 bottom-15 size-9.5 flex-cc c-p border border-g-300 rounded-md tad-300 hover:bg-g-200"
|
||||
@click="scrollToTop"
|
||||
>
|
||||
<ArtSvgIcon icon="ri:arrow-up-wide-line" class="text-g-500 text-lg" />
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCommon } from '@/hooks/core/useCommon'
|
||||
|
||||
defineOptions({ name: 'ArtBackToTop' })
|
||||
|
||||
const { scrollToTop } = useCommon()
|
||||
|
||||
const showButton = ref(false)
|
||||
const scrollThreshold = 300
|
||||
|
||||
onMounted(() => {
|
||||
const scrollContainer = document.getElementById('app-main')
|
||||
if (scrollContainer) {
|
||||
const { y } = useScroll(scrollContainer)
|
||||
watch(y, (newY: number) => {
|
||||
showButton.value = newY > scrollThreshold
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
21
src/components/core/base/art-logo/index.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<!-- 系统logo -->
|
||||
<template>
|
||||
<div class="flex-cc">
|
||||
<img :style="logoStyle" src="@imgs/common/logo.webp" alt="logo" class="w-full h-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'ArtLogo' })
|
||||
|
||||
interface Props {
|
||||
/** logo 大小 */
|
||||
size?: number | string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 36
|
||||
})
|
||||
|
||||
const logoStyle = computed(() => ({ width: `${props.size}px` }))
|
||||
</script>
|
||||
24
src/components/core/base/art-svg-icon/index.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<!-- 图标组件 -->
|
||||
<template>
|
||||
<Icon v-if="icon" :icon="icon" v-bind="bindAttrs" class="art-svg-icon inline" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
|
||||
defineOptions({ name: 'ArtSvgIcon', inheritAttrs: false })
|
||||
|
||||
interface Props {
|
||||
/** Iconify icon name */
|
||||
icon?: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const bindAttrs = computed<{ class: string; style: string }>(() => ({
|
||||
class: (attrs.class as string) || '',
|
||||
style: (attrs.style as string) || ''
|
||||
}))
|
||||
</script>
|
||||
103
src/components/core/cards/art-bar-chart-card/index.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<!-- 柱状图卡片 -->
|
||||
<template>
|
||||
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
|
||||
<div class="mb-5 flex-b items-start px-5 pt-5">
|
||||
<div>
|
||||
<p class="m-0 text-2xl font-medium leading-tight text-g-900">
|
||||
{{ value }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-g-600">{{ label }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-medium text-danger"
|
||||
:class="[percentage > 0 ? 'text-success' : '', isMiniChart ? 'absolute bottom-5' : '']"
|
||||
>
|
||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
||||
</div>
|
||||
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-600">
|
||||
{{ date }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="absolute bottom-0 left-0 right-0 mx-auto"
|
||||
:class="isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''"
|
||||
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import { type EChartsOption } from '@/plugins/echarts'
|
||||
|
||||
defineOptions({ name: 'ArtBarChartCard' })
|
||||
|
||||
interface Props {
|
||||
/** 数值 */
|
||||
value: number
|
||||
/** 标签 */
|
||||
label: string
|
||||
/** 百分比 +(绿色)-(红色) */
|
||||
percentage: number
|
||||
/** 日期 */
|
||||
date?: string
|
||||
/** 高度 */
|
||||
height?: number
|
||||
/** 颜色 */
|
||||
color?: string
|
||||
/** 图表数据 */
|
||||
chartData: number[]
|
||||
/** 柱状图宽度 */
|
||||
barWidth?: string
|
||||
/** 是否为迷你图表 */
|
||||
isMiniChart?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 11,
|
||||
barWidth: '26%'
|
||||
})
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef } = useChartComponent({
|
||||
props: {
|
||||
height: `${props.height}rem`,
|
||||
loading: false,
|
||||
isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0)
|
||||
},
|
||||
checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0),
|
||||
watchSources: [() => props.chartData, () => props.color, () => props.barWidth],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const computedColor = props.color || useChartOps().themeColor
|
||||
|
||||
return {
|
||||
grid: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 15,
|
||||
left: 0
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
show: false
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: props.chartData,
|
||||
type: 'bar',
|
||||
barWidth: props.barWidth,
|
||||
itemStyle: {
|
||||
color: computedColor,
|
||||
borderRadius: 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
74
src/components/core/cards/art-data-list-card/index.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<!-- 数据列表卡片 -->
|
||||
<template>
|
||||
<div class="art-card p-5">
|
||||
<div class="pb-3.5">
|
||||
<p class="text-lg font-medium">{{ title }}</p>
|
||||
<p class="text-sm text-g-600">{{ subtitle }}</p>
|
||||
</div>
|
||||
<ElScrollbar :style="{ height: maxHeight }">
|
||||
<div v-for="(item, index) in list" :key="index" class="flex-c py-3">
|
||||
<div v-if="item.icon" class="flex-cc mr-3 size-10 rounded-lg" :class="item.class">
|
||||
<ArtSvgIcon :icon="item.icon" class="text-xl" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="mb-1 text-sm">{{ item.title }}</div>
|
||||
<div class="text-xs text-g-500">{{ item.status }}</div>
|
||||
</div>
|
||||
<div class="ml-3 text-xs text-g-500">{{ item.time }}</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
<ElButton
|
||||
class="mt-[25px] w-full text-center"
|
||||
v-if="showMoreButton"
|
||||
v-ripple
|
||||
@click="handleMore"
|
||||
>查看更多</ElButton
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'ArtDataListCard' })
|
||||
|
||||
interface Props {
|
||||
/** 数据列表 */
|
||||
list: Activity[]
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 副标题 */
|
||||
subtitle?: string
|
||||
/** 最大显示数量 */
|
||||
maxCount?: number
|
||||
/** 是否显示更多按钮 */
|
||||
showMoreButton?: boolean
|
||||
}
|
||||
|
||||
interface Activity {
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 状态 */
|
||||
status: string
|
||||
/** 时间 */
|
||||
time: string
|
||||
/** 样式类名 */
|
||||
class: string
|
||||
/** 图标 */
|
||||
icon: string
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 66
|
||||
const DEFAULT_MAX_COUNT = 5
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
maxCount: DEFAULT_MAX_COUNT
|
||||
})
|
||||
|
||||
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`)
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 点击更多按钮事件 */
|
||||
(e: 'more'): void
|
||||
}>()
|
||||
|
||||
const handleMore = () => emit('more')
|
||||
</script>
|
||||
124
src/components/core/cards/art-donut-chart-card/index.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<!-- 环型图卡片 -->
|
||||
<template>
|
||||
<div class="art-card overflow-hidden" :style="{ height: `${height}rem` }">
|
||||
<div class="flex box-border h-full p-5 pr-2">
|
||||
<div class="flex w-full items-start gap-5">
|
||||
<div class="flex-b h-full flex-1 flex-col">
|
||||
<p class="m-0 text-xl font-medium leading-tight text-g-900">
|
||||
{{ title }}
|
||||
</p>
|
||||
<div>
|
||||
<p class="m-0 mt-2.5 text-xl font-medium leading-tight text-g-900">
|
||||
{{ formatNumber(value) }}
|
||||
</p>
|
||||
<div
|
||||
class="mt-1.5 text-xs font-medium"
|
||||
:class="percentage > 0 ? 'text-success' : 'text-danger'"
|
||||
>
|
||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
||||
<span v-if="percentageLabel">{{ percentageLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex gap-4 text-xs text-g-600">
|
||||
<div v-if="currentValue" class="flex-cc">
|
||||
<div class="size-2 bg-theme/100 rounded mr-2"></div>
|
||||
{{ currentValue }}
|
||||
</div>
|
||||
<div v-if="previousValue" class="flex-cc">
|
||||
<div class="size-2 bg-g-400 rounded mr-2"></div>
|
||||
{{ previousValue }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-c h-full max-w-40 flex-1">
|
||||
<div ref="chartRef" class="h-30 w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type EChartsOption } from '@/plugins/echarts'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
|
||||
defineOptions({ name: 'ArtDonutChartCard' })
|
||||
|
||||
interface Props {
|
||||
/** 数值 */
|
||||
value: number
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 百分比 */
|
||||
percentage: number
|
||||
/** 百分比标签 */
|
||||
percentageLabel?: string
|
||||
/** 当前年份 */
|
||||
currentValue?: string
|
||||
/** 去年年份 */
|
||||
previousValue?: string
|
||||
/** 高度 */
|
||||
height?: number
|
||||
/** 颜色 */
|
||||
color?: string
|
||||
/** 半径 */
|
||||
radius?: [string, string]
|
||||
/** 数据 */
|
||||
data: [number, number]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 9,
|
||||
radius: () => ['70%', '90%'],
|
||||
data: () => [0, 0]
|
||||
})
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef } = useChartComponent({
|
||||
props: {
|
||||
height: `${props.height}rem`,
|
||||
loading: false,
|
||||
isEmpty: props.data.every((val) => val === 0)
|
||||
},
|
||||
checkEmpty: () => props.data.every((val) => val === 0),
|
||||
watchSources: [
|
||||
() => props.data,
|
||||
() => props.color,
|
||||
() => props.radius,
|
||||
() => props.currentValue,
|
||||
() => props.previousValue
|
||||
],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const computedColor = props.color || useChartOps().themeColor
|
||||
|
||||
return {
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: props.radius,
|
||||
avoidLabelOverlap: false,
|
||||
label: {
|
||||
show: false
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: props.data[0],
|
||||
name: props.currentValue,
|
||||
itemStyle: { color: computedColor }
|
||||
},
|
||||
{
|
||||
value: props.data[1],
|
||||
name: props.previousValue,
|
||||
itemStyle: { color: '#e6e8f7' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
89
src/components/core/cards/art-image-card/index.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<!-- 图片卡片 -->
|
||||
<template>
|
||||
<div class="w-full c-p" @click="handleClick">
|
||||
<div class="art-card overflow-hidden">
|
||||
<div class="relative w-full aspect-[16/10] overflow-hidden">
|
||||
<ElImage
|
||||
:src="props.imageUrl"
|
||||
fit="cover"
|
||||
loading="lazy"
|
||||
class="w-full h-full transition-transform duration-300 ease-in-out hover:scale-105"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="flex-cc w-full h-full bg-[#f5f7fa]">
|
||||
<ElIcon><Picture /></ElIcon>
|
||||
</div>
|
||||
</template>
|
||||
</ElImage>
|
||||
<div
|
||||
class="absolute right-3.5 bottom-3.5 py-1 px-2 text-xs bg-g-200 rounded"
|
||||
v-if="props.readTime"
|
||||
>
|
||||
{{ props.readTime }} 阅读
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div
|
||||
class="inline-block py-0.5 px-2 mb-2 text-xs bg-g-300/70 rounded"
|
||||
v-if="props.category"
|
||||
>
|
||||
{{ props.category }}
|
||||
</div>
|
||||
<p class="m-0 mb-3 text-base font-medium">{{ props.title }}</p>
|
||||
<div class="flex-c gap-4 text-xs text-g-600">
|
||||
<span class="flex-c gap-1" v-if="props.views">
|
||||
<ElIcon class="text-base"><View /></ElIcon>
|
||||
{{ props.views }}
|
||||
</span>
|
||||
<span class="flex-c gap-1" v-if="props.comments">
|
||||
<ElIcon class="text-base"><ChatLineRound /></ElIcon>
|
||||
{{ props.comments }}
|
||||
</span>
|
||||
<span>{{ props.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Picture, View, ChatLineRound } from '@element-plus/icons-vue'
|
||||
|
||||
defineOptions({ name: 'ArtImageCard' })
|
||||
|
||||
interface Props {
|
||||
/** 图片地址 */
|
||||
imageUrl: string
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 分类 */
|
||||
category?: string
|
||||
/** 阅读时间 */
|
||||
readTime?: string
|
||||
/** 浏览量 */
|
||||
views?: number
|
||||
/** 评论数 */
|
||||
comments?: number
|
||||
/** 日期 */
|
||||
date?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
imageUrl: '',
|
||||
title: '',
|
||||
category: '',
|
||||
readTime: '',
|
||||
views: 0,
|
||||
comments: 0,
|
||||
date: ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', card: Props): void
|
||||
}>()
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click', props)
|
||||
}
|
||||
</script>
|
||||
126
src/components/core/cards/art-line-chart-card/index.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<!-- 折线图卡片 -->
|
||||
<template>
|
||||
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
|
||||
<div class="mb-2.5 flex-b items-start p-5">
|
||||
<div>
|
||||
<p class="text-2xl font-medium leading-none">
|
||||
{{ value }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-g-500">{{ label }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-medium"
|
||||
:class="[
|
||||
percentage > 0 ? 'text-success' : 'text-danger',
|
||||
isMiniChart ? 'absolute bottom-5' : ''
|
||||
]"
|
||||
>
|
||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
||||
</div>
|
||||
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-500">
|
||||
{{ date }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="absolute bottom-0 left-0 right-0 box-border w-full"
|
||||
:class="isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''"
|
||||
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||||
import { getCssVar, hexToRgba } from '@/utils/ui'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
|
||||
defineOptions({ name: 'ArtLineChartCard' })
|
||||
|
||||
interface Props {
|
||||
/** 数值 */
|
||||
value: number
|
||||
/** 标签 */
|
||||
label: string
|
||||
/** 百分比 */
|
||||
percentage: number
|
||||
/** 日期 */
|
||||
date?: string
|
||||
/** 高度 */
|
||||
height?: number
|
||||
/** 颜色 */
|
||||
color?: string
|
||||
/** 是否显示区域颜色 */
|
||||
showAreaColor?: boolean
|
||||
/** 图表数据 */
|
||||
chartData: number[]
|
||||
/** 是否为迷你图表 */
|
||||
isMiniChart?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 11
|
||||
})
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef } = useChartComponent({
|
||||
props: {
|
||||
height: `${props.height}rem`,
|
||||
loading: false,
|
||||
isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0)
|
||||
},
|
||||
checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0),
|
||||
watchSources: [() => props.chartData, () => props.color, () => props.showAreaColor],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const computedColor = props.color || useChartOps().themeColor
|
||||
|
||||
return {
|
||||
grid: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
show: false,
|
||||
boundaryGap: false
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: props.chartData,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: computedColor
|
||||
},
|
||||
areaStyle: props.showAreaColor
|
||||
? {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: props.color
|
||||
? hexToRgba(props.color, 0.2).rgba
|
||||
: hexToRgba(getCssVar('--el-color-primary'), 0.2).rgba
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: props.color
|
||||
? hexToRgba(props.color, 0.01).rgba
|
||||
: hexToRgba(getCssVar('--el-color-primary'), 0.01).rgba
|
||||
}
|
||||
])
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
86
src/components/core/cards/art-progress-card/index.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<!-- 进度条卡片 -->
|
||||
<template>
|
||||
<div class="art-card h-32 flex flex-col justify-center px-5">
|
||||
<div class="mb-3.5 flex-c" :style="{ justifyContent: icon ? 'space-between' : 'flex-start' }">
|
||||
<div v-if="icon" class="size-11 flex-cc bg-g-300 text-xl rounded-lg" :class="iconStyle">
|
||||
<ArtSvgIcon :icon="icon" class="text-2xl"></ArtSvgIcon>
|
||||
</div>
|
||||
<div>
|
||||
<ArtCountTo
|
||||
class="mb-1 block text-2xl font-semibold"
|
||||
:target="percentage"
|
||||
:duration="2000"
|
||||
suffix="%"
|
||||
:style="{ textAlign: icon ? 'right' : 'left' }"
|
||||
/>
|
||||
<p class="text-sm text-g-500">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ElProgress
|
||||
:percentage="currentPercentage"
|
||||
:stroke-width="strokeWidth"
|
||||
:show-text="false"
|
||||
:color="color"
|
||||
class="[&_.el-progress-bar__outer]:bg-[rgb(240_240_240)]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'ArtProgressCard' })
|
||||
|
||||
interface Props {
|
||||
/** 进度百分比 */
|
||||
percentage: number
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 颜色 */
|
||||
color?: string
|
||||
/** 图标 */
|
||||
icon?: string
|
||||
/** 图标样式 */
|
||||
iconStyle?: string
|
||||
/** 进度条宽度 */
|
||||
strokeWidth?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
strokeWidth: 5,
|
||||
color: '#67C23A'
|
||||
})
|
||||
|
||||
const animationDuration = 500
|
||||
const currentPercentage = ref(0)
|
||||
|
||||
const animateProgress = () => {
|
||||
const startTime = Date.now()
|
||||
const startValue = currentPercentage.value
|
||||
const endValue = props.percentage
|
||||
|
||||
const animate = () => {
|
||||
const currentTime = Date.now()
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / animationDuration, 1)
|
||||
|
||||
currentPercentage.value = startValue + (endValue - startValue) * progress
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
animateProgress()
|
||||
})
|
||||
|
||||
// 当 percentage 属性变化时重新执行动画
|
||||
watch(
|
||||
() => props.percentage,
|
||||
() => {
|
||||
animateProgress()
|
||||
}
|
||||
)
|
||||
</script>
|
||||