feat(media-supply): add media resource supply marketplace
Desktop Client Build / Resolve Build Metadata (push) Successful in 43s
Frontend CI / Frontend (push) Successful in 3m49s
Backend CI / Backend (push) Failing after 7m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m4s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 28s
Desktop Client Build / Resolve Build Metadata (push) Successful in 43s
Frontend CI / Frontend (push) Successful in 3m49s
Backend CI / Backend (push) Failing after 7m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m4s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 28s
Introduce an end-to-end media-supply feature: tenant-side resource sync service/worker backed by a Meijiequan supplier client, ops-side management APIs, and admin/ops web views for resources, orders, favorites and submission. Adds a shared digitocr helper, MediaSupply config blocks for tenant and ops, shared types, and migrations for supplier media resources, price overrides, customer visibility and order refunds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,7 @@ import {
|
||||
ControlOutlined,
|
||||
CrownOutlined,
|
||||
DatabaseOutlined,
|
||||
DollarCircleOutlined,
|
||||
FileSearchOutlined,
|
||||
GlobalOutlined,
|
||||
LineChartOutlined,
|
||||
@@ -94,6 +95,7 @@ const menuLeaves: MenuLeaf[] = [
|
||||
{ key: '/compliance/stats', label: '合规统计', path: '/compliance/stats' },
|
||||
{ key: '/accounts', label: '操作员管理', path: '/accounts' },
|
||||
{ key: '/jobs', label: '任务中心', path: '/jobs' },
|
||||
{ key: '/media-supply', label: '媒体资源运营', path: '/media-supply' },
|
||||
{ key: '/scheduler', label: '调度中心', path: '/scheduler' },
|
||||
{ key: '/audits', label: '审计日志', path: '/audits' },
|
||||
]
|
||||
@@ -171,6 +173,11 @@ const menuItems = computed<ItemType[]>(() => [
|
||||
label: '任务中心',
|
||||
icon: () => h(PartitionOutlined),
|
||||
},
|
||||
{
|
||||
key: '/media-supply',
|
||||
label: '媒体资源运营',
|
||||
icon: () => h(DollarCircleOutlined),
|
||||
},
|
||||
{
|
||||
key: '/scheduler',
|
||||
label: '调度中心',
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { http } from '@/lib/http'
|
||||
|
||||
export interface OpsMediaSupplyResource {
|
||||
id: number
|
||||
supplier_resource_id: string
|
||||
model_id: number
|
||||
name: string
|
||||
status: string
|
||||
cost_price_cents: number
|
||||
cost_prices: Record<string, number>
|
||||
sell_price_cents: number
|
||||
resource_url?: string | null
|
||||
baidu_weight?: number | null
|
||||
resource_remark?: string | null
|
||||
customer_visible: boolean
|
||||
channel_type?: string | null
|
||||
region?: string | null
|
||||
inclusion_effect?: string | null
|
||||
link_type?: string | null
|
||||
publish_rate?: string | null
|
||||
delivery_speed?: string | null
|
||||
last_synced_at: string
|
||||
}
|
||||
|
||||
export interface OpsMediaSupplyResourcesParams {
|
||||
model_id?: number
|
||||
keyword?: string
|
||||
remark_keyword?: string
|
||||
channel_type?: string
|
||||
region?: string
|
||||
inclusion_effect?: string
|
||||
link_type?: string
|
||||
min_price_cents?: number
|
||||
max_price_cents?: number
|
||||
visibility?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface OpsMediaSupplyResourcesResponse {
|
||||
items: OpsMediaSupplyResource[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface OpsMediaSupplyWallet {
|
||||
tenant_id: number
|
||||
tenant_name?: string | null
|
||||
user_id: number
|
||||
user_name?: string | null
|
||||
user_phone?: string | null
|
||||
balance_cents: number
|
||||
updated_at: string
|
||||
last_ledger_at?: string | null
|
||||
ledger_entries: number
|
||||
}
|
||||
|
||||
export interface OpsMediaSupplyWalletsResponse {
|
||||
items: OpsMediaSupplyWallet[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface OpsMediaSupplyWalletsParams {
|
||||
keyword?: string
|
||||
tenant_id?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export const opsMediaSupplyApi = {
|
||||
listResources(params: OpsMediaSupplyResourcesParams) {
|
||||
return http.get<OpsMediaSupplyResourcesResponse>(
|
||||
'/media-supply/resources',
|
||||
params as Record<string, unknown>,
|
||||
)
|
||||
},
|
||||
setPrice(id: number, payload: { price_type?: string; sell_price_cents: number; enabled?: boolean }) {
|
||||
return http.put<{ updated: boolean }, typeof payload>(`/media-supply/resources/${id}/price`, payload)
|
||||
},
|
||||
setVisibility(id: number, customerVisible: boolean) {
|
||||
return http.put<{ updated: boolean }, { customer_visible: boolean }>(
|
||||
`/media-supply/resources/${id}/visibility`,
|
||||
{ customer_visible: customerVisible },
|
||||
)
|
||||
},
|
||||
queueSync(modelId = 1) {
|
||||
return http.post<{ job_id: number }, { model_id: number }>('/media-supply/sync-jobs', {
|
||||
model_id: modelId,
|
||||
})
|
||||
},
|
||||
listWallets(params: OpsMediaSupplyWalletsParams) {
|
||||
return http.get<OpsMediaSupplyWalletsResponse>(
|
||||
'/media-supply/wallets',
|
||||
params as Record<string, unknown>,
|
||||
)
|
||||
},
|
||||
adjustWallet(payload: { tenant_id: number; user_id: number; delta_cents: number; note?: string }) {
|
||||
return http.post<OpsMediaSupplyWallet, typeof payload>('/media-supply/wallets/adjustments', payload)
|
||||
},
|
||||
}
|
||||
@@ -49,6 +49,12 @@ export const router = createRouter({
|
||||
component: () => import('@/views/JobsView.vue'),
|
||||
meta: { title: '任务中心' },
|
||||
},
|
||||
{
|
||||
path: 'media-supply',
|
||||
name: 'media-supply',
|
||||
component: () => import('@/views/MediaSupplyOpsView.vue'),
|
||||
meta: { title: '媒体资源运营' },
|
||||
},
|
||||
{
|
||||
path: 'scheduler',
|
||||
name: 'scheduler',
|
||||
|
||||
@@ -0,0 +1,725 @@
|
||||
<template>
|
||||
<div class="media-ops-page">
|
||||
<div class="media-ops-header">
|
||||
<div>
|
||||
<h2 class="ops-page-title">媒体资源运营</h2>
|
||||
<div class="media-ops-header__meta">上游资源售价、客户可见性、投稿余额</div>
|
||||
</div>
|
||||
<a-button :loading="syncLoading" @click="queueSync">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
同步缓存
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-tabs v-model:active-key="activeTab" class="media-ops-tabs">
|
||||
<a-tab-pane key="resources" tab="资源与价格">
|
||||
<div class="ops-card media-ops-card">
|
||||
<div class="ops-toolbar media-ops-toolbar">
|
||||
<a-input
|
||||
v-model:value="resourceFilter.keyword"
|
||||
allow-clear
|
||||
placeholder="资源名称"
|
||||
style="width: 220px"
|
||||
@press-enter="resetResources"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="resourceFilter.remarkKeyword"
|
||||
allow-clear
|
||||
placeholder="备注"
|
||||
style="width: 220px"
|
||||
@press-enter="resetResources"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="resourceFilter.visibility"
|
||||
allow-clear
|
||||
placeholder="客户可见性"
|
||||
style="width: 150px"
|
||||
:options="visibilityOptions"
|
||||
@change="resetResources"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="resourceFilter.channelType"
|
||||
allow-clear
|
||||
placeholder="频道类型"
|
||||
style="width: 150px"
|
||||
@press-enter="resetResources"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="resourceFilter.region"
|
||||
allow-clear
|
||||
placeholder="地区"
|
||||
style="width: 130px"
|
||||
@press-enter="resetResources"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="resourceFilter.minPrice"
|
||||
allow-clear
|
||||
placeholder="最低售价"
|
||||
style="width: 120px"
|
||||
@press-enter="resetResources"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="resourceFilter.maxPrice"
|
||||
allow-clear
|
||||
placeholder="最高售价"
|
||||
style="width: 120px"
|
||||
@press-enter="resetResources"
|
||||
/>
|
||||
<a-button type="primary" @click="resetResources">查询</a-button>
|
||||
<a-button @click="clearResourceFilters">重置</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="resourceColumns"
|
||||
:data-source="resourceRows"
|
||||
:loading="resourceLoading"
|
||||
:pagination="resourcePagination"
|
||||
row-key="id"
|
||||
:scroll="{ x: 1680 }"
|
||||
@change="onResourceTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="resource-main">
|
||||
<a
|
||||
v-if="isValidUrl(record.resource_url)"
|
||||
:href="record.resource_url || undefined"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="resource-link"
|
||||
>
|
||||
{{ record.name }}
|
||||
</a>
|
||||
<strong v-else>{{ record.name }}</strong>
|
||||
<div class="resource-sub">
|
||||
<code>{{ record.supplier_resource_id }}</code>
|
||||
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'price'">
|
||||
<div class="price-stack">
|
||||
<strong>{{ formatSellPrice(record.sell_price_cents) }}</strong>
|
||||
<span>成本 {{ formatMoney(record.cost_price_cents) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'margin'">
|
||||
<span :class="record.sell_price_cents <= record.cost_price_cents ? 'margin-risk' : 'margin-ok'">
|
||||
{{ formatMoney(record.sell_price_cents - record.cost_price_cents) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'baidu_weight'">
|
||||
<a-tag color="blue">百度 {{ record.baidu_weight ?? 0 }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'customer_visible'">
|
||||
<a-switch
|
||||
:checked="record.customer_visible"
|
||||
checked-children="显示"
|
||||
un-checked-children="隐藏"
|
||||
:loading="visibilityLoadingId === record.id"
|
||||
@change="(checked: boolean | string | number) => updateVisibility(record, Boolean(checked))"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'remark'">
|
||||
<a-tooltip :title="displayValue(record.resource_remark)">
|
||||
<span class="remark-cell">{{ displayValue(record.resource_remark) }}</span>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'last_synced_at'">
|
||||
{{ formatDate(record.last_synced_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<div class="table-actions-row">
|
||||
<a-tooltip title="调整售价">
|
||||
<a-button type="text" class="action-btn action-edit" @click="openPriceModal(record)">
|
||||
<EditOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="wallets" tab="客户余额">
|
||||
<div class="ops-card media-ops-card">
|
||||
<div class="ops-toolbar media-ops-toolbar">
|
||||
<a-input
|
||||
v-model:value="walletFilter.keyword"
|
||||
allow-clear
|
||||
placeholder="客户 / 手机 / 租户"
|
||||
style="width: 260px"
|
||||
@press-enter="resetWallets"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="walletFilter.tenantId"
|
||||
allow-clear
|
||||
placeholder="租户 ID"
|
||||
style="width: 130px"
|
||||
@press-enter="resetWallets"
|
||||
/>
|
||||
<a-button type="primary" @click="resetWallets">查询</a-button>
|
||||
<a-button @click="clearWalletFilters">重置</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="walletColumns"
|
||||
:data-source="walletRows"
|
||||
:loading="walletLoading"
|
||||
:pagination="walletPagination"
|
||||
row-key="wallet_key"
|
||||
:scroll="{ x: 1100 }"
|
||||
@change="onWalletTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'user'">
|
||||
<div class="resource-main">
|
||||
<strong>{{ record.user_name || record.user_phone || `用户 ${record.user_id}` }}</strong>
|
||||
<div class="resource-sub">
|
||||
<code>U{{ record.user_id }}</code>
|
||||
<span>{{ record.user_phone || '未绑定手机' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'tenant'">
|
||||
<div>{{ record.tenant_name || `租户 ${record.tenant_id}` }}</div>
|
||||
<div class="muted">T{{ record.tenant_id }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'balance_cents'">
|
||||
<strong class="wallet-balance">{{ formatMoney(record.balance_cents) }}</strong>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'ledger'">
|
||||
<div>{{ record.ledger_entries }} 条</div>
|
||||
<div class="muted">{{ record.last_ledger_at ? formatDate(record.last_ledger_at) : '暂无账单' }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'updated_at'">
|
||||
{{ formatDate(record.updated_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-button type="link" @click="openWalletModal(record)">调整余额</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<a-modal
|
||||
v-model:open="priceModalOpen"
|
||||
title="调整媒体售价"
|
||||
:confirm-loading="priceSaving"
|
||||
@ok="submitPrice"
|
||||
@cancel="priceTarget = null"
|
||||
>
|
||||
<div v-if="priceTarget" class="edit-stack">
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="资源">{{ priceTarget.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="成本">{{ formatMoney(priceTarget.cost_price_cents) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="当前售价">{{ formatSellPrice(priceTarget.sell_price_cents) }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="平台售价">
|
||||
<a-input v-model:value="priceForm.yuan" prefix="¥" placeholder="不能低于上游成本价" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="walletModalOpen"
|
||||
title="手动调整客户媒体余额"
|
||||
:confirm-loading="walletSaving"
|
||||
@ok="submitWalletAdjustment"
|
||||
@cancel="walletTarget = null"
|
||||
>
|
||||
<div v-if="walletTarget" class="edit-stack">
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="客户">{{ walletTarget.user_name || walletTarget.user_phone || walletTarget.user_id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="租户">{{ walletTarget.tenant_name || walletTarget.tenant_id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="当前余额">{{ formatMoney(walletTarget.balance_cents) }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="调整金额">
|
||||
<a-input v-model:value="walletForm.yuan" prefix="¥" placeholder="正数充值,负数扣减" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="walletForm.note" :rows="3" placeholder="账单备注,客户可见" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EditOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { showOpsError } from '@/lib/errors'
|
||||
import {
|
||||
opsMediaSupplyApi,
|
||||
type OpsMediaSupplyResource,
|
||||
type OpsMediaSupplyWallet,
|
||||
} from '@/lib/media-supply'
|
||||
|
||||
interface WalletRow extends OpsMediaSupplyWallet {
|
||||
wallet_key: string
|
||||
}
|
||||
|
||||
const MODEL_ID_AUTHORITY_NEWS = 1
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const activeTab = ref('resources')
|
||||
const resourcePage = ref(1)
|
||||
const resourcePageSize = ref(PAGE_SIZE)
|
||||
const resourceLoading = ref(false)
|
||||
const resourceTotal = ref(0)
|
||||
const resourceRows = ref<OpsMediaSupplyResource[]>([])
|
||||
const visibilityLoadingId = ref<number | null>(null)
|
||||
const syncLoading = ref(false)
|
||||
const priceModalOpen = ref(false)
|
||||
const priceSaving = ref(false)
|
||||
const priceTarget = ref<OpsMediaSupplyResource | null>(null)
|
||||
const priceForm = reactive({
|
||||
yuan: '',
|
||||
})
|
||||
|
||||
const walletPage = ref(1)
|
||||
const walletPageSize = ref(PAGE_SIZE)
|
||||
const walletLoading = ref(false)
|
||||
const walletTotal = ref(0)
|
||||
const walletRows = ref<WalletRow[]>([])
|
||||
const walletModalOpen = ref(false)
|
||||
const walletSaving = ref(false)
|
||||
const walletTarget = ref<WalletRow | null>(null)
|
||||
const walletForm = reactive({
|
||||
yuan: '',
|
||||
note: '',
|
||||
})
|
||||
|
||||
const resourceFilter = reactive({
|
||||
keyword: '',
|
||||
remarkKeyword: '',
|
||||
visibility: undefined as string | undefined,
|
||||
channelType: '',
|
||||
region: '',
|
||||
minPrice: '',
|
||||
maxPrice: '',
|
||||
})
|
||||
|
||||
const walletFilter = reactive({
|
||||
keyword: '',
|
||||
tenantId: '',
|
||||
})
|
||||
|
||||
const visibilityOptions = [
|
||||
{ label: '客户可见', value: 'visible' },
|
||||
{ label: '客户隐藏', value: 'hidden' },
|
||||
]
|
||||
|
||||
const resourceColumns: TableColumnsType<OpsMediaSupplyResource> = [
|
||||
{ title: '媒体名称', key: 'name', width: 320, fixed: 'left' },
|
||||
{ title: '售价 / 成本', key: 'price', width: 150, align: 'right' },
|
||||
{ title: '毛利', key: 'margin', width: 120, align: 'right' },
|
||||
{ title: '频道', dataIndex: 'channel_type', key: 'channel_type', width: 120 },
|
||||
{ title: '地区', dataIndex: 'region', key: 'region', width: 110 },
|
||||
{ title: '收录', dataIndex: 'inclusion_effect', key: 'inclusion_effect', width: 140 },
|
||||
{ title: '权重', key: 'baidu_weight', width: 110, align: 'center' },
|
||||
{ title: '链接', dataIndex: 'link_type', key: 'link_type', width: 110 },
|
||||
{ title: '出稿', dataIndex: 'publish_rate', key: 'publish_rate', width: 110 },
|
||||
{ title: '速度', dataIndex: 'delivery_speed', key: 'delivery_speed', width: 140 },
|
||||
{ title: '备注', key: 'remark', width: 320 },
|
||||
{ title: '客户可见', key: 'customer_visible', width: 130, align: 'center' },
|
||||
{ title: '同步时间', key: 'last_synced_at', width: 170 },
|
||||
{ title: '操作', key: 'actions', width: 90, fixed: 'right', align: 'center' },
|
||||
]
|
||||
|
||||
const walletColumns: TableColumnsType<WalletRow> = [
|
||||
{ title: '客户', key: 'user', width: 260, fixed: 'left' },
|
||||
{ title: '租户', key: 'tenant', width: 220 },
|
||||
{ title: '余额', key: 'balance_cents', width: 160, align: 'right' },
|
||||
{ title: '账单', key: 'ledger', width: 180 },
|
||||
{ title: '更新时间', key: 'updated_at', width: 180 },
|
||||
{ title: '操作', key: 'actions', width: 130, fixed: 'right', align: 'center' },
|
||||
]
|
||||
|
||||
const resourcePagination = computed<TablePaginationConfig>(() => ({
|
||||
current: resourcePage.value,
|
||||
pageSize: resourcePageSize.value,
|
||||
total: resourceTotal.value,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}))
|
||||
|
||||
const walletPagination = computed<TablePaginationConfig>(() => ({
|
||||
current: walletPage.value,
|
||||
pageSize: walletPageSize.value,
|
||||
total: walletTotal.value,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}))
|
||||
|
||||
watch(activeTab, (key) => {
|
||||
if (key === 'wallets' && walletRows.value.length === 0) {
|
||||
void loadWallets()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadResources()
|
||||
})
|
||||
|
||||
async function loadResources(): Promise<void> {
|
||||
resourceLoading.value = true
|
||||
try {
|
||||
const result = await opsMediaSupplyApi.listResources({
|
||||
model_id: MODEL_ID_AUTHORITY_NEWS,
|
||||
keyword: resourceFilter.keyword.trim() || undefined,
|
||||
remark_keyword: resourceFilter.remarkKeyword.trim() || undefined,
|
||||
visibility: resourceFilter.visibility,
|
||||
channel_type: resourceFilter.channelType.trim() || undefined,
|
||||
region: resourceFilter.region.trim() || undefined,
|
||||
min_price_cents: yuanToCentsOptional(resourceFilter.minPrice),
|
||||
max_price_cents: yuanToCentsOptional(resourceFilter.maxPrice),
|
||||
page: resourcePage.value,
|
||||
page_size: resourcePageSize.value,
|
||||
})
|
||||
resourceRows.value = result.items
|
||||
resourceTotal.value = result.total
|
||||
} catch (error) {
|
||||
showOpsError(error)
|
||||
} finally {
|
||||
resourceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWallets(): Promise<void> {
|
||||
walletLoading.value = true
|
||||
try {
|
||||
const result = await opsMediaSupplyApi.listWallets({
|
||||
keyword: walletFilter.keyword.trim() || undefined,
|
||||
tenant_id: parsePositiveNumber(walletFilter.tenantId),
|
||||
page: walletPage.value,
|
||||
page_size: walletPageSize.value,
|
||||
})
|
||||
walletRows.value = result.items.map((item) => ({
|
||||
...item,
|
||||
wallet_key: `${item.tenant_id}:${item.user_id}`,
|
||||
}))
|
||||
walletTotal.value = result.total
|
||||
} catch (error) {
|
||||
showOpsError(error)
|
||||
} finally {
|
||||
walletLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetResources(): void {
|
||||
resourcePage.value = 1
|
||||
void loadResources()
|
||||
}
|
||||
|
||||
function clearResourceFilters(): void {
|
||||
resourceFilter.keyword = ''
|
||||
resourceFilter.remarkKeyword = ''
|
||||
resourceFilter.visibility = undefined
|
||||
resourceFilter.channelType = ''
|
||||
resourceFilter.region = ''
|
||||
resourceFilter.minPrice = ''
|
||||
resourceFilter.maxPrice = ''
|
||||
resetResources()
|
||||
}
|
||||
|
||||
function resetWallets(): void {
|
||||
walletPage.value = 1
|
||||
void loadWallets()
|
||||
}
|
||||
|
||||
function clearWalletFilters(): void {
|
||||
walletFilter.keyword = ''
|
||||
walletFilter.tenantId = ''
|
||||
resetWallets()
|
||||
}
|
||||
|
||||
function onResourceTableChange(pagination: TablePaginationConfig): void {
|
||||
resourcePage.value = pagination.current ?? 1
|
||||
resourcePageSize.value = pagination.pageSize ?? PAGE_SIZE
|
||||
void loadResources()
|
||||
}
|
||||
|
||||
function onWalletTableChange(pagination: TablePaginationConfig): void {
|
||||
walletPage.value = pagination.current ?? 1
|
||||
walletPageSize.value = pagination.pageSize ?? PAGE_SIZE
|
||||
void loadWallets()
|
||||
}
|
||||
|
||||
async function queueSync(): Promise<void> {
|
||||
syncLoading.value = true
|
||||
try {
|
||||
await opsMediaSupplyApi.queueSync(MODEL_ID_AUTHORITY_NEWS)
|
||||
message.success('同步任务已提交')
|
||||
} catch (error) {
|
||||
showOpsError(error)
|
||||
} finally {
|
||||
syncLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openPriceModal(row: OpsMediaSupplyResource): void {
|
||||
priceTarget.value = row
|
||||
priceForm.yuan = centsToYuan(row.sell_price_cents)
|
||||
priceModalOpen.value = true
|
||||
}
|
||||
|
||||
async function submitPrice(): Promise<void> {
|
||||
if (!priceTarget.value) return
|
||||
const cents = roundUpToYuanCents(yuanToCents(priceForm.yuan))
|
||||
if (cents <= 0) {
|
||||
message.warning('请输入有效售价')
|
||||
return
|
||||
}
|
||||
if (cents < priceTarget.value.cost_price_cents) {
|
||||
message.warning('售价不能低于成本价')
|
||||
return
|
||||
}
|
||||
priceSaving.value = true
|
||||
try {
|
||||
await opsMediaSupplyApi.setPrice(priceTarget.value.id, {
|
||||
price_type: 'price',
|
||||
sell_price_cents: cents,
|
||||
enabled: true,
|
||||
})
|
||||
message.success('售价已更新')
|
||||
priceModalOpen.value = false
|
||||
priceTarget.value = null
|
||||
await loadResources()
|
||||
} catch (error) {
|
||||
showOpsError(error)
|
||||
} finally {
|
||||
priceSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateVisibility(row: OpsMediaSupplyResource, checked: boolean): Promise<void> {
|
||||
visibilityLoadingId.value = row.id
|
||||
try {
|
||||
await opsMediaSupplyApi.setVisibility(row.id, checked)
|
||||
row.customer_visible = checked
|
||||
message.success(checked ? '已在客户后台显示' : '已从客户后台隐藏')
|
||||
} catch (error) {
|
||||
showOpsError(error)
|
||||
} finally {
|
||||
visibilityLoadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function openWalletModal(row: WalletRow): void {
|
||||
walletTarget.value = row
|
||||
walletForm.yuan = ''
|
||||
walletForm.note = ''
|
||||
walletModalOpen.value = true
|
||||
}
|
||||
|
||||
async function submitWalletAdjustment(): Promise<void> {
|
||||
if (!walletTarget.value) return
|
||||
const cents = yuanToCents(walletForm.yuan)
|
||||
if (cents === 0) {
|
||||
message.warning('请输入调整金额')
|
||||
return
|
||||
}
|
||||
if (walletTarget.value.balance_cents + cents < 0) {
|
||||
message.warning('扣减后余额不能为负')
|
||||
return
|
||||
}
|
||||
walletSaving.value = true
|
||||
try {
|
||||
await opsMediaSupplyApi.adjustWallet({
|
||||
tenant_id: walletTarget.value.tenant_id,
|
||||
user_id: walletTarget.value.user_id,
|
||||
delta_cents: cents,
|
||||
note: walletForm.note.trim() || undefined,
|
||||
})
|
||||
message.success('余额已调整')
|
||||
walletModalOpen.value = false
|
||||
walletTarget.value = null
|
||||
await loadWallets()
|
||||
} catch (error) {
|
||||
showOpsError(error)
|
||||
} finally {
|
||||
walletSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatMoney(cents?: number | null): string {
|
||||
if (typeof cents !== 'number' || !Number.isFinite(cents)) return '--'
|
||||
return `¥${(cents / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
function formatSellPrice(cents?: number | null): string {
|
||||
if (typeof cents !== 'number' || !Number.isFinite(cents)) return '--'
|
||||
return `¥${Math.ceil(cents / 100)}`
|
||||
}
|
||||
|
||||
function centsToYuan(cents: number): string {
|
||||
return (cents / 100).toFixed(2)
|
||||
}
|
||||
|
||||
function yuanToCents(value: string): number {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return 0
|
||||
return Math.round(numeric * 100)
|
||||
}
|
||||
|
||||
function roundUpToYuanCents(cents: number): number {
|
||||
return cents > 0 ? Math.ceil(cents / 100) * 100 : 0
|
||||
}
|
||||
|
||||
function yuanToCentsOptional(value: string): number | undefined {
|
||||
const cents = yuanToCents(value)
|
||||
return cents > 0 ? cents : undefined
|
||||
}
|
||||
|
||||
function parsePositiveNumber(value: string): number | undefined {
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : undefined
|
||||
}
|
||||
|
||||
function displayValue(value?: string | null): string {
|
||||
return value?.trim() || '--'
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return '--'
|
||||
return dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '--'
|
||||
}
|
||||
|
||||
function isValidUrl(value?: string | null): boolean {
|
||||
return /^https?:\/\//i.test(String(value ?? '').trim())
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === 'normal' || status === 'enabled') return '可投'
|
||||
if (status === 'disabled') return '不可投'
|
||||
return status || '--'
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
if (status === 'normal' || status === 'enabled') return 'green'
|
||||
if (status === 'disabled') return 'default'
|
||||
return 'blue'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.media-ops-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.media-ops-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.media-ops-header .ops-page-title {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.media-ops-header__meta {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.media-ops-tabs :deep(.ant-tabs-nav) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.media-ops-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.media-ops-toolbar {
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.resource-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-main strong,
|
||||
.resource-link,
|
||||
.remark-cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-link {
|
||||
color: #1677ff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.resource-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.price-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.price-stack span,
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.margin-ok {
|
||||
color: #15803d;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.margin-risk {
|
||||
color: #b91c1c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.remark-cell {
|
||||
display: block;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.wallet-balance {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.edit-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.media-ops-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user