Files
geo/apps/ops-web/src/views/MediaSupplyOpsView.vue
T
root aa96143754 style: format web apps with prettier and sort imports
Apply repo-wide Prettier/lint normalization across admin-web,
desktop-client and ops-web: single quotes, no semicolons, trailing
commas, consistent line wrapping, and import ordering. Also drop an
unused brand-logo import in DesktopShell.vue.

No behavior changes — formatting only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:56:18 +08:00

757 lines
23 KiB
Vue

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