feat: tenant content publish

This commit is contained in:
2025-12-25 14:29:16 +08:00
parent a66c0d9b90
commit 6542c71ec0
15 changed files with 1082 additions and 4 deletions

View File

@@ -3,5 +3,4 @@
## API Access Constraints
- `frontend/portal` 业务禁止调用任何 `/super/v1/*` 接口(包括本地开发的 Vite 代理)。
- Portal 仅允许使用面向用户/租户公开的接口前缀(例如 `/v1/*`,具体以后端实际路由为准)。
- Portal 仅允许使用面向用户/租户公开的接口前缀(例如 `/v1/*``/t/*`,具体以后端实际路由为准)。

View File

@@ -48,7 +48,7 @@ const router = createRouter({
{ path: 'admin', name: 'adminDashboard', component: TitlePage, meta: { title: '管理概览(仪表盘)' } },
{ path: 'admin/contents', name: 'adminContents', component: TitlePage, meta: { title: '内容列表(管理)' } },
{ path: 'admin/contents/new', name: 'adminContentNew', component: TitlePage, meta: { title: '内容发布' } },
{ path: 'admin/contents/new', name: 'adminContentNew', component: () => import('@/views/admin/ContentPublish.vue'), meta: { title: '内容发布' } },
{ path: 'admin/contents/:contentId/edit', name: 'adminContentEdit', component: TitlePage, meta: { title: '内容编辑' } },
{ path: 'admin/assets', name: 'adminAssets', component: TitlePage, meta: { title: '素材库' } },
{ path: 'admin/orders', name: 'adminOrders', component: TitlePage, meta: { title: '订单列表(管理)' } },

View File

@@ -0,0 +1,209 @@
<script setup>
import { requestJson } from '@/service/apiClient';
import { useSession } from '@/service/session';
import { useToast } from 'primevue/usetoast';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const toast = useToast();
const router = useRouter();
const route = useRoute();
const { isLoggedIn } = useSession();
const submitting = ref(false);
const tenantCode = ref('');
const title = ref('');
const summary = ref('');
const detail = ref('');
const tags = ref([]);
const coverAssetIDsInput = ref('');
const audioAssetIDsInput = ref('');
const videoAssetIDsInput = ref('');
const imageAssetIDsInput = ref('');
const priceAmount = ref(0);
const hasText = computed(() => String(detail.value || '').trim().length > 0);
const hasAnyMedia = computed(() => {
return (
String(audioAssetIDsInput.value || '').trim() ||
String(videoAssetIDsInput.value || '').trim() ||
String(imageAssetIDsInput.value || '').trim()
);
});
function parseIDList(input) {
const raw = String(input || '')
.split(/[,\n\r\t ]+/)
.map((v) => v.trim())
.filter(Boolean);
const ids = raw.map((v) => Number.parseInt(v, 10)).filter((v) => Number.isFinite(v) && v > 0);
const uniq = Array.from(new Set(ids));
return uniq;
}
async function submit() {
if (submitting.value) return;
const tenant = String(tenantCode.value || '').trim();
if (!tenant) {
toast.add({ severity: 'warn', summary: '请填写租户 ID', detail: '例如abcdedf', life: 2500 });
return;
}
const t = String(title.value || '').trim();
if (!t) {
toast.add({ severity: 'warn', summary: '请填写标题', detail: '标题不能为空', life: 2500 });
return;
}
const coverAssetIDs = parseIDList(coverAssetIDsInput.value);
if (coverAssetIDs.length < 1 || coverAssetIDs.length > 3) {
toast.add({ severity: 'warn', summary: '展示图数量不正确', detail: '展示图需为 1-3 张(填图片资源 ID', life: 2800 });
return;
}
const audioAssetIDs = parseIDList(audioAssetIDsInput.value);
const videoAssetIDs = parseIDList(videoAssetIDsInput.value);
const imageAssetIDs = parseIDList(imageAssetIDsInput.value);
if (!hasText.value && audioAssetIDs.length === 0 && videoAssetIDs.length === 0 && imageAssetIDs.length === 0) {
toast.add({ severity: 'warn', summary: '内容为空', detail: '请至少提供一种内容类型(文字/音频/视频/多图)', life: 2800 });
return;
}
const amount = Number(priceAmount.value || 0);
if (!Number.isFinite(amount) || amount < 0) {
toast.add({ severity: 'warn', summary: '价格不正确', detail: '价格需为 0 或正整数(单位:分)', life: 2500 });
return;
}
try {
submitting.value = true;
const payload = await requestJson(`/t/${encodeURIComponent(tenant)}/v1/admin/contents/publish`, {
method: 'POST',
auth: true,
body: {
title: t,
summary: String(summary.value || '').trim(),
detail: String(detail.value || '').trim(),
tags: Array.isArray(tags.value) ? tags.value : [],
cover_asset_ids: coverAssetIDs,
audio_asset_ids: audioAssetIDs,
video_asset_ids: videoAssetIDs,
image_asset_ids: imageAssetIDs,
price_amount: amount,
currency: 'CNY'
}
});
toast.add({
severity: 'success',
summary: '提交成功',
detail: `内容已进入审核ID: ${payload?.content?.id || '-'})`,
life: 2500
});
await router.push('/admin/contents');
} catch (err) {
const status = err?.status;
const msg = String(err?.payload?.message || err?.payload?.error || err?.message || '').trim();
if (status === 401) {
toast.add({ severity: 'warn', summary: '请先登录', detail: '登录后再提交发布', life: 2500 });
const redirect = typeof route.fullPath === 'string' ? route.fullPath : '/admin/contents/new';
await router.push(`/auth/login?redirect=${encodeURIComponent(redirect)}`);
return;
}
toast.add({ severity: 'error', summary: '提交失败', detail: msg || '请检查资源是否已处理完成ready然后重试', life: 3500 });
} finally {
submitting.value = false;
}
}
onMounted(async () => {
if (!isLoggedIn.value) {
const redirect = typeof route.fullPath === 'string' ? route.fullPath : '/admin/contents/new';
await router.push(`/auth/login?redirect=${encodeURIComponent(redirect)}`);
}
});
</script>
<template>
<div class="card max-w-3xl mx-auto">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold">内容发布</h1>
<div class="text-muted-color mt-2">支持文字/音频/视频/多图组合展示图需 1-3 价格单位为分</div>
</div>
<Button label="提交" icon="pi pi-send" size="large" :loading="submitting" @click="submit" />
</div>
<Divider class="my-6" />
<div class="flex flex-col gap-5">
<div>
<label for="tenantCode" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">租户 ID</label>
<InputText id="tenantCode" v-model="tenantCode" size="large" class="w-full text-xl py-3" placeholder="例如 abcdedf" autocomplete="off" />
<small class="text-muted-color">当前 Portal 暂不支持自动选择租户这里先手动填写</small>
</div>
<div>
<label for="title" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">标题</label>
<InputText id="title" v-model="title" size="large" class="w-full text-xl py-3" placeholder="请输入标题" autocomplete="off" />
</div>
<div>
<label for="summary" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">简介</label>
<InputText id="summary" v-model="summary" size="large" class="w-full text-xl py-3" placeholder="用于列表展示(建议 ≤ 256 字符)" autocomplete="off" />
</div>
<div>
<label for="detail" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">详细文字内容</label>
<Textarea id="detail" v-model="detail" autoResize rows="6" class="w-full text-lg" placeholder="可选:填写后视为包含文字内容类型" />
</div>
<div>
<label class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">标签</label>
<Chips v-model="tags" class="w-full" placeholder="回车添加标签(最多建议 20 个)" />
</div>
<div>
<label for="coverAssets" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">展示图图片资源 ID</label>
<InputText
id="coverAssets"
v-model="coverAssetIDsInput"
size="large"
class="w-full text-xl py-3"
placeholder="1-3 个图片资源 ID使用逗号分隔例如12,13,14"
autocomplete="off"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="videoAssets" class="block text-surface-900 dark:text-surface-0 font-medium mb-1">视频资源 ID</label>
<InputText id="videoAssets" v-model="videoAssetIDsInput" size="large" class="w-full" placeholder="例如21,22" autocomplete="off" />
</div>
<div>
<label for="audioAssets" class="block text-surface-900 dark:text-surface-0 font-medium mb-1">音频资源 ID</label>
<InputText id="audioAssets" v-model="audioAssetIDsInput" size="large" class="w-full" placeholder="例如31,32" autocomplete="off" />
</div>
<div>
<label for="imageAssets" class="block text-surface-900 dark:text-surface-0 font-medium mb-1">多图资源 ID</label>
<InputText id="imageAssets" v-model="imageAssetIDsInput" size="large" class="w-full" placeholder="例如41,42,43" autocomplete="off" />
</div>
</div>
<div>
<label for="priceAmount" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">价格</label>
<InputNumber id="priceAmount" v-model="priceAmount" :min="0" size="large" class="w-full" placeholder="0 表示免费" />
</div>
<div class="text-sm text-muted-color">
提示如提交失败且提示资源未处理完成请先确保对应资源已变为 ready媒体转码/处理完成
</div>
</div>
</div>
</template>

View File

@@ -19,6 +19,10 @@ export default defineConfig({
'/v1': {
target: 'http://localhost:8080',
changeOrigin: true
},
'/t': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
},