diff --git a/src/App.vue b/src/App.vue index 678312c..4b81793 100644 --- a/src/App.vue +++ b/src/App.vue @@ -22,6 +22,7 @@ appStore.initTheme() @@ -48,4 +49,15 @@ body { .@{prefix-cls}-grey-mode { filter: grayscale(100%); } + +.app-copyright { + position: fixed; + right: 20px; + bottom: 10px; + z-index: 5; + font-size: 12px; + line-height: 1; + color: var(--el-text-color-secondary, #909399); + pointer-events: none; +} diff --git a/src/api/admin/contentTag.ts b/src/api/admin/contentTag.ts new file mode 100644 index 0000000..11d92f5 --- /dev/null +++ b/src/api/admin/contentTag.ts @@ -0,0 +1,41 @@ +// Author: fruit.com.cn + +import request from '@/axios' + +export interface ContentTagItem { + id: number + name: string + nameEn: string + nameId: string + locales?: Record + bgColor?: string + sort: number + status: number +} + +export interface ContentTagSaveData { + name: string + locales: Record + bgColor?: string + sort?: number + status?: number +} + +export const listContentTagsApi = (enabledOnly = false) => { + return request.get({ + url: '/admin/content-tags', + params: enabledOnly ? { enabledOnly: true } : undefined + }) +} + +export const createContentTagApi = (data: ContentTagSaveData) => { + return request.post({ url: '/admin/content-tags', data }) +} + +export const updateContentTagApi = (id: number, data: ContentTagSaveData) => { + return request.put({ url: `/admin/content-tags/${id}`, data }) +} + +export const deleteContentTagApi = (id: number) => { + return request.delete({ url: `/admin/content-tags/${id}` }) +} diff --git a/src/api/admin/drama.ts b/src/api/admin/drama.ts index abe00bf..6187444 100644 --- a/src/api/admin/drama.ts +++ b/src/api/admin/drama.ts @@ -52,6 +52,7 @@ export const listDramaCategoryOptionsApi = () => { export interface DramaCategorySaveData { name: string + locales: Record sort?: number status?: number } diff --git a/src/api/admin/dramaClassification.ts b/src/api/admin/dramaClassification.ts index 246539d..15769a1 100644 --- a/src/api/admin/dramaClassification.ts +++ b/src/api/admin/dramaClassification.ts @@ -6,6 +6,9 @@ import type { PageResult } from './types' export interface DramaClassificationItem { id: number name: string + nameEn: string + nameId: string + locales?: Record sort: number status: number dramaCount: number @@ -14,6 +17,7 @@ export interface DramaClassificationItem { export interface DramaClassificationSaveData { name: string + locales: Record sort?: number status?: number } diff --git a/src/api/admin/dramaPlaylistCategory.ts b/src/api/admin/dramaPlaylistCategory.ts index 45c87de..3969e32 100644 --- a/src/api/admin/dramaPlaylistCategory.ts +++ b/src/api/admin/dramaPlaylistCategory.ts @@ -7,7 +7,13 @@ import type { ClassificationDramaItem } from './dramaClassification' export interface DramaPlaylistCategoryItem { id: number name: string + nameEn: string + nameId: string description: string + descriptionEn: string + descriptionId: string + locales?: Record + descriptionLocales?: Record sort: number status: number dramaCount: number @@ -15,7 +21,8 @@ export interface DramaPlaylistCategoryItem { export interface DramaPlaylistCategorySaveData { name: string - description?: string + locales: Record + descriptionLocales?: Record sort?: number status?: number } diff --git a/src/api/admin/transcodeNotice.ts b/src/api/admin/transcodeNotice.ts new file mode 100644 index 0000000..423d6ae --- /dev/null +++ b/src/api/admin/transcodeNotice.ts @@ -0,0 +1,27 @@ +// Author: fruit.com.cn + +import request from '@/axios' + +export interface TranscodeNoticeItem { + id: number + status: string + dramaTitle: string + episodeNo?: number + episodeTitle?: string + progress?: number + errorMsg?: string + updatedAt?: string +} + +export interface TranscodeNotice { + running: number + success: number + failed: number + runningList: TranscodeNoticeItem[] + successList: TranscodeNoticeItem[] + failedList: TranscodeNoticeItem[] +} + +export const getTranscodeNoticesApi = () => { + return request.get({ url: '/admin/notifications/transcode' }) +} diff --git a/src/api/admin/types.ts b/src/api/admin/types.ts index ccda91e..7bd71f1 100644 --- a/src/api/admin/types.ts +++ b/src/api/admin/types.ts @@ -8,6 +8,9 @@ export interface PageResult { export interface DramaCategory { id: number name: string + nameEn?: string + nameId?: string + locales?: Record sort?: number status?: number /** 该类型下的短剧数量,管理列表才有 */ diff --git a/src/constants/contentLanguages.ts b/src/constants/contentLanguages.ts new file mode 100644 index 0000000..1a2ed16 --- /dev/null +++ b/src/constants/contentLanguages.ts @@ -0,0 +1,46 @@ +// Author: fruit.com.cn + +export interface ContentLanguage { + code: string + label: string +} + +export const CONTENT_LANGUAGES: ContentLanguage[] = [ + { code: 'zh', label: '中文' }, + { code: 'en', label: '英文' }, + { code: 'id', label: '印尼语' } +] + +export function languageLabel(code: string) { + return CONTENT_LANGUAGES.find((item) => item.code === code)?.label || code +} + +export function languageCodes(locales?: Record | null) { + const source = locales || {} + return CONTENT_LANGUAGES.map((item) => item.code).filter((code) => source[code]) +} + +export function localesFromColumns( + locales: Record | null | undefined, + zh?: string, + en?: string, + id?: string +) { + if (locales && Object.keys(locales).length) { + return { ...locales } + } + const result: Record = {} + if (zh) result.zh = zh + if (en) result.en = en + if (id) result.id = id + if (!result.zh) result.zh = '' + return result +} + +export function payloadLocales(locales: Record, codes: string[]) { + const result: Record = {} + codes.forEach((code) => { + result[code] = String(locales[code] || '').trim() + }) + return result +} diff --git a/src/layout/components/ToolHeader.vue b/src/layout/components/ToolHeader.vue index 44ee8c8..eb17ab1 100644 --- a/src/layout/components/ToolHeader.vue +++ b/src/layout/components/ToolHeader.vue @@ -6,6 +6,7 @@ import { Collapse } from '@/components/Collapse' import { LocaleDropdown } from '@/components/LocaleDropdown' import { SizeDropdown } from '@/components/SizeDropdown' import { UserInfo } from '@/components/UserInfo' +import TranscodeNoticeButton from './TranscodeNoticeButton.vue' import { Screenfull } from '@/components/Screenfull' import { Breadcrumb } from '@/components/Breadcrumb' import { useAppStore } from '@/store/modules/app' @@ -73,6 +74,7 @@ export default defineComponent({ color="var(--top-header-text-color)" > ) : undefined} + @@ -95,7 +97,7 @@ export default defineComponent({ } } -@media (max-width: 768px) { +@media (width <= 768px) { .@{prefix-cls} { padding-right: 8px; padding-left: 8px; diff --git a/src/layout/components/TranscodeNoticeButton.vue b/src/layout/components/TranscodeNoticeButton.vue new file mode 100644 index 0000000..6bf6944 --- /dev/null +++ b/src/layout/components/TranscodeNoticeButton.vue @@ -0,0 +1,89 @@ + + + + + + + + diff --git a/src/permission.ts b/src/permission.ts index dc61a1a..557b305 100644 --- a/src/permission.ts +++ b/src/permission.ts @@ -96,7 +96,12 @@ router.beforeEach(async (to, from, next) => { const redirectPath = from.query.redirect || to.path const redirect = decodeURIComponent(redirectPath as string) - const nextData = to.path === redirect ? { ...to, replace: true } : { path: redirect } + // 只按路径重进。刷新时动态路由还没注册,先被兜底路由接住; + // 若把当时匹配到的 name 带回去,会一直停在 404。 + const nextData = + to.path === redirect + ? { path: to.path, query: to.query, hash: to.hash, replace: true } + : { path: redirect, replace: true } permissionStore.setIsAddRouters(true) next(nextData) } diff --git a/src/router/index.ts b/src/router/index.ts index 123ec04..a6f450a 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -52,6 +52,35 @@ export const constantRouterMap: AppRouteRecordRaw[] = [ title: '404', noTagsView: true } + }, + { + path: '/notifications', + component: Layout, + name: 'Notifications', + meta: { + hidden: true, + title: '通知' + }, + children: [ + { + path: 'transcode', + name: 'transcode-notices', + component: () => import('@/views/common/TranscodeNoticeView.vue'), + meta: { + title: '转码通知' + } + } + ] + }, + { + path: '/:pathMatch(.*)*', + name: 'UnresolvedPath', + component: () => import('@/views/Error/404.vue'), + meta: { + hidden: true, + title: '404', + noTagsView: true + } } ] @@ -232,7 +261,14 @@ const router = createRouter({ }) export const resetRouter = (): void => { - const resetWhiteNameList = ['Redirect', 'RedirectParent', 'Login', 'NoFind', 'Root'] + const resetWhiteNameList = [ + 'Redirect', + 'RedirectParent', + 'Login', + 'NoFind', + 'Root', + 'UnresolvedPath' + ] router.getRoutes().forEach((route) => { const { name } = route if (name && !resetWhiteNameList.includes(name as string)) { diff --git a/src/utils/transcodeNoticeSeen.ts b/src/utils/transcodeNoticeSeen.ts new file mode 100644 index 0000000..ea6b829 --- /dev/null +++ b/src/utils/transcodeNoticeSeen.ts @@ -0,0 +1,51 @@ +// Author: fruit.com.cn + +const SEEN_KEY = 'transcode-notice-seen' + +export const TRANSCODE_NOTICE_SEEN = 'transcode-notice-seen' + +export interface NoticeCounts { + running: number + success: number + failed: number +} + +/** 上次打开通知页时已经看过的数量。 */ +export function readSeen(): NoticeCounts { + try { + const raw = localStorage.getItem(SEEN_KEY) + if (!raw) return { running: 0, success: 0, failed: 0 } + const data = JSON.parse(raw) as NoticeCounts + return { + running: Number(data.running || 0), + success: Number(data.success || 0), + failed: Number(data.failed || 0) + } + } catch { + return { running: 0, success: 0, failed: 0 } + } +} + +/** 比上次查看多出来的数量,看过的不再计入角标。 */ +export function unreadCounts(current: NoticeCounts): NoticeCounts { + const seen = readSeen() + return { + running: Math.max(0, current.running - seen.running), + success: Math.max(0, current.success - seen.success), + failed: Math.max(0, current.failed - seen.failed) + } +} + +/** 把当前数量记为已读,并通知顶栏收起角标。 */ +export function markTranscodeNoticeSeen(current: NoticeCounts) { + const seen = readSeen() + localStorage.setItem( + SEEN_KEY, + JSON.stringify({ + running: Math.max(seen.running, current.running), + success: Math.max(seen.success, current.success), + failed: Math.max(seen.failed, current.failed) + }) + ) + window.dispatchEvent(new Event(TRANSCODE_NOTICE_SEEN)) +} diff --git a/src/views/Login/Login.vue b/src/views/Login/Login.vue index f98d12b..93ce65f 100644 --- a/src/views/Login/Login.vue +++ b/src/views/Login/Login.vue @@ -31,6 +31,94 @@ const themeChange = () => { {{ underlineToHump(appStore.getTitle) }} +

{{ t('login.consoleLabel') }}

{{ t('login.welcome') }}

@@ -81,16 +169,58 @@ const themeChange = () => { flex-direction: column; justify-content: space-between; + &::before, &::after { position: absolute; - right: -80px; - bottom: -120px; - width: 360px; - height: 360px; - border: 1px solid rgb(255 255 255 / 16%); + border: 1px solid rgb(255 255 255 / 12%); border-radius: 50%; content: ''; } + + &::before { + top: -140px; + left: -80px; + width: 420px; + height: 420px; + } + + &::after { + right: -120px; + bottom: -160px; + width: 460px; + height: 460px; + } +} + +.hero-fruits { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; +} + +.fruit { + position: absolute; + filter: drop-shadow(0 18px 24px rgb(0 0 0 / 28%)); +} + +.cherry { + top: 16%; + right: 14%; + width: 150px; +} + +.mango { + top: 30%; + left: 38%; + width: 210px; +} + +.banana { + top: 54%; + left: 12%; + width: 200px; + transform: rotate(-8deg); } .hero-brand { @@ -113,13 +243,14 @@ const themeChange = () => { .hero-copy { position: relative; z-index: 1; - max-width: 460px; + max-width: 420px; h1 { - margin: 10px 0 12px; - font-size: 40px; - font-weight: 700; - line-height: 1.2; + margin: 12px 0 0; + font-size: 44px; + font-weight: 600; + line-height: 1.15; + letter-spacing: -0.03em; color: #fff; } } @@ -191,8 +322,12 @@ const themeChange = () => { padding: 16px 24px 20px; } + .hero-fruits { + display: none; + } + .hero-copy h1 { - margin: 6px 0 8px; + margin: 6px 0 0; font-size: 28px; } diff --git a/src/views/common/DashboardView.vue b/src/views/common/DashboardView.vue index c7e4bfa..43feaae 100644 --- a/src/views/common/DashboardView.vue +++ b/src/views/common/DashboardView.vue @@ -5,6 +5,8 @@ import { computed, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { AppIcons } from '@/constants/icons' import { pageDramasApi } from '@/api/admin/drama' +import { getDashboardOverviewApi, getDashboardTrendApi } from '@/api/admin/dashboard' +import DashboardMetricWall from '@/views/dashboard/DashboardMetricWall.vue' import { useRbac } from '@/stores/rbac' import { formatCount } from '@/utils/format' import { statusClass } from '@/utils/status' @@ -15,6 +17,8 @@ const { can } = useRbac() const loading = ref(false) const dramas = ref([]) const total = ref(0) +const overview = ref(null) +const trend = ref(null) const totalViews = computed(() => dramas.value.reduce((sum, item) => sum + Number(item.views || 0), 0) @@ -25,6 +29,12 @@ const reviewingCount = computed( const publishedCount = computed( () => dramas.value.filter((item) => item.status === 'published').length ) +const metricFallback = computed(() => ({ + 'content.dramaTotal': total.value, + 'content.dramaReviewing': reviewingCount.value, + 'content.dramaPublished': publishedCount.value, + 'playback.viewTotal': totalViews.value +})) const recentDramas = computed(() => [...dramas.value] .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))) @@ -34,10 +44,16 @@ const recentDramas = computed(() => onMounted(async () => { loading.value = true try { - const res = await pageDramasApi({ pageIndex: 1, pageSize: 200 }) + const [res, board, trendRes] = await Promise.all([ + pageDramasApi({ pageIndex: 1, pageSize: 200 }), + getDashboardOverviewApi().catch(() => null), + getDashboardTrendApi().catch(() => null) + ]) // 接口失败时 axios 拦截器会把业务错误吞成 undefined,这里不能让页面跟着崩 dramas.value = res?.data?.list || [] total.value = res?.data?.total || 0 + overview.value = board?.data || null + trend.value = trendRes?.data || null } finally { loading.value = false } @@ -45,72 +61,83 @@ onMounted(async () => { + + diff --git a/src/views/common/TranscodeNoticeView.vue b/src/views/common/TranscodeNoticeView.vue new file mode 100644 index 0000000..1ba7fbf --- /dev/null +++ b/src/views/common/TranscodeNoticeView.vue @@ -0,0 +1,100 @@ + + + + + + + + diff --git a/src/views/components/LocaleTextFields.vue b/src/views/components/LocaleTextFields.vue new file mode 100644 index 0000000..f331fb1 --- /dev/null +++ b/src/views/components/LocaleTextFields.vue @@ -0,0 +1,67 @@ + + + + + + diff --git a/src/views/contentManagement/CategoryDramaView.vue b/src/views/contentManagement/CategoryDramaView.vue index 8107ff2..ed235bb 100644 --- a/src/views/contentManagement/CategoryDramaView.vue +++ b/src/views/contentManagement/CategoryDramaView.vue @@ -6,6 +6,12 @@ import { computed, onMounted, reactive, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { AppIcon } from '@/components/AppIcon' import { AppIcons } from '@/constants/icons' +import { + languageCodes, + languageLabel, + localesFromColumns, + payloadLocales +} from '@/constants/contentLanguages' import { bindClassificationDramasApi, createClassificationApi, @@ -18,6 +24,8 @@ import { } from '@/api/admin/dramaClassification' import { statusClass } from '@/utils/status' import { useRbac } from '@/stores/rbac' +import ClassificationDramaDialogs from './ClassificationDramaDialogs.vue' +import ClassificationSideList from './ClassificationSideList.vue' import PageHeading from '@/views/components/PageHeading.vue' const { can } = useRbac() @@ -46,7 +54,9 @@ const keyword = ref('') const dialogVisible = ref(false) const saving = ref(false) const editingId = ref(null) -const form = reactive({ name: '', sort: 1, status: 1 }) +const languages = ref(['zh']) +const emptyForm = () => ({ locales: { zh: '' }, sort: 1, status: 1 }) +const form = reactive(emptyForm()) const candidateVisible = ref(false) const candidateLoading = ref(false) @@ -110,25 +120,39 @@ function changePage(value) { function openCreate() { editingId.value = null + languages.value = ['zh'] const maxSort = classifications.value.reduce((max, item) => Math.max(max, item.sort || 0), 0) - Object.assign(form, { name: '', sort: maxSort + 1, status: 1 }) + Object.assign(form, emptyForm(), { sort: maxSort + 1 }) dialogVisible.value = true } function openEdit(row) { editingId.value = row.id - Object.assign(form, { name: row.name, sort: row.sort ?? 0, status: row.status ?? 1 }) + const locales = localesFromColumns(row.locales, row.name, row.nameEn, row.nameId) + languages.value = languageCodes(locales) + Object.assign(form, { + locales, + sort: row.sort ?? 0, + status: row.status ?? 1 + }) dialogVisible.value = true } async function saveClassification() { - if (!form.name.trim()) { - ElMessage.warning('请填写分类名称') + const locales = payloadLocales(form.locales, languages.value) + const missing = languages.value.find((code) => !locales[code]) + if (!locales.zh || missing) { + ElMessage.warning(missing ? `请填写${languageLabel(missing)}名称` : '请填写中文名称') return } saving.value = true try { - const payload = { name: form.name.trim(), sort: form.sort, status: form.status } + const payload = { + name: locales.zh, + locales, + sort: form.sort, + status: form.status + } if (editingId.value) { await updateClassificationApi(editingId.value, payload) ElMessage.success('分类已更新') @@ -256,48 +280,13 @@ onMounted(loadClassifications)
- +
@@ -376,77 +365,27 @@ onMounted(loadClassifications)
- - - - - -
- - - - - - -
-
- -
- - - - - - - - - - - - - - -
- -
- -
+ diff --git a/src/views/contentManagement/CategoryManagementView.vue b/src/views/contentManagement/CategoryManagementView.vue index ea00632..7e6e67a 100644 --- a/src/views/contentManagement/CategoryManagementView.vue +++ b/src/views/contentManagement/CategoryManagementView.vue @@ -4,6 +4,12 @@ import { onMounted, reactive, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { AppIcons } from '@/constants/icons' +import { + languageCodes, + languageLabel, + localesFromColumns, + payloadLocales +} from '@/constants/contentLanguages' import { createDramaCategoryApi, deleteDramaCategoryApi, @@ -11,6 +17,7 @@ import { updateDramaCategoryApi } from '@/api/admin/drama' import { useRbac } from '@/stores/rbac' +import LocaleTextFields from '@/views/components/LocaleTextFields.vue' import PageHeading from '@/views/components/PageHeading.vue' const { can } = useRbac() @@ -20,7 +27,8 @@ const list = ref([]) const dialogVisible = ref(false) const saving = ref(false) const editingId = ref(null) -const form = reactive({ name: '', sort: 1, status: 1 }) +const languages = ref(['zh']) +const form = reactive({ locales: { zh: '' }, sort: 1, status: 1 }) async function load() { loading.value = true @@ -38,21 +46,29 @@ function nextSort() { function openCreate() { editingId.value = null - Object.assign(form, { name: '', sort: nextSort(), status: 1 }) + languages.value = ['zh'] + Object.assign(form, { locales: { zh: '' }, sort: nextSort(), status: 1 }) dialogVisible.value = true } function openEdit(row) { editingId.value = row.id - Object.assign(form, { name: row.name, sort: row.sort ?? 0, status: row.status ?? 1 }) + const locales = localesFromColumns(row.locales, row.name, row.nameEn, row.nameId) + languages.value = languageCodes(locales) + Object.assign(form, { locales, sort: row.sort ?? 0, status: row.status ?? 1 }) dialogVisible.value = true } async function saveCategory() { - if (!form.name.trim()) return ElMessage.warning('请填写类型名称') + const locales = payloadLocales(form.locales, languages.value) + const missing = languages.value.find((code) => !locales[code]) + if (!locales.zh || missing) { + ElMessage.warning(missing ? `请填写${languageLabel(missing)}名称` : '请填写中文名称') + return + } saving.value = true try { - const payload = { name: form.name.trim(), sort: form.sort, status: form.status } + const payload = { name: locales.zh, locales, sort: form.sort, status: form.status } if (editingId.value) { await updateDramaCategoryApi(editingId.value, payload) ElMessage.success('类型已更新') @@ -115,7 +131,20 @@ onMounted(load) empty-text="还没有短剧类型" > - + + + + @@ -146,11 +175,14 @@ onMounted(load) - + - - - +
@@ -166,3 +198,9 @@ onMounted(load) + + diff --git a/src/views/contentManagement/ClassificationDramaDialogs.vue b/src/views/contentManagement/ClassificationDramaDialogs.vue new file mode 100644 index 0000000..abccbd1 --- /dev/null +++ b/src/views/contentManagement/ClassificationDramaDialogs.vue @@ -0,0 +1,158 @@ + + + + + + + + diff --git a/src/views/contentManagement/ClassificationSideList.vue b/src/views/contentManagement/ClassificationSideList.vue new file mode 100644 index 0000000..0c9abd3 --- /dev/null +++ b/src/views/contentManagement/ClassificationSideList.vue @@ -0,0 +1,140 @@ + + + + + + + + diff --git a/src/views/contentManagement/DramaManagementView.vue b/src/views/contentManagement/DramaManagementView.vue index ba65d0c..b46faf8 100644 --- a/src/views/contentManagement/DramaManagementView.vue +++ b/src/views/contentManagement/DramaManagementView.vue @@ -5,6 +5,13 @@ import { computed, nextTick, onMounted, reactive, ref } from 'vue' import { useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' import { AppIcons } from '@/constants/icons' +import { + languageLabel, + languageCodes, + localesFromColumns, + payloadLocales +} from '@/constants/contentLanguages' +import LocaleTextFields from '@/views/components/LocaleTextFields.vue' import { AppImage } from '@/components/AppImage' import { changeDramaStatusApi, @@ -16,6 +23,7 @@ import { } from '@/api/admin/drama' import { useRbac } from '@/stores/rbac' import { formatCount } from '@/utils/format' +import { listContentTagsApi } from '@/api/admin/contentTag' import { statusClass } from '@/utils/status' import { uploadFile } from '@/utils/uploader' @@ -30,8 +38,7 @@ const categories = ref([]) const keyword = ref('') const statusFilter = ref(undefined) const categoryIds = ref([]) -const dramaTypes = ref([]) -const hotFilter = ref(undefined) +const tags = ref([]) const pageIndex = ref(1) const pageSize = ref(10) @@ -45,11 +52,14 @@ const editingId = ref(null) const editingCommentCount = ref(0) const editingLikeCount = ref(0) const formRef = ref() +const textLanguages = ref(['zh']) const form = reactive({ title: '', + titleLocales: { zh: '' }, categoryIds: [], - dramaTypes: ['LIVE'], + tagId: null, description: '', + synopsisLocales: { zh: '' }, freeEpisodes: 3, unlockCoin: 0, favoriteCount: 0, @@ -62,16 +72,6 @@ const form = reactive({ const MAX_COVER_SIZE = 5 * 1024 * 1024 const COVER_TYPES = ['image/jpeg', 'image/png', 'image/webp'] -const formRules = { - title: [{ required: true, message: '请输入短剧名称', trigger: 'blur' }], - categoryIds: [ - { type: 'array', required: true, min: 1, message: '请至少选择一个类型', trigger: 'change' } - ], - dramaTypes: [ - { type: 'array', required: true, min: 1, message: '请至少选择一个内容类型', trigger: 'change' } - ] -} - const coverDisplayName = computed( () => coverFileName.value || (form.cover ? coverBasename(form.cover) : '尚未选择封面') ) @@ -90,11 +90,6 @@ const statusOptions = [ { code: 'offline', text: '已下线' } ] -const dramaTypeOptions = [ - { value: 'ANIME', label: '漫剧' }, - { value: 'LIVE', label: '真人剧' } -] - const actionMeta = { submit: { label: '提交审核', done: '已提交审核' }, approve: { label: '审核通过', done: '已通过审核' }, @@ -111,9 +106,7 @@ async function load() { pageSize: pageSize.value, keyword: keyword.value.trim() || undefined, status: statusFilter.value || undefined, - categoryIds: categoryIds.value.length ? categoryIds.value : undefined, - dramaTypes: dramaTypes.value.length ? dramaTypes.value : undefined, - isHot: hotFilter.value + categoryIds: categoryIds.value.length ? categoryIds.value : undefined }) list.value = res.data?.list || [] total.value = res.data?.total || 0 @@ -127,6 +120,11 @@ async function loadCategories() { categories.value = res.data || [] } +async function loadTags() { + const res = await listContentTagsApi(true) + tags.value = res.data || [] +} + function search() { pageIndex.value = 1 load() @@ -136,8 +134,6 @@ function resetFilters() { keyword.value = '' statusFilter.value = undefined categoryIds.value = [] - dramaTypes.value = [] - hotFilter.value = undefined search() } @@ -146,24 +142,50 @@ function handleSizeChange() { load() } +function selectedCategoryIds(row) { + const values = + Array.isArray(row.categoryIds) && row.categoryIds.length + ? row.categoryIds + : row.categoryId + ? [row.categoryId] + : [] + return values.filter((id) => Number(id) > 0) +} + function coverBasename(path) { if (!path) return '' const clean = path.split('?')[0].split('#')[0] return decodeURIComponent(clean.split('/').pop() || clean) } +/** 长整型计数字段在接口里是字符串,数字输入框只接受 number。 */ +function toCount(value) { + const count = Number(value) + return Number.isFinite(count) ? count : 0 +} + async function resetValidation() { await nextTick() formRef.value?.clearValidate() } +function changeTextLanguages(codes) { + textLanguages.value = codes + codes.forEach((code) => { + if (form.synopsisLocales[code] == null) form.synopsisLocales[code] = '' + }) +} + function openCreate() { editingId.value = null + textLanguages.value = ['zh'] Object.assign(form, { title: '', + titleLocales: { zh: '' }, categoryIds: [], - dramaTypes: ['LIVE'], + tagId: null, description: '', + synopsisLocales: { zh: '' }, freeEpisodes: 3, unlockCoin: 0, favoriteCount: 0, @@ -182,41 +204,35 @@ function openCreate() { function openEdit(row) { editingId.value = row.id + const titleLocales = localesFromColumns(row.titleLocales, row.title) + const synopsisLocales = localesFromColumns(row.synopsisLocales, row.description) + textLanguages.value = languageCodes(titleLocales) + textLanguages.value.forEach((code) => { + if (synopsisLocales[code] == null) synopsisLocales[code] = '' + }) Object.assign(form, { title: row.title, - categoryIds: - Array.isArray(row.categoryIds) && row.categoryIds.length - ? row.categoryIds - : row.categoryId - ? [row.categoryId] - : [], - dramaTypes: - Array.isArray(row.dramaTypes) && row.dramaTypes.length - ? row.dramaTypes - : row.dramaType - ? [row.dramaType] - : ['LIVE'], + titleLocales, + categoryIds: selectedCategoryIds(row), + tagId: Number(row.tagId) > 0 ? row.tagId : null, description: row.description || '', - freeEpisodes: row.freeEpisodes ?? 3, - unlockCoin: row.unlockCoin ?? 0, - favoriteCount: row.favoriteCount ?? 0, - heat: row.views ?? 0, + synopsisLocales, + freeEpisodes: toCount(row.freeEpisodes ?? 3), + unlockCoin: toCount(row.unlockCoin), + favoriteCount: toCount(row.favoriteCount), + heat: toCount(row.views), isHot: !!row.isHot, - sort: row.sort ?? 0, + sort: toCount(row.sort), cover: row.cover || '' }) coverFileName.value = row.cover ? coverBasename(row.cover) : '' coverError.value = '' - editingCommentCount.value = row.commentCount ?? 0 - editingLikeCount.value = row.likeCount ?? 0 + editingCommentCount.value = toCount(row.commentCount) + editingLikeCount.value = toCount(row.likeCount) dialogVisible.value = true resetValidation() } -function dramaTypeLabel(value) { - return dramaTypeOptions.find((item) => item.value === value)?.label || '真人剧' -} - function rowCategoryList(row) { if (Array.isArray(row.categoryNames) && row.categoryNames.length) { return row.categoryNames.filter(Boolean) @@ -228,25 +244,26 @@ function rowCategoryList(row) { .filter(Boolean) } -function rowDramaTypes(row) { - if (Array.isArray(row.dramaTypes) && row.dramaTypes.length) return row.dramaTypes - return row.dramaType ? [row.dramaType] : [] -} - async function saveDrama() { if (saving.value || uploadingCover.value) return - try { - await formRef.value?.validate() - } catch { + const titles = payloadLocales(form.titleLocales, textLanguages.value) + const missing = textLanguages.value.find((code) => !titles[code]) + if (!titles.zh || missing) { + ElMessage.warning(missing ? `请填写${languageLabel(missing)}名称` : '请输入短剧名称') return } + const synopses = payloadLocales(form.synopsisLocales, textLanguages.value) const categoryNames = form.categoryIds .map((id) => categories.value.find((item) => item.id === id)?.name) .filter(Boolean) const payload = { ...form, - title: form.title.trim(), - genre: categoryNames.join(' / ') + title: titles.zh, + titleLocales: titles, + description: synopses.zh || '', + synopsisLocales: synopses, + genre: categoryNames.join(' / '), + tagId: form.tagId || 0 } saving.value = true try { @@ -370,7 +387,7 @@ function reviewActions(row) { } onMounted(async () => { - await loadCategories() + await Promise.all([loadCategories(), loadTags()]) await load() }) @@ -413,22 +430,6 @@ onMounted(async () => { :key="item.id" :label="item.name" :value="item.id" />重置
共 {{ total }} 部短剧
@@ -465,24 +466,17 @@ onMounted(async () => { > — - {{ row.tagName }}— { }} -