Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ce014e7b3 | ||
|
|
23026dc371 | ||
|
|
2f52011491 | ||
|
|
dd4be08d49 |
No files matched your search
@@ -4,11 +4,14 @@ import request from '@/axios'
|
||||
import type { PageResult } from './types'
|
||||
|
||||
export interface DramaClassificationItem {
|
||||
id: number
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
nameEn: string
|
||||
nameId: string
|
||||
locales?: Record<string, string>
|
||||
layoutType: 'GRID' | 'RANKING'
|
||||
revision: string
|
||||
sort: number
|
||||
status: number
|
||||
dramaCount: number
|
||||
@@ -18,17 +21,20 @@ export interface DramaClassificationItem {
|
||||
export interface DramaClassificationSaveData {
|
||||
name: string
|
||||
locales: Record<string, string>
|
||||
code?: string
|
||||
layoutType?: 'GRID' | 'RANKING'
|
||||
sort?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface ClassificationDramaItem {
|
||||
id: number
|
||||
id: string
|
||||
title: string
|
||||
cover: string
|
||||
status: string
|
||||
totalEpisodes: number
|
||||
sort: number
|
||||
position?: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
@@ -43,19 +49,19 @@ export const listClassificationsApi = () => {
|
||||
}
|
||||
|
||||
export const createClassificationApi = (data: DramaClassificationSaveData) => {
|
||||
return request.post<number>({ url: '/admin/drama-classifications', data })
|
||||
return request.post<string>({ url: '/admin/drama-classifications', data })
|
||||
}
|
||||
|
||||
export const updateClassificationApi = (id: number, data: DramaClassificationSaveData) => {
|
||||
export const updateClassificationApi = (id: string, data: DramaClassificationSaveData) => {
|
||||
return request.put<void>({ url: `/admin/drama-classifications/${id}`, data })
|
||||
}
|
||||
|
||||
export const deleteClassificationApi = (id: number) => {
|
||||
export const deleteClassificationApi = (id: string) => {
|
||||
return request.delete<void>({ url: `/admin/drama-classifications/${id}` })
|
||||
}
|
||||
|
||||
/** 分类下已关联的短剧 */
|
||||
export const listClassificationDramasApi = (id: number, params: ClassificationDramaPageParams) => {
|
||||
export const listClassificationDramasApi = (id: string, params: ClassificationDramaPageParams) => {
|
||||
return request.get<PageResult<ClassificationDramaItem>>({
|
||||
url: `/admin/drama-classifications/${id}/dramas`,
|
||||
params
|
||||
@@ -64,7 +70,7 @@ export const listClassificationDramasApi = (id: number, params: ClassificationDr
|
||||
|
||||
/** 尚未关联到该分类的短剧(添加弹窗用) */
|
||||
export const listClassificationDramaCandidatesApi = (
|
||||
id: number,
|
||||
id: string,
|
||||
params: ClassificationDramaPageParams
|
||||
) => {
|
||||
return request.get<PageResult<ClassificationDramaItem>>({
|
||||
@@ -74,7 +80,7 @@ export const listClassificationDramaCandidatesApi = (
|
||||
}
|
||||
|
||||
/** 批量关联短剧,返回实际新增数量 */
|
||||
export const bindClassificationDramasApi = (id: number, dramaIds: number[]) => {
|
||||
export const bindClassificationDramasApi = (id: string, dramaIds: string[]) => {
|
||||
return request.post<number>({
|
||||
url: `/admin/drama-classifications/${id}/dramas`,
|
||||
data: { dramaIds }
|
||||
@@ -82,8 +88,16 @@ export const bindClassificationDramasApi = (id: number, dramaIds: number[]) => {
|
||||
}
|
||||
|
||||
/** 从分类移除短剧 */
|
||||
export const unbindClassificationDramaApi = (id: number, dramaId: number) => {
|
||||
export const unbindClassificationDramaApi = (id: string, dramaId: string) => {
|
||||
return request.delete<void>({
|
||||
url: `/admin/drama-classifications/${id}/dramas/${dramaId}`
|
||||
})
|
||||
}
|
||||
|
||||
/** 原子保存推荐榜完整内容与顺序 */
|
||||
export const saveRankingItemsApi = (id: string, dramaIds: string[]) => {
|
||||
return request.put<void>({
|
||||
url: `/admin/drama-classifications/${id}/ranking-items`,
|
||||
data: { dramaIds }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"prefix":"material-symbols","info":{"name":"Material Symbols","total":15728,"author":{"name":"Google","url":"https://github.com/google/material-design-icons"},"license":{"title":"Apache 2.0","spdx":"Apache-2.0","url":"https://github.com/google/material-design-icons/blob/master/LICENSE"},"samples":["downloading","privacy-tip","filter-drama-outline","assignment-ind","monitoring","desktop-access-disabled-outline-sharp"],"height":24,"category":"Material","tags":["Has Padding"],"palette":false},"lastModified":1791524209,"width":24,"height":24,"icons":{"menu-rounded":{"body":"<path fill=\"currentColor\" d=\"M4 18q-.425 0-.712-.288T3 17t.288-.712T4 16h16q.425 0 .713.288T21 17t-.288.713T20 18zm0-5q-.425 0-.712-.288T3 12t.288-.712T4 11h16q.425 0 .713.288T21 12t-.288.713T20 13zm0-5q-.425 0-.712-.288T3 7t.288-.712T4 6h16q.425 0 .713.288T21 7t-.288.713T20 8z\"/>"},"menu-open-rounded":{"body":"<path fill=\"currentColor\" d=\"M4 18q-.425 0-.712-.288T3 17t.288-.712T4 16h11q.425 0 .713.288T16 17t-.288.713T15 18zm14.9-1.7l-3.6-3.6q-.3-.3-.3-.7t.3-.7l3.6-3.6q.275-.275.7-.275t.7.275t.275.7t-.275.7L17.4 12l2.9 2.9q.275.275.275.7t-.275.7t-.7.275t-.7-.275M4 13q-.425 0-.712-.288T3 12t.288-.712T4 11h8q.425 0 .713.288T13 12t-.288.713T12 13zm0-5q-.425 0-.712-.288T3 7t.288-.712T4 6h11q.425 0 .713.288T16 7t-.288.713T15 8z\"/>"},"dashboard-rounded":{"body":"<path fill=\"currentColor\" d=\"M14 9q-.425 0-.712-.288T13 8V4q0-.425.288-.712T14 3h6q.425 0 .713.288T21 4v4q0 .425-.288.713T20 9zM4 13q-.425 0-.712-.288T3 12V4q0-.425.288-.712T4 3h6q.425 0 .713.288T11 4v8q0 .425-.288.713T10 13zm10 8q-.425 0-.712-.288T13 20v-8q0-.425.288-.712T14 11h6q.425 0 .713.288T21 12v8q0 .425-.288.713T20 21zM4 21q-.425 0-.712-.288T3 20v-4q0-.425.288-.712T4 15h6q.425 0 .713.288T11 16v4q0 .425-.288.713T10 21z\"/>"},"video-library-rounded":{"body":"<path fill=\"currentColor\" d=\"M17.525 10.625q.35-.225.35-.625t-.35-.625L12.65 6.25q-.375-.25-.763-.038t-.387.663v6.25q0 .45.388.663t.762-.038zM8 18q-.825 0-1.412-.587T6 16V4q0-.825.588-1.412T8 2h12q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18zm-4 4q-.825 0-1.412-.587T2 20V7q0-.425.288-.712T3 6t.713.288T4 7v13h13q.425 0 .713.288T18 21t-.288.713T17 22z\"/>"},"settings-rounded":{"body":"<path fill=\"currentColor\" d=\"M10.825 22q-.675 0-1.162-.45t-.588-1.1L8.85 18.8q-.325-.125-.612-.3t-.563-.375l-1.55.65q-.625.275-1.25.05t-.975-.8l-1.175-2.05q-.35-.575-.2-1.225t.675-1.075l1.325-1Q4.5 12.5 4.5 12.337v-.675q0-.162.025-.337l-1.325-1Q2.675 9.9 2.525 9.25t.2-1.225L3.9 5.975q.35-.575.975-.8t1.25.05l1.55.65q.275-.2.575-.375t.6-.3l.225-1.65q.1-.65.588-1.1T10.825 2h2.35q.675 0 1.163.45t.587 1.1l.225 1.65q.325.125.613.3t.562.375l1.55-.65q.625-.275 1.25-.05t.975.8l1.175 2.05q.35.575.2 1.225t-.675 1.075l-1.325 1q.025.175.025.338v.674q0 .163-.05.338l1.325 1q.525.425.675 1.075t-.2 1.225l-1.2 2.05q-.35.575-.975.8t-1.25-.05l-1.5-.65q-.275.2-.575.375t-.6.3l-.225 1.65q-.1.65-.587 1.1t-1.163.45zm1.225-6.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5\"/>"},"group-rounded":{"body":"<path fill=\"currentColor\" d=\"M1 17.2q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2v.8q0 .825-.587 1.413T15 20H3q-.825 0-1.412-.587T1 18zM18.45 20q.275-.45.413-.962T19 18v-1q0-1.1-.612-2.113T16.65 13.15q1.275.15 2.4.513t2.1.887q.9.5 1.375 1.112T23 17v1q0 .825-.587 1.413T21 20zM6.175 10.825Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175m11.65 0Q16.65 12 15 12q-.275 0-.7-.062t-.7-.138q.675-.8 1.038-1.775T15 8t-.362-2.025T13.6 4.2q.35-.125.7-.163T15 4q1.65 0 2.825 1.175T19 8t-1.175 2.825\"/>"},"movie-edit-rounded":{"body":"<path fill=\"currentColor\" d=\"M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4l1.625 3.25q.175.35.5.55t.7.2q.75 0 1.15-.638t.05-1.312L7 4h2l1.625 3.25q.175.35.5.55t.7.2q.75 0 1.15-.638t.05-1.312L12 4h2l1.625 3.25q.175.35.5.55t.7.2q.75 0 1.15-.638t.05-1.312L17 4h3q.825 0 1.413.587T22 6v3.45q0 .3-.25.475t-.55.1q-.825-.15-1.638.087T18.1 11l-5.525 5.525q-.275.275-.425.638t-.15.762V19q0 .425-.288.713T11 20zm10 0v-1.65q0-.2.075-.387t.225-.338l5.225-5.2q.225-.225.5-.325t.55-.1q.3 0 .575.113t.5.337l.925.925q.2.225.313.5t.112.55t-.1.563t-.325.512l-5.2 5.2q-.15.15-.337.225T16.65 21H15q-.425 0-.712-.287T14 20m6.575-4.6l.925-.975l-.925-.925l-.95.95z\"/>"},"policy-rounded":{"body":"<path fill=\"currentColor\" d=\"M11.675 21.875q-.15-.025-.3-.075Q8 20.675 6 17.637T4 11.1V6.375q0-.625.363-1.125t.937-.725l6-2.25q.35-.125.7-.125t.7.125l6 2.25q.575.225.938.725T20 6.375V11.1q0 1.575-.413 3.063T18.4 17l-2.95-2.95q.275-.475.413-.987T16 12q0-1.65-1.175-2.825T12 8T9.175 9.175T8 12t1.175 2.825T12 16q.525 0 1.038-.137T14 15.45l3.225 3.2q-.95 1.125-2.087 1.913T12.625 21.8q-.15.05-.3.075T12 21.9t-.325-.025m-1.088-8.462Q10 12.825 10 12t.588-1.412T12 10t1.413.588T14 12t-.587 1.413T12 14t-1.412-.587\"/>"},"payments-rounded":{"body":"<path fill=\"currentColor\" d=\"M3 20q-.825 0-1.412-.587T1 18V8q0-.425.288-.712T2 7t.713.288T3Line truncated
|
||||
@@ -5,7 +5,7 @@ import { computed, unref } from 'vue'
|
||||
import { ElIcon } from 'element-plus'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { Icon } from '@iconify/vue/offline'
|
||||
import { normalizeAppIcon } from '@/constants/icons'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
@@ -29,11 +29,6 @@ const symbolId = computed(() => {
|
||||
return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : unref(normalizedIcon)
|
||||
})
|
||||
|
||||
// 是否使用在线图标
|
||||
const isUseOnline = computed(() => {
|
||||
return import.meta.env.VITE_USE_ONLINE_ICON === 'true'
|
||||
})
|
||||
|
||||
const getIconifyStyle = computed(() => {
|
||||
const { color, size } = props
|
||||
return {
|
||||
@@ -49,10 +44,7 @@ const getIconifyStyle = computed(() => {
|
||||
<use :xlink:href="symbolId" />
|
||||
</svg>
|
||||
|
||||
<template v-else>
|
||||
<Icon v-if="isUseOnline" :icon="normalizedIcon" :style="getIconifyStyle" />
|
||||
<div v-else :class="`${normalizedIcon} iconify`" :style="getIconifyStyle"></div>
|
||||
</template>
|
||||
<Icon v-else :icon="normalizedIcon" :style="getIconifyStyle" />
|
||||
</ElIcon>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import 'vue/jsx'
|
||||
// 引入unocss
|
||||
import '@/plugins/unocss'
|
||||
|
||||
// 注册后台使用的本地图标子集
|
||||
import '@/plugins/icons'
|
||||
|
||||
// 导入全局的svg图标
|
||||
import '@/plugins/svgIcon'
|
||||
|
||||
|
||||
@@ -2,22 +2,12 @@
|
||||
|
||||
import * as echarts from 'echarts/core'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
MapChart,
|
||||
PictorialBarChart,
|
||||
RadarChart
|
||||
} from 'echarts/charts'
|
||||
import { BarChart, LineChart, PieChart } from 'echarts/charts'
|
||||
|
||||
import {
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
PolarComponent,
|
||||
AriaComponent,
|
||||
ParallelComponent,
|
||||
LegendComponent
|
||||
} from 'echarts/components'
|
||||
|
||||
@@ -28,16 +18,10 @@ echarts.use([
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
PolarComponent,
|
||||
AriaComponent,
|
||||
ParallelComponent,
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
MapChart,
|
||||
CanvasRenderer,
|
||||
PictorialBarChart,
|
||||
RadarChart
|
||||
CanvasRenderer
|
||||
])
|
||||
|
||||
export default echarts
|
||||
@@ -0,0 +1,6 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import { addCollection } from '@iconify/vue/offline'
|
||||
import materialSymbols from '@/assets/icons/material-symbols-subset.json'
|
||||
|
||||
addCollection(materialSymbols)
|
||||
@@ -4,6 +4,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import { AppIcon } from '@/components/AppIcon'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
import {
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
listClassificationDramaCandidatesApi,
|
||||
listClassificationDramasApi,
|
||||
listClassificationsApi,
|
||||
saveRankingItemsApi,
|
||||
unbindClassificationDramaApi,
|
||||
updateClassificationApi
|
||||
} from '@/api/admin/dramaClassification'
|
||||
@@ -43,6 +45,7 @@ const activeId = ref(null)
|
||||
const activeClassification = computed(
|
||||
() => classifications.value.find((item) => item.id === activeId.value) || null
|
||||
)
|
||||
const isRanking = computed(() => activeClassification.value?.layoutType === 'RANKING')
|
||||
|
||||
const loading = ref(false)
|
||||
const dramas = ref([])
|
||||
@@ -50,12 +53,19 @@ const total = ref(0)
|
||||
const pageIndex = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const keyword = ref('')
|
||||
const rankingDirty = ref(false)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref(null)
|
||||
const languages = ref(['zh'])
|
||||
const emptyForm = () => ({ locales: { zh: '' }, sort: 1, status: 1 })
|
||||
const emptyForm = () => ({
|
||||
locales: { zh: '' },
|
||||
code: '',
|
||||
layoutType: 'GRID',
|
||||
sort: 1,
|
||||
status: 1
|
||||
})
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const candidateVisible = ref(false)
|
||||
@@ -91,10 +101,11 @@ async function loadDramas() {
|
||||
const res = await listClassificationDramasApi(activeId.value, {
|
||||
keyword: keyword.value || undefined,
|
||||
pageIndex: pageIndex.value,
|
||||
pageSize: pageSize.value
|
||||
pageSize: isRanking.value ? 50 : pageSize.value
|
||||
})
|
||||
dramas.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
rankingDirty.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -105,6 +116,7 @@ function selectClassification(row) {
|
||||
activeId.value = row.id
|
||||
pageIndex.value = 1
|
||||
keyword.value = ''
|
||||
rankingDirty.value = false
|
||||
loadDramas()
|
||||
}
|
||||
|
||||
@@ -132,6 +144,8 @@ function openEdit(row) {
|
||||
languages.value = languageCodes(locales)
|
||||
Object.assign(form, {
|
||||
locales,
|
||||
code: row.code || '',
|
||||
layoutType: row.layoutType || 'GRID',
|
||||
sort: row.sort ?? 0,
|
||||
status: row.status ?? 1
|
||||
})
|
||||
@@ -150,6 +164,10 @@ async function saveClassification() {
|
||||
const payload = {
|
||||
name: locales.zh,
|
||||
locales,
|
||||
code:
|
||||
form.code ||
|
||||
(form.layoutType === 'RANKING' && !editingId.value ? 'RECOMMEND_RANKING' : undefined),
|
||||
layoutType: form.layoutType,
|
||||
sort: form.sort,
|
||||
status: form.status
|
||||
}
|
||||
@@ -197,7 +215,12 @@ async function removeDrama(row) {
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await unbindClassificationDramaApi(activeId.value, row.id)
|
||||
if (isRanking.value) {
|
||||
const remaining = dramas.value.filter((item) => item.id !== row.id).map((item) => item.id)
|
||||
await saveRankingItemsApi(activeId.value, remaining)
|
||||
} else {
|
||||
await unbindClassificationDramaApi(activeId.value, row.id)
|
||||
}
|
||||
ElMessage.success('已移出')
|
||||
await Promise.all([loadDramas(), loadClassifications()])
|
||||
}
|
||||
@@ -247,11 +270,23 @@ async function confirmBind() {
|
||||
}
|
||||
binding.value = true
|
||||
try {
|
||||
const res = await bindClassificationDramasApi(
|
||||
activeId.value,
|
||||
selectedDramas.value.map((item) => item.id)
|
||||
)
|
||||
ElMessage.success(`已关联 ${res.data ?? selectedDramas.value.length} 部短剧`)
|
||||
if (isRanking.value) {
|
||||
const existingIds = dramas.value.map((item) => item.id)
|
||||
const selectedIds = selectedDramas.value.map((item) => item.id)
|
||||
const nextIds = [...new Set([...existingIds, ...selectedIds])]
|
||||
if (nextIds.length > 50) {
|
||||
ElMessage.warning('推荐榜最多 50 部短剧')
|
||||
return
|
||||
}
|
||||
await saveRankingItemsApi(activeId.value, nextIds)
|
||||
ElMessage.success(`推荐榜已增加 ${selectedIds.length} 部短剧`)
|
||||
} else {
|
||||
const res = await bindClassificationDramasApi(
|
||||
activeId.value,
|
||||
selectedDramas.value.map((item) => item.id)
|
||||
)
|
||||
ElMessage.success(`已关联 ${res.data ?? selectedDramas.value.length} 部短剧`)
|
||||
}
|
||||
candidateVisible.value = false
|
||||
await Promise.all([loadDramas(), loadClassifications()])
|
||||
} finally {
|
||||
@@ -259,6 +294,22 @@ async function confirmBind() {
|
||||
}
|
||||
}
|
||||
|
||||
function onRankingChange() {
|
||||
dramas.value = dramas.value.map((item, index) => ({ ...item, position: index + 1 }))
|
||||
rankingDirty.value = true
|
||||
}
|
||||
|
||||
async function saveRankingOrder() {
|
||||
if (!activeId.value || !dramas.value.length) return
|
||||
await saveRankingItemsApi(
|
||||
activeId.value,
|
||||
dramas.value.map((item) => item.id)
|
||||
)
|
||||
rankingDirty.value = false
|
||||
ElMessage.success('推荐榜顺序已保存')
|
||||
await loadClassifications()
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return '—'
|
||||
return String(value).replace('T', ' ').slice(0, 19)
|
||||
@@ -271,7 +322,7 @@ onMounted(loadClassifications)
|
||||
<PageHeading
|
||||
eyebrow="内容管理"
|
||||
title="分类管理"
|
||||
description="分类是运营自定义的分组,与「类型」无关;一个分类可以关联任意多部短剧。"
|
||||
description="普通分类使用剧目网格;推荐榜使用独立排行布局,并按后台配置顺序展示。"
|
||||
>
|
||||
<el-button v-if="can('drama:create')" type="primary" class="primary-button" @click="openCreate">
|
||||
<AppIcon :icon="AppIcons.add" />
|
||||
@@ -293,9 +344,11 @@ onMounted(loadClassifications)
|
||||
<div class="classification-toolbar__title">
|
||||
{{ activeClassification?.name || '请选择分类' }}
|
||||
<small>共 {{ total }} 部关联短剧</small>
|
||||
<el-tag v-if="isRanking" size="small" type="warning">推荐榜</el-tag>
|
||||
</div>
|
||||
<div class="classification-toolbar__search">
|
||||
<el-input
|
||||
v-if="!isRanking"
|
||||
v-model="keyword"
|
||||
placeholder="搜索短剧名称"
|
||||
clearable
|
||||
@@ -303,7 +356,15 @@ onMounted(loadClassifications)
|
||||
@keyup.enter="search"
|
||||
@clear="search"
|
||||
/>
|
||||
<el-button type="primary" plain @click="search">查询</el-button>
|
||||
<el-button v-if="!isRanking" type="primary" plain @click="search">查询</el-button>
|
||||
<el-button
|
||||
v-if="isRanking && can('drama:update')"
|
||||
type="primary"
|
||||
:disabled="!rankingDirty"
|
||||
@click="saveRankingOrder"
|
||||
>
|
||||
保存排序
|
||||
</el-button>
|
||||
<el-button v-if="can('drama:update')" :disabled="!activeId" @click="openCandidates">
|
||||
<AppIcon :icon="AppIcons.add" />
|
||||
添加短剧
|
||||
@@ -311,7 +372,41 @@ onMounted(loadClassifications)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VueDraggable
|
||||
v-if="isRanking"
|
||||
v-model="dramas"
|
||||
item-key="id"
|
||||
handle=".ranking-row__drag"
|
||||
class="ranking-list"
|
||||
@end="onRankingChange"
|
||||
>
|
||||
<div v-for="(row, index) in dramas" :key="row.id" class="ranking-row">
|
||||
<button class="ranking-row__drag" type="button" title="拖动调整排名">
|
||||
<AppIcon :icon="AppIcons.drag" />
|
||||
</button>
|
||||
<span class="ranking-row__rank">{{ index + 1 }}</span>
|
||||
<img v-if="row.cover" :src="row.cover" class="ranking-row__cover" alt="封面" />
|
||||
<div v-else class="ranking-row__cover ranking-row__cover--empty">无</div>
|
||||
<div class="ranking-row__content">
|
||||
<strong>{{ row.title }}</strong>
|
||||
<span class="status-dot" :class="statusClass(row.status)">
|
||||
{{ STATUS_TEXT[row.status] || row.status }} · {{ row.totalEpisodes || 0 }} 集
|
||||
</span>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="can('drama:update')"
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="removeDrama(row)"
|
||||
>
|
||||
移出
|
||||
</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!dramas.length" description="推荐榜还没有短剧" />
|
||||
</VueDraggable>
|
||||
|
||||
<el-table
|
||||
v-else
|
||||
v-loading="loading"
|
||||
:data="dramas"
|
||||
row-key="id"
|
||||
@@ -353,7 +448,7 @@ onMounted(loadClassifications)
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="classification-pagination">
|
||||
<div v-if="!isRanking" class="classification-pagination">
|
||||
<el-pagination
|
||||
:current-page="pageIndex"
|
||||
:page-size="pageSize"
|
||||
@@ -434,6 +529,74 @@ onMounted(loadClassifications)
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ranking-list {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.ranking-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 48px 52px minmax(0, 1fr) auto;
|
||||
min-height: 72px;
|
||||
padding: 8px 12px;
|
||||
background: var(--el-bg-color);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ranking-row + .ranking-row {
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.ranking-row__drag {
|
||||
display: inline-flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: grab;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ranking-row__rank {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ranking-row__cover {
|
||||
width: 44px;
|
||||
height: 58px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ranking-row__cover--empty {
|
||||
display: flex;
|
||||
color: var(--el-text-color-placeholder);
|
||||
background: var(--el-fill-color-light);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ranking-row__content {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ranking-row__content strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.classification-cover--empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -64,6 +64,23 @@ const emit = defineEmits([
|
||||
@update:languages="emit('update:languages', $event)"
|
||||
@update:locales="emit('update:form', { ...form, locales: $event })"
|
||||
/>
|
||||
<el-form-item label="展示样式">
|
||||
<el-radio-group
|
||||
:model-value="form.layoutType"
|
||||
@update:model-value="emit('update:form', { ...form, layoutType: $event })"
|
||||
>
|
||||
<el-radio-button label="GRID">普通分类</el-radio-button>
|
||||
<el-radio-button label="RANKING">推荐榜</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类编码">
|
||||
<el-input
|
||||
:model-value="form.code"
|
||||
maxlength="64"
|
||||
placeholder="例如 RECOMMEND_RANKING"
|
||||
@update:model-value="emit('update:form', { ...form, code: $event.toUpperCase() })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="classification-form-row">
|
||||
<el-form-item label="排序">
|
||||
<el-input-number
|
||||
|
||||
@@ -29,6 +29,9 @@ const { can } = useRbac()
|
||||
>
|
||||
<div class="classification-list__main">
|
||||
<span class="classification-list__name">{{ item.name }}</span>
|
||||
<el-tag v-if="item.layoutType === 'RANKING'" size="small" type="warning">
|
||||
推荐榜
|
||||
</el-tag>
|
||||
<span class="classification-list__count">{{ item.dramaCount || 0 }}</span>
|
||||
</div>
|
||||
<div class="classification-list__actions" @click.stop>
|
||||
|
||||
@@ -46,6 +46,13 @@ export const BANNER_IMAGE: ImagePreset = {
|
||||
maxHeight: 1164
|
||||
}
|
||||
|
||||
export const LOGIN_BACKGROUND: ImagePreset = {
|
||||
label: '登录页背景',
|
||||
hint: '建议 1170×2532(390×844dp × 3,iPhone 整屏竖图),min 720×1560',
|
||||
maxWidth: 1170,
|
||||
maxHeight: 2532
|
||||
}
|
||||
|
||||
/** 按规格压缩:尺寸未超且体积不大时原样返回,不做二次编码。 */
|
||||
export function compressForPreset(file: File, preset: ImagePreset): Promise<File> {
|
||||
return compressImage(file, {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import echarts from '@/plugins/echarts'
|
||||
import type { ECharts } from 'echarts/core'
|
||||
import type { DashboardOverview, DashboardTrend } from '@/api/admin/dashboard'
|
||||
import { formatCount } from '@/utils/format'
|
||||
|
||||
@@ -16,8 +16,10 @@ const props = defineProps<{
|
||||
const trendRef = ref<HTMLElement>()
|
||||
const deviceRef = ref<HTMLElement>()
|
||||
const clock = ref('')
|
||||
let trendChart: echarts.ECharts | null = null
|
||||
let deviceChart: echarts.ECharts | null = null
|
||||
let trendChart: ECharts | null = null
|
||||
let deviceChart: ECharts | null = null
|
||||
let echartsPromise: Promise<typeof import('@/plugins/echarts').default> | null = null
|
||||
let active = true
|
||||
let timer = 0
|
||||
|
||||
const AXIS = '#909399'
|
||||
@@ -78,12 +80,19 @@ function tick() {
|
||||
clock.value = new Date().toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function renderCharts() {
|
||||
renderTrend()
|
||||
renderDevices()
|
||||
async function loadEcharts() {
|
||||
echartsPromise ??= import('@/plugins/echarts').then((module) => module.default)
|
||||
return echartsPromise
|
||||
}
|
||||
|
||||
function renderTrend() {
|
||||
async function renderCharts() {
|
||||
const echarts = await loadEcharts()
|
||||
if (!active) return
|
||||
renderTrend(echarts)
|
||||
renderDevices(echarts)
|
||||
}
|
||||
|
||||
function renderTrend(echarts: typeof import('@/plugins/echarts').default) {
|
||||
if (!trendRef.value) return
|
||||
trendChart = trendChart ?? echarts.init(trendRef.value)
|
||||
const trend = props.trend
|
||||
@@ -130,7 +139,7 @@ function renderTrend() {
|
||||
})
|
||||
}
|
||||
|
||||
function renderDevices() {
|
||||
function renderDevices(echarts: typeof import('@/plugins/echarts').default) {
|
||||
if (!deviceRef.value) return
|
||||
deviceChart = deviceChart ?? echarts.init(deviceRef.value)
|
||||
const android = metric('user', 'androidActive7d') ?? 0
|
||||
@@ -186,6 +195,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
active = false
|
||||
window.clearInterval(timer)
|
||||
window.removeEventListener('resize', onResize)
|
||||
trendChart?.dispose()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listConfigsApi, updateConfigsApi } from '@/api/admin/config'
|
||||
import { uploadFile } from '@/utils/uploader'
|
||||
import { compressForPreset, LOGIN_BACKGROUND } from '@/views/contentManagement/upload/imagePresets'
|
||||
import { useRbac } from '@/stores/rbac'
|
||||
|
||||
const { can } = useRbac()
|
||||
@@ -14,6 +16,15 @@ const workspaceName = ref('')
|
||||
const defaultStatus = ref('draft')
|
||||
const registerMaxPerIp = ref(5)
|
||||
const registerMaxPerDevice = ref(3)
|
||||
const loginBackgroundUrl = ref('')
|
||||
|
||||
const MAX_LOGIN_BG_SIZE = 8 * 1024 * 1024 // 8 MB
|
||||
const ALLOWED_LOGIN_BG_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
||||
const uploadingBg = ref(false)
|
||||
const bgError = ref('')
|
||||
const bgFileName = ref('')
|
||||
|
||||
const canSave = computed(() => can('system:role'))
|
||||
|
||||
const statusOptions = [
|
||||
{ code: 'draft', text: '草稿' },
|
||||
@@ -34,6 +45,7 @@ async function load() {
|
||||
defaultStatus.value = map['content.default-status'] || 'draft'
|
||||
registerMaxPerIp.value = parseRegisterLimit(map['app.register.max-per-ip-per-day'], 5)
|
||||
registerMaxPerDevice.value = parseRegisterLimit(map['app.register.max-per-device-per-day'], 3)
|
||||
loginBackgroundUrl.value = map['app.login.background.url'] || ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -46,7 +58,8 @@ async function save() {
|
||||
'workspace.name': workspaceName.value,
|
||||
'content.default-status': defaultStatus.value,
|
||||
'app.register.max-per-ip-per-day': String(registerMaxPerIp.value),
|
||||
'app.register.max-per-device-per-day': String(registerMaxPerDevice.value)
|
||||
'app.register.max-per-device-per-day': String(registerMaxPerDevice.value),
|
||||
'app.login.background.url': loginBackgroundUrl.value
|
||||
})
|
||||
ElMessage.success('设置已保存')
|
||||
} finally {
|
||||
@@ -54,6 +67,52 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
function beforeBgUpload(file) {
|
||||
bgError.value = ''
|
||||
if (!ALLOWED_LOGIN_BG_TYPES.includes(file.type)) {
|
||||
bgError.value = '图片仅支持 JPG、PNG 或 WebP 格式'
|
||||
return false
|
||||
}
|
||||
if (file.size > MAX_LOGIN_BG_SIZE) {
|
||||
bgError.value = '图片大小不能超过 8MB'
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function uploadBg(options) {
|
||||
uploadingBg.value = true
|
||||
bgError.value = ''
|
||||
try {
|
||||
const file = await compressForPreset(options.file, LOGIN_BACKGROUND)
|
||||
const asset = await uploadFile(file, {
|
||||
bizType: 'LOGIN_BG'
|
||||
})
|
||||
loginBackgroundUrl.value = asset.url
|
||||
bgFileName.value = file.name
|
||||
ElMessage.success('登录页背景已上传')
|
||||
} catch {
|
||||
bgError.value = '图片上传失败,请检查网络后重试'
|
||||
} finally {
|
||||
uploadingBg.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeBg() {
|
||||
if (uploadingBg.value) return
|
||||
loginBackgroundUrl.value = ''
|
||||
bgFileName.value = ''
|
||||
bgError.value = ''
|
||||
}
|
||||
|
||||
const loginBgPreview = computed(() => {
|
||||
const url = loginBackgroundUrl.value
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
||||
if (url.startsWith('/')) return url
|
||||
return `/${url}`
|
||||
})
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -77,10 +136,75 @@ onMounted(load)
|
||||
><el-form-item label="单 IP 24 小时注册上限(0 或负数表示不限制)"
|
||||
><el-input-number v-model="registerMaxPerIp" :step="1" :precision="0" /></el-form-item
|
||||
><el-form-item label="单设备 24 小时注册上限(0 或负数表示不限制)"
|
||||
><el-input-number v-model="registerMaxPerDevice" :step="1" :precision="0" /></el-form-item
|
||||
><el-button type="primary" :loading="saving" :disabled="!can('system:role')" @click="save"
|
||||
><el-input-number v-model="registerMaxPerDevice" :step="1" :precision="0"
|
||||
/></el-form-item>
|
||||
<el-divider content-position="left">App 登录页</el-divider>
|
||||
<el-form-item label="登录页背景图">
|
||||
<div class="login-bg-field">
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:before-upload="beforeBgUpload"
|
||||
:http-request="uploadBg"
|
||||
accept=".jpg,.jpeg,.png,.webp"
|
||||
>
|
||||
<el-button :disabled="uploadingBg" :loading="uploadingBg">
|
||||
{{ loginBackgroundUrl ? '重新上传' : '上传背景图' }}
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<span class="field-hint">建议 {{ LOGIN_BACKGROUND.hint }}</span>
|
||||
<span v-if="bgFileName" class="field-filename">当前:{{ bgFileName }}</span>
|
||||
<span v-if="bgError" class="field-error">{{ bgError }}</span>
|
||||
<div v-if="loginBgPreview" class="login-bg-preview">
|
||||
<img :src="loginBgPreview" alt="登录页背景预览" />
|
||||
<el-button class="remove-bg-btn" text type="danger" size="small" @click="removeBg"
|
||||
>移除</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="saving" :disabled="!canSave" @click="save"
|
||||
>保存设置</el-button
|
||||
></el-form
|
||||
></section
|
||||
>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-bg-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.field-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.field-filename {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.field-error {
|
||||
font-size: 12px;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.login-bg-preview {
|
||||
position: relative;
|
||||
margin-top: 4px;
|
||||
width: 96px;
|
||||
aspect-ratio: 1170 / 2532;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
.login-bg-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.remove-bg-btn {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
</style>
|
||||
+2
-31
@@ -1,35 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import { defineConfig, toEscapedSelector as e, presetUno, presetIcons } from 'unocss'
|
||||
import { defineConfig, toEscapedSelector as e, presetUno } from 'unocss'
|
||||
import transformerVariantGroup from '@unocss/transformer-variant-group'
|
||||
import { loadEnv } from 'vite'
|
||||
import { AppIcons } from './src/constants/icons'
|
||||
|
||||
const root = process.cwd()
|
||||
|
||||
const createPresetIcons = () => {
|
||||
const isBuild = !!process.argv[4]
|
||||
let env = {} as any
|
||||
if (!isBuild) {
|
||||
env = loadEnv(process.argv[3], root)
|
||||
} else {
|
||||
env = loadEnv(process.argv[4], root)
|
||||
}
|
||||
// @ts-ignore
|
||||
if (env.VITE_USE_ONLINE_ICON === 'true') {
|
||||
return []
|
||||
} else {
|
||||
return [
|
||||
presetIcons({
|
||||
prefix: '',
|
||||
collections: {
|
||||
'material-symbols': () =>
|
||||
import('@iconify/json/json/material-symbols.json').then((i) => i.default)
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
// ...UnoCSS options
|
||||
@@ -141,8 +113,7 @@ ${selector}:after {
|
||||
}
|
||||
]
|
||||
],
|
||||
safelist: Object.values(AppIcons),
|
||||
presets: [presetUno({ dark: 'class', attributify: false }), ...createPresetIcons()],
|
||||
presets: [presetUno({ dark: 'class', attributify: false })],
|
||||
transformers: [transformerVariantGroup()],
|
||||
content: {
|
||||
pipeline: {
|
||||
|
||||
Reference in new issue
Block a user