feat(admin-frontend): 补齐活跃筛选与支付快照能力

新增用户管理“活跃状态”高级筛选,并在后端支持
activity_status 复合规则,支持按活跃与非活跃筛选用户。

补齐订单支付成功快照落库与后台展示,保存支付渠道、
支付方法、实付金额和支付 IP,并在订单详情中优先展示。

同时增强节点页在线/离线筛选与批量删除、仪表盘快捷入口,
并修复已关闭工单再次回复后自动重开的统一语义。

附带同步测试、迁移、CI 工作流命名及知识库记录
This commit is contained in:
yinjianm
2026-04-25 00:59:08 +08:00
parent 2218457237
commit c64badfc23
55 changed files with 2023 additions and 71 deletions
+4
View File
@@ -514,6 +514,10 @@ export function batchUpdateNodes(payload: AdminNodeBatchUpdatePayload): Promise<
return unwrapPost<boolean>('/server/manage/batchUpdate', payload as unknown as Record<string, unknown>)
}
export function batchDeleteNodes(ids: number[]): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/manage/batchDelete', { ids })
}
export function saveNode(payload: AdminNodeSavePayload): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/manage/save', payload as unknown as Record<string, unknown>)
}
+12
View File
@@ -579,6 +579,13 @@ export interface AdminOrderUserRef {
plan_id?: number | null
}
export interface AdminOrderPaymentRef {
id: number
name: string
payment: string
icon?: string | null
}
export interface AdminCommissionLogItem {
id: number
invite_user_id: number
@@ -597,6 +604,10 @@ export interface AdminOrderListItem {
plan_id: number | null
coupon_id?: number | null
payment_id?: number | null
payment_channel?: string | null
payment_method?: string | null
payment_amount?: number | null
payment_ip?: string | null
type: number
period: string
trade_no: string
@@ -621,6 +632,7 @@ export interface AdminOrderListItem {
export interface AdminOrderDetail extends AdminOrderListItem {
user?: AdminOrderUserRef | null
invite_user?: AdminOrderUserRef | null
payment?: AdminOrderPaymentRef | null
commission_log?: AdminCommissionLogItem[]
surplus_orders?: AdminOrderListItem[]
}
+12
View File
@@ -1,6 +1,7 @@
import type { AdminNodeItem } from '@/types/api'
export type NodeRelationFilter = 'all' | 'parent' | 'child'
export type NodeStatusFilter = 'all' | 'online' | 'offline'
export interface NodeStatusMeta {
label: string
@@ -119,11 +120,13 @@ export function filterNodes(
keyword: string,
typeFilter: string,
groupFilter: string,
statusFilter: NodeStatusFilter = 'all',
relationFilter: NodeRelationFilter = 'all',
): AdminNodeItem[] {
const normalizedKeyword = normalizeText(keyword)
const normalizedType = normalizeText(typeFilter)
const normalizedGroup = normalizeText(groupFilter)
const normalizedStatus = normalizeText(statusFilter)
const normalizedRelation = normalizeText(relationFilter)
return nodes.filter((node) => {
@@ -142,6 +145,15 @@ export function filterNodes(
}
}
const nodeStatus = getNodeStatusMeta(node).dotClass
if (normalizedStatus === 'online' && nodeStatus !== 'online') {
return false
}
if (normalizedStatus === 'offline' && nodeStatus !== 'offline') {
return false
}
if (normalizedRelation === 'parent' && node.parent_id) {
return false
}
+33
View File
@@ -2,6 +2,7 @@ import type {
AdminOrderDetail,
AdminOrderFilter,
AdminOrderListItem,
AdminOrderPaymentRef,
AdminPlanListItem,
} from '@/types/api'
import { formatPlanPrice } from './plans'
@@ -113,6 +114,15 @@ function toAmount(value: unknown): number {
return Number.isFinite(numeric) ? numeric : 0
}
function toDisplayText(value: unknown): string | null {
if (!['string', 'number'].includes(typeof value)) {
return null
}
const text = String(value).trim()
return text ? text : null
}
function toTimestampMilliseconds(value: number | string | null | undefined): number | null {
if (value === null || value === undefined || value === '') {
return null
@@ -256,6 +266,29 @@ export function getOrderPeriodLabel(period: string | null | undefined): string {
return findPeriodMeta(period)?.label ?? (period || '-')
}
type OrderPaymentDisplayTarget = {
payment_channel?: string | null
payment_method?: string | null
payment?: AdminOrderPaymentRef | null
}
export function getOrderPaymentChannel(order?: OrderPaymentDisplayTarget | null): string {
return (
toDisplayText(order?.payment_channel)
?? toDisplayText(order?.payment?.name)
?? toDisplayText(order?.payment?.payment)
?? '-'
)
}
export function getOrderPaymentMethod(order?: OrderPaymentDisplayTarget | null): string {
return (
toDisplayText(order?.payment_method)
?? toDisplayText(order?.payment?.payment)
?? '-'
)
}
export function getOrderFilterLabel(type: OrderFilterValue<number>): string {
return getOptionLabel(ORDER_TYPE_OPTIONS, type)
}
+18 -2
View File
@@ -32,6 +32,7 @@ export type UserAdvancedFieldKey =
| 'email'
| 'id'
| 'plan_id'
| 'activity_status'
| 'transfer_enable'
| 'total_used'
| 'online_count'
@@ -52,7 +53,7 @@ export type UserAdvancedOperator =
| 'null'
| 'notnull'
export type UserAdvancedInputKind = 'text' | 'number' | 'plan' | 'status' | 'date'
export type UserAdvancedInputKind = 'text' | 'number' | 'plan' | 'status' | 'activity' | 'date'
export interface UserAdvancedFilterItem {
key: string
@@ -77,6 +78,11 @@ export const USER_STATUS_VALUE_OPTIONS = [
{ label: '封禁', value: 1 },
] as const
export const USER_ACTIVITY_STATUS_OPTIONS = [
{ label: '活跃', value: 1 },
{ label: '非活跃', value: 0 },
] as const
export const USER_ADVANCED_FIELD_DEFINITIONS: UserAdvancedFieldDefinition[] = [
{
field: 'email',
@@ -113,6 +119,12 @@ export const USER_ADVANCED_FIELD_DEFINITIONS: UserAdvancedFieldDefinition[] = [
{ value: 'notnull', label: '已订阅' },
],
},
{
field: 'activity_status',
label: '活跃状态',
input: 'activity',
operators: [{ value: 'eq', label: '是' }],
},
{
field: 'transfer_enable',
label: '流量',
@@ -303,7 +315,7 @@ function normalizeAdvancedFilterValue(item: UserAdvancedFilterItem): string | nu
return timestamp ? `${item.operator}:${timestamp}` : null
}
if (item.field === 'id' || item.field === 'plan_id' || item.field === 'online_count' || item.field === 'banned') {
if (item.field === 'id' || item.field === 'plan_id' || item.field === 'activity_status' || item.field === 'online_count' || item.field === 'banned') {
const numeric = Number(item.value)
return Number.isFinite(numeric) ? `${item.operator}:${numeric}` : null
}
@@ -331,6 +343,10 @@ function formatAdvancedFilterValue(item: UserAdvancedFilterItem, plans: AdminPla
return Number(item.value) === 1 ? '封禁' : '正常'
}
if (item.field === 'activity_status') {
return Number(item.value) === 1 ? '活跃' : '非活跃'
}
if (item.field === 'transfer_enable' || item.field === 'total_used') {
return `${Number(item.value)} GB`
}
@@ -2,6 +2,8 @@
import { computed, onMounted, ref, watch } from 'vue'
import type { Component } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import type { LocationQueryRaw } from 'vue-router'
import {
Coin,
DataAnalysis,
@@ -9,6 +11,7 @@ import {
Download,
RefreshRight,
Tickets,
TopRight,
Upload,
User,
UserFilled,
@@ -53,9 +56,15 @@ interface MetricCard {
change?: string
tone: 'dark' | 'light' | 'soft'
icon: Component
action?: {
routeName: 'Tickets' | 'SubscriptionOrders' | 'Users'
query?: LocationQueryRaw
helperText: string
}
}
const app = useAppStore()
const router = useRouter()
const booting = ref(true)
const trendLoading = ref(false)
const rankLoading = ref(false)
@@ -151,6 +160,14 @@ const metricCards = computed<MetricCard[]>(() => [
detail: '待客服跟进',
tone: 'soft',
icon: Tickets,
action: {
routeName: 'Tickets',
query: {
source: 'dashboard',
focus: 'opening',
},
helperText: '进入工单台',
},
},
{
key: 'commissionPendingTotal',
@@ -160,6 +177,14 @@ const metricCards = computed<MetricCard[]>(() => [
change: formatPercent(dashboardStats.value.commissionGrowth),
tone: 'soft',
icon: Discount,
action: {
routeName: 'SubscriptionOrders',
query: {
source: 'dashboard',
workbench: 'pending',
},
helperText: '确认佣金',
},
},
{
key: 'newUsers',
@@ -177,6 +202,10 @@ const metricCards = computed<MetricCard[]>(() => [
detail: `在线 ${formatCompactNumber(dashboardStats.value.onlineUsers)} · 设备 ${formatCompactNumber(dashboardStats.value.onlineDevices)}`,
tone: 'light',
icon: UserFilled,
action: {
routeName: 'Users',
helperText: '查看用户',
},
},
{
key: 'monthUpload',
@@ -475,6 +504,17 @@ function handleRefresh() {
void refreshDashboard()
}
function openMetricCard(card: MetricCard) {
if (!card.action) {
return
}
void router.push({
name: card.action.routeName,
query: card.action.query,
})
}
function rankBarWidth(index: number): string {
return `${Math.max(28, 100 - index * 12)}%`
}
@@ -548,11 +588,15 @@ onMounted(() => {
</section>
<section class="metrics-grid">
<article
<component
v-for="card in metricCards"
:is="card.action ? 'button' : 'article'"
:key="card.key"
class="metric-card"
:class="`tone-${card.tone}`"
:class="[`tone-${card.tone}`, { 'metric-card--interactive': Boolean(card.action) }]"
:type="card.action ? 'button' : undefined"
:aria-label="card.action ? `${card.label}${card.action.helperText}` : undefined"
@click="openMetricCard(card)"
>
<div class="metric-card__meta">
<span>{{ card.label }}</span>
@@ -560,14 +604,20 @@ onMounted(() => {
</div>
<strong class="metric-card__value">{{ card.value }}</strong>
<p class="metric-card__detail">{{ card.detail }}</p>
<span
v-if="card.change"
class="metric-card__change"
:class="Number(card.change.replace('%', '')) >= 0 ? 'positive' : 'negative'"
>
{{ card.change }}
</span>
</article>
<div class="metric-card__footer">
<span
v-if="card.change"
class="metric-card__change"
:class="Number(card.change.replace('%', '')) >= 0 ? 'positive' : 'negative'"
>
{{ card.change }}
</span>
<span v-if="card.action" class="metric-card__action">
{{ card.action.helperText }}
<ElIcon class="metric-card__action-icon"><TopRight /></ElIcon>
</span>
</div>
</component>
</section>
<section class="content-grid">
@@ -1076,12 +1126,36 @@ onMounted(() => {
}
.metric-card {
border: 1px solid transparent;
min-height: 150px;
padding: 22px;
display: grid;
gap: 10px;
}
.metric-card--interactive {
appearance: none;
width: 100%;
text-align: left;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.metric-card--interactive:hover {
transform: translateY(-2px);
border-color: rgba(0, 113, 227, 0.14);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.08);
}
.metric-card--interactive:focus-visible {
outline: 2px solid rgba(0, 113, 227, 0.34);
outline-offset: 3px;
}
.metric-card--interactive:active {
transform: translateY(0);
}
.metric-card__meta {
display: flex;
align-items: center;
@@ -1126,11 +1200,37 @@ onMounted(() => {
color: var(--xboard-text-muted);
}
.metric-card__change {
.metric-card__footer {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 20px;
}
.metric-card__change {
font-size: 13px;
}
.metric-card__action {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
color: #0071e3;
font-size: 13px;
font-weight: 600;
}
.metric-card__action-icon {
font-size: 12px;
}
.tone-dark .metric-card__action {
color: #2997ff;
}
.panel {
padding: 28px;
}
+85 -16
View File
@@ -5,6 +5,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import type { TableInstance } from 'element-plus'
import {
Connection,
Delete,
MoreFilled,
Plus,
RefreshRight,
@@ -12,6 +13,7 @@ import {
User,
} from '@element-plus/icons-vue'
import {
batchDeleteNodes,
batchUpdateNodes,
copyNode,
deleteNode,
@@ -42,6 +44,7 @@ import {
getNodeStatusMeta,
getNodeTypeLabel,
type NodeRelationFilter,
type NodeStatusFilter,
} from '@/utils/nodes'
import { sortNodesByOrder } from '@/utils/nodeEditor'
@@ -60,10 +63,12 @@ const routes = ref<AdminNodeRouteItem[]>([])
const keyword = ref('')
const typeFilter = ref('all')
const groupFilter = ref('all')
const statusFilter = ref<NodeStatusFilter>('all')
const relationFilter = ref<NodeRelationFilter>('all')
const currentPage = ref(1)
const pageSize = ref(20)
const selectedNodeIds = ref<number[]>([])
const syncingSelection = ref(false)
const switchingIds = ref<number[]>([])
const workingIds = ref<number[]>([])
const editorVisible = ref(false)
@@ -72,12 +77,14 @@ const activeNode = ref<AdminNodeItem | null>(null)
const sortDialogVisible = ref(false)
const batchEditVisible = ref(false)
const batchSubmitting = ref(false)
const batchDeleting = ref(false)
const filteredNodes = computed(() => sortNodesByOrder(filterNodes(
nodes.value,
keyword.value,
typeFilter.value,
groupFilter.value,
statusFilter.value,
relationFilter.value,
)))
@@ -93,6 +100,7 @@ const hasActiveFilters = computed(() => (
keyword.value !== ''
|| typeFilter.value !== 'all'
|| groupFilter.value !== 'all'
|| statusFilter.value !== 'all'
|| relationFilter.value !== 'all'
))
@@ -106,7 +114,7 @@ const summaryCards = computed(() => [
const batchTargetLabel = computed(() => (
hasSelectedNodes.value
? `当前已选 ${selectedNodes.value.length} 个节点`
: '批量修改仅作用于已勾选节点'
: '批量修改 / 删除仅作用于已勾选节点'
))
function getRouteGroupQuery(): string {
@@ -182,12 +190,20 @@ function syncTableSelection() {
return
}
table.clearSelection()
paginatedNodes.value.forEach((node) => {
if (selectedNodeIds.value.includes(node.id)) {
table.toggleRowSelection(node, true)
}
})
syncingSelection.value = true
try {
table.clearSelection()
paginatedNodes.value.forEach((node) => {
if (selectedNodeIds.value.includes(node.id)) {
table.toggleRowSelection(node, true)
}
})
} finally {
nextTick(() => {
syncingSelection.value = false
})
}
})
}
@@ -220,6 +236,7 @@ function handleReset() {
keyword.value = ''
typeFilter.value = 'all'
groupFilter.value = 'all'
statusFilter.value = 'all'
relationFilter.value = 'all'
currentPage.value = 1
}
@@ -229,6 +246,10 @@ function openNodeGroupManagement() {
}
function handleSelectionChange(selection: AdminNodeItem[]) {
if (syncingSelection.value) {
return
}
const currentPageIds = paginatedNodes.value.map((item) => item.id)
const selectionIds = selection.map((item) => item.id)
const persistedIds = selectedNodeIds.value.filter((id) => !currentPageIds.includes(id))
@@ -281,6 +302,41 @@ async function handleBatchSubmit(payload: NodeBatchEditPayload) {
}
}
async function handleBatchDelete() {
if (!hasSelectedNodes.value) {
ElMessage.warning('请先勾选需要批量删除的节点')
return
}
const deleteCount = selectedNodes.value.length
try {
await ElMessageBox.confirm(
`确认批量删除 ${deleteCount} 个节点吗?此操作不可恢复。`,
'批量删除节点',
{
type: 'warning',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
},
)
batchDeleting.value = true
await batchDeleteNodes([...selectedNodeIds.value])
clearSelection()
ElMessage.success(`已批量删除 ${deleteCount} 个节点`)
await loadNodeBoard()
} catch (error) {
if (error === 'cancel' || error === 'close') {
return
}
ElMessage.error(error instanceof Error ? error.message : '批量删除失败')
} finally {
batchDeleting.value = false
}
}
async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
const previous = Boolean(node.show)
if (previous === nextValue) {
@@ -381,7 +437,7 @@ watch(
},
)
watch([keyword, typeFilter, groupFilter, relationFilter], () => {
watch([keyword, typeFilter, groupFilter, statusFilter, relationFilter], () => {
currentPage.value = 1
})
@@ -397,10 +453,7 @@ watch(
)
watch(
() => [
paginatedNodes.value.map((item) => item.id).join(','),
selectedNodeIds.value.join(','),
],
() => paginatedNodes.value.map((item) => item.id).join(','),
() => {
syncTableSelection()
},
@@ -415,7 +468,7 @@ watch(
<p class="nodes-kicker">Nodes</p>
<h1>节点管理</h1>
<span>
现在可以在同一页完成节点筛选分页浏览单行置顶批量修改以及新增编辑显隐和删除等运营动作
现在可以在同一页完成节点筛选在线 / 离线排查分页浏览单行置顶批量修改与批量删除以及新增编辑显隐和删除等运营动作
</span>
</div>
@@ -466,6 +519,12 @@ watch(
/>
</ElSelect>
<ElSelect v-model="statusFilter" class="toolbar-select" placeholder="状态">
<ElOption label="全部节点" value="all" />
<ElOption label="在线节点" value="online" />
<ElOption label="离线节点" value="offline" />
</ElSelect>
<ElSelect v-model="relationFilter" class="toolbar-select" placeholder="节点关系">
<ElOption label="全部节点" value="all" />
<ElOption label="父节点" value="parent" />
@@ -475,7 +534,17 @@ watch(
<div class="toolbar-actions">
<span class="scope-hint">{{ batchTargetLabel }}</span>
<ElButton :disabled="!hasSelectedNodes" @click="openBatchEditor">批量修改</ElButton>
<ElButton :disabled="!hasSelectedNodes || batchDeleting" @click="openBatchEditor">批量修改</ElButton>
<ElButton
type="danger"
plain
:disabled="!hasSelectedNodes"
:loading="batchDeleting"
@click="handleBatchDelete"
>
<ElIcon><Delete /></ElIcon>
批量删除
</ElButton>
<ElButton @click="openNodeGroupManagement">管理权限组</ElButton>
<ElButton @click="handleReset" :disabled="!hasActiveFilters">
<ElIcon><RefreshRight /></ElIcon>
@@ -486,7 +555,7 @@ watch(
</header>
<div v-if="hasSelectedNodes" class="selection-summary">
<span class="selection-summary__label">已勾选 {{ selectedNodes.length }} 个节点批量修改只会作用于这些节点</span>
<span class="selection-summary__label">已勾选 {{ selectedNodes.length }} 个节点批量修改与批量删除只会作用于这些节点</span>
<ElButton text @click="clearSelection">清空勾选</ElButton>
</div>
@@ -655,7 +724,7 @@ watch(
/>
<div class="footer-hint">
<ElIcon><Connection /></ElIcon>
<span>节点新增编辑置顶排序与批量修改已收敛到同一工作台</span>
<span>节点新增编辑置顶排序批量修改与批量删除已收敛到同一工作台</span>
</div>
</footer>
</section>
@@ -11,6 +11,8 @@ import {
formatOrderDateTime,
getCommissionStatusMeta,
getOrderPeriodLabel,
getOrderPaymentChannel,
getOrderPaymentMethod,
getOrderStatusMeta,
getOrderTypeMeta,
} from '@/utils/orders'
@@ -41,6 +43,8 @@ const commissionMeta = computed(() => getCommissionStatusMeta(
props.order?.actual_commission_balance,
))
const hasCommission = computed(() => hasOrderCommission(props.order))
const paymentChannel = computed(() => getOrderPaymentChannel(props.order))
const paymentMethod = computed(() => getOrderPaymentMethod(props.order))
const summaryCards = computed(() => [
{ label: '订单状态', value: statusMeta.value.label, detail: typeMeta.value.label },
@@ -56,10 +60,31 @@ const basicFields = computed(() => [
{ label: '订阅计划', value: props.order?.plan?.name || '-' },
{ label: '订单类型', value: typeMeta.value.label },
{ label: '订阅周期', value: getOrderPeriodLabel(props.order?.period) },
{ label: '回调编号', value: props.order?.callback_no || '-' },
{ label: '支付时间', value: formatOrderDateTime(props.order?.paid_at) },
])
const paymentSummaryDescription = computed(() => (
props.order?.paid_at
? '集中查看支付成功后的渠道、方法与平台流水快照。'
: '订单完成支付后,这里会展示支付渠道、支付方法与平台流水信息。'
))
const paymentFields = computed(() => [
{ label: '支付渠道', value: paymentChannel.value },
{ label: '支付方法', value: paymentMethod.value },
{ label: '平台订单号', value: props.order?.callback_no || '-' },
{ label: '商户订单号', value: props.order?.trade_no || '-' },
{ label: '订单金额', value: formatOrderAmount(props.order?.total_amount) },
{ label: '实际支付金额', value: formatOrderAmount(props.order?.payment_amount) },
{ label: '创建时间', value: formatOrderDateTime(props.order?.created_at) },
{ label: '完成时间', value: formatOrderDateTime(props.order?.paid_at) },
{ label: '支付 IP', value: props.order?.payment_ip || '-' },
{ label: '订单状态', value: statusMeta.value.label },
])
const paymentBadges = computed(() => ([
{ label: paymentChannel.value, tone: 'neutral' },
{ label: paymentMethod.value, tone: 'info' },
].filter((item) => item.label !== '-')))
const amountFields = computed(() => [
{ label: '订单金额', value: formatOrderAmount(props.order?.total_amount) },
{ label: '手续费', value: formatOrderAmount(props.order?.handling_amount) },
@@ -151,6 +176,33 @@ watch(
</div>
</section>
<section class="detail-card">
<header class="card-header">
<div>
<h3>支付成功信息</h3>
<p>{{ paymentSummaryDescription }}</p>
</div>
<div v-if="paymentBadges.length" class="payment-badges">
<span
v-for="item in paymentBadges"
:key="`${item.tone}-${item.label}`"
class="hero-badge"
:class="`is-${item.tone}`"
>
{{ item.label }}
</span>
</div>
</header>
<div class="description-grid">
<article v-for="item in paymentFields" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</div>
</section>
<section class="detail-card">
<header class="card-header">
<div>
@@ -326,6 +378,13 @@ watch(
gap: 10px;
}
.payment-badges {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.hero-badge {
display: inline-flex;
align-items: center;
@@ -498,6 +557,7 @@ watch(
}
.card-header,
.payment-badges,
.commission-actions,
.list-row,
.drawer-actions {
@@ -100,6 +100,34 @@ const scopedUserFilters = computed(() => (
: []
))
const dashboardWorkbench = computed<CommissionWorkbenchMode | null>(() => {
const raw = route.query.workbench
const value = Array.isArray(raw) ? raw[0] : raw
if (value === 'pending' || value === 'commission') {
return value
}
return null
})
const dashboardEntryNotice = computed(() => {
const raw = route.query.source
const source = Array.isArray(raw) ? raw[0] : raw
if (source !== 'dashboard') {
return ''
}
if (dashboardWorkbench.value === 'pending') {
return '已从仪表盘进入,当前默认展示真实待确认佣金订单。'
}
if (dashboardWorkbench.value === 'commission') {
return '已从仪表盘进入,当前默认展示全部佣金订单。'
}
return '已从仪表盘进入订单工作台。'
})
const scopedUserNotice = computed(() => (
scopedUserId.value
? `当前仅展示 ${scopedUserEmail.value || `用户 #${scopedUserId.value}`} 的订单。`
@@ -118,6 +146,10 @@ const commissionWorkbenchLabel = computed(() => {
})
const commissionWorkbenchNotice = computed(() => {
if (dashboardEntryNotice.value) {
return ''
}
if (commissionWorkbench.value === 'pending') {
return '当前仅展示真实待确认佣金订单,可在操作列直接确认。'
}
@@ -220,6 +252,35 @@ function handleCommissionWorkbench(command: string) {
refreshOrders(true)
}
function syncDashboardWorkbench() {
if (dashboardWorkbench.value === 'pending') {
commissionWorkbench.value = 'pending'
commissionFilter.value = 0
return
}
if (dashboardWorkbench.value === 'commission') {
commissionWorkbench.value = 'commission'
commissionFilter.value = 'all'
return
}
if (route.query.workbench !== undefined) {
commissionWorkbench.value = 'all'
commissionFilter.value = 'all'
}
}
function clearDashboardEntry() {
const nextQuery = { ...route.query }
delete nextQuery.source
delete nextQuery.workbench
void router.replace({
name: 'SubscriptionOrders',
query: nextQuery,
})
}
function clearFilters() {
keyword.value = ''
typeFilter.value = 'all'
@@ -397,13 +458,15 @@ watch([current, pageSize], () => {
})
watch(
() => [route.query.user_id, route.query.user_email],
() => [route.query.user_id, route.query.user_email, route.query.workbench],
() => {
syncDashboardWorkbench()
refreshOrders(true)
},
)
onMounted(() => {
syncDashboardWorkbench()
void Promise.all([loadPlans(), loadOrders()]).catch(() => {
ElMessage.error('订单管理页面初始化失败')
})
@@ -555,6 +618,19 @@ onMounted(() => {
</template>
</ElAlert>
<ElAlert
v-if="!errorMessage && dashboardEntryNotice"
class="orders-alert orders-alert--info"
type="info"
:closable="false"
show-icon
:title="dashboardEntryNotice"
>
<template #default>
<ElButton size="small" @click="clearDashboardEntry">关闭提示</ElButton>
</template>
</ElAlert>
<ElAlert
v-if="!errorMessage && scopedUserNotice"
class="orders-alert orders-alert--info"
@@ -40,6 +40,7 @@ type UploadError = Parameters<UploadRequestOptions['onError']>[0]
const statusMeta = computed(() => detail.value ? getTicketStatusMeta(detail.value) : null)
const levelMeta = computed(() => detail.value ? getTicketLevelMeta(detail.value.level) : null)
const willReopenClosedTicket = computed(() => detail.value?.status === 1)
async function loadSidebarTickets() {
if (!props.visible) {
@@ -312,6 +313,9 @@ watch(
</div>
<footer class="reply-box">
<p v-if="willReopenClosedTicket" class="reply-box__hint">
当前工单已关闭发送新回复后会自动重新开启
</p>
<ElInput
v-model="replyMessage"
type="textarea"
@@ -335,10 +339,9 @@ watch(
type="primary"
:icon="ChatLineRound"
:loading="replying"
:disabled="detail.status === 1"
@click="handleReply"
>
发送
{{ willReopenClosedTicket ? '发送并重开' : '发送' }}
</ElButton>
</div>
</footer>
@@ -561,6 +564,12 @@ watch(
background: rgba(255, 255, 255, 0.92);
}
.reply-box__hint {
color: var(--xboard-text-muted);
font-size: 13px;
line-height: 1.5;
}
.reply-box__actions {
display: flex;
justify-content: flex-end;
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { DataAnalysis, Search, View } from '@element-plus/icons-vue'
import { useRoute, useRouter } from 'vue-router'
import { closeTicket, fetchTickets } from '@/api/admin'
import type { AdminTicketListItem } from '@/types/api'
import { formatDateTime } from '@/utils/dashboard'
@@ -13,6 +14,8 @@ import {
import TicketWorkspaceDialog from './TicketWorkspaceDialog.vue'
const loading = ref(false)
const route = useRoute()
const router = useRouter()
const tickets = ref<AdminTicketListItem[]>([])
const total = ref(0)
const current = ref(1)
@@ -32,6 +35,38 @@ const headerStats = computed(() => [
{ label: '当前页', value: String(current.value) },
])
const dashboardFocus = computed<'opening' | 'closed' | 'all' | null>(() => {
const raw = route.query.focus
const value = Array.isArray(raw) ? raw[0] : raw
if (value === 'closed') {
return 'closed'
}
if (value === 'all') {
return 'all'
}
if (value === 'opening' || value === 'pending') {
return 'opening'
}
return null
})
const dashboardEntryNotice = computed(() => {
const raw = route.query.source
const source = Array.isArray(raw) ? raw[0] : raw
if (source !== 'dashboard') {
return ''
}
if (dashboardFocus.value === 'opening') {
return '已从仪表盘进入,这里默认展示待处理工单。'
}
return '已从仪表盘进入工单工作台。'
})
function statusValueToParam() {
if (statusFilter.value === 'opening') {
return 0
@@ -92,6 +127,24 @@ function handleSearch() {
void loadTickets()
}
function applyDashboardFocus() {
if (!dashboardFocus.value) {
return
}
statusFilter.value = dashboardFocus.value
}
function clearDashboardEntry() {
const nextQuery = { ...route.query }
delete nextQuery.source
delete nextQuery.focus
void router.replace({
name: 'Tickets',
query: nextQuery,
})
}
watch([current, pageSize], () => {
void loadTickets()
})
@@ -101,7 +154,15 @@ watch([statusFilter, levelFilter], () => {
void loadTickets()
})
watch(
() => route.query.focus,
() => {
applyDashboardFocus()
},
)
onMounted(() => {
applyDashboardFocus()
void loadTickets()
})
</script>
@@ -156,6 +217,19 @@ onMounted(() => {
</div>
</header>
<ElAlert
v-if="dashboardEntryNotice"
class="tickets-alert"
type="info"
:closable="false"
show-icon
:title="dashboardEntryNotice"
>
<template #default>
<ElButton size="small" @click="clearDashboardEntry">关闭提示</ElButton>
</template>
</ElAlert>
<ElTable :data="tickets" v-loading="loading" class="ticket-table" row-key="id">
<ElTableColumn label="工单号" width="92">
<template #default="{ row }">
@@ -340,6 +414,10 @@ onMounted(() => {
padding-bottom: 16px;
}
.tickets-alert {
margin-top: -4px;
}
.subject-cell {
display: grid;
gap: 6px;
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'
import { Delete, Plus } from '@element-plus/icons-vue'
import type { AdminPlanOption } from '@/types/api'
import {
USER_ACTIVITY_STATUS_OPTIONS,
cloneUserAdvancedFilters,
createEmptyUserAdvancedFilter,
getUserAdvancedFieldDefinition,
@@ -218,6 +219,19 @@ watch(() => props.visible, (visible) => {
/>
</ElSelect>
<ElSelect
v-else-if="getDefinition(filter.field).input === 'activity'"
v-model="filter.value"
placeholder="选择活跃状态"
>
<ElOption
v-for="option in USER_ACTIVITY_STATUS_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElDatePicker
v-else
v-model="filter.value"