Author SHA1 Message Date
Zhou Weidong 9ce014e7b3 feat(classification): 分类支持榜单布局与顺序维护
web-ci / build (push) Failing after 12s
2026-11-02 14:47:59 +08:00
Vicky 23026dc371 style(setting): 按 eslint 规则规范系统设置模板格式
web-ci / build (push) Canceled after 0s
2026-10-30 17:17:39 +08:00
Vicky 2f52011491 perf(build): 图标改为离线子集并精简图表依赖
web-ci / build (push) Canceled after 0s
2026-10-30 17:14:51 +08:00
Vicky dd4be08d49 feat(setting): 系统设置支持上传 App 登录页背景 2026-10-30 17:14:50 +08:00
13 changed files with 386 additions and 91 deletions

No files matched your search

+23 -9
View File
@@ -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
+2 -10
View File
@@ -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>
+3
View File
@@ -5,6 +5,9 @@ import 'vue/jsx'
// 引入unocss
import '@/plugins/unocss'
// 注册后台使用的本地图标子集
import '@/plugins/icons'
// 导入全局的svg图标
import '@/plugins/svgIcon'
+2 -18
View File
@@ -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
+6
View File
@@ -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)
+174 -11
View File
@@ -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, {
+18 -8
View 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()
+128 -4
View File
@@ -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
View File
@@ -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: {