feat: tenant content publish
This commit is contained in:
@@ -3,5 +3,4 @@
|
||||
## API Access Constraints
|
||||
|
||||
- `frontend/portal` 业务禁止调用任何 `/super/v1/*` 接口(包括本地开发的 Vite 代理)。
|
||||
- Portal 仅允许使用面向用户/租户公开的接口前缀(例如 `/v1/*`,具体以后端实际路由为准)。
|
||||
|
||||
- Portal 仅允许使用面向用户/租户公开的接口前缀(例如 `/v1/*`、`/t/*`,具体以后端实际路由为准)。
|
||||
|
||||
@@ -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: '订单列表(管理)' } },
|
||||
|
||||
209
frontend/portal/src/views/admin/ContentPublish.vue
Normal file
209
frontend/portal/src/views/admin/ContentPublish.vue
Normal 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>
|
||||
|
||||
@@ -19,6 +19,10 @@ export default defineConfig({
|
||||
'/v1': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/t': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user