Files
geo/apps/admin-web/src/views/MediaSupplySubmitView.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

772 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
CheckCircleOutlined,
CloseOutlined,
EditOutlined,
FileTextOutlined,
LeftOutlined,
SearchOutlined,
SendOutlined,
WalletOutlined,
} from '@ant-design/icons-vue'
import type {
ArticleDetail,
ArticleListItem,
ArticleListParams,
CreateMediaSupplyOrderRequest,
} from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { message } from 'ant-design-vue'
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import ArticleEditorCanvas from '@/components/ArticleEditorCanvas.vue'
import { articlesApi, imagesApi, mediaSupplyApi } from '@/lib/api'
import { formatDateTime, getGenerateStatusMeta, getSourceTypeLabel } from '@/lib/display'
import { formatError } from '@/lib/errors'
import {
clearMediaSupplySubmitSelection,
loadMediaSupplySubmitSelection,
type MediaSupplySubmitResourceSnapshot,
} from '@/lib/media-supply-submit-selection'
import { useCompanyStore } from '@/stores/company'
const MODEL_ID_AUTHORITY_NEWS = 1
const ARTICLE_SOURCE_ALL = 'template,kol,custom_generation,imitation,free_create'
type SubmitMode = 'manual' | 'article'
const router = useRouter()
const queryClient = useQueryClient()
const companyStore = useCompanyStore()
const resources = ref<MediaSupplySubmitResourceSnapshot[]>(loadMediaSupplySubmitSelection())
const mode = ref<SubmitMode>('article')
const articlePage = ref(1)
const articlePageSize = ref(8)
const articleKeyword = ref('')
const articleSourceType = ref(ARTICLE_SOURCE_ALL)
const selectedArticleId = ref<number | null>(null)
const selectedArticleLoading = ref(false)
const form = reactive({
title: '',
content: '',
})
const sourceOptions = [
{ label: '全部来源', value: ARTICLE_SOURCE_ALL },
{ label: '模版生成', value: 'template,kol' },
{ label: '自定义生成', value: 'custom_generation' },
{ label: '仿写创作', value: 'imitation' },
{ label: '自由创作', value: 'free_create' },
]
const walletQuery = useQuery({
queryKey: ['media-supply', 'wallet'],
queryFn: () => mediaSupplyApi.wallet(),
})
const articleParams = computed<ArticleListParams>(() => {
const params: ArticleListParams = {
page: articlePage.value,
page_size: articlePageSize.value,
generate_status: 'completed',
source_type: articleSourceType.value,
}
if (articleKeyword.value.trim()) {
params.keyword = articleKeyword.value.trim()
}
return params
})
const articlesQuery = useQuery({
queryKey: computed(() => [
'articles',
'media-supply-submit',
companyStore.currentBrandId,
articleParams.value,
]),
enabled: computed(() => mode.value === 'article' && Boolean(companyStore.currentBrandId)),
queryFn: () => articlesApi.list(articleParams.value),
})
const selectedTotal = computed(() =>
resources.value.reduce((sum, resource) => sum + resource.sell_price_cents, 0),
)
const walletBalance = computed(() => walletQuery.data.value?.balance_cents ?? 0)
const balanceEnough = computed(() => walletBalance.value >= selectedTotal.value)
const articleRows = computed(() => articlesQuery.data.value?.items ?? [])
const articleTotal = computed(() => articlesQuery.data.value?.total ?? 0)
const canSubmit = computed(
() =>
resources.value.length > 0 &&
form.title.trim() !== '' &&
form.content.trim() !== '' &&
balanceEnough.value,
)
const createOrderMutation = useMutation({
mutationFn: () => mediaSupplyApi.createOrder(buildCreateOrderPayload()),
onSuccess: async () => {
message.success('投稿已进入队列')
clearMediaSupplySubmitSelection()
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['media-supply', 'orders'] }),
queryClient.invalidateQueries({ queryKey: ['media-supply', 'wallet'] }),
queryClient.invalidateQueries({ queryKey: ['media-supply', 'wallet-ledgers'] }),
])
void router.replace({ name: 'media-supply-orders' })
},
onError: (error) => message.error(formatError(error)),
})
function buildCreateOrderPayload(): CreateMediaSupplyOrderRequest {
return {
article_id: mode.value === 'article' ? selectedArticleId.value : undefined,
model_id: MODEL_ID_AUTHORITY_NEWS,
title: form.title.trim(),
content: form.content.trim(),
items: resources.value.map((resource) => ({
resource_id: resource.id,
price_type: 'price',
})),
}
}
function setMode(nextMode: SubmitMode): void {
mode.value = nextMode
if (nextMode === 'manual') {
selectedArticleId.value = null
}
}
function applyArticleSearch(): void {
articlePage.value = 1
void articlesQuery.refetch()
}
function handleArticlePageChange(nextPage: number, nextPageSize: number): void {
articlePage.value = nextPage
articlePageSize.value = nextPageSize
}
async function selectArticle(article: ArticleListItem): Promise<void> {
if (selectedArticleLoading.value) {
return
}
selectedArticleLoading.value = true
try {
const detail = await articlesApi.detail(article.id)
selectedArticleId.value = detail.id
form.title = detail.title?.trim() || article.title?.trim() || ''
form.content = stripLeadingTitleHeading(form.title, detail.markdown_content?.trim() || '')
if (!form.content.trim()) {
message.warning('这篇文章暂无正文内容')
}
} catch (error) {
message.error(formatError(error))
} finally {
selectedArticleLoading.value = false
}
}
async function submitOrder(): Promise<void> {
if (resources.value.length === 0) {
message.warning('请选择媒体')
return
}
if (!form.title.trim() || !form.content.trim()) {
message.warning('请填写标题和正文')
return
}
const refreshedWallet = await walletQuery.refetch()
const latestBalance = refreshedWallet.data?.balance_cents ?? walletBalance.value
if (latestBalance < selectedTotal.value) {
message.warning('媒体投稿余额不足')
return
}
await createOrderMutation.mutateAsync()
}
async function uploadEditorImage(file: File): Promise<string> {
const result = await imagesApi.upload(file)
return result.url
}
function cancelSubmit(): void {
void router.push({ name: 'media-supply-resources' })
}
function articleTitle(article: ArticleListItem): string {
return article.title?.trim() || '未命名文章'
}
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 displayValue(value?: string | null): string {
return value?.trim() || '--'
}
function sourceLabel(
article: Pick<ArticleDetail | ArticleListItem, 'source_type' | 'generation_mode'>,
): string {
return getSourceTypeLabel(article.source_type, article.generation_mode)
}
function stripLeadingTitleHeading(titleValue: string, markdownValue: string): string {
const normalizedTitle = titleValue.trim()
if (!normalizedTitle) {
return markdownValue
}
const normalizedMarkdown = markdownValue.replace(/^\uFEFF/, '')
const match = normalizedMarkdown.match(/^#\s+(.+?)\s*(\r?\n|$)/)
if (!match || match[1].trim() !== normalizedTitle) {
return markdownValue
}
return normalizedMarkdown.slice(match[0].length).replace(/^\s*\r?\n/, '')
}
</script>
<template>
<div class="media-supply-submit-page">
<header class="submit-topbar">
<button type="button" class="back-button" @click="cancelSubmit">
<LeftOutlined />
<span>提交投稿</span>
</button>
<button type="button" class="close-button" aria-label="关闭" @click="cancelSubmit">
<CloseOutlined />
</button>
</header>
<a-empty v-if="resources.length === 0" description="还没有选择媒体">
<template #extra>
<a-button type="primary" @click="cancelSubmit">返回选择媒体</a-button>
</template>
</a-empty>
<template v-else>
<section class="summary-grid">
<article class="summary-tile">
<span>媒体数量</span>
<strong>{{ resources.length }}</strong>
</article>
<article class="summary-tile">
<span>投稿金额</span>
<strong>{{ formatSellPrice(selectedTotal) }}</strong>
</article>
<article class="summary-tile" :class="{ 'summary-tile--danger': !balanceEnough }">
<span>投稿余额</span>
<strong>{{ formatMoney(walletBalance) }}</strong>
</article>
</section>
<div class="submit-layout">
<aside class="submit-sidebar">
<section class="selected-media">
<div class="section-head">
<h2>已选媒体</h2>
<a-tag :color="balanceEnough ? 'green' : 'red'">
{{ balanceEnough ? '余额通过' : '余额不足' }}
</a-tag>
</div>
<div class="media-list">
<article v-for="resource in resources" :key="resource.id" class="media-row">
<div>
<strong>{{ resource.name }}</strong>
<span>
{{ displayValue(resource.channel_type) }} · {{ displayValue(resource.region) }}
</span>
</div>
<em>{{ formatSellPrice(resource.sell_price_cents) }}</em>
<p v-if="resource.resource_remark">{{ resource.resource_remark }}</p>
</article>
</div>
</section>
<section class="article-picker">
<div class="section-head">
<h2>稿件来源</h2>
</div>
<div class="mode-switch" role="tablist" aria-label="稿件来源">
<button
type="button"
:class="{ 'mode-switch__item--active': mode === 'article' }"
class="mode-switch__item"
@click="setMode('article')"
>
<FileTextOutlined />
选择文章
</button>
<button
type="button"
:class="{ 'mode-switch__item--active': mode === 'manual' }"
class="mode-switch__item"
@click="setMode('manual')"
>
<EditOutlined />
手写稿件
</button>
</div>
<div v-if="mode === 'article'" class="article-picker__body">
<div class="article-filters">
<a-input
v-model:value="articleKeyword"
allow-clear
placeholder="搜索文章"
@press-enter="applyArticleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-select
v-model:value="articleSourceType"
:options="sourceOptions"
@change="applyArticleSearch"
/>
</div>
<a-spin :spinning="articlesQuery.isPending.value || selectedArticleLoading">
<div v-if="articleRows.length" class="article-list">
<button
v-for="article in articleRows"
:key="article.id"
type="button"
class="article-row"
:class="{ 'article-row--active': selectedArticleId === article.id }"
@click="selectArticle(article)"
>
<span class="article-row__title">{{ articleTitle(article) }}</span>
<span class="article-row__meta">
{{ sourceLabel(article) }} · {{ formatDateTime(article.created_at) }}
</span>
<span class="article-row__foot">
<a-tag :color="getGenerateStatusMeta(article.generate_status).color">
{{ getGenerateStatusMeta(article.generate_status).label }}
</a-tag>
<span>{{ article.word_count }} </span>
<CheckCircleOutlined v-if="selectedArticleId === article.id" />
</span>
</button>
</div>
<a-empty v-else description="暂无可选文章" />
</a-spin>
<a-pagination
size="small"
:current="articlePage"
:page-size="articlePageSize"
:total="articleTotal"
:show-size-changer="false"
@change="handleArticlePageChange"
/>
</div>
</section>
</aside>
<main class="submit-main">
<section class="editor-shell">
<div class="editor-canvas-wrap">
<ArticleEditorCanvas
v-model:title="form.title"
v-model="form.content"
:article-id="null"
:ai-optimize-enabled="false"
:upload-image="uploadEditorImage"
/>
</div>
</section>
</main>
</div>
<footer class="submit-footer">
<div class="footer-balance" :class="{ 'footer-balance--danger': !balanceEnough }">
<WalletOutlined />
<span>
合计 {{ formatSellPrice(selectedTotal) }}余额 {{ formatMoney(walletBalance) }}
</span>
</div>
<div class="footer-actions">
<a-button @click="cancelSubmit">取消</a-button>
<a-button
type="primary"
:disabled="!canSubmit"
:loading="createOrderMutation.isPending.value"
@click="submitOrder"
>
<template #icon><SendOutlined /></template>
确认投稿
</a-button>
</div>
</footer>
</template>
</div>
</template>
<style scoped>
.media-supply-submit-page {
display: flex;
flex-direction: column;
gap: 16px;
min-height: calc(100vh - 132px);
color: #1f2937;
}
.submit-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.back-button,
.close-button,
.mode-switch__item,
.article-row {
border: 0;
background: transparent;
cursor: pointer;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 0;
color: #111827;
font-size: 18px;
font-weight: 800;
}
.back-button :deep(.anticon) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 999px;
border: 1px solid rgba(144, 157, 181, 0.24);
background: #fff;
}
.close-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 999px;
color: #475467;
border: 1px solid #e5e7eb;
background: #fff;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.summary-tile {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 92px;
padding: 18px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.summary-tile span {
color: #667085;
font-size: 13px;
}
.summary-tile strong {
color: #101828;
font-size: 28px;
line-height: 1;
}
.summary-tile--danger strong {
color: #d4380d;
}
.submit-layout {
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.submit-sidebar,
.submit-main {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.selected-media,
.article-picker {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid #eef2f7;
}
.section-head h2 {
margin: 0;
color: #111827;
font-size: 15px;
font-weight: 800;
}
.media-list {
display: flex;
flex-direction: column;
max-height: 360px;
overflow-y: auto;
}
.media-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 12px;
padding: 13px 16px;
border-bottom: 1px solid #f0f2f5;
}
.media-row:last-child {
border-bottom: 0;
}
.media-row strong,
.article-row__title {
display: block;
overflow: hidden;
color: #111827;
font-size: 14px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-row span,
.article-row__meta,
.article-row__foot {
color: #667085;
font-size: 12px;
}
.media-row em {
color: #e9232f;
font-style: normal;
font-weight: 800;
}
.media-row p {
grid-column: 1 / -1;
margin: 0;
overflow: hidden;
color: #667085;
font-size: 12px;
line-height: 1.55;
text-overflow: ellipsis;
white-space: nowrap;
}
.mode-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 12px;
}
.mode-switch__item {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
color: #475467;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-weight: 700;
}
.mode-switch__item--active {
color: #e9232f;
border-color: #ffb3ba;
background: #fff4f4;
}
.article-picker__body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 12px 14px;
}
.article-filters {
display: grid;
grid-template-columns: minmax(0, 1fr) 132px;
gap: 8px;
}
.article-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.article-row {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
padding: 12px;
text-align: left;
border: 1px solid #edf1f7;
border-radius: 8px;
background: #fbfcff;
}
.article-row--active {
border-color: #ff9ca7;
background: #fff7f7;
}
.article-row__foot {
display: flex;
align-items: center;
gap: 8px;
}
.article-row__foot :deep(.anticon) {
margin-left: auto;
color: #16a34a;
}
.editor-shell {
display: flex;
flex-direction: column;
min-height: 720px;
overflow: hidden;
background: transparent;
}
.editor-canvas-wrap {
display: flex;
flex: 1;
min-height: 0;
background: #fff;
}
.editor-canvas-wrap :deep(.article-editor-canvas) {
flex: 1;
}
.editor-canvas-wrap :deep(.article-editor-canvas__surface .ProseMirror) {
min-height: 560px;
}
.submit-footer {
position: sticky;
bottom: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: auto;
padding: 14px 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(10px);
}
.footer-balance {
display: inline-flex;
align-items: center;
gap: 8px;
color: #1554ad;
font-weight: 800;
}
.footer-balance--danger {
color: #d4380d;
}
.footer-actions {
display: flex;
gap: 10px;
}
@media (max-width: 1120px) {
.submit-layout {
grid-template-columns: 1fr;
}
.media-list {
max-height: 260px;
}
}
@media (max-width: 720px) {
.summary-grid {
grid-template-columns: 1fr;
}
.submit-footer {
position: static;
flex-direction: column;
align-items: stretch;
}
.footer-actions {
width: 100%;
}
.footer-actions :deep(.ant-btn) {
flex: 1;
}
.article-filters {
grid-template-columns: 1fr;
}
.editor-shell {
min-height: 620px;
}
.editor-canvas-wrap :deep(.article-editor-canvas__title-row .ant-input) {
font-size: 24px;
}
}
</style>