feat: 完善接口19-顶部通知铃铛与站内通知中心
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m5s

This commit is contained in:
luo
2026-07-25 10:20:53 +08:00
parent eb13763020
commit 9289a6e940
37 changed files with 1264 additions and 226 deletions

View File

@@ -6,8 +6,6 @@ import { BaseService } from '../BaseService'
import type {
PlatformAccount,
AccountQueryParams,
PlatformSalesperson,
PlatformSalespersonQueryParams,
CreatePlatformAccountParams,
BaseResponse,
PaginationResponse
@@ -25,16 +23,6 @@ export class AccountService extends BaseService {
return this.getPage<PlatformAccount>('/api/admin/accounts', params)
}
/**
* 获取启用的平台业务员候选
* GET /api/admin/accounts?account_type=platform&status=1
*/
static getPlatformSalespeople(
params: PlatformSalespersonQueryParams
): Promise<PaginationResponse<PlatformSalesperson>> {
return this.getPage<PlatformSalesperson>('/api/admin/accounts', params)
}
/**
* 创建账号
* POST /api/admin/accounts

View File

@@ -31,7 +31,9 @@ import type {
AssetOperationLogsParams,
AssetPackageUsageRecord,
UpdateAssetPackageUsedDataRequest,
UpdateAssetPackageExpiresAtRequest
UpdateAssetPackageExpiresAtRequest,
ExpiringAssetItem,
ExpiringAssetQueryParams
} from '@/types/api'
const runRateLimitedAssetAction = async <T>(
@@ -45,6 +47,19 @@ const runRateLimitedAssetAction = async <T>(
}
export class AssetService extends BaseService {
/**
* 获取管理端临期资产列表
* GET /api/admin/expiring-assets
*/
static getExpiringAssets(
params?: ExpiringAssetQueryParams
): Promise<
BaseResponse<{ items: ExpiringAssetItem[]; total: number; page: number; size: number }>
> {
return this.get<
BaseResponse<{ items: ExpiringAssetItem[]; total: number; page: number; size: number }>
>('/api/admin/expiring-assets', params)
}
/**
* 通过任意标识符查询设备或卡的完整详情
* 支持虚拟号、ICCID、IMEI、SN、MSISDN

View File

@@ -1,24 +1,25 @@
import { BaseService } from '../BaseService'
import type {
BaseResponse,
NotificationItem,
NotificationListResponse,
NotificationQueryParams,
NotificationSummary,
NotificationReadAllRequest,
NotificationReadAllResponse,
NotificationReadResponse,
NotificationUnreadCount,
NotificationUnreadSummary,
NotificationTarget
} from '@/types/api'
export class NotificationService extends BaseService {
static getUnreadCount(): Promise<
BaseResponse<number | { count?: number; unread_count?: number }>
> {
return this.get<BaseResponse<number | { count?: number; unread_count?: number }>>(
'/api/admin/notifications/unread-count'
)
static getUnreadCount(): Promise<BaseResponse<NotificationUnreadCount>> {
return this.get<BaseResponse<NotificationUnreadCount>>('/api/admin/notifications/unread-count')
}
static getUnreadSummary(): Promise<BaseResponse<NotificationSummary>> {
return this.get<BaseResponse<NotificationSummary>>('/api/admin/notifications/unread-summary')
static getUnreadSummary(): Promise<BaseResponse<NotificationUnreadSummary>> {
return this.get<BaseResponse<NotificationUnreadSummary>>(
'/api/admin/notifications/unread-summary'
)
}
static getNotifications(
@@ -27,15 +28,23 @@ export class NotificationService extends BaseService {
return this.get<BaseResponse<NotificationListResponse>>('/api/admin/notifications', params)
}
static markRead(id: number | string): Promise<BaseResponse<NotificationItem>> {
return this.put<BaseResponse<NotificationItem>>(`/api/admin/notifications/${id}/read`, {})
static markRead(id: number): Promise<BaseResponse<NotificationReadResponse>> {
return this.put<BaseResponse<NotificationReadResponse>>(
`/api/admin/notifications/${id}/read`,
{}
)
}
static markAllRead(): Promise<BaseResponse> {
return this.put<BaseResponse>('/api/admin/notifications/read-all', {})
static markAllRead(
data: NotificationReadAllRequest = {}
): Promise<BaseResponse<NotificationReadAllResponse>> {
return this.put<BaseResponse<NotificationReadAllResponse>>(
'/api/admin/notifications/read-all',
data
)
}
static getTarget(id: number | string): Promise<BaseResponse<NotificationTarget>> {
static getTarget(id: number): Promise<BaseResponse<NotificationTarget>> {
return this.get<BaseResponse<NotificationTarget>>(`/api/admin/notifications/${id}/target`)
}
}

View File

@@ -12,7 +12,9 @@ import type {
AssignShopRolesRequest,
UpdateShopCreditLimitRequest,
BaseResponse,
PaginationResponse
PaginationResponse,
ShopBusinessOwnerCandidate,
ShopBusinessOwnerCandidateQueryParams
} from '@/types/api'
export class ShopService extends BaseService {
@@ -25,6 +27,27 @@ export class ShopService extends BaseService {
return this.getPage<ShopResponse>('/api/admin/shops', params)
}
/**
* 获取店铺业务员候选
* GET /api/admin/shops/business-owner-candidates
*/
static getBusinessOwnerCandidates(
params?: ShopBusinessOwnerCandidateQueryParams
): Promise<PaginationResponse<ShopBusinessOwnerCandidate>> {
return this.getPage<ShopBusinessOwnerCandidate>(
'/api/admin/shops/business-owner-candidates',
params
)
}
/**
* 获取店铺详情
* GET /api/admin/shops/{id}
*/
static getShopDetail(id: number): Promise<BaseResponse<ShopResponse>> {
return this.getOne<ShopResponse>(`/api/admin/shops/${id}`)
}
/**
* 店铺级联查询(树结构)
* GET /api/admin/shops/cascade

View File

@@ -238,9 +238,7 @@
const { width } = useWindowSize()
const unreadCount = computed(() => notificationStore.unreadCount)
const formattedUnreadCount = computed(() =>
unreadCount.value > 99 ? '99+' : String(unreadCount.value)
)
const formattedUnreadCount = computed(() => notificationStore.displayCount)
const menuTopWidth = computed(() => {
return width.value * 0.5

View File

@@ -39,7 +39,7 @@
</span>
<span class="text">
<strong>{{ item.title }}</strong>
<small>{{ item.content }}</small>
<small>{{ item.body }}</small>
<time>{{ item.created_at || '-' }}</time>
</span>
<span v-if="!isRead(item)" class="unread-dot" />
@@ -85,23 +85,23 @@
return item.category === category
}
const isRead = (item: NotificationItem) =>
item.read === true || item.is_read === true || item.read_status === 'read'
const isRead = (item: NotificationItem) => item.is_read
const filteredItems = computed(() =>
notificationStore.summary.items.filter((item) => categoryMatches(item, activeCategory.value))
notificationStore.recentNotifications.filter((item) =>
categoryMatches(item, activeCategory.value)
)
)
const tabs = computed(() => {
const categories = notificationStore.summary.categories || {}
return [
{ value: 'all', label: '全部', count: notificationStore.unreadCount },
{ value: 'approval', label: '审批', count: categories.approval || 0 },
{ value: 'expiry', label: '临期', count: categories.expiry || categories.expiring || 0 },
{ value: 'all', label: '全部', count: notificationStore.summary.total },
{ value: 'approval', label: '审批', count: notificationStore.summary.approval },
{ value: 'expiry', label: '临期', count: notificationStore.summary.expiry },
{
value: 'system',
label: '同步/系统',
count: (categories.sync || 0) + (categories.system || 0)
count: notificationStore.summary.sync + notificationStore.summary.system
}
]
})
@@ -135,11 +135,11 @@
}
const targetResponse = await NotificationService.getTarget(item.id)
if (targetResponse.code !== 0 || !targetResponse.data) {
ElMessage.info(item.content || '该通知暂无可跳转目标')
ElMessage.info(item.body || '该通知暂无可跳转目标')
return
}
if (!navigateNotificationTarget(router, targetResponse.data)) {
ElMessage.info(targetResponse.data.message || item.content || '该通知暂无可跳转目标')
ElMessage.info(item.body || '该通知暂无可跳转目标')
} else {
close()
}

View File

@@ -108,9 +108,9 @@
background: var(--el-color-danger-light-9);
}
&.severity-success {
color: var(--el-color-success);
background: var(--el-color-success-light-9);
&.severity-critical {
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
}
}

View File

@@ -20,7 +20,8 @@ export const asyncRoutes: AppRouteRecord[] = [
name: 'Notifications',
component: RoutesAlias.Home,
meta: {
title: '通知中心'
title: '通知中心',
isHide: true
},
children: [
{
@@ -30,6 +31,29 @@ export const asyncRoutes: AppRouteRecord[] = [
meta: {
title: '通知中心',
permissions: [NOTIFICATION_PERMISSIONS.page],
keepAlive: true,
isHide: true,
isHideTab: true
}
}
]
},
// 临期资产
{
path: '/operations',
name: 'Operations',
component: RoutesAlias.Home,
meta: {
title: '运营管理'
},
children: [
{
path: 'expiring-assets',
name: 'ExpiringAssets',
component: RoutesAlias.ExpiringAssets,
meta: {
title: '临期资产',
keepAlive: true
}
}

View File

@@ -78,6 +78,7 @@ export enum RoutesAlias {
// 站内通知
Notifications = '/notifications', // 通知中心
ExpiringAssets = '/operations/expiring-assets', // 临期资产
// 佣金管理
WithdrawalApproval = '/commission-management/withdrawal-approval', // 提现审批

View File

@@ -1,22 +1,28 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { NotificationService } from '@/api/modules/notification'
import type { NotificationSummary } from '@/types/api'
import type { NotificationItem, NotificationUnreadSummary } from '@/types/api'
const getCountValue = (data: number | { count?: number; unread_count?: number } | undefined) => {
if (typeof data === 'number') return Math.max(0, data)
return Math.max(0, data?.unread_count ?? data?.count ?? 0)
}
const emptySummary = (): NotificationUnreadSummary => ({
approval: 0,
expiry: 0,
sync: 0,
system: 0,
total: 0
})
export const useNotificationStore = defineStore('notificationStore', () => {
const unreadCount = ref(0)
const summary = ref<NotificationSummary>({ items: [], categories: {}, total_unread: 0 })
const displayCount = ref('0')
const summary = ref<NotificationUnreadSummary>(emptySummary())
const recentNotifications = ref<NotificationItem[]>([])
const loading = ref(false)
const refreshUnreadCount = async () => {
const response = await NotificationService.getUnreadCount()
if (response.code === 0) {
unreadCount.value = getCountValue(response.data)
if (response.code === 0 && response.data) {
unreadCount.value = Math.max(0, response.data.count)
displayCount.value = response.data.display_count || '0'
}
return unreadCount.value
}
@@ -24,15 +30,15 @@ export const useNotificationStore = defineStore('notificationStore', () => {
const refreshSummary = async () => {
loading.value = true
try {
const response = await NotificationService.getUnreadSummary()
if (response.code === 0 && response.data) {
summary.value = {
...response.data,
items: (response.data.items || []).slice(0, 10)
}
if (response.data.total_unread !== undefined) {
unreadCount.value = Math.max(0, response.data.total_unread)
}
const [summaryResponse, listResponse] = await Promise.all([
NotificationService.getUnreadSummary(),
NotificationService.getNotifications({ page: 1, page_size: 10 })
])
if (summaryResponse.code === 0 && summaryResponse.data) {
summary.value = summaryResponse.data
}
if (listResponse.code === 0 && listResponse.data) {
recentNotifications.value = listResponse.data.items.slice(0, 10)
}
await refreshUnreadCount()
return summary.value
@@ -41,46 +47,27 @@ export const useNotificationStore = defineStore('notificationStore', () => {
}
}
const markRead = async (id: number | string) => {
const markRead = async (id: number) => {
const response = await NotificationService.markRead(id)
if (response.code === 0) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
summary.value = {
...summary.value,
items: summary.value.items.map((item) =>
item.id === id ? { ...item, read: true, is_read: true, read_status: 'read' } : item
)
}
await refreshUnreadCount()
await refreshSummary()
}
return response
}
const markAllRead = async () => {
const response = await NotificationService.markAllRead()
const markAllRead = async (category?: string) => {
const response = await NotificationService.markAllRead(category ? { category } : {})
if (response.code === 0) {
unreadCount.value = 0
summary.value = {
...summary.value,
total_unread: 0,
categories: Object.fromEntries(
Object.keys(summary.value.categories || {}).map((key) => [key, 0])
),
items: summary.value.items.map((item) => ({
...item,
read: true,
is_read: true,
read_status: 'read'
}))
}
await refreshUnreadCount()
await refreshSummary()
}
return response
}
return {
unreadCount,
displayCount,
summary,
recentNotifications,
loading,
refreshUnreadCount,
refreshSummary,

View File

@@ -107,20 +107,6 @@ export interface AccountQueryParams extends PaginationParams {
enterprise_id?: number // 按企业ID筛选
}
// 平台业务员候选账号
export interface PlatformSalesperson {
account_id: number
account_name: string
phone_masked: string
}
// 平台业务员候选查询参数
export interface PlatformSalespersonQueryParams extends PaginationParams {
account_type: 'platform'
status: AccountStatus
account_name?: string
}
// 代理商查询参数
export interface AgentQueryParams extends PaginationParams {
keyword?: string

View File

@@ -69,6 +69,35 @@ export type AssetRealnamePolicy = 'none' | 'before_order' | 'after_order'
// 预计最终到期时间状态
export type ExpiryEstimateStatus = 'exact' | 'waiting_activation' | 'none' | 'invalid_data'
// 临期等级由后端返回,前端仅用于展示和筛选结果标识
export type ExpiryLevel = string
export interface ExpiringAssetItem {
asset_type: AssetType
asset_id: number
identifier: string
shop_name?: string | null
package_name?: string | null
estimated_final_expires_at: string
days_until_final_expiry: number
expiry_level?: ExpiryLevel | null
expiry_level_name?: string | null
can_renew: boolean
}
export interface ExpiringAssetQueryParams {
asset_type?: AssetType
keyword?: string
shop_id?: number
package_id?: number
days_min?: number
days_max?: number
expires_from?: string
expires_to?: string
page?: number
size?: number
}
// 批量更新资产实名认证策略请求
export interface BatchUpdateAssetRealnamePolicyRequest {
asset_ids: number[]

View File

@@ -405,6 +405,9 @@ export interface StandaloneIotCard {
expiry_estimate_status?: ExpiryEstimateStatus // 预计最终到期时间状态
expiry_estimate_status_name?: string | null // 预计最终到期时间状态名称
is_expiring?: boolean // 是否临期,由后端判断
expiry_level?: string | null // 临期等级
expiry_level_name?: string | null // 临期等级名称
can_renew?: boolean // 是否允许续费
last_month_total_mb?: number // 上月流量总量(MB)
last_data_check_at?: string | null // 最后流量检查时间
last_real_name_check_at?: string | null // 最后实名检查时间

View File

@@ -73,6 +73,9 @@ export interface Device {
expiry_estimate_status?: ExpiryEstimateStatus // 预计最终到期时间状态
expiry_estimate_status_name?: string | null // 预计最终到期时间状态名称
is_expiring?: boolean // 是否临期,由后端判断
expiry_level?: string | null // 临期等级
expiry_level_name?: string | null // 临期等级名称
can_renew?: boolean // 是否允许续费
authorized_enterprise_id?: number | null // 当前有效授权企业ID
authorized_enterprise_name?: string | null // 当前有效授权企业名称
}

View File

@@ -1,51 +1,65 @@
export type NotificationCategory = 'approval' | 'expiry' | 'sync' | 'system' | 'balance' | string
export type NotificationReadStatus = 'read' | 'unread' | string
export type NotificationCategory = 'approval' | 'expiry' | 'sync' | 'system' | string
export type NotificationSeverity = 'info' | 'warning' | 'error' | 'critical' | string
export interface NotificationItem {
id: number | string
id: number
title: string
content: string
category?: NotificationCategory | null
type?: string | null
severity?: string | null
read?: boolean
is_read?: boolean
read_status?: NotificationReadStatus | null
created_at?: string | null
updated_at?: string | null
ref_type?: string | null
ref_id?: number | string | null
[key: string]: unknown
body: string
category: NotificationCategory
type: string
severity: NotificationSeverity
is_read: boolean
created_at: string
read_at: string | null
ref_type: string | null
ref_id: string | null
ref_key: string | null
}
export interface NotificationQueryParams {
page?: number
page_size?: number
category?: string
type?: string
severity?: string
read_status?: string
is_read?: boolean
page?: number
page_size?: number
}
export interface NotificationListResponse {
items: NotificationItem[]
page: number
size: number
total: number
page?: number
page_size?: number
size?: number
}
export interface NotificationSummary {
items: NotificationItem[]
categories?: Record<string, number>
total_unread?: number
export interface NotificationUnreadCount {
count: number
display_count: string
}
export interface NotificationUnreadSummary {
approval: number
expiry: number
sync: number
system: number
total: number
}
export interface NotificationReadResponse {
success: boolean
}
export interface NotificationReadAllRequest {
category?: string
}
export interface NotificationReadAllResponse {
updated_count: number
}
export interface NotificationTarget {
available?: boolean
route_name?: string | null
route_path?: string | null
route_params?: Record<string, string | number> | null
query?: Record<string, string | number | boolean> | null
message?: string | null
available: boolean
target_id: number | null
target_key: string
target_type: string
}

View File

@@ -21,9 +21,10 @@ export interface ShopResponse {
contact_name: string // 联系人姓名
contact_phone: string // 联系人电话
status: number // 状态 (0:禁用, 1:启用)
business_owner_account_id?: number | null // 平台业务员账号ID
business_owner_name?: string // 平台业务员名
business_owner_phone_masked?: string // 平台业务员脱敏手机号
business_owner_account_id: number | null // 平台业务员账号ID
business_owner_username: string // 平台业务员账号
business_owner_phone_summary: string // 平台业务员手机号摘要
business_owner_available: boolean // 是否仍可用于通知接收
}
// 店铺列表查询参数
@@ -71,6 +72,18 @@ export interface UpdateShopParams {
business_owner_account_id?: number | null // 平台业务员账号ID
}
// 店铺业务员候选
export interface ShopBusinessOwnerCandidate {
id: number // 平台业务员账号ID
username: string // 平台业务员账号名
phone_summary: string // 手机号摘要
}
// 店铺业务员候选查询参数
export interface ShopBusinessOwnerCandidateQueryParams extends PaginationParams {
keyword?: string // 用户名或手机号关键词
}
// 店铺列表分页响应
export interface ShopPageResult {
items: ShopResponse[] | null // 店铺列表

View File

@@ -29,3 +29,11 @@ export const getExpiryEstimateTooltip = (estimate: ExpiryEstimate): string => {
if (!estimate.is_expiring || estimate.days_until_final_expiry == null) return ''
return `剩余${estimate.days_until_final_expiry}`
}
export const getExpiryEstimateClass = (estimate: ExpiryEstimate): string => {
if (!estimate.is_expiring || estimate.days_until_final_expiry == null) return ''
if (estimate.days_until_final_expiry <= 3) return 'expiry-estimate--critical'
if (estimate.days_until_final_expiry <= 7) return 'expiry-estimate--warning'
if (estimate.days_until_final_expiry <= 15) return 'expiry-estimate--notice'
return ''
}

View File

@@ -1,31 +1,31 @@
import type { Router } from 'vue-router'
import type { NotificationTarget } from '@/types/api'
import { RoutesAlias } from '@/router/routesAlias'
export const navigateNotificationTarget = (router: Router, target?: NotificationTarget | null) => {
if (!target || target.available === false) return false
const query = target.query
? Object.fromEntries(Object.entries(target.query).map(([key, value]) => [key, String(value)]))
: undefined
type TargetRoute = {
path: string
query?: Record<string, string>
}
if (target.route_name) {
if (!router.hasRoute(target.route_name)) return false
void router.push({
name: target.route_name,
params: target.route_params || undefined,
query
})
return true
const resolveTargetRoute = (target: NotificationTarget): TargetRoute | null => {
const targetValues = [target.target_key, target.target_type]
if (targetValues.includes('expiring_assets') || targetValues.includes('expiring_asset_list')) {
return { path: RoutesAlias.ExpiringAssets }
}
const path = target.route_path || ''
if (!path.startsWith('/') || path.startsWith('//') || /^https?:\/\//i.test(path)) return false
if (targetValues.includes('notification_center')) return { path: RoutesAlias.Notifications }
const resolved = router.resolve({
path,
query
})
return null
}
export const navigateNotificationTarget = (router: Router, target?: NotificationTarget | null) => {
if (!target?.available) return false
const route = resolveTargetRoute(target)
if (!route) return false
const resolved = router.resolve({ path: route.path, query: route.query })
if (!resolved.matched.length || resolved.name === 'Exception404') return false
void router.push(resolved)
return true
}

View File

@@ -88,7 +88,7 @@
</ElDescriptionsItem>
<ElDescriptionsItem label="预计套餐到期时间">
<span
:class="{ 'expiry-estimate--expiring': cardInfo?.is_expiring }"
:class="getExpiryEstimateClass(cardInfo)"
:title="getExpiryEstimateTooltip(cardInfo)"
>
{{ getExpiryEstimateText(cardInfo) }}
@@ -182,7 +182,7 @@
</ElDescriptionsItem>
<ElDescriptionsItem label="预计套餐到期时间">
<span
:class="{ 'expiry-estimate--expiring': cardInfo?.is_expiring }"
:class="getExpiryEstimateClass(cardInfo)"
:title="getExpiryEstimateTooltip(cardInfo)"
>
{{ getExpiryEstimateText(cardInfo) }}
@@ -598,7 +598,11 @@
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { formatDateTime } from '@/utils/business/format'
import { getExpiryEstimateText, getExpiryEstimateTooltip } from '@/utils/business/expiryEstimate'
import {
getExpiryEstimateText,
getExpiryEstimateTooltip,
getExpiryEstimateClass
} from '@/utils/business/expiryEstimate'
// Props
interface BindingCard {
@@ -987,11 +991,24 @@
word-break: break-all;
}
.expiry-estimate--expiring {
.expiry-estimate--critical,
.expiry-estimate--warning,
.expiry-estimate--notice {
font-weight: 500;
}
.expiry-estimate--critical {
color: var(--el-color-danger);
}
.expiry-estimate--warning {
color: #8e44ad;
}
.expiry-estimate--notice {
color: #e83e8c;
}
.card-header {
display: flex;
gap: 12px;

View File

@@ -995,7 +995,11 @@
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import { getExpiryEstimateText, getExpiryEstimateTooltip } from '@/utils/business/expiryEstimate'
import {
getExpiryEstimateText,
getExpiryEstimateTooltip,
getExpiryEstimateClass
} from '@/utils/business/expiryEstimate'
import type { PackageSeriesResponse } from '@/types/api'
import type { EnterpriseItem } from '@/types/api/enterprise'
import type {
@@ -1900,7 +1904,7 @@
h(
'span',
{
class: { 'expiry-estimate--expiring': row.is_expiring },
class: getExpiryEstimateClass(row),
title: getExpiryEstimateTooltip(row)
},
getExpiryEstimateText(row)
@@ -3218,8 +3222,21 @@
cursor: pointer;
}
.expiry-estimate--expiring {
.expiry-estimate--critical,
.expiry-estimate--warning,
.expiry-estimate--notice {
font-weight: 500;
}
.expiry-estimate--critical {
color: var(--el-color-danger);
}
.expiry-estimate--warning {
color: #8e44ad;
}
.expiry-estimate--notice {
color: #e83e8c;
}
</style>

View File

@@ -1045,7 +1045,11 @@
import { useUserStore } from '@/store/modules/user'
import { storeToRefs } from 'pinia'
import { formatDateTime } from '@/utils/business/format'
import { getExpiryEstimateText, getExpiryEstimateTooltip } from '@/utils/business/expiryEstimate'
import {
getExpiryEstimateText,
getExpiryEstimateTooltip,
getExpiryEstimateClass
} from '@/utils/business/expiryEstimate'
import { formatRemainingTime, FrontendRateLimitError } from '@/utils/business/apiRateLimit'
import type {
StandaloneIotCard,
@@ -2127,7 +2131,7 @@
h(
'span',
{
class: { 'expiry-estimate--expiring': row.is_expiring },
class: getExpiryEstimateClass(row),
title: getExpiryEstimateTooltip(row)
},
getExpiryEstimateText(row)
@@ -3044,8 +3048,21 @@
cursor: pointer;
}
.expiry-estimate--expiring {
.expiry-estimate--critical,
.expiry-estimate--warning,
.expiry-estimate--notice {
font-weight: 500;
}
.expiry-estimate--critical {
color: var(--el-color-danger);
}
.expiry-estimate--warning {
color: #8e44ad;
}
.expiry-estimate--notice {
color: #e83e8c;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div class="console">
开发中敬请期待...
<ExpiringAssetStats />
<!--<CardList></CardList>-->
<!--<el-row :gutter="20">-->
@@ -29,13 +29,7 @@
</template>
<script setup lang="ts">
import CardList from './widget/CardList.vue'
import ActiveUser from './widget/ActiveUser.vue'
import SalesOverview from './widget/SalesOverview.vue'
import NewUser from './widget/NewUser.vue'
import Dynamic from './widget/Dynamic.vue'
import TodoList from './widget/TodoList.vue'
import AboutProject from './widget/AboutProject.vue'
import ExpiringAssetStats from './widget/ExpiringAssetStats.vue'
import { useCommon } from '@/composables/useCommon'
defineOptions({ name: 'Console' })

View File

@@ -0,0 +1,102 @@
<template>
<ElCard shadow="never" class="expiring-asset-stats">
<template #header>
<div class="card-header">
<span>临期资产</span>
<ElButton link type="primary" @click="() => goToList()">查看列表</ElButton>
</div>
</template>
<div class="stats-grid">
<div class="stat-item stat-item--card" @click="() => goToList('card')">
<div class="stat-label">临期网卡</div>
<div class="stat-value">{{ counts.card.toLocaleString() }}</div>
</div>
<div class="stat-item stat-item--device" @click="() => goToList('device')">
<div class="stat-label">临期设备</div>
<div class="stat-value">{{ counts.device.toLocaleString() }}</div>
</div>
</div>
</ElCard>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { AssetService } from '@/api/modules'
import type { AssetType } from '@/types/api'
import { RoutesAlias } from '@/router/routesAlias'
defineOptions({ name: 'ExpiringAssetStats' })
const router = useRouter()
const counts = reactive<Record<AssetType, number>>({ card: 0, device: 0 })
const loadCount = async (assetType: AssetType) => {
try {
const response = await AssetService.getExpiringAssets({
asset_type: assetType,
page: 1,
size: 1
})
if (response.code === 0 && response.data) counts[assetType] = response.data.total || 0
} catch {
counts[assetType] = 0
}
}
const goToList = (assetType?: AssetType) => {
router.push({
path: RoutesAlias.ExpiringAssets,
query: assetType ? { asset_type: assetType } : {}
})
}
onMounted(() => void Promise.all([loadCount('card'), loadCount('device')]))
</script>
<style scoped lang="scss">
.expiring-asset-stats {
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.stat-item {
padding: 20px;
cursor: pointer;
border-radius: 8px;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-2px);
}
}
.stat-item--card {
background: #fff0f6;
}
.stat-item--device {
background: #f4ecff;
}
.stat-label {
color: var(--el-text-color-secondary);
}
.stat-value {
margin-top: 8px;
font-size: 28px;
font-weight: 700;
color: var(--el-text-color-primary);
}
}
</style>

View File

@@ -20,10 +20,10 @@
<div class="filters">
<ElSelect v-model="filters.category" clearable placeholder="通知分类" @change="reload">
<ElOption label="余额预警" value="balance" />
<ElOption label="临期提醒" value="expiry" />
<ElOption label="审批" value="approval" />
<ElOption label="同步/系统" value="system" />
<ElOption label="同步" value="sync" />
<ElOption label="系统" value="system" />
</ElSelect>
<ElSelect v-model="filters.type" clearable placeholder="通知类型" @change="reload">
<ElOption v-for="type in typeOptions" :key="type" :label="type" :value="type" />
@@ -32,11 +32,11 @@
<ElOption label="提示" value="info" />
<ElOption label="警告" value="warning" />
<ElOption label="严重" value="error" />
<ElOption label="成功" value="success" />
<ElOption label="严重" value="critical" />
</ElSelect>
<ElSelect v-model="filters.read_status" clearable placeholder="已读状态" @change="reload">
<ElOption label="未读" value="unread" />
<ElOption label="已读" value="read" />
<ElSelect v-model="filters.is_read" clearable placeholder="已读状态" @change="reload">
<ElOption label="未读" :value="false" />
<ElOption label="已读" :value="true" />
</ElSelect>
<ElButton @click="loadNotifications">刷新</ElButton>
</div>
@@ -59,7 +59,8 @@
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="content" label="通知内容" min-width="300" show-overflow-tooltip />
<ElTableColumn prop="body" label="通知内容" min-width="300" show-overflow-tooltip />
<ElTableColumn prop="read_at" label="已读时间" width="180" />
<ElTableColumn prop="created_at" label="时间" width="180" />
<ElTableColumn label="操作" width="100" fixed="right">
<template #default="scope">
@@ -118,11 +119,10 @@
category: undefined,
type: undefined,
severity: undefined,
read_status: undefined
is_read: undefined
})
const isRead = (item: NotificationItem) =>
item.read === true || item.is_read === true || item.read_status === 'read'
const isRead = (item: NotificationItem) => item.is_read
const getCategoryLabel = (category?: string | null) =>
({
@@ -137,14 +137,14 @@
'-'
const getSeverityLabel = (severity?: string | null) =>
({ info: '提示', warning: '警告', error: '严重', success: '成功' })[severity || ''] ||
({ info: '提示', warning: '警告', error: '严重', critical: '严重' })[severity || ''] ||
severity ||
'-'
const getSeverityType = (severity?: string | null) => {
if (severity === 'error') return 'danger'
if (severity === 'warning') return 'warning'
if (severity === 'success') return 'success'
if (severity === 'critical') return 'danger'
return 'info'
}
@@ -204,12 +204,12 @@
}
const targetResponse = await NotificationService.getTarget(item.id)
if (targetResponse.code !== 0 || !targetResponse.data) {
ElMessage.info(item.content || '该通知暂无可跳转目标')
ElMessage.info(item.body || '该通知暂无可跳转目标')
await loadNotifications()
return
}
if (!navigateNotificationTarget(router, targetResponse.data)) {
ElMessage.info(targetResponse.data.message || item.content || '该通知暂无可跳转目标')
ElMessage.info(item.body || '该通知暂无可跳转目标')
}
await loadNotifications()
} catch (error: any) {

View File

@@ -0,0 +1,364 @@
<template>
<ArtTableFullScreen>
<div class="expiring-assets-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="true"
label-width="85"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<div class="page-intro"> 仅展示可预计且尚未过期的资产0-3 天资产优先显示 </div>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
row-key="asset_id"
:loading="loading"
:data="items"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="140"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h, onMounted, reactive, ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElTag } from 'element-plus'
import { AssetService, PackageManageService, ShopService } from '@/api/modules'
import type {
AssetType,
ExpiringAssetItem,
ExpiringAssetQueryParams,
PackageResponse,
ShopResponse
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { RoutesAlias } from '@/router/routesAlias'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'ExpiringAssets' })
const router = useRouter()
const route = useRoute()
const tableRef = ref()
const loading = ref(false)
const shopLoading = ref(false)
const packageLoading = ref(false)
const shopOptions = ref<ShopResponse[]>([])
const packageOptions = ref<PackageResponse[]>([])
const items = ref<ExpiringAssetItem[]>([])
const searchForm = reactive<ExpiringAssetQueryParams>({
asset_type: undefined,
keyword: '',
shop_id: undefined,
package_id: undefined,
days_min: undefined,
days_max: undefined,
expires_from: undefined,
expires_to: undefined
})
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 })
const searchFormItems = computed<SearchFormItem[]>(() => [
{
label: '资产类型',
prop: 'asset_type',
type: 'select',
config: { clearable: true, placeholder: '全部' },
options: [
{ label: '网卡', value: 'card' },
{ label: '设备', value: 'device' }
]
},
{
label: '关键词',
prop: 'keyword',
type: 'input',
config: { clearable: true, placeholder: '请输入资产标识' }
},
{
label: '店铺',
prop: 'shop_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
loading: shopLoading.value,
remoteMethod: (query: string) => searchShops(query),
placeholder: '请选择或搜索店铺'
},
options: () => shopOptions.value.map((shop) => ({ label: shop.shop_name, value: shop.id }))
},
{
label: '套餐',
prop: 'package_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
loading: packageLoading.value,
remoteMethod: (query: string) => searchPackages(query),
placeholder: '请选择或搜索套餐'
},
options: () =>
packageOptions.value.map((pkg) => ({
label: `${pkg.package_name} (${pkg.package_code})`,
value: pkg.id
}))
},
{
label: '最小剩余天数',
prop: 'days_min',
type: 'input',
config: { clearable: true, inputmode: 'numeric', placeholder: '最小天数' }
},
{
label: '最大剩余天数',
prop: 'days_max',
type: 'input',
config: { clearable: true, inputmode: 'numeric', placeholder: '最大天数' }
},
{
label: '预计到期起始',
prop: 'expires_from',
type: 'date',
config: { valueFormat: 'YYYY-MM-DD', clearable: true }
},
{
label: '预计到期结束',
prop: 'expires_to',
type: 'date',
config: { valueFormat: 'YYYY-MM-DD', clearable: true }
}
])
const columnOptions = [
{ label: '资产类型', prop: 'asset_type' },
{ label: '资产标识', prop: 'identifier' },
{ label: '店铺', prop: 'shop_name' },
{ label: '当前套餐', prop: 'package_name' },
{ label: '预计最终到期', prop: 'estimated_final_expires_at' },
{ label: '剩余天数', prop: 'days_until_final_expiry' },
{ label: '临期等级', prop: 'expiry_level_name' }
]
const getExpiryClass = (days: number) => {
if (days <= 3) return 'expiry-critical'
if (days <= 7) return 'expiry-warning'
if (days <= 15) return 'expiry-notice'
return ''
}
const getExpiryTagType = (days: number) => {
if (days <= 3) return 'danger'
if (days <= 7) return 'warning'
return 'info'
}
const searchShops = async (query = '') => {
shopLoading.value = true
try {
const response = await ShopService.getShops({
page: 1,
page_size: 20,
shop_name: query || undefined
})
if (response.code === 0) shopOptions.value = response.data.items || []
} finally {
shopLoading.value = false
}
}
const searchPackages = async (query = '') => {
packageLoading.value = true
try {
const response = await PackageManageService.getPackages({
page: 1,
page_size: 20,
package_name: query || undefined
})
if (response.code === 0) packageOptions.value = response.data.items || []
} finally {
packageLoading.value = false
}
}
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'asset_type',
label: '资产类型',
width: 100,
formatter: (row: ExpiringAssetItem) => (row.asset_type === 'card' ? '网卡' : '设备')
},
{
prop: 'identifier',
label: '资产标识',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: ExpiringAssetItem) => row.identifier || '-'
},
{
prop: 'shop_name',
label: '店铺',
minWidth: 140,
showOverflowTooltip: true,
formatter: (row: ExpiringAssetItem) => row.shop_name || '-'
},
{
prop: 'package_name',
label: '当前套餐',
minWidth: 160,
showOverflowTooltip: true,
formatter: (row: ExpiringAssetItem) => row.package_name || '-'
},
{
prop: 'estimated_final_expires_at',
label: '预计最终到期',
width: 180,
formatter: (row: ExpiringAssetItem) =>
h(
'span',
{ class: getExpiryClass(row.days_until_final_expiry) },
formatDateTime(row.estimated_final_expires_at)
)
},
{
prop: 'days_until_final_expiry',
label: '剩余天数',
width: 100,
formatter: (row: ExpiringAssetItem) =>
h(
ElTag,
{ type: getExpiryTagType(row.days_until_final_expiry), size: 'small' },
() => `${row.days_until_final_expiry}`
)
},
{
prop: 'expiry_level_name',
label: '临期等级',
width: 120,
formatter: (row: ExpiringAssetItem) => row.expiry_level_name || '-'
}
])
const loadAssets = async () => {
loading.value = true
try {
const response = await AssetService.getExpiringAssets({
...searchForm,
page: pagination.currentPage,
size: pagination.pageSize
})
if (response.code === 0 && response.data) {
items.value = response.data.items || []
pagination.total = response.data.total || 0
}
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.currentPage = 1
void loadAssets()
}
const handleReset = () => {
Object.assign(searchForm, {
asset_type: undefined,
keyword: '',
shop_id: undefined,
package_id: undefined,
days_min: undefined,
days_max: undefined,
expires_from: undefined,
expires_to: undefined
})
pagination.currentPage = 1
void loadAssets()
}
const handleRefresh = () => void loadAssets()
const handleSizeChange = (value: number) => {
pagination.pageSize = value
void loadAssets()
}
const handleCurrentChange = (value: number) => {
pagination.currentPage = value
void loadAssets()
}
const goToAsset = (row: ExpiringAssetItem) => {
router.push({
path: RoutesAlias.AssetInformation,
query: row.asset_type === 'card' ? { iccid: row.identifier } : { virtual_no: row.identifier }
})
}
const getActions = (row: ExpiringAssetItem) => [
{ label: '查看资产', type: 'primary' as const, handler: () => goToAsset(row) },
...(row.can_renew
? [{ label: '续费', type: 'primary' as const, handler: () => goToAsset(row) }]
: [])
]
onMounted(() => {
const assetType = route.query.asset_type
if (assetType === 'card' || assetType === 'device')
searchForm.asset_type = assetType as AssetType
void Promise.all([loadAssets(), searchShops(), searchPackages()])
})
</script>
<style scoped lang="scss">
.expiring-assets-page {
.page-intro {
color: var(--el-text-color-secondary);
}
.expiry-critical,
.expiry-warning,
.expiry-notice {
font-weight: 600;
}
.expiry-critical {
color: #f56c6c;
}
.expiry-warning {
color: #8e44ad;
}
.expiry-notice {
color: #e83e8c;
}
}
</style>

View File

@@ -141,14 +141,14 @@
>
<ElOption
v-for="salesperson in salespersonOptions"
:key="salesperson.account_id"
:label="salesperson.account_name"
:value="salesperson.account_id"
:key="salesperson.id"
:label="salesperson.username"
:value="salesperson.id"
>
<div style="display: flex; justify-content: space-between">
<span>{{ salesperson.account_name }}</span>
<span>{{ salesperson.username }}</span>
<span style="font-size: 12px; color: var(--el-text-color-secondary)">
{{ salesperson.phone_masked }}
{{ salesperson.phone_summary }}
</span>
</div>
</ElOption>
@@ -328,10 +328,14 @@
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { generateShopCode } from '@/utils/codeGenerator'
import { ShopService, RoleService, AccountService } from '@/api/modules'
import { ShopService, RoleService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
import type { PlatformSalesperson, ShopResponse, ShopRoleResponse } from '@/types/api'
import { AccountStatus, RoleType, RoleStatus } from '@/types/api'
import type {
ShopBusinessOwnerCandidate,
ShopResponse,
ShopRoleResponse
} from '@/types/api'
import { RoleType, RoleStatus } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
import { regionData } from '@/utils/constants/regionData'
@@ -349,7 +353,7 @@
const defaultRoleLoading = ref(false)
const defaultRoleList = ref<any[]>([])
const salespersonLoading = ref(false)
const salespersonOptions = ref<PlatformSalesperson[]>([])
const salespersonOptions = ref<ShopBusinessOwnerCandidate[]>([])
const detailDialogVisible = ref(false)
const detailShop = ref<ShopResponse | null>(null)
@@ -418,13 +422,11 @@
const searchPlatformSalespeople = async (query: string) => {
salespersonLoading.value = true
try {
const res = await AccountService.getPlatformSalespeople({
page: 1,
page_size: 20,
account_type: 'platform',
status: AccountStatus.ENABLED,
account_name: query || undefined
})
const res = await ShopService.getBusinessOwnerCandidates({
page: 1,
page_size: 20,
keyword: query || undefined
})
if (res.code === 0) {
salespersonOptions.value = res.data.items || []
}
@@ -436,15 +438,25 @@
}
}
const formatBusinessOwner = (shop: ShopResponse) => {
if (!shop.business_owner_name) return '-'
return shop.business_owner_phone_masked
? `${shop.business_owner_name} (${shop.business_owner_phone_masked})`
: shop.business_owner_name
}
const formatBusinessOwner = (shop: ShopResponse) => {
if (!shop.business_owner_username) return '-'
return shop.business_owner_phone_summary
? `${shop.business_owner_username} (${shop.business_owner_phone_summary})`
: shop.business_owner_username
}
const showDetail = (row: ShopResponse) => {
detailShop.value = row
const showDetail = async (row: ShopResponse) => {
try {
const response = await ShopService.getShopDetail(row.id)
if (response.code !== 0 || !response.data) {
ElMessage.error(response.msg || '获取店铺详情失败')
return
}
detailShop.value = response.data
} catch (error: any) {
ElMessage.error(error?.message || '获取店铺详情失败')
return
}
detailDialogVisible.value = true
}
@@ -570,8 +582,8 @@
},
options: () =>
salespersonOptions.value.map((salesperson) => ({
label: `${salesperson.account_name} (${salesperson.phone_masked})`,
value: salesperson.account_id
label: `${salesperson.username} (${salesperson.phone_summary})`,
value: salesperson.id
}))
}
])
@@ -584,7 +596,7 @@
{ label: '所在地区', prop: 'region' },
{ label: '联系人', prop: 'contact_name' },
{ label: '联系电话', prop: 'contact_phone' },
{ label: '平台业务员', prop: 'business_owner_name' },
{ label: '平台业务员', prop: 'business_owner_username' },
...(canModifyShopStatus ? [{ label: '状态', prop: 'status' }] : []),
{ label: '创建时间', prop: 'created_at' },
{ label: '操作', prop: 'operation' }
@@ -717,11 +729,11 @@
width: 130
},
{
prop: 'business_owner_name',
prop: 'business_owner_username',
label: '平台业务员',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: ShopResponse) => formatBusinessOwner(row)
formatter: (row: ShopResponse) => formatBusinessOwner(row)
},
...(canModifyShopStatus
? [