From d283d223c0be354cfa4d3ec0f1f89c36e3ded21b Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 09:12:11 +0800 Subject: [PATCH 01/36] =?UTF-8?q?chore:=20=E5=BF=BD=E7=95=A5=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E4=BA=A7=E7=89=A9=20apk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c4af407..cbf030a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist-ssr *-lock.* pnpm-debug stats.html +*.apk From 1737f37d829da2304f07e6104c420388f2bcdd3c Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 09:15:19 +0800 Subject: [PATCH 02/36] =?UTF-8?q?fix(login):=20=E5=BF=98=E8=AE=B0=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E9=93=BE=E6=8E=A5=E5=8A=A0=E6=8F=90=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E7=82=B9=E5=87=BB=E6=97=A0=E5=93=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/en.ts | 1 + src/locales/zh-CN.ts | 1 + src/views/Login/components/LoginForm.vue | 8 ++++++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/locales/en.ts b/src/locales/en.ts index 3e35f06..5043eef 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -112,6 +112,7 @@ export default { login: 'Sign in', remember: 'Remember me', forgetPassword: 'Forget password', + forgetPasswordHint: 'Please contact the administrator to reset your password', usernamePlaceholder: 'Please input username', passwordPlaceholder: 'Please input password' }, diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 1961130..535f9f7 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -112,6 +112,7 @@ export default { login: '登录', remember: '记住我', forgetPassword: '忘记密码', + forgetPasswordHint: '请联系管理员重置密码', usernamePlaceholder: '请输入用户名', passwordPlaceholder: '请输入密码' }, diff --git a/src/views/Login/components/LoginForm.vue b/src/views/Login/components/LoginForm.vue index ec583b9..bf7f6fd 100644 --- a/src/views/Login/components/LoginForm.vue +++ b/src/views/Login/components/LoginForm.vue @@ -4,7 +4,7 @@ import { reactive, ref, watch, onMounted, unref } from 'vue' import { Form, FormSchema } from '@/components/Form' import { useI18n } from '@/hooks/web/useI18n' -import { ElCheckbox, ElLink } from 'element-plus' +import { ElCheckbox, ElLink, ElMessage } from 'element-plus' import { useForm } from '@/hooks/web/useForm' import { loginApi, getAdminRoleApi } from '@/api/login' import { useAppStore } from '@/store/modules/app' @@ -92,7 +92,11 @@ const schema = reactive([ <>
- + ElMessage.info(t('login.forgetPasswordHint'))} + > {t('login.forgetPassword')}
From 45507dee8d4d8cc5c59b3cf9cad299f308db7b37 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 13:35:55 +0800 Subject: [PATCH 03/36] =?UTF-8?q?perf(admin):=20=E9=99=8D=E4=BD=8E?= =?UTF-8?q?=E9=A6=96=E5=B1=8F=E4=BD=93=E7=A7=AF=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20Windows=20=E8=AE=BF=E9=97=AE=E7=99=BD=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Editor 改 defineAsyncComponent,IEditorConfig 改 import type 2) dev server 绑定 :: 双栈,避免 Windows 下 localhost 走 ::1 超时 3) dev 脚本去掉 POSIX 环境变量前缀,Windows 可直接 pnpm dev --- package.json | 2 +- src/components/Form/src/helper/componentMap.ts | 6 +++++- src/components/Form/src/types/index.ts | 3 ++- vite.config.mts | 5 ++++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 0c3f7e6..4153b9f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "i": "pnpm install", - "dev": "VITE_CJS_IGNORE_WARNING=1 vite --mode base", + "dev": "vite --mode base", "ts:check": "pnpm vue-tsc --noEmit --skipLibCheck", "build:pro": "VITE_CJS_IGNORE_WARNING=1 vite build --mode pro", "build:dev": "VITE_CJS_IGNORE_WARNING=1 vite build --mode dev", diff --git a/src/components/Form/src/helper/componentMap.ts b/src/components/Form/src/helper/componentMap.ts index 67b06ef..1858a19 100644 --- a/src/components/Form/src/helper/componentMap.ts +++ b/src/components/Form/src/helper/componentMap.ts @@ -1,5 +1,6 @@ // Author: fruit.com.cn +import { defineAsyncComponent } from 'vue' import type { Component } from 'vue' import { ElCascader, @@ -23,9 +24,12 @@ import { ElUpload } from 'element-plus' import { InputPassword } from '@/components/InputPassword' -import { Editor } from '@/components/Editor' import { ComponentName } from '../types' +// wang-editor 打包后约 1.1MB:改成异步组件,只有真正渲染编辑器字段时才加载, +// 避免登录页这类只是用了 Form 的页面把它一并拉进首屏。 +const Editor = defineAsyncComponent(() => import('@/components/Editor')) + const componentMap: Recordable = { RadioGroup: ElRadioGroup, RadioButton: ElRadioGroup, diff --git a/src/components/Form/src/types/index.ts b/src/components/Form/src/types/index.ts index 402116d..a9f1e84 100644 --- a/src/components/Form/src/types/index.ts +++ b/src/components/Form/src/types/index.ts @@ -22,7 +22,8 @@ import { ISelectProps, UploadProps } from 'element-plus' -import { IEditorConfig } from '@wangeditor/editor' +// 仅类型引用:必须用 import type,否则会把整个 wang-editor 打进登录页的依赖链 +import type { IEditorConfig } from '@wangeditor/editor' import { CSSProperties } from 'vue' export interface PlaceholderModel { diff --git a/vite.config.mts b/vite.config.mts index fa5bcbc..b31af6d 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -139,7 +139,10 @@ export default ({ command, mode }: ConfigEnv): UserConfig => { hmr: { overlay: false }, - host: '0.0.0.0' + // 必须同时监听 IPv6:Windows 上 localhost 先解析成 ::1, + // 只绑 0.0.0.0 会让每次新建连接都等 IPv6 超时(约 2s)再回退到 IPv4, + // 表现为页面白屏很久。'::' 是双栈,IPv4 局域网访问同样可用。 + host: '::' }, optimizeDeps: { include: [ From c8fe571be19d5a6a10040107c6a8fc79dae6fe77 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 15:43:51 +0800 Subject: [PATCH 04/36] =?UTF-8?q?feat(admin):=20=E7=9F=AD=E5=89=A7?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=B1=95=E7=A4=BA=E5=B9=B6=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E4=BA=92=E5=8A=A8=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin/drama.ts | 1 + src/api/admin/types.ts | 8 ++ src/styles/business/drama-management.css | 18 +++ .../contentManagement/DramaManagementView.vue | 107 +++++++++++++++++- 4 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/api/admin/drama.ts b/src/api/admin/drama.ts index 753bff4..885227d 100644 --- a/src/api/admin/drama.ts +++ b/src/api/admin/drama.ts @@ -16,6 +16,7 @@ export interface DramaPageParams { status?: string categoryId?: number isHot?: boolean + dramaTypes?: Array<'ANIME' | 'LIVE'> } export const pageDramasApi = (params: DramaPageParams) => { diff --git a/src/api/admin/types.ts b/src/api/admin/types.ts index 15780cb..632951a 100644 --- a/src/api/admin/types.ts +++ b/src/api/admin/types.ts @@ -25,6 +25,7 @@ export interface DramaItem { description?: string cover?: string language?: string + dramaType?: 'ANIME' | 'LIVE' episodes?: number freeEpisodes?: number unlockCoin?: number @@ -33,6 +34,9 @@ export interface DramaItem { status: string statusText: string views?: number + favoriteCount?: number + likeCount?: number + commentCount?: number sort?: number publishAt?: string createdAt?: string @@ -47,8 +51,12 @@ export interface DramaSaveData { description?: string cover?: string language?: string + dramaType?: 'ANIME' | 'LIVE' freeEpisodes?: number unlockCoin?: number + favoriteCount?: number + likeCount?: number + heat?: number isHot?: boolean sort?: number } diff --git a/src/styles/business/drama-management.css b/src/styles/business/drama-management.css index da70d90..4cb4cf9 100644 --- a/src/styles/business/drama-management.css +++ b/src/styles/business/drama-management.css @@ -147,6 +147,24 @@ width: 180px !important; } +.readonly-stat { + min-width: 180px; + height: 32px; + padding: 0 12px; + color: var(--el-text-color-secondary); + line-height: 32px; + background: var(--el-fill-color-lighter); + border: 1px solid var(--el-border-color-lighter); + border-radius: var(--el-border-radius-base); +} + +.readonly-stat-hint { + margin: 4px 0 0; + color: var(--el-text-color-placeholder); + font-size: 12px; + line-height: 1.4; +} + .cover-editor { display: flex; gap: 18px; diff --git a/src/views/contentManagement/DramaManagementView.vue b/src/views/contentManagement/DramaManagementView.vue index 9941efe..d914ec6 100644 --- a/src/views/contentManagement/DramaManagementView.vue +++ b/src/views/contentManagement/DramaManagementView.vue @@ -30,6 +30,7 @@ const categories = ref([]) const keyword = ref('') const statusFilter = ref(undefined) const categoryId = ref(undefined) +const dramaTypeFilter = ref(undefined) const hotFilter = ref(undefined) const pageIndex = ref(1) const pageSize = ref(10) @@ -41,13 +42,18 @@ const coverPercent = ref(0) const coverError = ref('') const coverFileName = ref('') const editingId = ref(null) +const editingCommentCount = ref(0) const formRef = ref() const form = reactive({ title: '', categoryId: undefined, + dramaType: 'LIVE', description: '', freeEpisodes: 3, unlockCoin: 0, + favoriteCount: 0, + likeCount: 0, + heat: 0, isHot: false, cover: '' }) @@ -57,7 +63,8 @@ const COVER_TYPES = ['image/jpeg', 'image/png', 'image/webp'] const formRules = { title: [{ required: true, message: '请输入短剧名称', trigger: 'blur' }], - categoryId: [{ required: true, message: '请选择短剧类型', trigger: 'change' }] + categoryId: [{ required: true, message: '请选择短剧类型', trigger: 'change' }], + dramaType: [{ required: true, message: '请选择内容类型', trigger: 'change' }] } const coverDisplayName = computed( @@ -78,6 +85,11 @@ const statusOptions = [ { code: 'offline', text: '已下线' } ] +const dramaTypeOptions = [ + { value: 'ANIME', label: '漫剧' }, + { value: 'LIVE', label: '真人剧' } +] + const actionMeta = { submit: { label: '提交审核', done: '已提交审核' }, approve: { label: '审核通过', done: '已通过审核' }, @@ -95,6 +107,7 @@ async function load() { keyword: keyword.value.trim() || undefined, status: statusFilter.value || undefined, categoryId: categoryId.value, + dramaTypes: dramaTypeFilter.value ? [dramaTypeFilter.value] : undefined, isHot: hotFilter.value }) list.value = res.data?.list || [] @@ -118,6 +131,7 @@ function resetFilters() { keyword.value = '' statusFilter.value = undefined categoryId.value = undefined + dramaTypeFilter.value = undefined hotFilter.value = undefined search() } @@ -143,14 +157,19 @@ function openCreate() { Object.assign(form, { title: '', categoryId: undefined, + dramaType: 'LIVE', description: '', freeEpisodes: 3, unlockCoin: 0, + favoriteCount: 0, + likeCount: 0, + heat: 0, isHot: false, cover: '' }) coverFileName.value = '' coverError.value = '' + editingCommentCount.value = 0 dialogVisible.value = true resetValidation() } @@ -160,18 +179,27 @@ function openEdit(row) { Object.assign(form, { title: row.title, categoryId: row.categoryId || undefined, + dramaType: row.dramaType || 'LIVE', description: row.description || '', freeEpisodes: row.freeEpisodes ?? 3, unlockCoin: row.unlockCoin ?? 0, + favoriteCount: row.favoriteCount ?? 0, + likeCount: row.likeCount ?? 0, + heat: row.views ?? 0, isHot: !!row.isHot, cover: row.cover || '' }) coverFileName.value = row.cover ? coverBasename(row.cover) : '' coverError.value = '' + editingCommentCount.value = row.commentCount ?? 0 dialogVisible.value = true resetValidation() } +function dramaTypeLabel(value) { + return dramaTypeOptions.find((item) => item.value === value)?.label || '真人剧' +} + async function saveDrama() { if (saving.value || uploadingCover.value) return try { @@ -344,6 +372,12 @@ onMounted(async () => { :key="item.id" :label="item.name" :value="item.id" /> { > + - + + + diff --git a/src/views/contentManagement/upload/BatchUploadDialog.vue b/src/views/contentManagement/upload/BatchUploadDialog.vue new file mode 100644 index 0000000..130be68 --- /dev/null +++ b/src/views/contentManagement/upload/BatchUploadDialog.vue @@ -0,0 +1,319 @@ + + + + + + + + diff --git a/src/views/contentManagement/upload/CreateEpisodesDialog.vue b/src/views/contentManagement/upload/CreateEpisodesDialog.vue new file mode 100644 index 0000000..13b771e --- /dev/null +++ b/src/views/contentManagement/upload/CreateEpisodesDialog.vue @@ -0,0 +1,241 @@ + + + + + + + + diff --git a/src/views/contentManagement/upload/UploadTaskRow.vue b/src/views/contentManagement/upload/UploadTaskRow.vue new file mode 100644 index 0000000..24033d8 --- /dev/null +++ b/src/views/contentManagement/upload/UploadTaskRow.vue @@ -0,0 +1,119 @@ + + + + + + + + diff --git a/src/views/contentManagement/upload/uploadSessions.ts b/src/views/contentManagement/upload/uploadSessions.ts new file mode 100644 index 0000000..513ac63 --- /dev/null +++ b/src/views/contentManagement/upload/uploadSessions.ts @@ -0,0 +1,72 @@ +// Author: fruit.com.cn +/** 剧集批量上传断点会话:localStorage 读写与清理 */ + +export const UPLOAD_SESSION_KEY = 'reel_upload_sessions' +export const UPLOAD_SESSION_TTL_MS = 24 * 60 * 60 * 1000 + +export interface UploadSessionRecord { + uploadId: string + fileName: string + fileSize: number + lastModified: number + dramaId: string + bizType: string + createdAt: number +} + +/** 读取全部会话,解析失败时返回空数组。 */ +export function loadUploadSessions(): UploadSessionRecord[] { + try { + const raw = localStorage.getItem(UPLOAD_SESSION_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? (parsed as UploadSessionRecord[]) : [] + } catch { + return [] + } +} + +/** 覆盖写入全部会话。 */ +export function saveUploadSessions(sessions: UploadSessionRecord[]): void { + localStorage.setItem(UPLOAD_SESSION_KEY, JSON.stringify(sessions)) +} + +/** 按 dramaId 过滤且未超过 TTL 的未完成会话。 */ +export function listPendingSessions(dramaId: string, now = Date.now()): UploadSessionRecord[] { + const dramaKey = String(dramaId) + return loadUploadSessions().filter( + (item) => + String(item.dramaId) === dramaKey && + now - Number(item.createdAt || 0) <= UPLOAD_SESSION_TTL_MS + ) +} + +/** 新增或按 uploadId 覆盖一条会话。 */ +export function upsertUploadSession(record: UploadSessionRecord): void { + const next = loadUploadSessions().filter((item) => item.uploadId !== record.uploadId) + next.push(record) + saveUploadSessions(next) +} + +/** 按 uploadId 移除会话。 */ +export function removeUploadSession(uploadId: string): void { + if (!uploadId) return + saveUploadSessions(loadUploadSessions().filter((item) => item.uploadId !== uploadId)) +} + +/** 批量移除会话。 */ +export function removeUploadSessions(uploadIds: string[]): void { + if (!uploadIds.length) return + const idSet = new Set(uploadIds) + saveUploadSessions(loadUploadSessions().filter((item) => !idSet.has(item.uploadId))) +} + +/** 用文件名 + size + lastModified 匹配本地文件。 */ +export function matchFileForSession(session: UploadSessionRecord, files: File[]): File | undefined { + return files.find( + (file) => + file.name === session.fileName && + file.size === session.fileSize && + file.lastModified === session.lastModified + ) +} diff --git a/src/views/contentManagement/upload/useUploadQueue.ts b/src/views/contentManagement/upload/useUploadQueue.ts new file mode 100644 index 0000000..dd0312b --- /dev/null +++ b/src/views/contentManagement/upload/useUploadQueue.ts @@ -0,0 +1,310 @@ +// Author: fruit.com.cn +/** 剧集批量上传队列:文件并发、进度加权、断点会话、失败隔离 */ + +import { computed, ref } from 'vue' +import { getUploadProgressApi } from '@/api/admin/media' +import { probeVideoDuration, resumeUpload, uploadFile } from '@/utils/uploader' +import { + listPendingSessions, + matchFileForSession, + removeUploadSession, + type UploadSessionRecord, + upsertUploadSession +} from './uploadSessions' + +export type UploadItemStatus = 'waiting' | 'uploading' | 'merging' | 'done' | 'failed' | 'paused' + +export interface UploadQueueItem { + id: string + file: File + fileName: string + fileSize: number + lastModified: number + percent: number + status: UploadItemStatus + error: string + uploadId: string + assetId?: number + url?: string + durationSec?: number +} + +export interface UseUploadQueueOptions { + /** 当前短剧 id(调用时读取,避免切换剧后闭包过期) */ + getDramaId: () => string + bizType?: string +} + +const FILE_CONCURRENCY = 2 +const CHUNK_CONCURRENCY = 3 +const DEFAULT_BIZ_TYPE = 'EPISODE' +const MERGING_PERCENT = 95 +const MAX_PERCENT = 100 + +let queueSeq = 0 + +function nextItemId(): string { + queueSeq += 1 + return `uq-${Date.now()}-${queueSeq}` +} + +function extractErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message + return String(error || 'upload failed') +} + +function isHttpNotFound(error: unknown): boolean { + const status = (error as { response?: { status?: number } })?.response?.status + return status === 404 +} + +/** + * 创建上传队列控制器。 + * @param options dramaId 与可选 bizType + */ +export function useUploadQueue(options: UseUploadQueueOptions) { + const items = ref([]) + const paused = ref(false) + const bizType = options.bizType || DEFAULT_BIZ_TYPE + let running = 0 + + const overallPercent = computed(() => { + const list = items.value + if (!list.length) return 0 + const totalBytes = list.reduce((sum, item) => sum + item.fileSize, 0) + if (totalBytes <= 0) return 0 + const weighted = list.reduce((sum, item) => sum + item.fileSize * item.percent, 0) + return Math.min(MAX_PERCENT, Math.round(weighted / totalBytes)) + }) + + const hasActiveUpload = computed(() => + items.value.some( + (item) => + item.status === 'waiting' || + item.status === 'uploading' || + item.status === 'merging' || + item.status === 'paused' + ) + ) + + const doneItems = computed(() => items.value.filter((item) => item.status === 'done')) + + function persistSession(item: UploadQueueItem, uploadId: string): void { + item.uploadId = uploadId + upsertUploadSession({ + uploadId, + fileName: item.fileName, + fileSize: item.fileSize, + lastModified: item.lastModified, + dramaId: String(options.getDramaId()), + bizType, + createdAt: Date.now() + }) + } + + function clearSession(uploadId: string): void { + if (uploadId) removeUploadSession(uploadId) + } + + async function runOne(item: UploadQueueItem, resumeId?: string): Promise { + item.status = 'uploading' + item.error = '' + try { + const durationSec = await probeVideoDuration(item.file) + item.durationSec = durationSec + const uploadOptions = { + bizType, + concurrency: CHUNK_CONCURRENCY, + durationSec, + onSession: (uploadId: string) => persistSession(item, uploadId), + onProgress: (percent: number) => { + item.percent = percent + if (percent >= MERGING_PERCENT && item.status === 'uploading') { + item.status = 'merging' + } + } + } + const asset = resumeId + ? await resumeUpload(item.file, resumeId, uploadOptions) + : await uploadFile(item.file, uploadOptions) + item.percent = MAX_PERCENT + item.status = 'done' + item.assetId = asset.id + item.url = asset.url + if (asset.durationSec != null) item.durationSec = asset.durationSec + clearSession(item.uploadId || resumeId || '') + } catch (error) { + item.status = 'failed' + item.error = extractErrorMessage(error) + } + } + + function pump(): void { + if (paused.value) return + while (running < FILE_CONCURRENCY && !paused.value) { + const next = items.value.find((item) => item.status === 'waiting') + if (!next) return + // 立刻占位,避免同步 while 循环把同一项派发两次 + next.status = 'uploading' + running += 1 + const resumeId = next.uploadId || undefined + void runOne(next, resumeId).finally(() => { + running -= 1 + if (!paused.value) pump() + }) + } + } + + /** 入队本地文件(调用方已完成类型/大小校验)。 */ + function enqueue(files: File[]): void { + for (const file of files) { + items.value.push({ + id: nextItemId(), + file, + fileName: file.name, + fileSize: file.size, + lastModified: file.lastModified, + percent: 0, + status: paused.value ? 'paused' : 'waiting', + error: '', + uploadId: '' + }) + } + } + + /** 开始或继续派发。 */ + function start(): void { + paused.value = false + for (const item of items.value) { + if (item.status === 'paused') item.status = 'waiting' + } + void pump() + } + + /** 暂停:不派新任务;进行中分片跑完。 */ + function pause(): void { + paused.value = true + for (const item of items.value) { + if (item.status === 'waiting') item.status = 'paused' + } + } + + /** 重试单个失败项。 */ + function retry(id: string): void { + const item = items.value.find((row) => row.id === id) + if (!item || item.status !== 'failed') return + item.error = '' + item.percent = item.uploadId ? item.percent : 0 + item.status = paused.value ? 'paused' : 'waiting' + if (!paused.value) void pump() + } + + /** 重试全部失败项。 */ + function retryAll(): void { + for (const item of items.value) { + if (item.status === 'failed') { + item.error = '' + item.status = paused.value ? 'paused' : 'waiting' + } + } + if (!paused.value) void pump() + } + + /** 移除单项并清理会话。 */ + function remove(id: string): void { + const index = items.value.findIndex((row) => row.id === id) + if (index < 0) return + const [removed] = items.value.splice(index, 1) + clearSession(removed.uploadId) + } + + /** 清空队列并清理本队列会话。 */ + function clear(): void { + for (const item of items.value) { + clearSession(item.uploadId) + } + items.value = [] + paused.value = false + running = 0 + } + + /** 读取当前剧未完成会话(24h 内)。 */ + function getPendingSessions(): UploadSessionRecord[] { + return listPendingSessions(String(options.getDramaId())) + } + + /** + * 用本地重选文件匹配会话并入队续传。 + * @returns matched / unmatched 会话列表 + */ + async function resumeFromSessions( + sessions: UploadSessionRecord[], + files: File[] + ): Promise<{ matched: UploadSessionRecord[]; unmatched: UploadSessionRecord[] }> { + const matched: UploadSessionRecord[] = [] + const unmatched: UploadSessionRecord[] = [] + for (const session of sessions) { + const file = matchFileForSession(session, files) + if (!file) { + unmatched.push(session) + continue + } + try { + const progressRes = await getUploadProgressApi(session.uploadId) + const progress = progressRes?.data + if (!progress) { + unmatched.push(session) + continue + } + if (String(progress.status || '').toLowerCase() === 'completed') { + clearSession(session.uploadId) + continue + } + items.value.push({ + id: nextItemId(), + file, + fileName: file.name, + fileSize: file.size, + lastModified: file.lastModified, + percent: Math.min(MERGING_PERCENT, Number(progress.percent) || 0), + status: paused.value ? 'paused' : 'waiting', + error: '', + uploadId: session.uploadId + }) + matched.push(session) + } catch (error) { + if (isHttpNotFound(error)) { + clearSession(session.uploadId) + } else { + unmatched.push(session) + } + } + } + return { matched, unmatched } + } + + /** 放弃续传:清理给定会话。 */ + function abandonSessions(sessions: UploadSessionRecord[]): void { + for (const session of sessions) { + clearSession(session.uploadId) + } + } + + return { + items, + overallPercent, + hasActiveUpload, + doneItems, + paused, + enqueue, + start, + pause, + retry, + retryAll, + remove, + clear, + getPendingSessions, + resumeFromSessions, + abandonSessions + } +} diff --git a/src/views/contentManagement/upload/videoLimit.ts b/src/views/contentManagement/upload/videoLimit.ts new file mode 100644 index 0000000..a649d8a --- /dev/null +++ b/src/views/contentManagement/upload/videoLimit.ts @@ -0,0 +1,3 @@ +// Author: fruit.com.cn +/** 剧集视频大小上限,单文件上传与批量上传共用。 */ +export const MAX_VIDEO_SIZE = 500 * 1024 * 1024 From c71a12f6f48ea58419c7402343781159bd189338 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 22:04:36 +0800 Subject: [PATCH 13/36] =?UTF-8?q?fix(admin):=20=E7=BB=9F=E4=B8=80=E5=89=A7?= =?UTF-8?q?=E9=9B=86=E4=B8=8A=E4=BC=A0=E6=A0=BC=E5=BC=8F=E4=B8=8E=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- src/locales/en.ts | 2 +- src/locales/zh-CN.ts | 2 +- .../contentManagement/EpisodeManagementView.vue | 13 ++++++------- .../contentManagement/upload/BatchUploadDialog.vue | 9 +++------ src/views/contentManagement/upload/videoLimit.ts | 6 ++++++ 5 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/locales/en.ts b/src/locales/en.ts index ab44e71..8375904 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -537,7 +537,7 @@ export default { retry: 'Retry', retryAll: 'Retry all', remove: 'Remove', - hint: 'Supports MP4 / MOV / MKV / WEBM, max 500MB each; up to 2 files in parallel.', + hint: 'Supports MP4 / MOV / MKV / WEBM, max 500MB each; multi-select allowed, 2 upload in parallel, the rest queue automatically.', invalidType: 'Only MP4, MOV, MKV, WEBM are allowed', oversize: 'File exceeds 500MB and was not queued', overallProgress: 'Overall progress', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index a66b726..63286e2 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -531,7 +531,7 @@ export default { retry: '重试', retryAll: '全部重试', remove: '移除', - hint: '支持 MP4 / MOV / MKV / WEBM,单文件不超过 500MB;同时最多上传 2 个文件。', + hint: '支持 MP4 / MOV / MKV / WEBM,单文件不超过 500MB;可一次多选,同时上传 2 个,其余自动排队。', invalidType: '仅支持 MP4、MOV、MKV、WEBM', oversize: '文件超过 500MB,未入队', overallProgress: '总进度', diff --git a/src/views/contentManagement/EpisodeManagementView.vue b/src/views/contentManagement/EpisodeManagementView.vue index bc7f1f2..6c7eac7 100644 --- a/src/views/contentManagement/EpisodeManagementView.vue +++ b/src/views/contentManagement/EpisodeManagementView.vue @@ -20,7 +20,7 @@ import { useI18n } from '@/hooks/web/useI18n' import { probeVideoDuration, uploadFile } from '@/utils/uploader' import BatchUploadDialog from './upload/BatchUploadDialog.vue' import { listPendingSessions } from './upload/uploadSessions' -import { MAX_VIDEO_SIZE } from './upload/videoLimit' +import { MAX_VIDEO_SIZE, VIDEO_ACCEPT_ATTR, VIDEO_EXTENSIONS } from './upload/videoLimit' const route = useRoute() const { can } = useRbac() @@ -62,7 +62,6 @@ const statusOptions = [ { code: 'offline', text: '已下线' } ] -const VIDEO_EXTENSIONS = ['mp4', 'mov'] const formRules = { number: [{ required: true, message: '请输入集号', trigger: 'change' }], title: [{ required: true, message: '请输入剧集标题', trigger: 'blur' }], @@ -218,7 +217,7 @@ function beforeVideoUpload(file) { .pop() ?.toLowerCase() if (!VIDEO_EXTENSIONS.includes(extension)) { - videoError.value = '视频仅支持 MP4 或 MOV 格式' + videoError.value = t('episodeBatch.invalidType') return false } if (file.size > MAX_VIDEO_SIZE) { @@ -556,7 +555,7 @@ onMounted(async () => { drag class="episode-video-empty-upload" :show-file-list="false" - accept=".mp4,.mov" + :accept="VIDEO_ACCEPT_ATTR" :before-upload="beforeVideoUpload" :http-request="uploadVideo" :disabled="uploadingVideo" @@ -564,7 +563,7 @@ onMounted(async () => {
拖放或点击上传 - MP4、MOV + MP4、MOV、MKV、WEBM
@@ -582,7 +581,7 @@ onMounted(async () => { {{ videoDisplayName }}
-

支持 MP4、MOV,单个文件不超过 500MB。

+

{{ t('episodeBatch.hint') }}

{ props.modelValue, set: (value: boolean) => emit('update:modelValue', value) @@ -208,7 +205,7 @@ function onEpisodesCreated(): void { type="file" multiple hidden - :accept="ACCEPT_ATTR" + :accept="VIDEO_ACCEPT_ATTR" @change="onPickFiles" /> diff --git a/src/views/contentManagement/upload/videoLimit.ts b/src/views/contentManagement/upload/videoLimit.ts index a649d8a..4f66667 100644 --- a/src/views/contentManagement/upload/videoLimit.ts +++ b/src/views/contentManagement/upload/videoLimit.ts @@ -1,3 +1,9 @@ // Author: fruit.com.cn /** 剧集视频大小上限,单文件上传与批量上传共用。 */ export const MAX_VIDEO_SIZE = 500 * 1024 * 1024 + +/** 允许的视频扩展名(小写,不含点),单文件与批量上传同源。 */ +export const VIDEO_EXTENSIONS: string[] = ['mp4', 'mov', 'mkv', 'webm'] + +/** el-upload / input[type=file] 的 accept 属性,与 VIDEO_EXTENSIONS 保持一致。 */ +export const VIDEO_ACCEPT_ATTR = '.mp4,.mov,.mkv,.webm,video/*' From cff864d5827474c4f9f83bc810bba32546ad18bf Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 22:07:04 +0800 Subject: [PATCH 14/36] =?UTF-8?q?refactor(admin):=20=E6=8B=86=E5=88=86?= =?UTF-8?q?=E5=89=A7=E9=9B=86=E7=AE=A1=E7=90=86=E9=A1=B5=E8=B6=85=E9=95=BF?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../EpisodeManagementView.vue | 402 +----------------- .../upload/EpisodeFormDialog.vue | 280 ++++++++++++ .../upload/EpisodeVideoUploader.vue | 172 ++++++++ 3 files changed, 467 insertions(+), 387 deletions(-) create mode 100644 src/views/contentManagement/upload/EpisodeFormDialog.vue create mode 100644 src/views/contentManagement/upload/EpisodeVideoUploader.vue diff --git a/src/views/contentManagement/EpisodeManagementView.vue b/src/views/contentManagement/EpisodeManagementView.vue index 6c7eac7..bfb449d 100644 --- a/src/views/contentManagement/EpisodeManagementView.vue +++ b/src/views/contentManagement/EpisodeManagementView.vue @@ -1,26 +1,19 @@ + + diff --git a/src/views/contentManagement/upload/EpisodeVideoUploader.vue b/src/views/contentManagement/upload/EpisodeVideoUploader.vue new file mode 100644 index 0000000..d3bcc6b --- /dev/null +++ b/src/views/contentManagement/upload/EpisodeVideoUploader.vue @@ -0,0 +1,172 @@ + + + + + + From 4841b0bc24f08720886e0c6ce0e132df6350fe71 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 22:08:47 +0800 Subject: [PATCH 15/36] =?UTF-8?q?fix(admin):=20=E7=A7=BB=E9=99=A4=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E6=8F=90=E7=A4=BA=E4=B8=AD=E7=9A=84=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上传为异步队列,文件数量不设上限,提示只保留格式与单文件大小限制 --- src/locales/en.ts | 2 +- src/locales/zh-CN.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/locales/en.ts b/src/locales/en.ts index 8375904..d6067d4 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -537,7 +537,7 @@ export default { retry: 'Retry', retryAll: 'Retry all', remove: 'Remove', - hint: 'Supports MP4 / MOV / MKV / WEBM, max 500MB each; multi-select allowed, 2 upload in parallel, the rest queue automatically.', + hint: 'Supports MP4 / MOV / MKV / WEBM, max 500MB each', invalidType: 'Only MP4, MOV, MKV, WEBM are allowed', oversize: 'File exceeds 500MB and was not queued', overallProgress: 'Overall progress', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 63286e2..07c068d 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -531,7 +531,7 @@ export default { retry: '重试', retryAll: '全部重试', remove: '移除', - hint: '支持 MP4 / MOV / MKV / WEBM,单文件不超过 500MB;可一次多选,同时上传 2 个,其余自动排队。', + hint: '支持 MP4 / MOV / MKV / WEBM,单文件不超过 500MB', invalidType: '仅支持 MP4、MOV、MKV、WEBM', oversize: '文件超过 500MB,未入队', overallProgress: '总进度', From 836600b8115375b60138a3ce4bbe812b372be040 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 22:10:35 +0800 Subject: [PATCH 16/36] =?UTF-8?q?chore(build):=20commit-msg=20=E6=8B=A6?= =?UTF-8?q?=E6=88=AA=20AI=20=E7=BD=B2=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .husky/commit-msg | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.husky/commit-msg b/.husky/commit-msg index 10cb551..ae34e3a 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,4 +1,9 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" +if grep -qiE "(co-authored-by|generated with|assisted by).*(cursor|claude|codex|gpt|copilot|ai)" "$1"; then + echo "commit message contains AI attribution, remove it before committing" >&2 + exit 1 +fi + pnpm commitlint --edit "$1" From 5741ebbf17ada566ac3e5d9ac962ce6a9a910793 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 22:20:10 +0800 Subject: [PATCH 17/36] =?UTF-8?q?fix(admin):=20=E7=AE=80=E5=8C=96=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=BB=BA=E5=88=86=E9=9B=86=E8=A1=A8=E5=8D=95=E5=B9=B6?= =?UTF-8?q?=E5=B0=86=E7=A7=BB=E9=99=A4=E6=93=8D=E4=BD=9C=E7=BD=AE=E5=8F=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/en.ts | 6 +- src/locales/zh-CN.ts | 5 +- .../upload/CreateEpisodesDialog.vue | 97 +++++++++---------- .../upload/UploadTaskRow.vue | 2 + 4 files changed, 55 insertions(+), 55 deletions(-) diff --git a/src/locales/en.ts b/src/locales/en.ts index d6067d4..c15626d 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -554,19 +554,19 @@ export default { resumeMatched: 'Matched {count} file(s) and resumed', resumeUnmatched: '{count} session(s) could not match local files; pick again manually', createTitle: 'Create episodes in batch', + createHint: + 'Title uses the video file name; episode numbers are assigned automatically. You can edit them later in episode settings.', colSource: 'Source file', colNumber: 'Episode No.', colTitle: 'Title', colFree: 'Free', colResult: 'Result', - numberConflict: 'Episode number conflict', missingVideo: 'Missing video URL', createOk: 'Created', createFailed: 'Create failed', createAllSuccess: 'All episodes created', createPartial: 'Some episodes failed; you can retry', - fixConflict: 'Resolve episode number conflicts first', - submitCreate: 'Submit', + submitCreate: 'Create', retryFailed: 'Retry failed', status: { waiting: 'Waiting', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 07c068d..023a84f 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -546,19 +546,18 @@ export default { resumeMatched: '已匹配 {count} 个文件并开始续传', resumeUnmatched: '有 {count} 个会话无法匹配本地文件,请手动重选', createTitle: '批量创建分集', + createHint: '标题取视频文件名,集号自动顺延分配,创建后可在分集编辑中修改', colSource: '源文件', colNumber: '集号', colTitle: '标题', colFree: '免费', colResult: '结果', - numberConflict: '集号冲突', missingVideo: '缺少视频地址', createOk: '已创建', createFailed: '创建失败', createAllSuccess: '全部分集已创建', createPartial: '部分分集创建失败,可重试', - fixConflict: '请先解决集号冲突', - submitCreate: '提交创建', + submitCreate: '创建', retryFailed: '重试失败项', status: { waiting: '等待中', diff --git a/src/views/contentManagement/upload/CreateEpisodesDialog.vue b/src/views/contentManagement/upload/CreateEpisodesDialog.vue index 13b771e..31683bf 100644 --- a/src/views/contentManagement/upload/CreateEpisodesDialog.vue +++ b/src/views/contentManagement/upload/CreateEpisodesDialog.vue @@ -1,5 +1,5 @@ - +
+ + +