chore: 初始化平台管理端

This commit is contained in:
msumshk
2026-01-29 04:21:09 +00:00
commit 914dcc4166
533 changed files with 104838 additions and 0 deletions

34
src/App.vue Normal file
View 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
View 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
View 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
View 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

View 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
})
}

View 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}`
})
}

View 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}`
})
}

View 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
}
})
}

View 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 }
})
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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'
})
}

View 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
View 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
View 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
View 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 租户IDSnowflake long → string
* @param data 更新数据tenantId 会以入参为准覆盖)
* @returns void后端 BaseResponse<void> 的 data 字段)
*
* 后端接口PUT /api/admin/v1/tenants/{tenantId}
* 预期响应BaseResponse<void>
* 预期错误码400参数错误、404租户不存在、409code 冲突)
* 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
}
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 B

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View 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;
}

View 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;
}
}

View 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);
}
}
}

View File

@@ -0,0 +1,2 @@
// 导入暗黑主题
@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *;

View 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'
)
);

View 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;
}

File diff suppressed because it is too large Load Diff

View 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);
}

View 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;
}
}

View 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');
}

View 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;
}
}
}
}
}

View 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);
}

View File

@@ -0,0 +1,11 @@
// 主题切换过渡优化,优化除视觉上的不适感
.theme-change {
* {
transition: 0s !important;
}
.el-switch__core,
.el-switch__action {
transition: all 0.3s !important;
}
}

View 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;
}

View 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
View 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>
`

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 {}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

Some files were not shown because too many files have changed in this diff Show More