FastMovieAI AI短剧创作平台 二开基线

- 源码: xhadmincn/FastMovieAI (Apache 2.0)
- 二开: Docker部署, Swoole改Select, route.php修复, 补update/VERSION
- vendor/示例资源已gitignore
This commit is contained in:
李建琦
2026-08-26 18:37:36 +08:00
commit eae151fd57
888 changed files with 105571 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
VITE_REQUEST_BASE_URL = http://localhost:36300
+1
View File
@@ -0,0 +1 @@
# VITE_REQUEST_BASE_URL = https://fastmovie.admin.xhadmin.com.cn
+81
View File
@@ -0,0 +1,81 @@
{
"globals": {
"Component": true,
"ComponentPublicInstance": true,
"ComputedRef": true,
"DirectiveBinding": true,
"EffectScope": true,
"ElMessage": true,
"ElMessageBox": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,
"InjectionKey": true,
"MaybeRef": true,
"MaybeRefOrGetter": true,
"PropType": true,
"Ref": true,
"ShallowRef": true,
"Slot": true,
"Slots": true,
"VNode": true,
"WritableComputedRef": true,
"computed": true,
"createApp": true,
"customRef": true,
"defineAsyncComponent": true,
"defineComponent": true,
"effectScope": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"getCurrentWatcher": true,
"h": true,
"inject": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"isShallow": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onUnmounted": true,
"onUpdated": true,
"onWatcherCleanup": true,
"provide": true,
"reactive": true,
"readonly": true,
"ref": true,
"resolveComponent": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"toRaw": true,
"toRef": true,
"toRefs": true,
"toValue": true,
"triggerRef": true,
"unref": true,
"useAttrs": true,
"useCssModule": true,
"useCssVars": true,
"useId": true,
"useModel": true,
"useSlots": true,
"useTemplateRef": true,
"watch": true,
"watchEffect": true,
"watchPostEffect": true,
"watchSyncEffect": true
}
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+1
View File
@@ -0,0 +1 @@
# 请将伪静态规则或自定义Apache配置填写到此处
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
+75
View File
@@ -0,0 +1,75 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const ElMessage: typeof import('element-plus/es').ElMessage
const ElMessageBox: typeof import('element-plus/es').ElMessageBox
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const effectScope: typeof import('vue').effectScope
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const inject: typeof import('vue').inject
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useId: typeof import('vue').useId
const useModel: typeof import('vue').useModel
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+29
View File
@@ -0,0 +1,29 @@
import fs from 'fs';
// 读取 package.json 文件
const packageJsonPath = './package.json';
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
const packageJson = JSON.parse(packageJsonContent);
// 递增版本号
const versionParts = packageJson.version.split('.');
versionParts[2] = (parseInt(versionParts[2], 10) + 1).toString();
if (versionParts[2] > 99) {
versionParts[2] = '0';
versionParts[1] = (parseInt(versionParts[1], 10) + 1).toString();
}
const newVersion = versionParts.join('.');
// 更新 package.json 中的版本号
packageJson.version = newVersion;
// 写入 package.json 文件
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
fs.writeFileSync('./public/fastmovie/static/version.json', JSON.stringify({ version: newVersion }, null, 2));
const indexHtml = fs.readFileSync('./index.html', 'utf8');
// 查找meta标签name=version,修改content=版本号
const newHtml = indexHtml.replace(/<meta name="version" content=".*">/, '<meta name="version" content="' + newVersion + '">');
fs.writeFileSync('./index.html', newHtml);
console.log(`Version updated to: ${newVersion}`);
+90
View File
@@ -0,0 +1,90 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
Code: typeof import('./src/components/x-header-tools/modules/code.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElCountdown: typeof import('element-plus/es')['ElCountdown']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMention: typeof import('element-plus/es')['ElMention']
ElOption: typeof import('element-plus/es')['ElOption']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSkeletonItem: typeof import('element-plus/es')['ElSkeletonItem']
ElSlider: typeof import('element-plus/es')['ElSlider']
ElSplitter: typeof import('element-plus/es')['ElSplitter']
ElSplitterPanel: typeof import('element-plus/es')['ElSplitterPanel']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
ElWatermark: typeof import('element-plus/es')['ElWatermark']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
XHeaderTools: typeof import('./src/components/x-header-tools/index.vue')['default']
XlActor: typeof import('./src/components/xl-actor/index.vue')['default']
XlActorCreate: typeof import('./src/components/xl-actor/xl-actor-create.vue')['default']
XlAspectRatio: typeof import('./src/components/xl-aspect-ratio/index.vue')['default']
XlCharacterLook: typeof import('./src/components/xl-character-look/index.vue')['default']
XlCharacterLookCreate: typeof import('./src/components/xl-character-look/xl-character-look-create.vue')['default']
XlDialogueCreate: typeof import('./src/components/xl-dialogue/xl-dialogue-create.vue')['default']
XlEpisodeDuration: typeof import('./src/components/xl-episode-duration/index.vue')['default']
XlEpisodeSum: typeof import('./src/components/xl-episode-sum/index.vue')['default']
XlHeaderUserinfo: typeof import('./src/components/xl-header-userinfo/index.vue')['default']
XlIcode: typeof import('./src/components/xl-icode/index.vue')['default']
XlInvitationCode: typeof import('./src/components/xl-invitation-code/index.vue')['default']
XlLoading: typeof import('./src/components/xl-loading/index.vue')['default']
XlModels: typeof import('./src/components/xl-models/index.vue')['default']
XlNotice: typeof import('./src/components/xl-notice/index.vue')['default']
XLogin: typeof import('./src/components/x-login/index.vue')['default']
XlPay: typeof import('./src/components/xl-pay/index.vue')['default']
XlProp: typeof import('./src/components/xl-prop/index.vue')['default']
XlPropCreate: typeof import('./src/components/xl-prop/xl-prop-create.vue')['default']
XlQrcode: typeof import('./src/components/xl-qrcode/index.vue')['default']
XlQrcodeView: typeof import('./src/components/xl-qrcode-view/index.vue')['default']
XlSceneCreate: typeof import('./src/components/xl-scene/xl-scene-create.vue')['default']
XlStoryboard: typeof import('./src/components/xl-storyboard/index.vue')['default']
XlStyle: typeof import('./src/components/xl-style/index.vue')['default']
XlTabs: typeof import('./src/components/xl-tabs/index.vue')['default']
XlTabsItem: typeof import('./src/components/xl-tabs-item/index.vue')['default']
XlUser: typeof import('./src/components/xl-userinfo/modules/xl-user.vue')['default']
XlUserinfo: typeof import('./src/components/xl-userinfo/index.vue')['default']
XlUserPoints: typeof import('./src/components/xl-user-points/index.vue')['default']
XlVoice: typeof import('./src/components/xl-voice/index.vue')['default']
XVcode: typeof import('./src/components/x-vcode/index.vue')['default']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>loading...</title>
<meta name="version" content="0.0.55">
<!-- <meta name="base-url" content="https://fastmovie.ai"> -->
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+6431
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "short-play",
"private": true,
"version": "0.0.55",
"type": "module",
"scripts": {
"dev": "vite --mode development --port 36310 --host",
"build": "node build-version.mjs && vue-tsc --noEmit && vite build --mode production",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@webav/av-canvas": "^1.2.7",
"@webav/av-cliper": "^1.2.7",
"axios": "^1.13.2",
"element-plus": "^2.11.7",
"highlight.js": "^11.11.1",
"isomorphic-dompurify": "^2.31.0",
"marked": "^16.4.1",
"marked-highlight": "^2.2.2",
"mp4box": "^2.3.0",
"pinia": "^3.0.3",
"qrcode": "^1.5.4",
"vue": "^3.5.22",
"vue-i18n": "^11.1.12",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@types/node": "^24.10.0",
"@types/qrcode": "^1.5.6",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"@vitejs/plugin-vue": "^6.0.1",
"eslint": "^9.39.1",
"eslint-plugin-vue": "^10.5.1",
"sass-embedded": "^1.93.3",
"typescript": "^5.9.3",
"unplugin-auto-import": "^20.2.0",
"unplugin-icons": "^22.5.0",
"unplugin-vue-components": "^30.0.0",
"vite": "^7.1.7",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-inspect": "^11.3.3",
"vue-tsc": "^3.1.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

@@ -0,0 +1,3 @@
{
"version": "0.0.55"
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="256" height="256" viewBox="0 0 256 256"><defs><clipPath id="master_svg0_8_3947"><rect x="0" y="0" width="256" height="256" rx="50"/></clipPath></defs><g clip-path="url(#master_svg0_8_3947)"><rect x="0" y="0" width="256" height="256" rx="50" fill="#000000" fill-opacity="1"/><ellipse cx="75.03030395507812" cy="183.04131317138672" rx="23.030303955078125" ry="22.95867919921875" fill="#FE6056" fill-opacity="1" style="mix-blend-mode:passthrough"/><path d="M52,97.91735800000001L158.08264,97.91735800000001C183.44211,97.91735800000001,204,77.359459,204,52L77.584738,52C63.454677000000004,52,52,63.454675,52,77.58473599999999L52,97.91735800000001Z" fill="#0DF283" fill-opacity="1"/><g><path d="M72.266665,81.18941307067871L52,81.18941307067871L52,109.47000307067871Q52.37587196,108.81541207067872,52.79403096,108.18682507067871Q53.2121899,107.55823707067871,53.6708326,106.95836607067871Q54.1294756,106.35849607067871,54.6266241,105.78992607067872Q55.1237726,105.2213570706787,55.6572828,104.6865440706787Q56.1907935,104.15173107067871,56.7583656,103.65297907067871Q57.3259373,103.15422407067871,57.9251223,102.6936820706787Q58.5243077,102.23314107067871,59.152523,101.81279907067871Q59.7807374,101.39245407067871,60.4352722,101.01412007067871Q61.0898075,100.6357860706787,61.7678394,100.30109607067871Q62.445872,99.96640607067872,63.144478,99.67679807067871Q63.843084,99.3871940706787,64.559252,99.14392107067872Q65.275419,98.90064807067871,66.00606,98.7047600706787Q66.7367,98.50887107067871,67.478662,98.3612120706787Q68.220623,98.21355207067872,68.970707,98.11475607067871Q69.72079099999999,98.01596307067871,70.475761,97.96645707067871Q71.230732,97.91695207067872,71.987335,97.91695207067872Q72.127005,97.91695207067872,72.266665,97.91864007067872L72.266665,81.18941307067871Z" fill-rule="evenodd" fill="#0DF283" fill-opacity="1"/></g><path d="M52,152.5578494855957L158.08264,152.5578494855957C183.44211,152.5578494855957,204,131.99995048559572,204,106.6404914855957L77.584738,106.6404914855957C63.454677000000004,106.6404914855957,52,118.0951664855957,52,132.2252274855957L52,152.5578494855957Z" fill="#FEBC2F" fill-opacity="1" style="mix-blend-mode:passthrough"/><g><path d="M72.266665,134.53646850585938L52,134.53646850585938L52,164.11246850585937Q52.37587196,163.45787850585938,52.79403096,162.82929050585938Q53.2121899,162.20070450585936,53.6708326,161.6008315058594Q54.1294756,161.00096150585938,54.6266241,160.43239250585938Q55.1237726,159.8638225058594,55.6572828,159.32901050585937Q56.1907935,158.7941975058594,56.7583656,158.29544250585937Q57.3259373,157.79669150585937,57.9251223,157.33614950585937Q58.5243077,156.87560850585936,59.152523,156.4552655058594Q59.7807374,156.03491950585936,60.4352722,155.65658550585937Q61.0898075,155.27825150585937,61.7678394,154.94356150585938Q62.445872,154.60887150585938,63.144478,154.31926350585937Q63.843084,154.02965950585937,64.559252,153.78638650585938Q65.275419,153.54311350585937,66.00606,153.34722550585937Q66.7367,153.15133650585938,67.478662,153.00367750585937Q68.220623,152.85601850585937,68.970707,152.75722150585938Q69.72079099999999,152.65842850585938,70.475761,152.60892250585937Q71.230732,152.5594175058594,71.987335,152.5594175058594Q72.127005,152.5594175058594,72.266665,152.56110550585936L72.266665,134.53646850585938Z" fill-rule="evenodd" fill="#FEBC2F" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

+62
View File
@@ -0,0 +1,62 @@
// 方向
$direction: top, right, bottom, left;
// 边距
$spacing: 3px;
// 圆角
$rounded-size: 2px;
// 边框粗细
$border-size: 1px;
// 背景颜色
$color-list: (
"white":var(--el-bg-color),
"primary":var(--el-color-primary),
"primary-light-3":var(--el-color-primary-light-3),
"primary-light-9":var(--el-color-primary-light-9),
"success":var(--el-color-success),
"success-light-3":var(--el-color-success-light-3),
"warning":var(--el-color-warning),
"warning-light-3":var(--el-color-warning-light-3),
"danger":var(--el-color-danger),
"danger-light-3":var(--el-color-danger-light-3),
"danger-dark-2":var(--el-color-danger-dark-2),
"error":var(--el-color-error),
"hover":var(--el-bg-color-page),
"overlay":var(--el-bg-color-overlay),
"mask":var(--el-mask-color),
"mask-light":var(--el-mask-color-extra-light),
"dark":var(--el-color-black),
"page":var(--el-bg-color-page),
"info":var(--el-color-info),
"gray":#272727
);
// 文字颜色
$text-color-list: (
"primary":var(--el-color-primary),
"primary-light-3":var(--el-color-primary-light-3),
"success":var(--el-color-success),
"danger":var(--el-color-danger),
"warning":var(--el-color-warning),
"error":var(--el-color-error),
"white":var(--el-bg-color),
"grey":var(--el-color-info),
"info":var(--el-color-info),
"text-primary":var(--el-text-color-primary),
"regular":var(--el-text-color-regular),
"secondary":var(--el-text-color-secondary),
"placeholder":var(--el-text-color-placeholder),
"disable":var(--el-text-color-disabled),
"dark":var(--el-text-color-primary),
"bg":var(--el-bg-color),
"price":#f56c6c
);
// 自适应断点
$breakpoint: (
"m":375px,
"p":480px,
"xs": 768px,
"sm": 960px,
"md": 1280px,
"lg": 1440px,
"xl": 1920px,
"xxl": 2560px
);
+576
View File
@@ -0,0 +1,576 @@
@import "./variables";
.pb-safe-area {
padding-bottom: constant(safe-area-inset-bottom) !important;
padding-bottom: env(safe-area-inset-bottom) !important;
}
.pt-safe-area {
padding-top: constant(safe-area-inset-top) !important;
padding-top: env(safe-area-inset-top) !important;
}
.mx-auto,
.ml-auto {
margin-left: auto !important;
}
.mx-auto,
.mr-auto {
margin-right: auto !important;
}
@for $i from 0 through 10 {
.p-#{$i},
.pl-#{$i},
.px-#{$i} {
padding-left: $spacing * $i !important;
}
.p-#{$i},
.pr-#{$i},
.px-#{$i} {
padding-right: $spacing * $i !important;
}
.p-#{$i},
.pt-#{$i},
.py-#{$i} {
padding-top: $spacing * $i !important;
}
.p-#{$i},
.pb-#{$i},
.py-#{$i} {
padding-bottom: $spacing * $i !important;
}
.m-#{$i},
.ml-#{$i},
.mx-#{$i} {
margin-left: $spacing * $i !important;
}
.m-#{$i},
.mr-#{$i},
.mx-#{$i} {
margin-right: $spacing * $i !important;
}
.m-#{$i},
.mt-#{$i},
.my-#{$i} {
margin-top: $spacing * $i !important;
}
.m-#{$i},
.mb-#{$i},
.my-#{$i} {
margin-bottom: $spacing * $i !important;
}
.top-#{$i} {
top: $spacing * $i !important;
}
.left-#{$i} {
left: $spacing * $i !important;
}
.right-#{$i} {
right: $spacing * $i !important;
}
.bottom-#{$i} {
bottom: $spacing * $i !important;
}
.top--#{$i} {
top: -$spacing * $i !important;
}
.left--#{$i} {
left: -$spacing * $i !important;
}
.right--#{$i} {
right: -$spacing * $i !important;
}
.bottom--#{$i} {
bottom: -$spacing * $i !important;
}
}
.inline-flex {
display: inline-flex;
}
.flex {
display: flex;
&-shrink-0 {
flex-shrink: 0;
}
@each $var in wrap,
nowrap {
&-#{$var} {
flex-wrap: $var;
}
}
@each $var in column,
column-reverse,
row,
row-reverse {
&-#{$var} {
flex-direction: $var;
}
}
@each $var in center,
baseline,
flex-start,
flex-end {
&-y-#{$var} {
align-items: $var;
}
}
@each $var in center,
flex-start,
flex-end,
space-around,
space-between,
space-evenly {
&-x-#{$var} {
justify-content: $var;
}
}
@for $i from 1 through 12 {
&-#{$i} {
flex: $i;
min-width: 0;
}
}
&-center {
align-items: center;
justify-content: center;
}
}
[class*="grid-columns-"] {
display: grid;
align-content: flex-start;
}
.grid {
display: grid;
grid-template-columns: repeat(24, minmax(0px, 1fr));
@for $i from 1 through 23 {
&-columns-#{$i} {
grid-template-columns: repeat($i, minmax(0px, 1fr));
}
}
@for $i from 1 through 10 {
&-gap-#{$i},
&-gap-x-#{$i} {
column-gap: $spacing * $i !important;
}
&-gap-#{$i},
&-gap-y-#{$i} {
row-gap: $spacing * $i !important;
}
}
@for $i from 1 through 24 {
&-column-#{$i} {
grid-column: span $i / span $i;
}
&-row-#{$i} {
grid-row: span $i / span $i;
}
}
@each $key, $value in $breakpoint {
@media only screen and (min-width: $value) {
@for $i from 1 through 24 {
&-columns-#{$key}-#{$i} {
grid-template-columns: repeat($i, minmax(0px, 1fr));
}
&-column-#{$key}-#{$i} {
grid-column: span $i / span $i;
}
&-row-#{$key}-#{$i} {
grid-row: span $i / span $i;
}
}
}
}
}
@each $var in relative,
absolute,
fixed,
sticky {
.position-#{$var} {
position: $var;
}
}
@for $i from 1 through 20 {
.vh-#{$i * 5} {
height: $i * 5vh;
}
.vw-#{$i * 5} {
width: $i * 5vw;
}
.h-#{$i * 5} {
height: $i * 5%;
}
.w-#{$i * 5} {
width: $i * 5%;
}
.min-h-#{$i * 5} {
min-height: $i * 5%;
}
.min-w-#{$i * 5} {
min-width: $i * 5%;
}
.min-vh-#{$i * 5} {
min-height: $i * 5vh;
}
.min-vw-#{$i * 5} {
min-width: $i * 5vw;
}
.max-h-#{$i * 5} {
max-height: $i * 5%;
}
.max-w-#{$i * 5} {
max-width: $i * 5%;
}
.max-vh-#{$i * 5} {
max-height: $i * 5vh;
}
.max-vw-#{$i * 5} {
max-width: $i * 5vw;
}
}
.bg {
background-color: var(--el-bg-color);
}
.bg-filter {
backdrop-filter: blur(6px);
}
.bg-mosaic,
.el-avatar.bg-mosaic,
#app .el-avatar {
--el-avatar-bg-color:#1E1E1E;
/* background: linear-gradient(45deg, rgba(255, 255, 255, 0.05) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255, 255, 255, 0.05) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.05) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.05) 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px; */
}
@each $key,
$color in $color-list {
.bg-#{$key},
.hover-bg-#{$key}:hover {
background-color: $color;
}
}
@for $i from 1 through 20 {
.bg-#{$i * 5} {
background-color: rgba(var(--el-bg-color-rgb-r), var(--el-bg-color-rgb-g), var(--el-bg-color-rgb-b), $i * 0.05);
}
}
.text {
@each $key,
$color in $text-color-list {
&-#{$key} {
color: $color;
}
}
@each $var in nowrap,
wrap {
&-#{$var} {
white-space: $var;
}
}
@each $var in break-all,
break-word,
keep-all {
&-#{$var} {
word-break: $var;
}
}
@for $i from 1 through 10 {
&-ellipsis-#{$i} {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
line-clamp: $i;
-webkit-line-clamp: $i;
-webkit-box-orient: vertical;
}
}
@for $i from 1 through 5 {
&-spacing-#{$i} {
word-spacing: $i * 0.53333vw;
}
}
@for $i from 1 through 5 {
&-letter-spacing-#{$i} {
letter-spacing: $i * 0.53333vw;
}
}
@each $var in left,
center,
right,
initial {
&-#{$var} {
text-align: $var;
}
}
@each $var in capitalize,
uppercase,
lowercase {
&-#{$var} {
text-transform: $var;
}
}
&-s {
text-decoration: line-through;
}
}
@each $i in $direction {
.border,
.border-#{$i} {
border-#{$i}-width: $border-size;
border-#{$i}-style: solid;
border-#{$i}-color: var(--el-border-color);
}
@each $key,
$var in $color-list {
.border-#{$key},
.border-#{$i}-#{$key} {
border-#{$i}-color: $var;
}
.border-hover-#{$key}:hover,
.border-hover-#{$i}-#{$key}:hover {
border-#{$i}-color: $var;
}
}
}
@for $i from 0 through 6 {
@each $n in $direction {
.border-#{$i},
.border-#{$n}-#{$i} {
border-#{$n}-width: $i * $border-size;
}
}
}
@each $i in solid,
dotted,
dashed,
double,
groove,
ridge,
inset,
outset {
@each $n in $direction {
.border-#{$i},
.border-#{$n}-#{$i} {
border-#{$n}-style: $i;
}
}
}
.rounded-circle {
border-radius: 50% !important;
}
.rounded-round {
border-radius: 100px !important;
}
@for $i from 0 through 6 {
@if $i ==1 {
.rounded,
.rounded-left,
.rounded-bottom,
.rounded-bottom-left {
border-bottom-left-radius: $i * $rounded-size;
}
.rounded,
.rounded-left,
.rounded-top,
.rounded-top-left {
border-top-left-radius: $i * $rounded-size;
}
.rounded,
.rounded-right,
.rounded-bottom,
.rounded-bottom-right {
border-bottom-right-radius: $i * $rounded-size;
}
.rounded,
.rounded-right,
.rounded-top,
.rounded-top-right {
border-top-right-radius: $i * $rounded-size;
}
}
@else {
.rounded-#{$i},
.rounded-left-#{$i},
.rounded-bottom-#{$i},
.rounded-bottom-left-#{$i} {
border-bottom-left-radius: $i * $rounded-size !important;
}
.rounded-#{$i},
.rounded-left-#{$i},
.rounded-top-#{$i},
.rounded-top-left-#{$i} {
border-top-left-radius: $i * $rounded-size !important;
}
.rounded-#{$i},
.rounded-right-#{$i},
.rounded-bottom-#{$i},
.rounded-bottom-right-#{$i} {
border-bottom-right-radius: $i * $rounded-size !important;
}
.rounded-#{$i},
.rounded-right-#{$i},
.rounded-top-#{$i},
.rounded-top-right-#{$i} {
border-top-right-radius: $i * $rounded-size !important;
}
}
}
.font-weight {
&-bold {
font-weight: bold;
}
&-normal {
font-weight: normal;
}
@for $i from 1 through 9 {
&-#{$i * 100} {
font-weight: $i * 100;
}
}
}
@for $i from 1 through 10 {
.h#{$i} {
font-size: 32px - ($i * 2px);
}
}
.line-height {
line-height: 1;
@for $i from 1 through 10 {
&-#{$i} {
line-height: 20px + ($i * 2px);
}
}
}
@each $i in hidden,
visible,
auto,
scroll {
.overflow-#{$i},
.overflow-x-#{$i} {
overflow-x: $i;
}
.overflow-#{$i},
.overflow-y-#{$i} {
overflow-y: $i;
}
}
img {
vertical-align: middle;
}
.shadow {
box-shadow: var(--el-box-shadow);
@each $i in light,
lighter,
dark {
&-#{$i} {
box-shadow:var(--el-box-shadow-#{$i});
}
}
}
.pointer {
cursor: pointer;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+698
View File
@@ -0,0 +1,698 @@
html,
body {
padding: 0;
margin: 0;
font-size: 14px;
font-synthesis: none;
color: var(--el-text-color-primary);
background-color: var(--el-bg-color);
}
a {
text-decoration: none;
}
* {
outline: none;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.layouts {
width: 100%;
max-width: var(--layouts-width);
margin: 0 auto;
padding-left: 10px;
padding-right: 10px;
}
.layouts-empty {
min-height: calc(100vh - var(--header-height) - var(--footer-height));
}
.el-main.layouts-main-scrollbar {
display: -webkit-box !important;
display: -ms-flexbox !important;
display: flex !important;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
overflow: hidden;
}
.table-layouts {
overflow: hidden;
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
}
.z-index {
z-index: 1;
}
.z-index-10 {
z-index: 10;
}
.z-index-100 {
z-index: 100;
}
.z-index-1000 {
z-index: 1000;
}
.z-index-10000 {
z-index: 10000;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar:horizontal {
height: 6px;
}
::-webkit-scrollbar-track {
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background-color: #E5EAF3;
border-radius: 10px;
-webkit-transition: all .2s ease-in-out;
transition: all .2s ease-in-out;
}
::-webkit-scrollbar-thumb:hover {
cursor: pointer;
background-color: #E5EAF3;
}
.circular {
display: inline;
height: var(--el-loading-spinner-size);
width: var(--el-loading-spinner-size);
-webkit-animation: 2s linear 0s infinite normal none running loading-rotate;
animation: 2s linear 0s infinite normal none running loading-rotate;
}
.el-text--small {
--el-link-font-size: var(--el-font-size-extra-small) !important;
}
.el-switch__core .el-switch__action {
color: var(--el-switch-on-color) !important;
}
.el-switch.is-checked .el-switch__core .el-switch__action {
color: var(--el-switch-on-color) !important;
}
.el-radio {
margin-right: 10px !important;
}
.el-button.el-button--success:not(.is-text) {
--el-button-text-color: var(--el-bg-color);
}
.el-popper.is-red {
/* Set padding to ensure the height is 32px */
padding: 6px 12px;
background: #f04d4d;
color: #FFFFFF;
}
.el-popper.is-red .el-popper__arrow::before {
background: #f04d4d;
right: 0;
}
.el-messagebox-width .el-message-box__message {
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
overflow: hidden;
}
.userinfo-menu {
width: 180px;
--el-dropdown-menuItem-hover-fill: var(--el-bg-color-page) !important;
--el-dropdown-menuItem-hover-color: var(--el-text-color-primary) !important;
--el-text-color-regular: var(--el-text-color-primary) !important;
}
.userinfo-menu .el-dropdown-menu {
padding: 10px;
}
.userinfo-menu .el-dropdown-menu__item {
border-radius: 6px;
}
.userinfo-menu .el-dropdown-menu__item .el-icon {
color: var(--el-text-color-primary);
font-size: 16px;
}
.userinfo-menu .el-dropdown-menu__item:hover {
background-color: var(--el-bg-color-page);
}
.el-button--dark {
--el-button-bg-color: #303133;
--el-button-text-color: var(--el-color-white);
--el-button-border-color: #303133;
--el-button-hover-bg-color: rgb(110, 111, 112);
--el-button-hover-text-color: var(--el-color-white);
--el-button-hover-border-color: rgb(110, 111, 112);
--el-button-active-bg-color: rgb(42, 43, 45);
--el-button-active-border-color: rgb(42, 43, 45);
}
.el-messagebox-payment {
padding: 0;
}
.el-messagebox-payment .el-message-box__header,
.el-messagebox-payment .el-message-box__btns {
display: none;
}
.el-messagebox-payment .el-message-box__message {
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
}
.el-messagebox-payment .recharge .close {
display: block !important;
}
.languare-list .languare-item {
padding: 6px;
border-radius: 6px;
cursor: pointer;
}
.languare-list .languare-item:hover {
color: var(--el-menu-hover-text-color);
background-color: var(--el-menu-hover-bg-color);
}
.w-e-text-container [data-slate-editor] pre > code {
text-shadow: none !important;
}
.el-loading-parent--relative .el-loading-spinner {
--el-loading-spinner-size: 20px;
--el-color-primary: #FFFFFF;
}
.el-loading-parent--relative .el-loading-spinner .el-loading-text {
font-size: 12px;
}
.action-dialog {
--el-dialog-border-radius: 10px;
--el-messagebox-font-size: 14px;
--max-height: calc(100vh - 32px);
max-height: var(--max-height);
}
.action-dialog .el-message-box__header {
padding: 20px;
margin-right: 0;
}
.action-dialog .el-message-box__header .el-message-box__title {
font-weight: 600;
}
.action-dialog .el-message-box__header .el-message-box__headerbtn {
top: 26px;
right: 12px;
}
.action-dialog .el-message-box__message {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
padding: 20px;
max-height: calc(var(--max-height) - 24px - 64px);
}
.action-dialog .el-message-box__message .el-scrollbar {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
}
.action-dialog .el-message-box__message .el-scrollbar .el-form.layouts {
padding-top: 0px;
}
.action-dialog .el-dialog__body .item-label {
width: 140px;
}
.action-dialog .el-message-box__btns {
display: none;
}
.full-action-dialog {
height: var(--max-height);
}
.full-action-dialog .el-message-box__message {
height: calc(var(--max-height) - 24px - 64px);
}
.el-form .none-label .el-form-item__label {
display: none !important;
}
.el-form .el-form-item__content {
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-box-align: start;
-ms-flex-align: start;
align-items: flex-start;
}
.el-form .submit-item .el-form-item__content {
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-pack: start;
-ms-flex-pack: start;
justify-content: flex-start;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.el-select .el-select-dropdown__item {
height: auto;
}
.el-table td.el-table__cell .cell {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: 6px;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
}
.el-message-box.x-login-message-box {
--el-messagebox-width: min(980px, 100%);
--el-messagebox-height: 550px;
--el-messagebox-padding-primary: 0px;
--el-messagebox-border-radius: 16px;
height: var(--el-messagebox-height);
}
.el-message-box.x-login-message-box .el-message-box__message {
width: 100%;
}
.el-message-box.x-login-message-box .el-message-box__btns {
display: none;
}
.x-loading-message-box-modal .el-overlay-message-box {
padding: 0px !important;
}
.el-message-box.x-loading-message-box {
--el-messagebox-width: 100vw;
--el-messagebox-height: 100vh;
--el-messagebox-padding-primary: 0px;
--el-messagebox-border-radius: 0px;
height: var(--el-messagebox-height);
}
.el-message-box.x-loading-message-box .el-message-box__message {
height: var(--el-messagebox-height);
width: 100%;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
gap: 10px;
}
.el-message-box.x-loading-message-box .el-message-box__btns {
display: none;
}
.text-active {
color: transparent;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-image: -webkit-gradient(linear, right top, left top, from(#79FFFF), to(#0DF283));
background-image: linear-gradient(to left, #79FFFF 0%, #0DF283 100%);
}
.bg-active {
background-image: -webkit-gradient(linear, right top, left top, from(#79FFFF), to(#0DF283));
background-image: linear-gradient(to left, #79FFFF 0%, #0DF283 100%);
}
.el-overlay.el-modal-dialog {
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
}
.el-dialog.drama-dialog {
--el-dialog-width: min(100vw, 500px);
--el-dialog-padding-primary: 0;
--el-dialog-border-radius: 16px;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
gap: 10px;
}
.el-dialog.drama-dialog .el-dialog__header {
padding: 20px;
border-bottom: 1px solid #262626;
position: relative;
}
.el-dialog.drama-dialog .el-dialog__header .el-dialog__headerbtn {
top: 50%;
-webkit-transform: translateY(-50%);
transform: translateY(-50%);
font-size: 20px;
}
.el-dialog.drama-dialog .el-dialog__header .el-dialog__headerbtn .el-dialog__close {
color: var(--el-text-color-regular);
}
.el-dialog.drama-dialog .el-dialog__body {
padding: 20px;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
gap: 20px;
}
.el-dialog.drama-dialog .el-dialog__footer {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
padding: 20px;
gap: 20px;
}
.el-dialog.drama-dialog .el-dialog__footer .el-button {
min-width: 100px;
font-weight: 600;
}
.el-dialog.drama-dialog .el-dialog__footer .el-button + .el-button {
margin-left: 0;
}
.el-dialog.generate-scene-dialog,
.el-dialog.generate-storyboard-dialog {
--el-dialog-width: min(100vw, 500px);
--el-dialog-padding-primary: 0;
--el-dialog-border-radius: 16px;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
gap: 10px;
}
.el-dialog.generate-scene-dialog .el-dialog__header,
.el-dialog.generate-storyboard-dialog .el-dialog__header {
padding: 20px;
border-bottom: 1px solid #262626;
position: relative;
}
.el-dialog.generate-scene-dialog .el-dialog__header .el-dialog__headerbtn,
.el-dialog.generate-storyboard-dialog .el-dialog__header .el-dialog__headerbtn {
top: 50%;
-webkit-transform: translateY(-50%);
transform: translateY(-50%);
font-size: 20px;
}
.el-dialog.generate-scene-dialog .el-dialog__header .el-dialog__headerbtn .el-dialog__close,
.el-dialog.generate-storyboard-dialog .el-dialog__header .el-dialog__headerbtn .el-dialog__close {
color: var(--el-text-color-regular);
}
.el-dialog.generate-scene-dialog .el-dialog__body,
.el-dialog.generate-storyboard-dialog .el-dialog__body {
padding: 20px;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
gap: 20px;
}
.el-dialog.generate-scene-dialog .el-dialog__footer,
.el-dialog.generate-storyboard-dialog .el-dialog__footer {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
padding: 20px;
gap: 20px;
}
.el-dialog.generate-scene-dialog .el-dialog__footer .el-button,
.el-dialog.generate-storyboard-dialog .el-dialog__footer .el-button {
min-width: 100px;
font-weight: 600;
}
.el-dialog.generate-scene-dialog .el-dialog__footer .el-button + .el-button,
.el-dialog.generate-storyboard-dialog .el-dialog__footer .el-button + .el-button {
margin-left: 0;
}
.tabs-segmented.el-segmented {
--el-border-radius-base: 8px;
--el-segmented-bg-color: rgba(30, 30, 30, 0.4) !important;
--el-border-color: rgba(255, 255, 255, 0.3);
--el-segmented-item-hover-bg-color: rgba(255, 255, 255, 0.1);
--el-segmented-item-active-bg-color: rgba(255, 255, 255, 0.1);
--el-segmented-padding: 4px;
--el-segmented-item-selected-color: var(--el-bg-color);
--el-segmented-item-selected-bg-color: #FFFFFF;
--el-segmented-item-selected-color: var(--el-bg-color);
font-weight: 600;
}
.tabs-segmented.el-segmented .el-segmented__item {
padding: 8px 0;
width: 120px;
}
.tabs-segmented.el-segmented .el-segmented__group {
gap: 10px;
}
.prompt-popper .el-mention-dropdown {
--el-mention-option-height: auto;
}
.prompt-popper .el-mention-dropdown .el-mention-dropdown__list {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
gap: 6px;
}
.prompt-popper .el-mention-dropdown .el-mention-dropdown__list .el-mention-dropdown__item {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
gap: 6px;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
}
.episode-popover.el-popover {
--el-popover-bg-color: var(--el-mask-color-extra-light);
--el-bg-color-overlay: var(--el-popover-bg-color);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
min-width: 100px !important;
}
.episode-popover.el-popover.is-light > .el-popper__arrow::before {
background: var(--el-popover-bg-color);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
}
.el-popper.model-popover {
--el-popover-bg-color: rgba(30, 30, 30, 0.5);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
border-radius: 20px;
--el-popover-border-color: rgba(255, 255, 255, 0.1);
--el-box-shadow-light: none;
/* border: 0px solid rgba(255,255,255,0.3); */
}
.el-popper.model-popover .el-popper__arrow::before {
background-color: rgba(30, 30, 30, 0.5);
}
.icon-aspect-ratio {
width: 34px;
height: 34px;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
}
.icon-aspect-ratio:before {
content: '';
display: block;
border-radius: 4px;
-webkit-box-shadow: inset 0 0 0 2px var(--el-text-color-primary);
box-shadow: inset 0 0 0 2px var(--el-text-color-primary);
}
.icon-aspect-ratio[view="9:16"]:before {
width: 14.625px;
height: 26px;
}
.icon-aspect-ratio[view="16:9"]:before {
width: 26px;
height: 14.625px;
}
.icon-aspect-ratio[view="3:4"]:before {
width: 19.5px;
height: 26px;
}
.icon-aspect-ratio[view="4:3"]:before {
width: 26px;
height: 19.5px;
}
.icon-aspect-ratio[view="1:1"]:before {
width: 26px;
height: 26px;
}
.icon-aspect-ratio[view="2:3"]:before {
width: 17.333px;
height: 26px;
}
.icon-aspect-ratio[view="3:2"]:before {
width: 26px;
height: 17.333px;
}
.input-button {
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
border-radius: 20px;
cursor: pointer;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
padding-top: 2px;
padding-bottom: 2px;
}
.input-button-selected {
background: rgba(255, 255, 255, 0.08);
}
.input-button:hover {
background: rgba(255, 255, 255, 0.16);
}
.el-dialog__headerbtn:focus .el-dialog__close, .el-dialog__headerbtn:hover .el-dialog__close {
color: var(--el-color-success) !important;
}
/*# sourceMappingURL=style.css.map */
File diff suppressed because one or more lines are too long
+120
View File
@@ -0,0 +1,120 @@
html[data-theme="dark"] {
color-scheme: dark;
--el-color-primary: #006EFF;
--el-color-primary-light-3: rgb(6, 83, 185);
--el-color-primary-light-5: #2a598a;
--el-color-primary-light-7: #213d5b;
--el-color-primary-light-8: #1d3043;
--el-color-primary-light-9: #18222c;
--el-color-primary-dark-2: rgb(51, 139, 255);
--el-color-success: #0DF283;
--el-color-success-light-3: rgb(25, 150, 75);
--el-color-success-light-5: #3e6b27;
--el-color-success-light-7: #2d481f;
--el-color-success-light-8: #25371c;
--el-color-success-light-9: #1c2518;
--el-color-success-dark-2: rgb(73, 215, 130);
--el-color-warning: #FBAC15;
--el-color-warning-light-3: rgb(182, 126, 21);
--el-color-warning-light-5: #7d5b28;
--el-color-warning-light-7: #533f20;
--el-color-warning-light-8: #3e301c;
--el-color-warning-light-9: #292218;
--el-color-warning-dark-2: rgb(252, 189, 68);
--el-color-danger: #E4221C;
--el-color-danger-light-3: rgb(166, 30, 26);
--el-color-danger-light-5: #854040;
--el-color-danger-light-7: #582e2e;
--el-color-danger-light-8: #412626;
--el-color-danger-light-9: #2b1d1d;
--el-color-danger-dark-2: rgb(233, 78, 73);
--el-color-error: #E4221C;
--el-color-error-light-3: rgb(166, 30, 26);
--el-color-error-light-5: #854040;
--el-color-error-light-7: #582e2e;
--el-color-error-light-8: #412626;
--el-color-error-light-9: #2b1d1d;
--el-color-error-dark-2: rgb(233, 78, 73);
--el-color-info: #909399;
--el-color-info-light-3: #6b6d71;
--el-color-info-light-5: #525457;
--el-color-info-light-7: #393a3c;
--el-color-info-light-8: #2d2d2f;
--el-color-info-light-9: #202121;
--el-color-info-dark-2: #a6a9ad;
--el-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, .36), 0px 8px 20px rgba(0, 0, 0, .72);
--el-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, .72);
--el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, .72);
--el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, .72), 0px 12px 32px #000000, 0px 8px 16px -8px #000000;
--el-bg-color-page: #1E1E1E;
--el-bg-color: #0C0C0D;
--el-bg-color-rgb-r: 10;
--el-bg-color-rgb-g: 10;
--el-bg-color-rgb-b: 10;
--el-bg-color-overlay: #171717;
--el-text-color-primary: #E5EAF3;
--el-text-color-regular: #CFD3DC;
--el-text-color-secondary: #A3A6AD;
--el-text-color-placeholder: #8D9095;
--el-text-color-disabled: #6C6E72;
--el-border-color-darker: #636466;
--el-border-color-dark: #58585B;
--el-border-color: #272727;
--el-border-color-light: #414243;
--el-border-color-lighter: #363637;
--el-border-color-extra-light: #2B2B2C;
--el-fill-color-darker: #424243;
--el-fill-color-dark: #39393A;
--el-fill-color: #303030;
--el-fill-color-light: #262727;
--el-fill-color-lighter: #1D1D1D;
--el-fill-color-extra-light: #191919;
--el-fill-color-blank: transparent;
--el-mask-color: rgba(0, 0, 0, .8);
--el-mask-color-extra-light: rgba(0, 0, 0, .3);
}
html[data-theme="dark"] .el-button {
--el-button-disabled-text-color: rgba(255, 255, 255, .5)
;
}
html[data-theme="dark"] .el-card {
--el-card-bg-color: var(--el-bg-color-overlay)
;
}
html[data-theme="dark"] .el-empty {
--el-empty-fill-color-0: var(--el-color-black);
--el-empty-fill-color-1: #4b4b52;
--el-empty-fill-color-2: #36383d;
--el-empty-fill-color-3: #1e1e20;
--el-empty-fill-color-4: #262629;
--el-empty-fill-color-5: #202124;
--el-empty-fill-color-6: #212224;
--el-empty-fill-color-7: #1b1c1f;
--el-empty-fill-color-8: #1c1d1f;
--el-empty-fill-color-9: #18181a;
}
html[data-theme="light"] {
--el-bg-color-rgb-r: 255;
--el-bg-color-rgb-g: 255;
--el-bg-color-rgb-b: 255;
--el-color-primary: #006EFF;
--el-color-primary-light-3: rgb(77, 154, 255);
--el-color-primary-dark-2: rgb(4, 92, 208);
--el-color-success: #1BCD63;
--el-color-success-light-3: rgb(95, 220, 146);
--el-color-success-dark-2: rgb(26, 168, 83);
--el-color-warning: #FBAC15;
--el-color-warning-light-3: rgb(252, 197, 91);
--el-color-warning-dark-2: rgb(205, 142, 21);
--el-color-danger: #E4221C;
--el-color-danger-light-3: rgb(236, 100, 96);
--el-color-danger-dark-2: rgb(186, 31, 26);
--el-color-error: #E4221C;
--el-color-error-light-3: rgb(236, 100, 96);
--el-color-error-dark-2: rgb(186, 31, 26);
}
/*# sourceMappingURL=theme.css.map */
+9
View File
@@ -0,0 +1,9 @@
{
"version": 3,
"mappings": "AAAA,AAAA,IAAI,CAAA,AAAA,UAAC,CAAW,MAAM,AAAjB,EAAmB;EACvB,YAAY,EAAE,IAAI;EAClB,kBAAkB,CAAA,QAAC;EACnB,0BAA0B,CAAA,gBAAC;EAC3B,0BAA0B,CAAA,QAAC;EAC3B,0BAA0B,CAAA,QAAC;EAC3B,0BAA0B,CAAA,QAAC;EAC3B,0BAA0B,CAAA,QAAC;EAC3B,yBAAyB,CAAA,kBAAC;EAC1B,kBAAkB,CAAA,QAAC;EACnB,0BAA0B,CAAA,iBAAC;EAC3B,0BAA0B,CAAA,QAAC;EAC3B,0BAA0B,CAAA,QAAC;EAC3B,0BAA0B,CAAA,QAAC;EAC3B,0BAA0B,CAAA,QAAC;EAC3B,yBAAyB,CAAA,kBAAC;EAC1B,kBAAkB,CAAA,QAAC;EACnB,0BAA0B,CAAA,kBAAC;EAC3B,0BAA0B,CAAA,QAAC;EAC3B,0BAA0B,CAAA,QAAC;EAC3B,0BAA0B,CAAA,QAAC;EAC3B,0BAA0B,CAAA,QAAC;EAC3B,yBAAyB,CAAA,kBAAC;EAC1B,iBAAiB,CAAA,QAAC;EAClB,yBAAyB,CAAA,iBAAC;EAC1B,yBAAyB,CAAA,QAAC;EAC1B,yBAAyB,CAAA,QAAC;EAC1B,yBAAyB,CAAA,QAAC;EAC1B,yBAAyB,CAAA,QAAC;EAC1B,wBAAwB,CAAA,iBAAC;EACzB,gBAAgB,CAAA,QAAC;EACjB,wBAAwB,CAAA,iBAAC;EACzB,wBAAwB,CAAA,QAAC;EACzB,wBAAwB,CAAA,QAAC;EACzB,wBAAwB,CAAA,QAAC;EACzB,wBAAwB,CAAA,QAAC;EACzB,uBAAuB,CAAA,iBAAC;EACxB,eAAe,CAAA,QAAC;EAChB,uBAAuB,CAAA,QAAC;EACxB,uBAAuB,CAAA,QAAC;EACxB,uBAAuB,CAAA,QAAC;EACxB,uBAAuB,CAAA,QAAC;EACxB,uBAAuB,CAAA,QAAC;EACxB,sBAAsB,CAAA,QAAC;EACvB,eAAe,CAAA,sEAAC;EAChB,qBAAqB,CAAA,gCAAC;EACtB,uBAAuB,CAAA,+BAAC;EACxB,oBAAoB,CAAA,wFAAC;EACrB,kBAAkB,CAAA,QAAC;EACnB,aAAa,CAAA,QAAC;EACd,mBAAmB,CAAA,GAAC;EACpB,mBAAmB,CAAA,GAAC;EACpB,mBAAmB,CAAA,GAAC;EACpB,qBAAqB,CAAA,QAAC;EACtB,uBAAuB,CAAA,QAAC;EACxB,uBAAuB,CAAA,QAAC;EACxB,yBAAyB,CAAA,QAAC;EAC1B,2BAA2B,CAAA,QAAC;EAC5B,wBAAwB,CAAA,QAAC;EACzB,wBAAwB,CAAA,QAAC;EACzB,sBAAsB,CAAA,QAAC;EACvB,iBAAiB,CAAA,QAAC;EAClB,uBAAuB,CAAA,QAAC;EACxB,yBAAyB,CAAA,QAAC;EAC1B,6BAA6B,CAAA,QAAC;EAC9B,sBAAsB,CAAA,QAAC;EACvB,oBAAoB,CAAA,QAAC;EACrB,eAAe,CAAA,QAAC;EAChB,qBAAqB,CAAA,QAAC;EACtB,uBAAuB,CAAA,QAAC;EACxB,2BAA2B,CAAA,QAAC;EAC5B,qBAAqB,CAAA,YAAC;EACtB,eAAe,CAAA,kBAAC;EAChB,2BAA2B,CAAA,kBAAC;CAC5B;;AAED,AAAA,IAAI,CAAA,AAAA,UAAC,CAAW,MAAM,AAAjB,EAAmB,UAAU,CAAC;EAClC,+BAA+B,CAAA;AAAC;CAChC;;AAED,AAAA,IAAI,CAAA,AAAA,UAAC,CAAW,MAAM,AAAjB,EAAmB,QAAQ,CAAC;EAChC,kBAAkB,CAAA;AAAC;CACnB;;AAED,AAAA,IAAI,CAAA,AAAA,UAAC,CAAW,MAAM,AAAjB,EAAmB,SAAS,CAAC;EACjC,uBAAuB,CAAA,sBAAC;EACxB,uBAAuB,CAAA,QAAC;EACxB,uBAAuB,CAAA,QAAC;EACxB,uBAAuB,CAAA,QAAC;EACxB,uBAAuB,CAAA,QAAC;EACxB,uBAAuB,CAAA,QAAC;EACxB,uBAAuB,CAAA,QAAC;EACxB,uBAAuB,CAAA,QAAC;EACxB,uBAAuB,CAAA,QAAC;EACxB,uBAAuB,CAAA,QAAC;CACxB;;AAED,AAAA,IAAI,CAAA,AAAA,UAAC,CAAW,OAAO,AAAlB,EAAoB;EACxB,mBAAmB,CAAA,IAAC;EACpB,mBAAmB,CAAA,IAAC;EACpB,mBAAmB,CAAA,IAAC;EACpB,kBAAkB,CAAA,QAAC;EACnB,0BAA0B,CAAA,kBAAC;EAC3B,yBAAyB,CAAA,gBAAC;EAC1B,kBAAkB,CAAA,QAAC;EACnB,0BAA0B,CAAA,kBAAC;EAC3B,yBAAyB,CAAA,iBAAC;EAC1B,kBAAkB,CAAA,QAAC;EACnB,0BAA0B,CAAA,kBAAC;EAC3B,yBAAyB,CAAA,kBAAC;EAC1B,iBAAiB,CAAA,QAAC;EAClB,yBAAyB,CAAA,kBAAC;EAC1B,wBAAwB,CAAA,iBAAC;EACzB,gBAAgB,CAAA,QAAC;EACjB,wBAAwB,CAAA,kBAAC;EACzB,uBAAuB,CAAA,iBAAC;CACxB",
"sources": [
"../theme.scss"
],
"names": [],
"file": "theme.css"
}
+619
View File
@@ -0,0 +1,619 @@
html,
body {
padding: 0;
margin: 0;
font-size: 14px;
font-synthesis: none;
color: var(--el-text-color-primary);
background-color: var(--el-bg-color);
}
a {
text-decoration: none;
}
* {
outline: none;
box-sizing: border-box;
}
.layouts {
width: 100%;
max-width: var(--layouts-width);
margin: 0 auto;
padding-left: 10px;
padding-right: 10px;
}
.layouts-empty {
min-height: calc(100vh - var(--header-height) - var(--footer-height));
}
.el-main.layouts-main-scrollbar {
display: flex !important;
flex-direction: column;
overflow: hidden;
}
.table-layouts {
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
}
.z-index {
z-index: 1
}
.z-index-10 {
z-index: 10;
}
.z-index-100 {
z-index: 100;
}
.z-index-1000 {
z-index: 1000;
}
.z-index-10000 {
z-index: 10000;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar:horizontal {
height: 6px
}
::-webkit-scrollbar-track {
border-radius: 10px
}
::-webkit-scrollbar-thumb {
background-color: #E5EAF3;
border-radius: 10px;
transition: all .2s ease-in-out
}
::-webkit-scrollbar-thumb:hover {
cursor: pointer;
background-color: #E5EAF3
}
.circular {
display: inline;
height: var(--el-loading-spinner-size);
width: var(--el-loading-spinner-size);
animation: 2s linear 0s infinite normal none running loading-rotate;
}
.el-text--small {
--el-link-font-size: var(--el-font-size-extra-small) !important;
}
.el-switch__core .el-switch__action {
color: var(--el-switch-on-color) !important;
}
.el-switch.is-checked .el-switch__core .el-switch__action {
color: var(--el-switch-on-color) !important;
}
.el-radio {
margin-right: 10px !important;
}
.el-button.el-button--success:not(.is-text) {
--el-button-text-color: var(--el-bg-color);
}
.el-popper.is-red {
/* Set padding to ensure the height is 32px */
padding: 6px 12px;
background: rgb(240, 77, 77);
color: #FFFFFF;
}
.el-popper.is-red .el-popper__arrow::before {
background: rgb(240, 77, 77);
right: 0;
}
.el-messagebox-width {
.el-message-box__message {
flex: 1;
overflow: hidden;
}
}
.userinfo-menu {
width: 180px;
--el-dropdown-menuItem-hover-fill: var(--el-bg-color-page) !important;
--el-dropdown-menuItem-hover-color: var(--el-text-color-primary) !important;
--el-text-color-regular: var(--el-text-color-primary) !important;
.el-dropdown-menu {
padding: 10px;
}
.el-dropdown-menu__item {
.el-icon {
color: var(--el-text-color-primary);
font-size: 16px;
}
border-radius: 6px;
}
.el-dropdown-menu__item:hover {
background-color: var(--el-bg-color-page);
}
}
.el-button--dark {
--el-button-bg-color: #303133;
--el-button-text-color: var(--el-color-white);
--el-button-border-color: #303133;
--el-button-hover-bg-color: rgb(110, 111, 112);
--el-button-hover-text-color: var(--el-color-white);
--el-button-hover-border-color: rgb(110, 111, 112);
--el-button-active-bg-color: rgb(42, 43, 45);
--el-button-active-border-color: rgb(42, 43, 45);
}
.el-messagebox-payment {
.el-message-box__header,
.el-message-box__btns {
display: none;
}
.el-message-box__message {
flex: 1;
}
padding: 0;
.recharge {
.close {
display: block !important;
}
}
}
.languare-list {
.languare-item {
padding: 6px;
border-radius: 6px;
cursor: pointer;
&:hover {
color: var(--el-menu-hover-text-color);
background-color: var(--el-menu-hover-bg-color);
}
}
}
.w-e-text-container [data-slate-editor] pre>code {
text-shadow: none !important;
}
.el-loading-parent--relative {
.el-loading-spinner {
--el-loading-spinner-size: 20px;
--el-color-primary: #FFFFFF;
.el-loading-text {
font-size: 12px;
}
}
}
.action-dialog {
--el-dialog-border-radius: 10px;
--el-messagebox-font-size: 14px;
--max-height: calc(100vh - 32px);
max-height: var(--max-height);
.el-message-box__header {
padding: 20px;
margin-right: 0;
.el-message-box__title {
font-weight: 600;
}
.el-message-box__headerbtn {
top: 26px;
right: 12px;
}
}
.el-message-box__message {
display: flex;
flex-direction: column;
padding: 20px;
max-height: calc(var(--max-height) - 24px - 64px);
.el-scrollbar {
display: flex;
flex-direction: column;
.el-form.layouts {
padding-top: 0px;
}
}
}
.el-dialog__body .item-label {
width: 140px;
}
.el-message-box__btns {
display: none;
}
}
.full-action-dialog {
height: var(--max-height);
.el-message-box__message {
height: calc(var(--max-height) - 24px - 64px);
}
}
.el-form {
.none-label {
.el-form-item__label {
display: none !important;
}
}
.el-form-item__content {
flex-direction: column;
justify-content: center;
align-items: flex-start;
}
.submit-item {
.el-form-item__content {
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
}
}
.el-select {
.el-select-dropdown__item {
height: auto;
}
}
.el-table td.el-table__cell .cell {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.el-message-box.x-login-message-box {
--el-messagebox-width: min(980px, 100%);
--el-messagebox-height: 550px;
--el-messagebox-padding-primary: 0px;
--el-messagebox-border-radius: 16px;
height: var(--el-messagebox-height);
.el-message-box__message {
width: 100%;
}
.el-message-box__btns {
display: none;
}
}
.x-loading-message-box-modal {
.el-overlay-message-box {
padding: 0px !important;
}
}
.el-message-box.x-loading-message-box {
--el-messagebox-width: 100vw;
--el-messagebox-height: 100vh;
--el-messagebox-padding-primary: 0px;
--el-messagebox-border-radius: 0px;
height: var(--el-messagebox-height);
.el-message-box__message {
height: var(--el-messagebox-height);
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
}
.el-message-box__btns {
display: none;
}
}
.text-active {
color: transparent;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-image: linear-gradient(to left, #79FFFF 0%, #0DF283 100%);
}
.bg-active {
background-image: linear-gradient(to left, #79FFFF 0%, #0DF283 100%);
}
.el-overlay.el-modal-dialog {
backdrop-filter: blur(6px);
}
.el-dialog.drama-dialog {
--el-dialog-width: min(100vw, 500px);
--el-dialog-padding-primary: 0;
--el-dialog-border-radius: 16px;
display: flex;
flex-direction: column;
gap: 10px;
.el-dialog__header {
padding: 20px;
border-bottom: 1px solid #262626;
position: relative;
.el-dialog__headerbtn {
top: 50%;
transform: translateY(-50%);
font-size: 20px;
.el-dialog__close {
color: var(--el-text-color-regular);
}
}
}
.el-dialog__body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.el-dialog__footer {
display: flex;
justify-content: center;
padding: 20px;
gap: 20px;
.el-button {
min-width: 100px;
font-weight: 600;
}
.el-button+.el-button {
margin-left: 0;
}
}
}
.el-dialog.generate-scene-dialog,
.el-dialog.generate-storyboard-dialog {
--el-dialog-width: min(100vw, 500px);
--el-dialog-padding-primary: 0;
--el-dialog-border-radius: 16px;
display: flex;
flex-direction: column;
gap: 10px;
.el-dialog__header {
padding: 20px;
border-bottom: 1px solid #262626;
position: relative;
.el-dialog__headerbtn {
top: 50%;
transform: translateY(-50%);
font-size: 20px;
.el-dialog__close {
color: var(--el-text-color-regular);
}
}
}
.el-dialog__body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.el-dialog__footer {
display: flex;
justify-content: center;
padding: 20px;
gap: 20px;
.el-button {
min-width: 100px;
font-weight: 600;
}
.el-button+.el-button {
margin-left: 0;
}
}
}
.tabs-segmented.el-segmented {
--el-border-radius-base: 8px;
--el-segmented-bg-color: rgba(30, 30, 30, 0.4) !important;
--el-border-color: rgba(255, 255, 255, 0.3);
--el-segmented-item-hover-bg-color: rgba(255, 255, 255, 0.1);
--el-segmented-item-active-bg-color: rgba(255, 255, 255, 0.1);
--el-segmented-padding: 4px;
--el-segmented-item-selected-color: var(--el-bg-color);
--el-segmented-item-selected-bg-color: #FFFFFF;
--el-segmented-item-selected-color: var(--el-bg-color);
font-weight: 600;
.el-segmented__item {
padding: 8px 0;
width: 120px;
}
.el-segmented__group {
gap: 10px;
}
// .el-segmented__item-selected {
// background-image: linear-gradient(to left, #79FFFF 0%, #0DF283 100%);
// }
}
.prompt-popper {
.el-mention-dropdown {
--el-mention-option-height: auto;
.el-mention-dropdown__list {
display: flex;
flex-direction: column;
gap: 6px;
.el-mention-dropdown__item {
display: flex;
gap: 6px;
align-items: center;
justify-content: center;
}
}
}
}
.episode-popover.el-popover {
--el-popover-bg-color: var(--el-mask-color-extra-light);
--el-bg-color-overlay: var(--el-popover-bg-color);
backdrop-filter: blur(6px);
min-width: 100px !important;
&.is-light>.el-popper__arrow::before {
background: var(--el-popover-bg-color);
backdrop-filter: blur(4px);
}
}
.el-popper.model-popover {
--el-popover-bg-color: rgba(30, 30, 30, 0.5);
backdrop-filter: blur(20px);
border-radius: 20px;
--el-popover-border-color: rgba(255, 255, 255, 0.1);
--el-box-shadow-light: none;
/* border: 0px solid rgba(255,255,255,0.3); */
.el-popper__arrow::before {
background-color: rgba(30, 30, 30, 0.5);
}
}
.icon-aspect-ratio {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
&:before {
content: '';
display: block;
border-radius: 4px;
box-shadow: inset 0 0 0 2px var(--el-text-color-primary);
}
&[view="9:16"] {
&:before {
width: 14.625px;
height: 26px;
}
}
&[view="16:9"] {
&:before {
width: 26px;
height: 14.625px;
}
}
&[view="3:4"] {
&:before {
width: 19.5px;
height: 26px;
}
}
&[view="4:3"] {
&:before {
width: 26px;
height: 19.5px;
}
}
&[view="1:1"] {
&:before {
width: 26px;
height: 26px;
}
}
&[view="2:3"] {
&:before {
width: 17.333px;
height: 26px;
}
}
&[view="3:2"] {
&:before {
width: 26px;
height: 17.333px;
}
}
}
.input-button {
backdrop-filter: blur(10px);
border-radius: 20px;
cursor: pointer;
display: flex;
padding-top: 2px;
padding-bottom: 2px;
&-selected {
background: rgba(255, 255, 255, 0.08);
}
&:hover {
background: rgba(255, 255, 255, 0.16);
}
}
.el-dialog__headerbtn:focus .el-dialog__close, .el-dialog__headerbtn:hover .el-dialog__close{
color: var(--el-color-success) !important;
}
+117
View File
@@ -0,0 +1,117 @@
html[data-theme="dark"] {
color-scheme: dark;
--el-color-primary: #006EFF;
--el-color-primary-light-3: rgb(6, 83, 185);
--el-color-primary-light-5: #2a598a;
--el-color-primary-light-7: #213d5b;
--el-color-primary-light-8: #1d3043;
--el-color-primary-light-9: #18222c;
--el-color-primary-dark-2: rgb(51, 139, 255);
--el-color-success: #0DF283;
--el-color-success-light-3: rgb(25, 150, 75);
--el-color-success-light-5: #3e6b27;
--el-color-success-light-7: #2d481f;
--el-color-success-light-8: #25371c;
--el-color-success-light-9: #1c2518;
--el-color-success-dark-2: rgb(73, 215, 130);
--el-color-warning: #FBAC15;
--el-color-warning-light-3: rgb(182, 126, 21);
--el-color-warning-light-5: #7d5b28;
--el-color-warning-light-7: #533f20;
--el-color-warning-light-8: #3e301c;
--el-color-warning-light-9: #292218;
--el-color-warning-dark-2: rgb(252, 189, 68);
--el-color-danger: #E4221C;
--el-color-danger-light-3: rgb(166, 30, 26);
--el-color-danger-light-5: #854040;
--el-color-danger-light-7: #582e2e;
--el-color-danger-light-8: #412626;
--el-color-danger-light-9: #2b1d1d;
--el-color-danger-dark-2: rgb(233, 78, 73);
--el-color-error: #E4221C;
--el-color-error-light-3: rgb(166, 30, 26);
--el-color-error-light-5: #854040;
--el-color-error-light-7: #582e2e;
--el-color-error-light-8: #412626;
--el-color-error-light-9: #2b1d1d;
--el-color-error-dark-2: rgb(233, 78, 73);
--el-color-info: #909399;
--el-color-info-light-3: #6b6d71;
--el-color-info-light-5: #525457;
--el-color-info-light-7: #393a3c;
--el-color-info-light-8: #2d2d2f;
--el-color-info-light-9: #202121;
--el-color-info-dark-2: #a6a9ad;
--el-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, .36), 0px 8px 20px rgba(0, 0, 0, .72);
--el-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, .72);
--el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, .72);
--el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, .72), 0px 12px 32px #000000, 0px 8px 16px -8px #000000;
--el-bg-color-page: #1E1E1E;
--el-bg-color: #0C0C0D;
--el-bg-color-rgb-r: 10;
--el-bg-color-rgb-g: 10;
--el-bg-color-rgb-b: 10;
--el-bg-color-overlay: #171717;
--el-text-color-primary: #E5EAF3;
--el-text-color-regular: #CFD3DC;
--el-text-color-secondary: #A3A6AD;
--el-text-color-placeholder: #8D9095;
--el-text-color-disabled: #6C6E72;
--el-border-color-darker: #636466;
--el-border-color-dark: #58585B;
--el-border-color: #272727;
--el-border-color-light: #414243;
--el-border-color-lighter: #363637;
--el-border-color-extra-light: #2B2B2C;
--el-fill-color-darker: #424243;
--el-fill-color-dark: #39393A;
--el-fill-color: #303030;
--el-fill-color-light: #262727;
--el-fill-color-lighter: #1D1D1D;
--el-fill-color-extra-light: #191919;
--el-fill-color-blank: transparent;
--el-mask-color: rgba(0, 0, 0, .8);
--el-mask-color-extra-light: rgba(0, 0, 0, .3);
}
html[data-theme="dark"] .el-button {
--el-button-disabled-text-color: rgba(255, 255, 255, .5)
}
html[data-theme="dark"] .el-card {
--el-card-bg-color: var(--el-bg-color-overlay)
}
html[data-theme="dark"] .el-empty {
--el-empty-fill-color-0: var(--el-color-black);
--el-empty-fill-color-1: #4b4b52;
--el-empty-fill-color-2: #36383d;
--el-empty-fill-color-3: #1e1e20;
--el-empty-fill-color-4: #262629;
--el-empty-fill-color-5: #202124;
--el-empty-fill-color-6: #212224;
--el-empty-fill-color-7: #1b1c1f;
--el-empty-fill-color-8: #1c1d1f;
--el-empty-fill-color-9: #18181a;
}
html[data-theme="light"] {
--el-bg-color-rgb-r: 255;
--el-bg-color-rgb-g: 255;
--el-bg-color-rgb-b: 255;
--el-color-primary: #006EFF;
--el-color-primary-light-3: rgb(77, 154, 255);
--el-color-primary-dark-2: rgb(4, 92, 208);
--el-color-success: #1BCD63;
--el-color-success-light-3: rgb(95, 220, 146);
--el-color-success-dark-2: rgb(26, 168, 83);
--el-color-warning: #FBAC15;
--el-color-warning-light-3: rgb(252, 197, 91);
--el-color-warning-dark-2: rgb(205, 142, 21);
--el-color-danger: #E4221C;
--el-color-danger-light-3: rgb(236, 100, 96);
--el-color-danger-dark-2: rgb(186, 31, 26);
--el-color-error: #E4221C;
--el-color-error-light-3: rgb(236, 100, 96);
--el-color-error-dark-2: rgb(186, 31, 26);
}
+105
View File
@@ -0,0 +1,105 @@
/// <reference types="vite/client" />
interface ImportMateEnv {
readonly VITE_APP_TITLE: string,
readonly VITE_REQUEST_BASE_URL: string | undefined
}
interface ImportMate {
readonly env: ImportMateEnv
}
export { };
declare global {
interface ObjectInterface {
[propName: string]: any
}
type StorageInterface = string | string[] | object | object[] | number | number[] | null | ObjectInterface | ObjectInterface[]
type LanguageInterface = 'zh-CN' | 'en';
interface WebConfigInterface {
web_name?: string,
web_logo?: string,
web_icp?: string,
web_mps?: string,
web_mps_text?: string,
web_title?: string,
version_name?: string,
version?: number,
copyright?: string,
[propName: string]: any
}
interface WalletInterface {
uid: string,
channels_uid: number,
balance: number,
balance_sum: number,
balance_used: number,
points: number,
points_sum: number,
points_used: number,
tmp_points: number,
tmp_points_sum: number,
tmp_points_used: number,
available_points: number
}
interface StateInterface {
AsideState: boolean
NotMenusAsideState: boolean
language: LanguageInterface
InputFocusState: boolean
}
interface LanguageListInterface {
label: string,
value: LanguageInterface
}
interface UserInfoInterface {
nickname: string,
token: string,
[propName: string]: any
}
interface ModelInterface {
creative_script: any
creative_episode: any
creative_scenes: any
creative_storyboards: any
drama_cover: any
scene_image: any
actor_image: any
actor_three_view_image: any
storyboard_image: any
character_look_costume: any
actor_costume: any
actor_costume_three_view: any
prop_image: any
prop_three_view_image: any
storyboard_video: any
dialogue_voice: any
storyboard_narration_voice: any
storyboard_sfx_voice: any
storyboard_music_voice: any
[propName: string]: any
}
interface TrackResourceInterface {
id: string;
duration: number;
narration: string;
use_material_type: string;
video: string;
image: string;
dialogue_audio: string[];
narration_audio: string;
sfx_audio: string;
dialogues: any[];
}
interface TrackInterface {
video: string[];
video_audio: string[];
dialogue_audio: string[];
narration_audio: string[];
sfx_audio: string[];
}
interface TimeTask {
nextTime: number; // 下一次触发时间(绝对时间)
cancelled: boolean;
run(now: number): void;
}
}
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { useModelStore, useRefs, useStateStore, useUserStore, useWebConfigStore, useWalletStore } from '@/stores';
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import en from 'element-plus/es/locale/lang/en';
import { useI18n } from 'vue-i18n';
import { Push } from '@/common/push';
import { $http } from './common/http';
import { ResponseCode } from './common/const';
const { locale } = useI18n();
const stateStore = useStateStore();
const { STATE } = useRefs(stateStore);
const language = ref(zhCn);
watch(() => STATE.value.language, (newLanguage: LanguageInterface) => {
locale.value = newLanguage;
document.documentElement.setAttribute('lang', newLanguage);
switch (newLanguage) {
case 'zh-CN':
language.value = zhCn;
break;
case 'en':
language.value = en;
break;
}
});
const userStore = useUserStore();
userStore.userListener();
userStore.initUserInfo();
const walletStore = useWalletStore();
walletStore.initWallet();
const webConfigStore = useWebConfigStore();
const { WEBCONFIG } = useRefs(webConfigStore);
webConfigStore.initWebConfig();
const modelStore = useModelStore();
modelStore.initModel();
const { appContext } = getCurrentInstance()!
const global = appContext.config.globalProperties
const createPush = () => {
if (WEBCONFIG.value.push && !global.$push) {
const host = location.host;
// 建立连接
global.$push = new Push({
url: host === 'short-play-vue.yc.com' ? 'wss://' + host : WEBCONFIG.value.push.url, // websocket地址
app_key: WEBCONFIG.value.push.app_key,
auth: WEBCONFIG.value.push.auth // 订阅鉴权(仅限于私有频道)
});
}
}
const getUserInfo = () => {
return new Promise((resolve, reject) => {
$http.get('/app/user/api/User/info').then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
walletStore.getWallet();
const userStore = useUserStore();
userStore.setUserInfo(res.data as UserInfoInterface);
resolve(res.data);
} else {
reject();
}
}).catch(() => {
reject();
});
})
}
if (userStore.hasLogin()) {
getUserInfo()
}
const getModelsList = () => {
$http.get('/app/model/api/Model/models').then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
modelStore.setModel(res.data);
}
})
}
const getWebConfig = () => {
$http.get('/app/control/api/Public/config').then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
webConfigStore.setWebConfig(res.data as WebConfigInterface);
}
})
}
onMounted(() => {
getModelsList();
getWebConfig();
try {
createPush()
watch(() => WEBCONFIG.value.push, () => {
createPush()
})
} catch (error) {
}
})
</script>
<template>
<el-config-provider :locale="language">
<router-view />
</el-config-provider>
</template>
<style scoped lang="scss"></style>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
html[data-theme="dark"]{color-scheme:dark;--el-color-primary: #006EFF;--el-color-primary-light-3: rgb(6, 83, 185);--el-color-primary-light-5: #2a598a;--el-color-primary-light-7: #213d5b;--el-color-primary-light-8: #1d3043;--el-color-primary-light-9: #18222c;--el-color-primary-dark-2: rgb(51, 139, 255);--el-color-success: #0DF283;--el-color-success-light-3: rgb(25, 150, 75);--el-color-success-light-5: #3e6b27;--el-color-success-light-7: #2d481f;--el-color-success-light-8: #25371c;--el-color-success-light-9: #1c2518;--el-color-success-dark-2: rgb(73, 215, 130);--el-color-warning: #FBAC15;--el-color-warning-light-3: rgb(182, 126, 21);--el-color-warning-light-5: #7d5b28;--el-color-warning-light-7: #533f20;--el-color-warning-light-8: #3e301c;--el-color-warning-light-9: #292218;--el-color-warning-dark-2: rgb(252, 189, 68);--el-color-danger: #E4221C;--el-color-danger-light-3: rgb(166, 30, 26);--el-color-danger-light-5: #854040;--el-color-danger-light-7: #582e2e;--el-color-danger-light-8: #412626;--el-color-danger-light-9: #2b1d1d;--el-color-danger-dark-2: rgb(233, 78, 73);--el-color-error: #E4221C;--el-color-error-light-3: rgb(166, 30, 26);--el-color-error-light-5: #854040;--el-color-error-light-7: #582e2e;--el-color-error-light-8: #412626;--el-color-error-light-9: #2b1d1d;--el-color-error-dark-2: rgb(233, 78, 73);--el-color-info: #909399;--el-color-info-light-3: #6b6d71;--el-color-info-light-5: #525457;--el-color-info-light-7: #393a3c;--el-color-info-light-8: #2d2d2f;--el-color-info-light-9: #202121;--el-color-info-dark-2: #a6a9ad;--el-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, .36), 0px 8px 20px rgba(0, 0, 0, .72);--el-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, .72);--el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, .72);--el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, .72), 0px 12px 32px #000000, 0px 8px 16px -8px #000000;--el-bg-color-page: #1E1E1E;--el-bg-color: #0C0C0D;--el-bg-color-rgb-r: 10;--el-bg-color-rgb-g: 10;--el-bg-color-rgb-b: 10;--el-bg-color-overlay: #171717;--el-text-color-primary: #E5EAF3;--el-text-color-regular: #CFD3DC;--el-text-color-secondary: #A3A6AD;--el-text-color-placeholder: #8D9095;--el-text-color-disabled: #6C6E72;--el-border-color-darker: #636466;--el-border-color-dark: #58585B;--el-border-color: #272727;--el-border-color-light: #414243;--el-border-color-lighter: #363637;--el-border-color-extra-light: #2B2B2C;--el-fill-color-darker: #424243;--el-fill-color-dark: #39393A;--el-fill-color: #303030;--el-fill-color-light: #262727;--el-fill-color-lighter: #1D1D1D;--el-fill-color-extra-light: #191919;--el-fill-color-blank: transparent;--el-mask-color: rgba(0, 0, 0, .8);--el-mask-color-extra-light: rgba(0, 0, 0, .3)}html[data-theme="dark"] .el-button{--el-button-disabled-text-color: rgba(255, 255, 255, .5)
}html[data-theme="dark"] .el-card{--el-card-bg-color: var(--el-bg-color-overlay)
}html[data-theme="dark"] .el-empty{--el-empty-fill-color-0: var(--el-color-black);--el-empty-fill-color-1: #4b4b52;--el-empty-fill-color-2: #36383d;--el-empty-fill-color-3: #1e1e20;--el-empty-fill-color-4: #262629;--el-empty-fill-color-5: #202124;--el-empty-fill-color-6: #212224;--el-empty-fill-color-7: #1b1c1f;--el-empty-fill-color-8: #1c1d1f;--el-empty-fill-color-9: #18181a}html[data-theme="light"]{--el-bg-color-rgb-r: 255;--el-bg-color-rgb-g: 255;--el-bg-color-rgb-b: 255;--el-color-primary: #006EFF;--el-color-primary-light-3: rgb(77, 154, 255);--el-color-primary-dark-2: rgb(4, 92, 208);--el-color-success: #1BCD63;--el-color-success-light-3: rgb(95, 220, 146);--el-color-success-dark-2: rgb(26, 168, 83);--el-color-warning: #FBAC15;--el-color-warning-light-3: rgb(252, 197, 91);--el-color-warning-dark-2: rgb(205, 142, 21);--el-color-danger: #E4221C;--el-color-danger-light-3: rgb(236, 100, 96);--el-color-danger-dark-2: rgb(186, 31, 26);--el-color-error: #E4221C;--el-color-error-light-3: rgb(236, 100, 96);--el-color-error-dark-2: rgb(186, 31, 26)}
/*# sourceMappingURL=theme.min.css.map */
@@ -0,0 +1,9 @@
{
"version": 3,
"mappings": "AAAA,AAAA,IAAI,CAAA,AAAA,UAAC,CAAW,MAAM,AAAjB,CAAmB,CACvB,YAAY,CAAE,IAAI,CAClB,kBAAkB,CAAA,QAAC,CACnB,0BAA0B,CAAA,gBAAC,CAC3B,0BAA0B,CAAA,QAAC,CAC3B,0BAA0B,CAAA,QAAC,CAC3B,0BAA0B,CAAA,QAAC,CAC3B,0BAA0B,CAAA,QAAC,CAC3B,yBAAyB,CAAA,kBAAC,CAC1B,kBAAkB,CAAA,QAAC,CACnB,0BAA0B,CAAA,iBAAC,CAC3B,0BAA0B,CAAA,QAAC,CAC3B,0BAA0B,CAAA,QAAC,CAC3B,0BAA0B,CAAA,QAAC,CAC3B,0BAA0B,CAAA,QAAC,CAC3B,yBAAyB,CAAA,kBAAC,CAC1B,kBAAkB,CAAA,QAAC,CACnB,0BAA0B,CAAA,kBAAC,CAC3B,0BAA0B,CAAA,QAAC,CAC3B,0BAA0B,CAAA,QAAC,CAC3B,0BAA0B,CAAA,QAAC,CAC3B,0BAA0B,CAAA,QAAC,CAC3B,yBAAyB,CAAA,kBAAC,CAC1B,iBAAiB,CAAA,QAAC,CAClB,yBAAyB,CAAA,iBAAC,CAC1B,yBAAyB,CAAA,QAAC,CAC1B,yBAAyB,CAAA,QAAC,CAC1B,yBAAyB,CAAA,QAAC,CAC1B,yBAAyB,CAAA,QAAC,CAC1B,wBAAwB,CAAA,iBAAC,CACzB,gBAAgB,CAAA,QAAC,CACjB,wBAAwB,CAAA,iBAAC,CACzB,wBAAwB,CAAA,QAAC,CACzB,wBAAwB,CAAA,QAAC,CACzB,wBAAwB,CAAA,QAAC,CACzB,wBAAwB,CAAA,QAAC,CACzB,uBAAuB,CAAA,iBAAC,CACxB,eAAe,CAAA,QAAC,CAChB,uBAAuB,CAAA,QAAC,CACxB,uBAAuB,CAAA,QAAC,CACxB,uBAAuB,CAAA,QAAC,CACxB,uBAAuB,CAAA,QAAC,CACxB,uBAAuB,CAAA,QAAC,CACxB,sBAAsB,CAAA,QAAC,CACvB,eAAe,CAAA,sEAAC,CAChB,qBAAqB,CAAA,gCAAC,CACtB,uBAAuB,CAAA,+BAAC,CACxB,oBAAoB,CAAA,wFAAC,CACrB,kBAAkB,CAAA,QAAC,CACnB,aAAa,CAAA,QAAC,CACd,mBAAmB,CAAA,GAAC,CACpB,mBAAmB,CAAA,GAAC,CACpB,mBAAmB,CAAA,GAAC,CACpB,qBAAqB,CAAA,QAAC,CACtB,uBAAuB,CAAA,QAAC,CACxB,uBAAuB,CAAA,QAAC,CACxB,yBAAyB,CAAA,QAAC,CAC1B,2BAA2B,CAAA,QAAC,CAC5B,wBAAwB,CAAA,QAAC,CACzB,wBAAwB,CAAA,QAAC,CACzB,sBAAsB,CAAA,QAAC,CACvB,iBAAiB,CAAA,QAAC,CAClB,uBAAuB,CAAA,QAAC,CACxB,yBAAyB,CAAA,QAAC,CAC1B,6BAA6B,CAAA,QAAC,CAC9B,sBAAsB,CAAA,QAAC,CACvB,oBAAoB,CAAA,QAAC,CACrB,eAAe,CAAA,QAAC,CAChB,qBAAqB,CAAA,QAAC,CACtB,uBAAuB,CAAA,QAAC,CACxB,2BAA2B,CAAA,QAAC,CAC5B,qBAAqB,CAAA,YAAC,CACtB,eAAe,CAAA,kBAAC,CAChB,2BAA2B,CAAA,kBAAC,CAC5B,AAED,AAAA,IAAI,CAAA,AAAA,UAAC,CAAW,MAAM,AAAjB,EAAmB,UAAU,AAAC,CAClC,+BAA+B,CAAA;AAAC,CAChC,AAED,AAAA,IAAI,CAAA,AAAA,UAAC,CAAW,MAAM,AAAjB,EAAmB,QAAQ,AAAC,CAChC,kBAAkB,CAAA;AAAC,CACnB,AAED,AAAA,IAAI,CAAA,AAAA,UAAC,CAAW,MAAM,AAAjB,EAAmB,SAAS,AAAC,CACjC,uBAAuB,CAAA,sBAAC,CACxB,uBAAuB,CAAA,QAAC,CACxB,uBAAuB,CAAA,QAAC,CACxB,uBAAuB,CAAA,QAAC,CACxB,uBAAuB,CAAA,QAAC,CACxB,uBAAuB,CAAA,QAAC,CACxB,uBAAuB,CAAA,QAAC,CACxB,uBAAuB,CAAA,QAAC,CACxB,uBAAuB,CAAA,QAAC,CACxB,uBAAuB,CAAA,QAAC,CACxB,AAED,AAAA,IAAI,CAAA,AAAA,UAAC,CAAW,OAAO,AAAlB,CAAoB,CACxB,mBAAmB,CAAA,IAAC,CACpB,mBAAmB,CAAA,IAAC,CACpB,mBAAmB,CAAA,IAAC,CACpB,kBAAkB,CAAA,QAAC,CACnB,0BAA0B,CAAA,kBAAC,CAC3B,yBAAyB,CAAA,gBAAC,CAC1B,kBAAkB,CAAA,QAAC,CACnB,0BAA0B,CAAA,kBAAC,CAC3B,yBAAyB,CAAA,iBAAC,CAC1B,kBAAkB,CAAA,QAAC,CACnB,0BAA0B,CAAA,kBAAC,CAC3B,yBAAyB,CAAA,kBAAC,CAC1B,iBAAiB,CAAA,QAAC,CAClB,yBAAyB,CAAA,kBAAC,CAC1B,wBAAwB,CAAA,iBAAC,CACzB,gBAAgB,CAAA,QAAC,CACjB,wBAAwB,CAAA,kBAAC,CACzB,uBAAuB,CAAA,iBAAC,CACxB",
"sources": [
"../../../scss/theme.scss"
],
"names": [],
"file": "theme.min.css"
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

@@ -0,0 +1,122 @@
type FormRuleOption = {
value: string | number | boolean
base_point?: number
multiplier?: number
}
type FormRule = {
component: 'select' | 'radio' | 'checkbox' | 'switch' | string
helper_field: string
base_point?: number
multiplier?: number
options?: FormRuleOption[]
}
type FormData = Record<string, any>
export class PricingCalculator {
protected formRules: FormRule[]
protected formData: FormData
protected baseCost: number // 系统级基础积分
protected extraPoint = 0 // 规则附加积分
protected multiplier = 1.0
protected chargeableComponents = [
'select',
'radio',
'checkbox',
'switch',
]
constructor(formRules: FormRule[], formData: FormData, baseCost: number) {
this.formRules = formRules
this.formData = formData
this.baseCost = baseCost
}
/**
*
*/
calculate(): number {
for (const rule of this.formRules) {
if (!this.chargeableComponents.includes(rule.component)) {
continue
}
const value = this.getSubmittedValue(rule)
if (value === null || value === undefined) {
continue
}
// Field 级计费(switch
this.applyFieldPricing(rule, value)
// Option 级计费
if (rule.options && rule.options.length > 0) {
this.applyOptionPricing(rule, value)
}
}
return Math.ceil(this.baseCost * this.multiplier + this.extraPoint)
}
protected getSubmittedValue(rule: FormRule): any {
const field = rule.helper_field
if (field in this.formData) {
return this.formData[field]
}
return null
}
/**
* Field switch
*/
protected applyFieldPricing(rule: FormRule, value: any): void {
if (rule.component === 'switch' && value !== true) {
return
}
if (rule.base_point !== undefined && Number(rule.base_point)) {
this.extraPoint += Number(rule.base_point)
}
if (rule.multiplier !== undefined && Number(rule.multiplier)) {
this.multiplier *= Number(rule.multiplier)
}
}
/**
* Option
*/
protected applyOptionPricing(rule: FormRule, value: any): void {
for (const option of rule.options!) {
if (!this.optionMatched(rule.component, option, value)) {
continue
}
if (option.base_point !== undefined) {
if (typeof value === 'number') {
this.extraPoint += Number(option.base_point) * value
} else {
this.extraPoint += Number(option.base_point)
}
}
if (option.multiplier !== undefined) {
this.multiplier = this.multiplier * Number(option.multiplier)
console.log(this.multiplier, option.multiplier);
}
}
}
protected optionMatched(
component: string,
option: FormRuleOption,
value: any
): boolean {
if (component === 'checkbox') {
return Array.isArray(value) && value.includes(option.value)
}
return String(option.value) === String(value)
}
}
@@ -0,0 +1,353 @@
import {
Combinator,
AudioClip,
EmbedSubtitlesClip,
ImgClip,
MP4Clip,
renderTxt2ImgBitmap,
OffscreenSprite,
} from '@webav/av-cliper';
type ClipType = 'video' | 'audio' | 'image' | 'text';
/**
* SRT (HH:MM:SS,mmm)
*/
function formatSrtTime(us: number): string {
const totalMs = Math.floor(us / 1000);
const hours = Math.floor(totalMs / 3600000);
const minutes = Math.floor((totalMs % 3600000) / 60000);
const seconds = Math.floor((totalMs % 60000) / 1000);
const milliseconds = totalMs % 1000;
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')},${String(milliseconds).padStart(3, '0')}`;
}
/**
* SRT
* @param text
* @param startTimeUs
* @param endTimeUs
* @param index 1
* @returns SRT
*/
export function textToSrt(
text: string,
startTimeUs: number,
endTimeUs: number,
index = 1,
): string {
return `${index}\n${formatSrtTime(startTimeUs)} --> ${formatSrtTime(endTimeUs)}\n${text}\n\n`;
}
export interface SubtitleStyle {
color?: string;
textBgColor?: string | null;
type?: 'srt';
fontFamily?: string;
fontSize?: number;
letterSpacing?: string | null;
bottomOffset?: number;
strokeStyle?: string;
lineWidth?: number | null;
lineCap?: CanvasLineCap | null;
lineJoin?: CanvasLineJoin | null;
textShadow?: {
offsetX: number;
offsetY: number;
blur: number;
color: string;
};
videoWidth: number;
videoHeight: number;
fontWeight?: string | number;
fontStyle?: 'normal' | 'italic';
}
export interface ClipOptions {
type: ClipType;
stream?: ReadableStream|ImageBitmap;
text?: string;
style?: string;
// 字幕相关配置
subtitleSrt?: string; // SRT 格式的字幕内容
subtitleStyle?: SubtitleStyle; // 字幕样式配置
durationUs?: number; // 可选,默认用素材本身长度
trackId?: string | number; // 轨道ID,默认为 'main'(主轨道)
offsetUs?: number; // 插入时间(微秒),非主轨道可以使用此参数指定插入时间
}
export class ClipExporter {
private combinator: Combinator;
private tracks: any;
private width: number;
private height: number;
private durationUs: number = 0;
private cancelRef: boolean = false;
// 保存所有创建的资源,以便在取消时释放
private clips: Array<MP4Clip | ImgClip | AudioClip | EmbedSubtitlesClip> = [];
private sprites: OffscreenSprite[] = [];
constructor(width = 1280, height = 720, bgColor = '#000') {
this.width = width;
this.height = height;
this.combinator = new Combinator({
width,
height,
bgColor,
});
}
setTracks(tracks: any) {
this.tracks = tracks;
return this;
}
async initializationDialogues() {
if (this.cancelRef) {
return;
}
this.events['dialogues']?.(0);
const dialogueSubtitles: any[] = [];
const dialoguesLength = this.tracks.dialogues.length;
for (let i = 0; i < dialoguesLength; i++) {
if (this.cancelRef) {
return;
}
const track = this.tracks.dialogues[i];
dialogueSubtitles.push({
text: track.text,
start: track.startTimeUs,
end: track.endTimeUs,
});
}
if (this.cancelRef) {
return;
}
const clip = new EmbedSubtitlesClip(dialogueSubtitles, {
videoWidth: this.width,
videoHeight: this.height,
fontSize: 24,
fontFamily: 'Noto Sans SC',
strokeStyle: '#000',
lineWidth: 2,
lineJoin: 'round',
lineCap: 'round',
bottomOffset: 100,
});
this.clips.push(clip);
const spr = new OffscreenSprite(clip);
spr.time.offset = 0;
spr.time.duration = this.durationUs;
this.sprites.push(spr);
if (this.cancelRef) {
return;
}
await this.combinator.addSprite(spr);
this.events['dialogues']?.(100);
}
private narrationStyle = `
font-size: 20px;
font-weight:600;
color: white;
-webkit-text-stroke: 1px #000;
writing-mode: vertical-lr;
text-orientation: upright;
white-space:wrap;
padding:10px;`;
async initializationNarrations() {
if (this.cancelRef) {
return;
}
this.events['narrations']?.(0);
this.narrationStyle = `
height:${this.height / 2}px;
${this.narrationStyle}`;
const narrationsLength = this.tracks.narrations.length;
for (let i = 0; i < narrationsLength; i++) {
if (this.cancelRef) {
return;
}
const track = this.tracks.narrations[i];
const clip = new ImgClip(
await renderTxt2ImgBitmap(
track.text,
this.narrationStyle,
),
);
this.clips.push(clip);
await clip.ready;
if (this.cancelRef) {
return;
}
const spr = new OffscreenSprite(clip);
spr.time.offset = track.startTimeUs;
spr.time.duration = track.endTimeUs - track.startTimeUs;
this.sprites.push(spr);
if (this.cancelRef) {
return;
}
await this.combinator.addSprite(spr);
this.events['narrations']?.(i / narrationsLength * 100);
}
this.events['narrations']?.(100);
}
private events: any = {
video: () => { },
dialogues: () => { },
dialogues_audio: () => { },
narrations: () => { },
narrations_audio: () => { },
};
on(event: keyof typeof this.events, callback: (data: any) => void) {
this.events[event] = callback;
}
off(event: keyof typeof this.events) {
delete this.events[event];
}
async initialization() {
if (this.cancelRef) {
return;
}
let spr: OffscreenSprite;
let clip: MP4Clip | ImgClip | AudioClip;
this.events['video']?.(0);
const videoLength = this.tracks.video.length;
for (let i = 0; i < videoLength; i++) {
if (this.cancelRef) {
return;
}
const track = this.tracks.video[i];
if (track.type === 'video') {
clip = new MP4Clip(track.stream);
this.clips.push(clip);
spr = new OffscreenSprite(clip);
spr.time.offset = track.offsetUs;
spr.time.duration = track.durationUs;
} else {
clip = new ImgClip(track.stream);
this.clips.push(clip);
spr = new OffscreenSprite(clip);
spr.time.offset = track.offsetUs;
spr.time.duration = track.durationUs;
}
this.sprites.push(spr);
this.durationUs += track.durationUs;
if (this.cancelRef) {
return;
}
await this.combinator.addSprite(spr);
this.events['video']?.(i / videoLength * 100);
}
if (this.cancelRef) {
return;
}
this.events['dialogues_audio']?.(0);
const dialoguesAudioLength = this.tracks.dialogues_audio.length;
for (let i = 0; i < dialoguesAudioLength; i++) {
if (this.cancelRef) {
return;
}
const track = this.tracks.dialogues_audio[i];
clip = new AudioClip(track.stream);
this.clips.push(clip);
await clip.ready;
if (this.cancelRef) {
return;
}
spr = new OffscreenSprite(clip);
spr.time.offset = track.offsetUs;
spr.time.duration = clip.meta.duration;
this.sprites.push(spr);
if (this.cancelRef) {
return;
}
await this.combinator.addSprite(spr);
this.events['dialogues_audio']?.(i / dialoguesAudioLength * 100);
}
if (this.cancelRef) {
return;
}
this.events['narrations_audio']?.(0);
const narrationsAudioLength = this.tracks.narrations_audio.length;
for (let i = 0; i < narrationsAudioLength; i++) {
if (this.cancelRef) {
return;
}
const track = this.tracks.narrations_audio[i];
clip = new AudioClip(track.stream);
this.clips.push(clip);
await clip.ready;
if (this.cancelRef) {
return;
}
spr = new OffscreenSprite(clip);
spr.time.offset = track.offsetUs;
spr.time.duration = clip.meta.duration;
this.sprites.push(spr);
if (this.cancelRef) {
return;
}
await this.combinator.addSprite(spr);
this.events['narrations_audio']?.(i / narrationsAudioLength * 100);
}
if (this.cancelRef) {
return;
}
await this.initializationDialogues();
if (this.cancelRef) {
return;
}
await this.initializationNarrations();
if (this.cancelRef) {
return;
}
}
OutputProgress(callback: (data: any) => void) {
this.combinator.on('OutputProgress', (progress: number) => {
callback(progress * 100);
});
}
async export(writer: WritableStream) {
if(this.cancelRef){
return;
}
await this.combinator.output().pipeTo(writer);
}
destroy() {
// 释放所有 clips
this.clips.forEach(clip => {
try {
if (clip && typeof (clip as any).destroy === 'function') {
(clip as any).destroy();
}
} catch (error) {
console.warn('销毁 clip 时出错:', error);
}
});
this.clips = [];
// 释放所有 sprites
this.sprites.forEach(sprite => {
try {
if (sprite && typeof sprite.destroy === 'function') {
sprite.destroy();
}
} catch (error) {
console.warn('销毁 sprite 时出错:', error);
}
});
this.sprites = [];
// 销毁 combinator
if (this.combinator) {
try {
this.combinator.destroy();
} catch (error) {
console.warn('销毁 combinator 时出错:', error);
}
}
}
cancel() {
this.cancelRef = true;
// 取消时立即释放所有资源
this.destroy();
}
}
@@ -0,0 +1,71 @@
import type { IClip } from '@webav/av-cliper';
import { ImgClip } from '@webav/av-cliper';
export class ImageDurationClip implements IClip {
private img: ImgClip;
private durationMs: number;
ready: IClip['ready'];
constructor(img: ImgClip, durationMs: number) {
this.img = img;
this.durationMs = durationMs;
this.ready = img.ready;
}
get meta() {
const meta = this.img.meta;
return {
width: meta.width,
height: meta.height,
displayWidth: meta.width,
codedWidth: meta.width,
displayHeight: meta.height,
codedHeight: meta.height,
duration: this.durationMs,
};
}
async tick(time: number): Promise<Awaited<ReturnType<IClip['tick']>>> {
if (time >= this.durationMs) {
return {
state: 'done',
};
}
const ret = await this.img.tick(time);
return {
...ret,
state: 'success',
};
}
async split(time: number): Promise<[this, this]> {
if (time <= 0 || time >= this.durationMs) {
throw new Error('split time out of range');
}
const left = new ImageDurationClip(
await this.img.clone(),
time,
) as this;
const right = new ImageDurationClip(
await this.img.clone(),
this.durationMs - time,
) as this;
return [left, right];
}
async clone(): Promise<this> {
return new ImageDurationClip(
await this.img.clone(),
this.durationMs,
) as this;
}
destroy() {
this.img.destroy();
}
}
+19
View File
@@ -0,0 +1,19 @@
export const ResponseCode = {
SUCCESS: 200,
WAIT: 202,
SUCCESS_EVENT_PUSH: 201,
FAIL: 404,
NEED_LOGIN: 12000,
NEED_TWOFA: 12001,
CAPTCHA: 300,
SITE_ERROR: 400,
NEED_PAY: 9100,
PAY_SUCCESS: 9000,
PAY_FAIL: 9010,
REDIRECT: 302,
REDIRECT_CONFIRM: 303,
OPEN_NEW_WINDOW:304,
OPEN_NEW_WINDOW_CONFIRM:305,
NO_PERMISSION: 403,
LOCK: 423,
} as const
+525
View File
@@ -0,0 +1,525 @@
import { ElMessage } from "element-plus";
import { useWebConfigStore } from "@/stores";
import { i18n } from '@/locale';
const { t } = i18n.global;
/**
*
* @param func
* @param wait
* @returns function
*/
export function throttle<F extends (...args: any[]) => void>(func: F, wait: number): F {
let timer: ReturnType<typeof setTimeout> | undefined;
return function (this: ThisParameterType<F>, ...args: Parameters<F>) {
if (!timer) {
timer = setTimeout(() => {
timer = undefined;
func.apply(this, args);
}, wait);
}
} as F;
}
/**
*
* @param func
* @param wait
* @returns function
*/
export function debounce<F extends (...args: any[]) => void>(func: F, wait: number): F {
let timer: ReturnType<typeof setTimeout> | undefined;
return function (this: ThisParameterType<F>, ...args: Parameters<F>) {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
timer = undefined;
func.apply(this, args);
}, wait);
} as F;
}
/**
*
* @param func
* @param wait
*/
export function onScroll<F extends (...args: any[]) => void>(func: F, wait: number = 0): void {
let event = func;
if (wait > 0) {
event = debounce(func, wait);
}
const eventScroll = () => {
const scrollTop = globalThis.pageYOffset || document.documentElement.scrollTop;
event(scrollTop);
}
onMounted(() => {
globalThis.addEventListener('scroll', eventScroll);
});
onUnmounted(() => {
globalThis.removeEventListener('scroll', eventScroll);
});
}
/**
*
* @param func
* @param wait
*/
export function onWindowResize<F extends (...args: any[]) => void>(func: F, wait: number = 0): void {
let event = func;
if (wait > 0) {
event = debounce(func, wait);
}
const eventResize = () => {
event({ w: globalThis.innerWidth, h: globalThis.innerHeight });
}
onMounted(() => {
globalThis.addEventListener('resize', eventResize);
});
onUnmounted(() => {
globalThis.removeEventListener('resize', eventResize);
});
}
/**
*
* @param el
* @param func
*/
export function onObserve<F extends (...args: any[]) => void>(el: any, func: F): void {
const done = (target: Element) => {
observer.unobserve(target);
}
const observer = new IntersectionObserver((e) => {
func(e, done);
});
onMounted(() => {
el.value && observer.observe(el.value);
});
onUnmounted(() => {
el.value && done(el.value);
});
}
/**
* storage变化
* @param key key
* @param func
* @returns F()
* @example onStoreageChange('key',()=>{})
**/
export function onStoreageChange<F extends (...args: any[]) => void>(key: string, func: F): F {
const event = (e: StorageEvent): any => {
if (e.key === null) {
func();
} else if (e.key === key) {
func(e);
}
}
globalThis.addEventListener('storage', event);
return function () {
globalThis.removeEventListener('storage', event);
} as F
}
/**
*
* @param time
* @returns string
*/
export function timetostr(time: number | undefined): string {
let str = '';
if (!time) { return str }
let d = 0, h = 0, m = 0, s = 0;
if (time >= 3600 * 24) {
d = Math.floor(time / (3600 * 24));
time = time % (3600 * 24);
}
if (time >= 3600) {
h = Math.floor(time / (60 * 60));
time = time % (60 * 60);
}
if (time >= 60) {
m = Math.floor(time / 60);
}
s = time % 60;
if (d > 0) {
str = d + t('date.day');
}
if (d > 0 || h > 0) {
str += h + t('date.hour');
}
if (d > 0 || h > 0 || m > 0) {
str += m + t('date.minute');
}
if (d > 0 || h > 0 || m > 0 || s > 0) {
str += s + t('date.second');
}
return str
}
const copyText = (text: string) => {
return new Promise<void>((resolve, reject) => {
let input = document.createElement('input');
input.value = text;
document.body.appendChild(input);
input.select();
input.setSelectionRange(0, input.value.length);
if (document.execCommand('Copy')) {
resolve();
} else {
reject();
}
input.remove();
})
}
/**
*
* @param text
* @returns void
*/
export function setClipboard(text: string): void {
if (navigator.clipboard && globalThis.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success(t('message.copySuccess'));
}).catch(() => {
copyText(text).then(() => {
ElMessage.success(t('message.copySuccess'));
}).catch(() => {
ElMessage.error(t('message.copyFail'));
});
});
} else {
copyText(text).then(() => {
ElMessage.success(t('message.copySuccess'));
}).catch(() => {
ElMessage.error(t('message.copyFail'));
});
}
}
/**
*
* @param num
* @returns string
* @example 10000 => 10,000
* @example 10000.123 => 10,000.123
* @example 10000.123456 => 10,000.123456
*/
export function toThousands(num: number | string): string {
let result = '', counter = 0;
num = (num || 0).toString();
for (let i = num.length - 1; i >= 0; i--) {
counter++;
result = num.charAt(i) + result;
if (!(counter % 3) && i !== 0) { result = ',' + result; }
}
return result;
}
export function getRandomNumber(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1) + min);
}
export function openWin(url: string) {
globalThis.open(url);
}
/**
* #
* @param key
* @param value
* @returns void
* @example replaceUrlParam('key','value')
*/
export function replaceUrlParam(key: string, value: string): void {
const url = new URL(globalThis.location.href);
url.searchParams.set(key, value);
globalThis.history.replaceState({}, '', url.href);
}
/**
* #
* @param params
* @returns void
* @example replaceUrlParams({key1:'value1',key2:'value2'})
*/
export function replaceUrlParams(params: Record<string, string>): void {
const url = new URL(globalThis.location.href);
for (const key in params) {
url.searchParams.set(key, params[key]);
}
globalThis.history.replaceState({}, '', url.href);
}
/**
*
* @param title
* @returns void
* @example setDocumentTitle('标题')
*/
export function setDocumentTitle(title: string): void {
const { WEBCONFIG } = useWebConfigStore();
let webName = WEBCONFIG.web_title;
let subTitle = '';
if (webName) {
subTitle = ` - ${webName}`;
}
globalThis.document.title = `${title}${subTitle}`
}
/**
*
* @param arr
* @param fn
* @returns {clear:()=>void}
* @example timeChunk([1,2,3,4,5,6,7,8,9,10],(item)=>{console.log(item)})
*/
export function timeChunk<T>(arr: T[], fn: (item: T) => void): { clear: () => void } {
let isBreak = false;
const clear = () => {
isBreak = true;
}
let i = 0;
function _run() {
globalThis.requestIdleCallback((idle) => {
while (idle.timeRemaining() > 0 && i < arr.length) {
fn(arr[i++]);
}
if (i < arr.length && isBreak === false) {
_run();
}
})
}
if (arr.length > 0) _run();
return {
clear
}
}
/**
*
* @param num
* @returns string
* @example toChineseNumeral(10000) =>
* @example toChineseNumeral(10000.123) =>
*/
export function toChineseNumeral(num: number): string {
if (!num) {
return '零元整';
}
const fraction = ['角', '分', '厘'];
const digit = [
'零', '壹', '贰', '叁', '肆',
'伍', '陆', '柒', '捌', '玖'
];
const unit = [
['元', '万 ', '亿 '],
['', '拾', '佰', '仟']
];
let head = num < 0 ? '欠' : '';
num = Math.abs(num);
let s = '';
for (let i = 0; i < fraction.length; i++) {
s += (digit[Math.floor(num * 10 * Math.pow(10, i)) % 10] + fraction[i]).replace(/零./, '');
}
num = Math.floor(num);
for (let i = 0; i < unit[0].length && num > 0; i++) {
let p = '';
for (let j = 0; j < unit[1].length && num > 0; j++) {
p = digit[num % 10] + unit[1][j] + p;
num = Math.floor(num / 10);
}
s = p.replace(/(零.)*零$/, '').replace(/^$/, '零') + unit[0][i] + s;
}
return head + s.replace(/(零.)*零元/, '元')
.replace(/(零.)+/g, '零')
.replace(/^整$/, '零元整');
}
export const getRoundImage = () => {
return new Promise((resolve, reject) => {
try {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://picsum.photos/1920/1080?random', true);
xhr.responseType = 'blob';
xhr.onload = function () {
if (this.status === 200) {
const blob = this.response;
return resolve({ blob: URL.createObjectURL(blob) });
}
reject();
}
xhr.send();
} catch (e) {
reject();
}
});
}
export const hasWhere = (extra: any, form: any): boolean => {
if (!extra?.where) return true;
// 把每一种比较集中到一个函数里,便于复用
const match = (field: string, exp: string, value: any): boolean => {
const formValue = getTableValue(form, field);
if (Array.isArray(formValue) && Array.isArray(value)) {
switch (exp) {
case '=': return value.every(item => formValue.includes(item));
case '!=': return !value.every(item => formValue.includes(item));
case 'in': return value.some(item => formValue.includes(item));
case 'not in': return !value.some(item => formValue.includes(item));
default: return false;
}
} else if (Array.isArray(formValue)) {
switch (exp) {
case 'in': return formValue.includes(value);
case 'not in': return !formValue.includes(value);
default: return false;
}
} else {
switch (exp) {
case '=': return formValue === value;
case '!=': return formValue !== value;
case '>': return formValue > value;
case '>=': return formValue >= value;
case '<': return formValue < value;
case '<=': return formValue <= value;
case 'in': return Array.isArray(value) && value.includes(formValue);
case 'not in': return Array.isArray(value) && !value.includes(formValue);
case 'like': return typeof formValue === 'string' && formValue.includes(value);
case 'not like': return typeof formValue === 'string' && !formValue.includes(value);
case 'between': return formValue >= value[0] && formValue <= value[1];
case 'not between': return formValue < value[0] || formValue > value[1];
case 'null': return formValue === null;
case 'not null': return formValue !== null;
default: return false;
}
}
};
for (const condition of extra.where) {
const [field] = condition;
// --- AND 情况(field 是字符串) ---
if (typeof field === 'string') {
if (!match(field, condition[1], condition[2])) return false;
continue;
}
// --- OR 情况(field 不是字符串)---
// condition 形如:[[f1, op1, v1], [f2, op2, v2], ...]
let anyPassed = false;
for (const [subField, subExp, subVal] of condition as Array<[string, string, any]>) {
if (match(subField, subExp, subVal)) {
anyPassed = true;
break;
}
}
if (!anyPassed) return false; // 整组 OR 都未命中
}
return true; // 所有 AND/OR 条件全部满足
};
/**
*
* @param str
* @param length
* @returns string
* @example truncate('1234567890',5) => '12345'
* @example truncate('abcdefg',3)=>'abc'
* @example truncate('你好,世界!',1)=>'你'
*/
export function truncate(str: string, length: number, start: number = 0): string {
if (!str) return '';
// 如果长度大于字符串长度,则返回字符串
if (length >= str.length) return str;
// 如果取值不完整,则返回截取后的字符串
return [...str].slice(start, start + length).join('');
}
/**
*
* @param data /
* @param keys /
* @returns undefined | null
*/
export function deepGet<
T = any,
K extends ReadonlyArray<string | number | symbol> = Array<string | number | symbol>
>(data: any, keys: K): T | undefined | null {
// ① 递归出口:keys 已取完,直接返回当前 data
if (keys.length === 0) {
return data as T;
}
// ② 安全判空:中途遇到 null / undefined 立即返回 undefined
if (data === null) {
return null;
}
if (data === undefined) {
return undefined;
}
// ③ 递归深入:取出首键,继续在剩余子集上查找
const [firstKey, ...restKeys] = keys;
// 使用 `as any` 避免 TS “索引类型” 报错
return deepGet<T>((data as any)[firstKey], restKeys);
}
/**
* /
* @param obj /
* @param path 'apps.title' | 'tags[0]'
* @returns undefined
*/
export function getTableValue<T = any>(obj: any, path: string): T | undefined {
if (!path) return obj as T;
// 先把 tags[0] 这类写法转成 tags.0,便于统一 split
const segments = path
.replace(/\[(\d+)\]/g, '.$1') // 把 [index] 转成 .index
.split('.')
.filter(Boolean); // 过滤空串,防止连点
let cur: any = obj;
for (const key of segments) {
if (cur == null) return undefined; // 短路判空
cur = cur[key as keyof typeof cur];
}
return cur as T;
}
/**
*
* @param size
* @returns string
* @example fileSizeToStr(1024) => '1KB'
* @example fileSizeToStr(1024*1024) => '1MB'
* @example fileSizeToStr(1024*1024*1024) => '1GB'
*/
export function fileSizeToStr(size: number): string {
if (size < 1024) {
return size + 'B';
}
if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + 'KB';
}
if (size < 1024 * 1024 * 1024) {
return (size / 1024 * 1024).toFixed(2) + 'MB';
}
return (size / 1024 * 1024 * 1024).toFixed(2) + 'GB';
}
/**
* ID
* @param prefix
* @param moreEntropy
* @returns string
* @example uniqid() => '1719090240_abcdefg'
* @example uniqid('prefix_') => 'prefix_1719090240_abcdefg'
* @example uniqid('prefix_', true) => 'prefix_1719090240_abcdefg_hijklmn'
*/
export function uniqid(prefix: string = "", moreEntropy: boolean = false): string {
return prefix + Date.now().toString(16) + (moreEntropy ? Math.random().toString(36).substring(2, 15) : '') + Math.random().toString(36).substring(2, 15);
}
/**
*
* @param ms
* @returns string
* @example formatDuration(60) => '01:00'
* @example formatDuration(60*60) => '01:00:00'
*/
export function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const remainingSeconds = seconds % 60;
const remainingMinutes = minutes % 60;
const strArr = [remainingMinutes.toString().padStart(2, '0'), remainingSeconds.toString().padStart(2, '0')];
if (hours) {
strArr.unshift(hours.toString().padStart(2, '0'));
}
return strArr.join(':');
}
+200
View File
@@ -0,0 +1,200 @@
import { ElMessageBox } from "element-plus";
import { useStorage } from "@/composables/useStorage";
import axios, { AxiosResponse, AxiosError } from "axios";
import { useRefs, useStateStore, useUserStore, useWebConfigStore } from "@/stores";
import { getRoundImage } from "@/common/functions";
import { i18n } from '@/locale';
import { ResponseCode } from "./const";
import { useLogin } from "@/composables/useLogin";
const { t } = i18n.global;
let baseURL = globalThis.location.origin
if (import.meta.env.DEV) {
baseURL = baseURL + '/local'
} else if (import.meta.env.VITE_REQUEST_BASE_URL) {
baseURL = import.meta.env.VITE_REQUEST_BASE_URL
} else {
// 从meta中获取version
baseURL = document.querySelector('meta[name="base-url"]')?.getAttribute('content') || baseURL;
}
const getCompleteUrl = (path: string) => {
return `${baseURL}/${path}`;
}
const getHeaders = () => {
const headers: any = {};
const { hasLogin, getToken } = useUserStore();
if (hasLogin()) {
headers['Authorization'] = getToken();
}
const storage = useStorage();
if (storage.get('ICODE')) {
headers['X-ICODE'] = storage.get('ICODE') as string;
}
headers['X-Platform'] = 'pc';
const stateStore = useStateStore();
const { STATE } = useRefs(stateStore);
const lang = STATE.value.language;
headers['Accept-Language'] = lang;
headers['lang'] = lang;
if (import.meta.env.DEV) {
headers['X-developer'] = 'true';
}
return headers;
}
axios.interceptors.request.use((_config) => {
const headers = getHeaders();
Object.keys(headers).forEach((key) => {
_config.headers.set(key, headers[key as keyof typeof headers]);
});
_config.baseURL = baseURL;
_config.timeout = 600000;
return _config;
}, (error) => {
return Promise.reject(error);
});
axios.interceptors.response.use((response: AxiosResponse) => {
if (response?.data !== undefined) {
const userStore = useUserStore()
switch (response?.data?.code) {
case ResponseCode.NEED_LOGIN:
userStore.clearUserInfo();
useLogin().open();
break;
case ResponseCode.SUCCESS_EVENT_PUSH:
response.data.code = ResponseCode.SUCCESS;
if (response.data.data.event) {
if (Array.isArray(response.data.data.event)) {
response.data.data.event.forEach((e: string) => {
$eventBus.emit(e);
})
} else {
$eventBus.emit(response.data.data.event);
}
}
break;
case ResponseCode.NO_PERMISSION:
showErrorBox(response.data);
break;
}
return response.data;
}
}, (error: AxiosError) => {
if (import.meta.env.DEV && error.code != 'ERR_CANCELED') {
showErrorBox({
code: error.code,
msg: error.message,
data: {
method: error.config?.method,
url: error.config?.url,
}
});
}
return Promise.reject(error);
});
export const $http = {
getCompleteUrl,
getHeaders,
get: axios.get,
post: axios.post,
axios
}
const eventOn = (event: string, callback: Function) => {
globalThis.addEventListener(event, (e: any) => {
callback(e.detail)
})
}
const eventRemove = (event: string, callback: Function) => {
globalThis.removeEventListener(event, (e: any) => {
callback(e.detail)
})
}
const eventEmit = (event: string, data?: any) => {
globalThis.dispatchEvent(new CustomEvent(event, { detail: data }))
}
export const $eventBus = {
on: eventOn,
emit: eventEmit,
remove: eventRemove
}
export const showErrorBox = (res: any) => {
if (import.meta.env.PROD) return;
const content = [];
if (res.code) {
content.push(h('div', { class: 'flex py-2' }, [
h('div', { class: 'text-grey text-right mr-2', style: { width: '100px' } }, t('message.error_code')),
h('div', {}, res.code)
]))
}
content.push(h('div', { class: 'flex py-2' }, [
h('div', { class: 'text-grey text-right mr-2', style: { width: '100px' } }, t('message.error_text')),
h('div', {}, res.msg)
]))
if (res.data) {
for (const key in res.data) {
if (Object.prototype.hasOwnProperty.call(res.data, key)) {
const value = res.data[key];
content.push(h('div', { class: 'flex py-2' }, [
h('div', { class: 'text-grey text-right mr-2', style: { width: '100px' } }, key),
h('div', { class: 'text-break-all' }, value)
]))
}
}
}
ElMessageBox({
title: t('message.error'),
message: h('div', {}, content),
})
}
export const useLoginImageBuild = () => {
const webConfigStore = useWebConfigStore();
const { WEBCONFIG } = useRefs(webConfigStore);
const Image = ref<string>(WEBCONFIG.value.login?.image);
const BgImage = ref<string>('/static/bg.jpg');
let BgImageEr: NodeJS.Timeout | undefined;
const getLoginBg = () => {
getRoundImage().then((res: any) => {
BgImage.value = res.blob;
})
}
onMounted(() => {
switch (WEBCONFIG.value.login?.bg_image) {
case 'off':
BgImage.value = '';
break;
case 'auto':
BgImageEr = setInterval(() => {
getLoginBg();
}, 30000)
break;
default:
if (WEBCONFIG.value.login) {
if (Array.isArray(WEBCONFIG.value.login?.bg_image)) {
BgImageEr = setInterval(() => {
if (WEBCONFIG.value.login) {
BgImage.value = WEBCONFIG.value.login?.bg_image[Math.floor(Math.random() * WEBCONFIG.value.login?.bg_image.length)];
}
}, 30000)
} else {
BgImage.value = WEBCONFIG.value.login?.bg_image;
}
}
break;
}
})
onUnmounted(() => {
clearTimer();
})
const clear = () => {
BgImage.value = '';
}
const clearTimer = () => {
if (BgImageEr) {
clearInterval(BgImageEr);
BgImageEr = undefined;
}
}
return {
BgImage,
Image,
clear
};
}
+34
View File
@@ -0,0 +1,34 @@
// channel.ts
import { Dispatcher } from "@/common/push/dispatcher";
import { Connection } from "@/common/push/connection";
export class Channel extends Dispatcher {
public subscribed = false;
public subscribeCb: (() => void) | null = null;
private queue: (() => void)[] = [];
constructor(public connection: Connection, public channelName: string) {
super();
}
processSubscribe() {
if (this.connection.state !== "connected") return;
this.subscribeCb?.();
}
processQueue() {
if (this.connection.state !== "connected" || !this.subscribed) return;
this.queue.forEach(fn => fn());
this.queue = [];
}
trigger(event: string, data: any) {
if (!event.startsWith("client-")) {
throw new Error(`Event '${event}' should start with 'client-'`);
}
this.queue.push(() => {
this.connection.send({ event, data, channel: this.channelName });
});
this.processQueue();
}
}
+130
View File
@@ -0,0 +1,130 @@
// connection.ts
import { Dispatcher } from '@/common/push/dispatcher';
export interface ConnectionOptions {
url: string;
app_key: string;
onOpen?: (ev: Event) => void;
onMessage?: (ev: MessageEvent) => void;
onClose?: (ev: CloseEvent) => void;
onError?: (ev: Event) => void;
}
export class Connection extends Dispatcher {
private options: ConnectionOptions;
private websocket: WebSocket | null = null;
public state: "initialized" | "connecting" | "connected" | "disconnected" =
"initialized";
public doNotConnect = false;
public reconnectInterval = 1;
private reconnectTimer: number | null = null;
public socket_id?: string;
constructor(options: ConnectionOptions) {
super();
this.options = options;
this.connect();
}
private updateNetworkState(state: typeof this.state) {
const oldState = this.state;
this.state = state;
if (oldState !== state) {
this.emit("state_change", { previous: oldState, current: state });
}
}
connect() {
this.doNotConnect = false;
if (this.state === "connected") {
console.log(`networkState is "${this.state}" and do not need connect`);
return;
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.closeAndClean();
const { url, app_key, onOpen, onMessage, onClose, onError } = this.options;
const ws = new WebSocket(`${url}/app/${app_key}`);
this.updateNetworkState("connecting");
ws.onopen = ev => {
this.reconnectInterval = 1;
if (this.doNotConnect) {
this.updateNetworkState("disconnected");
ws.close();
return;
}
onOpen?.(ev);
};
if (onMessage) {
ws.onmessage = onMessage;
}
ws.onclose = ev => {
this.cleanupHandlers(ws);
this.updateNetworkState("disconnected");
if (!this.doNotConnect) this.waitReconnect();
onClose?.(ev);
};
ws.onerror = ev => {
this.close();
if (!this.doNotConnect) this.waitReconnect();
onError?.(ev);
};
this.websocket = ws;
}
private cleanupHandlers(ws: WebSocket) {
ws.onmessage = null;
ws.onopen = null;
ws.onclose = null;
ws.onerror = null;
}
closeAndClean() {
if (this.websocket) {
this.cleanupHandlers(this.websocket);
try {
this.websocket.close();
} catch { }
this.updateNetworkState("disconnected");
}
}
waitReconnect() {
if (this.state === "connected" || this.state === "connecting") return;
if (!this.doNotConnect) {
this.updateNetworkState("connecting");
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = window.setTimeout(() => {
this.connect();
}, this.reconnectInterval);
this.reconnectInterval =
this.reconnectInterval < 1000
? 1000
: Math.min(this.reconnectInterval * 2, 2000);
}
}
send(data: any) {
let dataString = JSON.stringify(data);
if (this.state !== "connected" || !this.websocket) {
console.trace(`networkState is "${this.state}", can not send ${dataString}`);
return;
}
this.websocket.send(dataString);
}
close() {
this.updateNetworkState("disconnected");
this.websocket?.close();
}
}
@@ -0,0 +1,90 @@
// dispatcher.ts
export interface EventCallback {
fn: (data?: any) => void;
context?: any;
}
export interface EventHandler {
(event: string, data?: any): void;
}
export class CallbackRegistry {
private _callbacks: Record<string, EventCallback[]> = {};
get(name: string) {
return this._callbacks[prefix(name)];
}
add(name: string, callback: (data?: any) => void, context?: any) {
const prefixed = prefix(name);
this._callbacks[prefixed] = this._callbacks[prefixed] || [];
this._callbacks[prefixed].push({ fn: callback, context });
}
remove(name?: string, callback?: Function, context?: any) {
if (!name && !callback && !context) {
this._callbacks = {};
return;
}
const names = name ? [prefix(name)] : Object.keys(this._callbacks);
if (callback || context) {
this.removeCallback(names, callback, context);
} else {
this.removeAllCallbacks(names);
}
}
private removeCallback(names: string[], callback?: Function, context?: any) {
names.forEach(name => {
this._callbacks[name] = (this._callbacks[name] || []).filter(
oning =>
(callback && callback !== oning.fn) ||
(context && context !== oning.context)
);
if (this._callbacks[name].length === 0) {
delete this._callbacks[name];
}
});
}
private removeAllCallbacks(names: string[]) {
names.forEach(name => {
delete this._callbacks[name];
});
}
}
export class Dispatcher {
private callbacks = new CallbackRegistry();
private globalCallbacks: EventHandler[] = [];
constructor(private failThrough?: EventHandler) {}
on(event: string, callback: (data?: any) => void, context?: any) {
this.callbacks.add(event, callback, context);
return this;
}
onGlobal(callback: EventHandler) {
this.globalCallbacks.push(callback);
return this;
}
off(event?: string, callback?: Function, context?: any) {
this.callbacks.remove(event, callback, context);
return this;
}
emit(event: string, data?: any) {
this.globalCallbacks.forEach(cb => cb(event, data));
const cbs = this.callbacks.get(event);
if (cbs?.length) {
cbs.forEach(c => c.fn.call(c.context || window, data));
} else if (this.failThrough) {
this.failThrough(event, data);
}
return this;
}
}
function prefix(name: string) {
return "_" + name;
}
+164
View File
@@ -0,0 +1,164 @@
// push.ts
import { Connection } from '@/common/push/connection';
import { Channel } from '@/common/push/channel';
import { ResponseCode } from '@/common/const';
import { $http } from '../http';
export interface PushOptions {
url: string;
app_key: string;
auth?: string;
heartbeat?: number;
pingTimeout?: number;
}
export class Push {
static instances: Push[] = [];
private pingTimeoutTimer: number | null = null;
public channels: Record<string, Channel> = {};
public connection: Connection | null = null;
constructor(private config: PushOptions) {
this.config.heartbeat = this.config.heartbeat ?? 25000;
this.config.pingTimeout = this.config.pingTimeout ?? 10000;
Push.instances.push(this);
this.createConnection();
}
private checkoutPing = () => {
setTimeout(() => {
if (this.connection?.state === "connected") {
this.connection.send({ event: "pusher:ping", data: {} });
if (this.pingTimeoutTimer) {
clearTimeout(this.pingTimeoutTimer);
}
this.pingTimeoutTimer = window.setTimeout(() => {
this.connection?.closeAndClean();
if (this.connection && !this.connection.doNotConnect) {
this.connection.waitReconnect();
}
}, this.config.pingTimeout);
}
}, this.config.heartbeat);
};
/**
*
* @param channelName
* @param api ,,
* @returns
*/
subscribe(channelName: string) {
if (this.channels[channelName]) return this.channels[channelName];
if (channelName.indexOf('private-') === 0) {
return this.createPrivateChannel(channelName);
}
if (channelName.indexOf('presence-') === 0) {
return this.createPresenceChannel(channelName);
}
return this.createChannel(channelName);
}
createChannel(channelName: string) {
const channel = new Channel(this.connection!, channelName);
this.channels[channelName] = channel;
channel.subscribeCb = () => {
this.connection?.send({ event: "pusher:subscribe", data: { channel: channelName } });
};
channel.processSubscribe();
return channel;
}
createPrivateChannel(channelName: string) {
let channel = new Channel(this.connection!, channelName);
this.channels[channelName] = channel;
channel.subscribeCb = () => {
if (!this.config.auth) return;
$http.post(this.config.auth, { channel_name: channelName, socket_id: this.connection!.socket_id }).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
res.data.channel = channelName;
this.connection!.send({ event: "pusher:subscribe", data: res.data });
} else {
channel.emit('subscription_error', res.msg);
}
}).catch((err: any) => {
channel.emit('subscription_error', err.message);
});
};
channel.processSubscribe();
return channel;
}
createPresenceChannel(channelName: string) {
return this.createPrivateChannel(channelName);
}
unsubscribe(channelName: string) {
if (this.channels[channelName]) {
delete this.channels[channelName];
if (this.connection?.state === "connected") {
this.connection.send({ event: "pusher:unsubscribe", data: { channel: channelName } });
}
}
}
unsubscribeAll() {
Object.keys(this.channels).forEach(name => this.unsubscribe(name));
this.channels = {};
}
private createConnection() {
if (this.connection) {
throw new Error("Connection already exist");
}
this.connection = new Connection({
url: this.config.url,
app_key: this.config.app_key,
onOpen: () => {
this.connection!.state = "connecting";
this.checkoutPing();
},
onMessage: ev => {
if (this.pingTimeoutTimer) {
clearTimeout(this.pingTimeoutTimer);
this.pingTimeoutTimer = null;
}
const params = JSON.parse(ev.data);
const { event, channel: channelName } = params;
if (event === "pusher:pong") {
this.checkoutPing();
return;
}
if (event === "pusher:connection_established") {
this.connection!.socket_id = JSON.parse(params.data).socket_id;
this.connection!.state = "connected";
this.subscribeAll();
}
const channel = this.channels[channelName];
if (channel) channel.emit(event, JSON.parse(params.data));
},
onClose: () => {
Object.values(this.channels).forEach(c => (c.subscribed = false));
},
onError: () => {
Object.values(this.channels).forEach(c => (c.subscribed = false));
}
});
}
private subscribeAll() {
if (this.connection?.state !== "connected") return;
Object.values(this.channels).forEach(ch => ch.processSubscribe());
}
disconnect() {
if (this.connection) {
this.connection.doNotConnect = true;
this.connection.close();
}
}
}
@@ -0,0 +1,19 @@
export class IntervalTask implements TimeTask {
cancelled = false;
count = 0;
constructor(
public interval: number,
public nextTime: number,
private cb: (count: number, now: number) => void
) {}
run(now: number) {
// 处理跳帧(后台 / tab 冻结)
const n = Math.floor((now - this.nextTime) / this.interval) + 1;
this.count += n;
this.nextTime += n * this.interval;
this.cb(this.count, now);
}
}
+52
View File
@@ -0,0 +1,52 @@
export class MinHeap<T> {
private data: T[] = [];
constructor(private compare: (a: T, b: T) => number) {}
peek(): T | undefined {
return this.data[0];
}
push(item: T) {
this.data.push(item);
this.bubbleUp(this.data.length - 1);
}
pop(): T | undefined {
if (this.data.length === 1) return this.data.pop();
const top = this.data[0];
this.data[0] = this.data.pop()!;
this.bubbleDown(0);
return top;
}
get size() {
return this.data.length;
}
private bubbleUp(i: number) {
while (i > 0) {
const p = (i - 1) >> 1;
if (this.compare(this.data[i], this.data[p]) >= 0) break;
[this.data[i], this.data[p]] = [this.data[p], this.data[i]];
i = p;
}
}
private bubbleDown(i: number) {
const n = this.data.length;
while (true) {
let s = i;
const l = i * 2 + 1;
const r = l + 1;
if (l < n && this.compare(this.data[l], this.data[s]) < 0) s = l;
if (r < n && this.compare(this.data[r], this.data[s]) < 0) s = r;
if (s === i) break;
[this.data[i], this.data[s]] = [this.data[s], this.data[i]];
i = s;
}
}
}
@@ -0,0 +1,74 @@
import { MinHeap } from './MinHeap';
import { TimeoutTask } from './TimeoutTask';
import { IntervalTask } from './IntervalTask';
export class Scheduler {
private heap = new MinHeap<TimeTask>(
(a, b) => a.nextTime - b.nextTime
);
private ticking = false;
constructor(private now: () => number) {}
/* ========== Timeout ========== */
setTimeout(cb: (now: number) => void, delay: number) {
const task = new TimeoutTask(this.now() + delay, cb);
this.heap.push(task);
this.ensureTicking();
return task;
}
clearTimeout(task: TimeTask) {
task.cancelled = true;
}
/* ========== Interval ========== */
setInterval(
cb: (count: number, now: number) => void,
interval: number
) {
const task = new IntervalTask(
interval,
this.now() + interval,
cb
);
this.heap.push(task);
this.ensureTicking();
return task;
}
clearInterval(task: TimeTask) {
task.cancelled = true;
}
/* ========== 调度循环 ========== */
private ensureTicking() {
if (!this.ticking) this.tick();
}
private tick = () => {
this.ticking = true;
const now = this.now();
while (this.heap.size && this.heap.peek()!.nextTime <= now) {
const task = this.heap.pop()!;
if (task.cancelled) continue;
task.run(now);
// interval 会重新入堆,timeout 不会
if (!task.cancelled) {
this.heap.push(task);
}
}
if (this.heap.size) {
requestAnimationFrame(this.tick);
} else {
this.ticking = false;
}
};
}
@@ -0,0 +1,14 @@
export class TimeoutTask implements TimeTask {
cancelled = false;
constructor(
public nextTime: number,
private cb: (now: number) => void
) {}
run(now: number) {
this.cb(now);
this.cancelled = true;
}
}
@@ -0,0 +1,194 @@
<script setup lang="ts">
import HelperSvg from '@/svg/icon/helper.vue';
import { useUserStore, useRefs, useWebConfigStore, useWalletStore } from '@/stores';
import { useNotify } from '@/composables/useNotify';
import IconEmailSvg from '@/svg/icon/icon-email.vue';
import IconPointsSvg from '@/svg/icon/icon-points.vue';
import { usePush } from '@/composables/usePush';
import IconWechatSvg from '@/svg/icon/icon-wechat.vue';
import router from '@/routers';
import { throttle } from '@/common/functions';
import { $http } from '@/common/http';
import { ResponseCode } from '@/common/const';
import IconVipSvg from '@/svg/icon/icon-vip.vue';
import { useLogin } from '@/composables/useLogin'
import { useInvitationCode } from '@/composables/useInvitationCode';
const login = useLogin()
const useInvitationCodeBox = useInvitationCode();
const props = withDefaults(defineProps<{
showMenu?: any[]
}>(), {
showMenu: () => ([]),
});
const showMenu = computed(() => {
return props.showMenu?.length > 0 ? props.showMenu : ['invitation', 'points', 'vip', 'language', 'helper', 'userinfo', 'wechat'];
});
const userStore = useUserStore();
const { USERINFO } = useRefs(userStore);
const webConfigStore = useWebConfigStore();
const { WEBCONFIG } = useRefs(webConfigStore);
const walletStore = useWalletStore();
const { WALLET } = useRefs(walletStore);
const notify = useNotify();
const { subscribe, unsubscribe, unsubscribeAll } = usePush();
const wechatGroupDialogVisible = ref(false);
const getUserInfo = throttle(() => {
$http.get('/app/user/api/User/info').then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
const userStore = useUserStore();
userStore.setUserInfo(res.data as UserInfoInterface);
}
}).catch(() => {
});
}, 1000);
const getWallet = throttle(() => {
walletStore.getWallet();
}, 1000);
const addListener = () => {
if (userStore.hasLogin()) {
subscribe('private-notify-' + USERINFO.value?.user, (res: any) => {
notify.parse(res);
});
subscribe('private-user-' + USERINFO.value?.user, () => {
getUserInfo();
});
subscribe('private-wallet-' + USERINFO.value?.user, () => {
getWallet();
});
}
subscribe('notify', (res: any) => {
notify.parse(res);
});
}
watch(USERINFO, (newVal, oldVal) => {
if (newVal !== oldVal && oldVal?.user) {
unsubscribe('private-notify-' + oldVal.user);
unsubscribe('private-user-' + oldVal.user);
}
addListener();
});
// const xlInvitationCodeRef = ref<any>(null);
const xlUserPointsRef = ref<any>(null);
//
const openWechatGroupDialog = () => {
wechatGroupDialogVisible.value = true;
};
//vip
const toVip = () => {
if (USERINFO.value) {
router.push('/vip');
return;
}
login.open();
}
//
const toUse = () => {
const url = WEBCONFIG.value.guide_url
if (url) {
window.open(url, '_blank');
} else {
router.push('/article/guide');
}
}
onMounted(() => {
addListener();
})
onUnmounted(() => {
unsubscribeAll();
})
</script>
<template>
<div class="flex flex-y-center flex-x-flex-end grid-gap-4 x-header-tools">
<div class="btn h10" @click="useInvitationCodeBox.open" v-if="USERINFO && showMenu?.includes('invitation')">
<el-icon :size="16">
<IconEmailSvg />
</el-icon>
<span class="h10 ">邀请好友得积分</span>
</div>
<div class="btn h10" @click="xlUserPointsRef.open" v-if="USERINFO && showMenu?.includes('points')">
<el-icon :size="16">
<IconPointsSvg />
</el-icon>
<span>{{ WALLET?.available_points || 0 }}</span>
<el-divider direction="vertical" />
<span class="h10" @click.stop="$router.push('/points')">充值</span>
</div>
<div class="x-header-tool" v-if="showMenu?.includes('vip')" @click="toVip">
<el-icon :size="26" class="x-header-tool-img">
<IconVipSvg />
</el-icon>
</div>
<div class="x-header-tool" v-if="showMenu?.includes('helper')" @click="toUse">
<el-icon alt="帮助" :size="26" class="x-header-tool-img" color="rgba(255,255,255,0.5)">
<HelperSvg />
</el-icon>
</div>
<div class="x-header-tool" v-if="showMenu?.includes('wechat')" @click="openWechatGroupDialog">
<el-icon alt="微信群" :size="26" class="x-header-tool-img" color="rgba(255,255,255,0.5)">
<IconWechatSvg />
</el-icon>
</div>
<xl-header-userinfo v-if="showMenu?.includes('userinfo')" />
<xl-user-points ref="xlUserPointsRef" />
</div>
<!-- 微信群二维码对话框 -->
<el-dialog v-model="wechatGroupDialogVisible" title="加入官方微信群" width="512px" align-center>
<div class="flex flex-column flex-y-center grid-gap-4 py-8" v-if="WEBCONFIG?.wechat_group_qrcode_url">
<el-image :src="WEBCONFIG.wechat_group_qrcode_url" style="width: 230px; height: 230px;" fit="contain"
:preview-src-list="[WEBCONFIG.wechat_group_qrcode_url]" preview-teleported />
<p class="text-center">请使用微信扫描下方二维码加入群聊</p>
</div>
<p v-else class="text-center text-secondary">二维码暂未配置</p>
</el-dialog>
</template>
<style scoped lang="scss">
.x-header-tools {
.btn {
background: rgba(30, 30, 30, 0.4);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
flex-shrink: 0;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.16);
}
}
.x-header-tool {
width: calc(var(--xl-header-height) - 20px);
height: calc(var(--xl-header-height) - 20px);
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
&:hover {
background-color: rgba(13, 242, 131, 0.10);
}
cursor: pointer;
&-img {
font-size: 30px;
width: 50%;
height: 50%;
}
}
}
</style>
<style>
.el-dialog {
--el-border-radius-base: 20px !important;
}
</style>
@@ -0,0 +1,6 @@
<template>
<div class="x-header-tools-code"> </div>
</template>
<script setup lang="ts">
</script>
@@ -0,0 +1,496 @@
<script setup lang="ts">
import { ResponseCode } from '@/common/const';
import { $http } from '@/common/http';
import { useVcode } from '@/composables/useVcode';
import { useUserStore, useWebConfigStore, useRefs } from '@/stores';
import { ElMessage } from 'element-plus';
import IconLoginPhoneSvg from '@/svg/icon/icon-login-phone.vue';
import IconLoginWechatSvg from '@/svg/icon/icon-login-wechat.vue';
import IconSecuritySvg from '@/svg/icon/icon-security.vue';
const userStore = useUserStore()
const webConfigStore = useWebConfigStore()
const { WEBCONFIG } = useRefs(webConfigStore)
const vcode = useVcode()
const mobileVcode = useVcode() //
const emit = defineEmits(['success', 'close'])
const close = () => {
emit('close')
}
const activeTabs = ref('login')
const tabs = ref([
{ label: '账号密码', value: 'login' },
{ label: '手机号登录', value: 'vcode' },
])
const form = reactive({
phoneCountryCode: '+86',
username: '',
password: '',
vcode: '',
token: null
})
//
const mobileForm = reactive({
phoneCountryCode: '+86',
username: '',
vcode: '',
token: null
})
const needMobile = ref(false) //
const phoneCountryCodeOptions = ref([
{ label: '+86', value: '+86' },
{ label: '+852', value: '+852' },
{ label: '+853', value: '+853' },
{ label: '+855', value: '+855' },
{ label: '+856', value: '+856' },
])
const loading = ref(false)
const mobileLoading = ref(false) // loading
//
const switchToQrcode = () => {
needMobile.value = false
qrcodeLoginData.value = null
qrcodeToken.value = null
activeTabs.value = 'qrcode'
}
//
const switchToLogin = () => {
needMobile.value = false
qrcodeLoginData.value = null
qrcodeToken.value = null
activeTabs.value = 'login'
}
const login = () => {
loading.value = true
$http.post('/app/user/api/Login/' + activeTabs.value, form).then(LoginRes).finally(() => {
loading.value = false
})
}
const vcodeBtnDisabled = computed(() => {
return vcode.btnDisabled.value || form.username.length !== 11
})
const mobileVcodeBtnDisabled = computed(() => {
return mobileVcode.btnDisabled.value || mobileForm.username.length !== 11
})
const submitDisabled = computed(() => {
switch (activeTabs.value) {
case 'login':
return !form.username.length || !form.password.length
case 'vcode':
return !form.username.length || !form.vcode.length
}
return false;
})
const mobileSubmitDisabled = computed(() => {
return !mobileForm.username.length || !mobileForm.vcode.length
})
//
const isMobileEmpty = (mobile: any): boolean => {
return mobile === null || mobile === undefined || mobile === '' || String(mobile).trim() === ''
}
const LoginRes = (res: any) => {
console.log(res);
if (res.code === ResponseCode.SUCCESS) {
//
if (isMobileEmpty(res.data?.mobile)) {
//
needMobile.value = true
// token使
qrcodeLoginData.value = res.data
qrcodeToken.value = res.data?.token || null
} else {
ElMessage.success('登录成功');
//
userStore.setUserInfo(res.data).then(() => {
emit('success', res)
}).catch(() => {
ElMessage.error('登录失败')
})
}
} else {
ElMessage.error(res.msg)
}
}
//
const qrcodeLoginData = ref<any>(null)
// token
const qrcodeToken = ref<string | null>(null)
//
const getMobileVcode = () => {
const headers = {
...$http.getHeaders(),
Authorization: qrcodeToken.value as string
}
mobileVcode.open({ username: mobileForm.username, countryCode: mobileForm.phoneCountryCode, scene: 'bind_mobile', headers: headers }).then((res: any) => {
mobileForm.vcode = ''
mobileForm.token = res.token
}).catch(() => {
})
}
//
const submitMobile = () => {
mobileLoading.value = true
const baseHeaders = $http.getHeaders()
if (qrcodeToken.value) {
baseHeaders['Authorization'] = qrcodeToken.value
}
$http.axios.post('/app/user/api/User/bindMobile', mobileForm, {
headers: {
...baseHeaders,
Authorization: qrcodeToken.value as string
},
}).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
ElMessage.success('登录成功');
const finalData = {
...qrcodeLoginData.value,
mobile: mobileForm.username
}
userStore.setUserInfo(finalData).then(() => {
needMobile.value = false
qrcodeToken.value = null
emit('success', finalData)
}).catch(() => {
ElMessage.error('登录失败')
})
} else {
ElMessage.error(res.msg)
}
}).catch((error: any) => {
const res = error?.response?.data || error
ElMessage.error(res?.msg || '绑定手机号失败')
}).finally(() => {
mobileLoading.value = false
})
}
const getVcode = () => {
vcode.open({ username: form.username, countryCode: form.phoneCountryCode, scene: 'signup' }).then((res: any) => {
form.vcode = ''
form.token = res.token
}).catch(() => {
})
}
// URL
const loginBackgroundImageUrl = computed(() => {
return WEBCONFIG.value?.login_background_image_url || '/fastmovie/static/image/login-image.png'
})
</script>
<template>
<div class="x-login">
<div class="x-login-image">
<el-image :src="loginBackgroundImageUrl" class="x-login-image-img" />
</div>
<div class="x-login-form">
<el-icon class="x-login-form-close" @click="close">
<Close />
</el-icon>
<template v-if="activeTabs !== 'qrcode'">
<el-segmented v-model="activeTabs" :options="tabs" class="tabs-segmented border" />
<div class="x-login-form-content">
<template v-if="activeTabs === 'login'">
<el-input v-model="form.username" placeholder="账号/手机号/邮箱" @keyup.enter="login">
<template #prepend>
<el-icon size="20" color="var(--el-bg-color)">
<User />
</el-icon>
</template>
</el-input>
<el-input type="password" v-model="form.password" placeholder="请输入密码" @keyup.enter="login">
<template #prepend>
<el-icon size="20" color="var(--el-bg-color)">
<Lock />
</el-icon>
</template>
</el-input>
</template>
<template v-else>
<el-input v-model="form.username" placeholder="手机号" @keyup.enter="login" :maxlength="11">
<template #prepend>
<el-select v-model="form.phoneCountryCode"
popper-style="--el-bg-color-overlay:rgba(247, 247, 247, 1);--el-border-color-light:rgba(247, 247, 247, 1);--el-text-color-regular:rgba(14, 14, 14, 1);--el-fill-color-light:var(--el-text-color-primary);">
<el-option v-for="item in phoneCountryCodeOptions" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
</template>
</el-input>
<el-input v-model="form.vcode" placeholder="请输入验证码" @keyup.enter="login" :maxlength="6">
<template #prepend>
<el-icon size="20" color="var(--el-bg-color)">
<IconSecuritySvg />
</el-icon>
</template>
<template #append>
<el-button type="primary" class="vcode-button" :disabled="vcodeBtnDisabled"
@click="getVcode">{{
vcode.btnText }}</el-button>
</template>
</el-input>
</template>
<el-button color="var(--el-bg-color)" @click="login" class="x-login-form-login-button"
:disabled="submitDisabled">
<span>确认登录</span>
</el-button>
<div class="x-login-other">
<div class="x-login-other-line"></div>
<span>其他方式登录</span>
<div class="x-login-other-line right"></div>
</div>
<div class="x-login-other-icons">
<el-icon alt="微信登录" class="x-login-other-icons-img" @click="switchToQrcode"
color="var(--el-color-success)">
<IconLoginWechatSvg />
</el-icon>
</div>
</div>
</template>
<template v-else-if="activeTabs === 'qrcode' && !needMobile">
<xl-qrcode-view url="/app/user/api/Login/qrcode" check="/app/user/api/Login/checkQrcode"
class="qrcode-view" @success="LoginRes" />
<div class="x-login-other">
<div class="x-login-other-line"></div>
<span>其他方式登录</span>
<div class="x-login-other-line right"></div>
</div>
<div class="x-login-other-icons">
<el-icon alt="手机号登录" class="x-login-other-icons-img" @click="switchToLogin"
color="var(--el-bg-color)">
<IconLoginPhoneSvg />
</el-icon>
</div>
</template>
<template v-else>
<div class="x-login-form-content">
<div class="x-login-mobile-title">请补充手机号</div>
<el-input v-model="mobileForm.username" placeholder="手机号" @keyup.enter="submitMobile"
:maxlength="11">
<template #prepend>
<el-select v-model="mobileForm.phoneCountryCode"
popper-style="--el-bg-color-overlay:rgba(247, 247, 247, 1);--el-border-color-light:rgba(247, 247, 247, 1);--el-text-color-regular:rgba(14, 14, 14, 1);--el-fill-color-light:var(--el-text-color-primary);">
<el-option v-for="item in phoneCountryCodeOptions" :key="item.value" :label="item.label"
:value="item.value" />
</el-select>
</template>
</el-input>
<el-input v-model="mobileForm.vcode" placeholder="请输入验证码" @keyup.enter="submitMobile"
:maxlength="6">
<template #prepend>
<el-icon size="20" color="var(--el-bg-color)">
<IconSecuritySvg />
</el-icon>
</template>
<template #append>
<el-button type="primary" class="vcode-button" :disabled="mobileVcodeBtnDisabled"
@click="getMobileVcode">{{
mobileVcode.btnText }}</el-button>
</template>
</el-input>
<el-button color="var(--el-bg-color)" @click="submitMobile" class="x-login-form-login-button"
:disabled="mobileSubmitDisabled" :loading="mobileLoading">
<span>确认</span>
</el-button>
</div>
</template>
<div class="x-login-form-agreement">
<span>登录即代表阅读并同意</span>
<el-link href="/#/article/user" type="success" target="_blank" underline="never">用户协议</el-link>
<span></span>
<el-link href="/#/article/privacy" type="success" target="_blank" underline="never">隐私政策</el-link>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.x-login {
width: 100%;
height: var(--el-messagebox-height);
display: flex;
--x-login-image-width: 375px;
.x-login-image {
width: var(--x-login-image-width);
height: 100%;
.x-login-image-img {
width: 100%;
height: 100%;
}
}
.x-login-form {
width: calc(100% - var(--x-login-image-width));
height: 100%;
background-color: #FFFFFF;
position: relative;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
.tabs-segmented {
--el-border-radius-base: 6px;
--el-segmented-bg-color: var(--el-bg-color-overlay);
--el-segmented-padding: 4px;
--el-segmented-item-selected-bg-color: #FFFFFF;
--el-segmented-item-selected-color: var(--el-bg-color);
font-weight: 600;
:deep(.el-segmented__item) {
padding: 8px 0;
width: 120px;
}
:deep(.el-segmented__group) {
gap: 10px;
}
}
.x-login-form-close {
position: absolute;
top: 10px;
right: 10px;
cursor: pointer;
font-size: 20px;
color: rgba(60, 60, 67, 0.60);
background-color: rgba(116, 116, 128, 0.08);
border-radius: 50%;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background-color: rgba(116, 116, 128, 0.16);
}
}
.x-login-other {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
.x-login-other-line {
flex: 1;
height: 1px;
background-color: rgba(60, 60, 67, 0.12);
background: linear-gradient(to right, rgba(60, 60, 67, 0), rgba(60, 60, 67, 0.12));
&.right {
background: linear-gradient(to right, rgba(60, 60, 67, 0.12), rgba(60, 60, 67, 0));
}
}
span {
font-size: 12px;
color: rgba(60, 60, 67, 0.60);
}
}
.x-login-other-icons {
.x-login-other-icons-img {
font-size: 50px;
width: 60px;
height: 60px;
cursor: pointer;
}
}
.x-login-form-content {
flex: 1;
width: 315px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
.x-login-mobile-title {
font-size: 18px;
font-weight: 600;
color: var(--el-color-black);
margin-bottom: 10px;
}
:deep(.el-input) {
--el-input-bg-color: rgba(247, 247, 247, 1);
--el-input-border-color: rgba(247, 247, 247, 1);
--el-input-height: 50px;
--el-input-border-radius: 12px;
--el-input-focus-border-color: var(--el-border-color-hover);
--el-input-text-color: #141414;
--el-font-size-base: 16px;
.el-input__inner {
font-weight: 600;
letter-spacing: 2px;
&::placeholder {
font-weight: 400;
font-size: 14px;
}
}
.el-input-group__append,
.el-input-group__prepend {
background-color: var(--el-input-bg-color);
}
.el-select {
width: 65px;
.el-select__suffix {
display: none;
}
}
}
.x-login-form-login-button {
width: 100%;
height: 50px;
line-height: 50px;
border-radius: 12px;
}
.vcode-button {
font-size: 12px;
font-weight: 600;
letter-spacing: 2px;
color: var(--el-bg-color-overlay);
&.disabled,
&:disabled {
color: var(--el-text-color-disabled);
}
}
}
.x-login-form-agreement {
font-size: 12px;
color: rgba(60, 60, 67, 0.60);
text-align: center;
:deep(.el-link) {
// --el-link-text-color: rgba(60, 60, 67, 0.60);
--el-link-font-size: 12px;
vertical-align: unset;
}
}
}
.qrcode-view {
--el-text-color-primary: var(--el-bg-color);
}
}
</style>
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { ResponseCode } from '@/common/const';
import { $http } from '@/common/http';
import { ElMessage } from 'element-plus';
const props = withDefaults(defineProps<{
data: any
}>(), {
data: () => ({}),
});
const emit = defineEmits(['success', 'close'])
const close = () => {
emit('close')
}
const form = reactive({
username: '',
captcha: '',
token: null,
...props.data,
})
const captcha = ref('')
const getCaptcha = () => {
$http.get('/app/user/api/Captcha/captcha_json').then((res: any) => {
if (res.code === 200) {
captcha.value = res.data.base64
form.token = res.data.token
}
})
}
const loading = ref(false)
const sendCode = () => {
loading.value = true
console.log(props.data.headers)
const headers = {
...props.data.headers
}
$http.post('/app/control/api/Public/getSmsVcode', form, { headers: headers }).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
emit('success', { token: form.token })
ElMessage.success(res.msg)
} else {
ElMessage.error(res.msg)
}
}).finally(() => {
loading.value = false
})
}
onMounted(() => {
getCaptcha()
})
</script>
<template>
<div class="x-vcode">
<div class="x-vcode-form">
<span>发送至{{ data.countryCode }} {{ data.username }}</span>
<el-input v-model="form.captcha" placeholder="请输入图形验证码" @keyup.enter="sendCode" :disabled="loading">
<template #append>
<el-image :src="captcha" class="x-login-form-captcha-img" alt="验证码" title="点击更换验证码"
@click="getCaptcha" />
</template>
</el-input>
</div>
<div>
<el-button @click="close" :disabled="loading">取消</el-button>
<el-button type="primary" @click="sendCode" :loading="loading">获取验证码</el-button>
</div>
</div>
</template>
<style scoped lang="scss">
.x-vcode {
width: 100%;
height: var(--el-messagebox-height);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 20px;
.x-vcode-form {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 20px;
}
:deep(.el-input) {
--el-input-height: 50px;
--el-input-border-radius: 12px;
font-weight: 600;
.x-login-form-captcha-img {
width: 100%;
height: 48px;
}
}
}
</style>
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { ResponseCode } from '@/common/const';
import { truncate } from '@/common/functions';
import { $http } from '@/common/http';
import { useRefs, useWebConfigStore } from '@/stores';
const props = withDefaults(defineProps<{
query?: any
types?: any[]
}>(), {
query: () => ({}),
types: () => ([]),
});
const webConfigStore = useWebConfigStore();
const { WEBCONFIG } = useRefs(webConfigStore);
const emit = defineEmits(['select']);
const ActorSearch = reactive({
type: 'all',
name: '',
species_type: '',
gender: '',
age: '',
})
const actorList = ref<any[]>([]);
const loading = ref(false);
const getActorList = () => {
loading.value = true;
actorList.value = [];
$http.get('/app/shortplay/api/Actor/index', {
params: {
...ActorSearch,
...props.query,
}
}).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
actorList.value = res.data;
}
}).finally(() => {
loading.value = false;
})
}
const handleActorItemClick = (item: any) => {
emit('select', item);
}
const actorCreateRef = ref<any>(null);
onMounted(() => {
getActorList();
})
</script>
<template>
<div class="flex flex-column grid-gap-4" style="--el-color-primary:var(--el-color-success);">
<el-form class="flex flex-center grid-gap-4" @submit.prevent="getActorList">
<el-form-item class="mb-0">
<xl-tabs v-model="ActorSearch.type" class="text-info" @change="getActorList">
<xl-tabs-item value="all">全部</xl-tabs-item>
<xl-tabs-item value="public">公共</xl-tabs-item>
<xl-tabs-item value="personal">个人</xl-tabs-item>
<xl-tabs-item v-for="item in props.types" :key="item.value" :value="item.value">
{{ item.label }}
</xl-tabs-item>
</xl-tabs>
</el-form-item>
<div class="flex-1"></div>
<el-form-item class="mb-0">
<el-input v-model="ActorSearch.name" placeholder="搜索演员" clearable @change="getActorList">
<template #suffix>
<el-icon>
<Search />
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item class="mb-0" style="width: 80px;">
<el-select v-model="ActorSearch.species_type" placeholder="物种" clearable :teleported="false"
@change="getActorList">
<el-option v-for="item in WEBCONFIG?.enum?.actor_species_type" :key="item.value" :label="item.label"
:value="item.value" />
</el-select>
</el-form-item>
<el-form-item class="mb-0" style="width: 80px;">
<el-select v-model="ActorSearch.gender" placeholder="性别" clearable :teleported="false"
@change="getActorList">
<el-option v-for="item in WEBCONFIG?.enum?.actor_gender" :key="item.value" :label="item.label"
:value="item.value" />
</el-select>
</el-form-item>
<el-form-item class="mb-0" style="width: 80px;">
<el-select v-model="ActorSearch.age" placeholder="年龄" clearable :teleported="false"
@change="getActorList">
<el-option v-for="item in WEBCONFIG?.enum?.actor_age" :key="item.value" :label="item.label"
:value="item.value" />
</el-select>
</el-form-item>
</el-form>
<el-scrollbar height="300px" v-loading="loading">
<div class="grid-columns-8 grid-gap-4">
<div class="grid-column-2 rounded-4 p-4 border-1 border-dashed actor-item flex flex-center grid-gap-4 "
@click="actorCreateRef?.open?.(null, props.query?.drama_id, props.query?.episode_id)"
v-if="ActorSearch.type !== 'public'">
<el-icon class="rounded-4" size="20"
style="height: 40px; width: 40px;background-color: var(--el-mask-color-extra-light);">
<Plus />
</el-icon>
<span>添加演员</span>
</div>
<div class="grid-column-2 rounded-4 p-4 flex flex-center grid-gap-2 actor-item actor-item-b"
v-for="item in actorList" @click="handleActorItemClick(item)">
<el-avatar :src="item.headimg" :size="40">
{{ truncate(item.name, 1) }}
</el-avatar>
<div class="flex-1 flex flex-column grid-gap-2">
<span>{{ item.name }}</span>
<div class="flex grid-gap-2">
<span class="bg h10 rounded-2 py-1 px-2">{{ item.species_type_enum?.label }}</span>
<span class="bg h10 rounded-2 py-1 px-2">{{ item.gender_enum?.label }}</span>
<span class="bg h10 rounded-2 py-1 px-2">{{ item.age_enum?.label }}</span>
</div>
</div>
</div>
</div>
</el-scrollbar>
<xl-actor-create ref="actorCreateRef" @success="getActorList" append-to-body />
</div>
</template>
<style lang="scss" scoped>
.actor-item {
cursor: pointer;
height: 80px;
border-color: var(--el-color-info);
&:hover {
background-color: rgba(255, 255, 255, 0.08);
}
.bg {
background-color: rgba(255, 255, 255, 0.1);
}
&-b {
border: 1px solid rgba(255, 255, 255, 0.1);
}
}
.el-input {
--el-input-border-radius: 20px;
}
.el-select {
--el-border-radius-base: 20px;
}
</style>
@@ -0,0 +1,621 @@
<script setup lang="ts">
import { $http } from '@/common/http';
import { ResponseCode } from '@/common/const';
import { ElMessage } from 'element-plus';
import { useRefs, useWebConfigStore } from '@/stores';
import IconModelSvg from '@/svg/icon/icon-model.vue';
import IconPointsSvg from '@/svg/icon/icon-points.vue';
import IconActorSvg from '@/svg/icon/icon-actor.vue';
import IconActorThreeViewSvg from '@/svg/icon/icon-actor-three-view.vue';
import IconImageSvg from '@/svg/icon/icon-image.vue';
import IconUploadImageSvg from '@/svg/icon/icon-upload-image.vue';
import { usePoints } from '@/composables/usePoints';
const webConfigStore = useWebConfigStore();
const { WEBCONFIG } = useRefs(webConfigStore);
const emit = defineEmits(['success']);
const actorDialogVisible = ref(false);
const actorForm = reactive<any>({
id: '',
drama_id: '',
episode_id: '',
image_state: false,
image_model_id: '',
three_view_image_state: false,
three_view_model_id: '',
status_enum: { value: 'initializing', label: '待初始化' },
name: '',
headimg: '',
three_view_image: '',
species_type: '',
gender: '',
age: '',
remarks: '',
image_reference_state: false,
reference_headimg: '',
voice_channel: null,
voice_id: null,
voice_name: null,
voice_model_id: null,
})
const actorFormRef = ref<any>(null);
const actorLoading = ref(false);
const actorFormRules = reactive({
image_model_id: [{ required: true, message: '请选择图片模型', trigger: 'change' }],
three_view_model_id: [{ required: true, message: '请选择三维模型', trigger: 'change' }],
species_type: [{ required: true, message: '请选择物种', trigger: 'change' }],
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
age: [{ required: true, message: '请选择年龄', trigger: 'change' }],
remarks: [{ required: true, message: '请输入备注', trigger: 'change' }],
})
const showForm=ref(false);
const openActorCreateDialog = (actor?: any, drama_id?: string | number, episode_id?: string | number) => {
showForm.value = true;
actorImageModel.value = 'remarks';
actorDialogVisible.value = true;
nextTick(() => {
if (actor) {
actorForm.id = actor.id;
actorForm.headimg = actor.headimg;
actorForm.three_view_image = actor.three_view_image;
actorForm.name = actor.name;
actorForm.species_type = actor.species_type;
actorForm.gender = actor.gender;
actorForm.age = actor.age;
actorForm.remarks = actor.remarks;
actorForm.voice_channel = actor.voice_channel;
actorForm.voice_id = actor.voice_id;
actorForm.voice_name = actor.voice_name;
actorForm.voice_model_id = actor.voice_model_id;
actorForm.drama_id = drama_id;
actorForm.episode_id = episode_id;
} else {
actorForm.drama_id = drama_id;
actorForm.episode_id = episode_id;
}
})
}
const uploadActor = (actor?: any, drama_id?: string | number, episode_id?: string | number) => {
showForm.value = false;
actorImageModel.value = 'upload';
actorDialogVisible.value = true;
nextTick(() => {
if (actor) {
actorForm.id = actor.id;
actorForm.headimg = actor.headimg;
actorForm.three_view_image = actor.three_view_image;
actorForm.name = actor.name;
actorForm.species_type = actor.species_type;
actorForm.gender = actor.gender;
actorForm.age = actor.age;
actorForm.remarks = actor.remarks;
actorForm.voice_channel = actor.voice_channel;
actorForm.voice_id = actor.voice_id;
actorForm.voice_name = actor.voice_name;
actorForm.voice_model_id = actor.voice_model_id;
actorForm.drama_id = drama_id;
actorForm.episode_id = episode_id;
} else {
actorForm.drama_id = drama_id;
actorForm.episode_id = episode_id;
}
})
}
const cancelActorDialog = () => {
actorFormRef.value?.resetFields();
actorDialogVisible.value = false;
actorForm.id = '';
actorForm.name = '';
actorForm.species_type = '';
actorForm.gender = '';
actorForm.age = '';
actorForm.remarks = '';
actorForm.image_model_state = false;
actorForm.image_model_id = '';
actorForm.three_view_model_state = false;
actorForm.three_view_model_id = '';
actorForm.status_enum = { value: 'initializing', label: '待初始化' };
actorForm.headimg = '';
actorForm.three_view_image = '';
actorForm.drama_id = '';
actorForm.episode_id = '';
actorForm.voice_channel = null;
actorForm.voice_id = null;
actorForm.voice_name = null;
actorForm.voice_model_id = null;
}
const submitActorDialog = (callback?: () => void) => {
$http.post('/app/shortplay/api/Actor/update', actorForm).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
ElMessage.success(res.msg);
actorForm.id = res.data.id;
emit('success');
if (callback) {
callback();
} else {
cancelActorDialog();
}
} else {
ElMessage.error(res.msg);
}
}).catch(() => {
ElMessage.error('更新失败');
})
}
const uploadImageRef = ref<any>(null);
const uploadReferenceImageRef = ref<any>(null);
const uploadHeadimgLoading = ref(false);
const uploadThreeViewLoading = ref(false);
const uploadLoading = ref(false);
const handleUploadSuccess = (response: any) => {
if (response.code === ResponseCode.SUCCESS) {
switch (response.data.dir_name) {
case 'actor/image':
uploadHeadimgLoading.value = false;
actorForm.headimg = response.data.url;
uploadImageRef.value?.clearFiles();
break;
case 'actor/three_view':
uploadThreeViewLoading.value = false;
actorForm.three_view_image = response.data.url;
uploadImageRef.value?.clearFiles();
break;
case 'actor/reference':
uploadLoading.value = false;
actorForm.image_reference_state = true;
actorForm.reference_headimg = response.data.url;
uploadReferenceImageRef.value?.clearFiles();
break;
}
} else {
ElMessage.error(response.msg);
}
}
const handleUploadError = () => {
uploadImageRef.value?.clearFiles();
uploadReferenceImageRef.value?.clearFiles();
}
const actorHeadimgButtonRef = ref();
const actorHeadimgPopoverRef = ref();
const actorHeadimgModel = ref<any>({});
const handleActorHeadimgSelect = (item?: any) => {
if (item) {
actorForm.image_model_id = item.id;
actorForm.image_state = true;
actorHeadimgModel.value = item;
} else {
actorForm.image_model_id = '';
actorForm.image_state = false;
actorHeadimgModel.value = {};
}
actorHeadimgPopoverRef.value?.hide();
}
const actorThreeViewModelButtonRef = ref();
const actorThreeViewModelPopoverRef = ref();
const actorImageThreeViewModel = ref<any>({});
const handleActorThreeViewModelSelect = (item?: any) => {
if (item) {
actorForm.three_view_model_id = item.id;
actorImageThreeViewModel.value = item;
actorForm.three_view_image_state = true;
} else {
actorForm.three_view_model_id = '';
actorImageThreeViewModel.value = {};
actorForm.three_view_image_state = false;
}
actorThreeViewModelPopoverRef.value?.hide();
}
const points = usePoints([actorHeadimgModel, actorImageThreeViewModel]);
const generateImageLoading = ref(false);
const handleGenerateImage = () => {
if (generateImageLoading.value || actorForm.status_enum.value === 'pending') return;
if (!actorForm.id) {
return submitActorDialog(handleGenerateImage);
}
generateImageLoading.value = true;
$http.post('/app/shortplay/api/Actor/initializing', actorForm).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
actorForm.status = res.data.status;
actorForm.status_enum = res.data.status_enum;
} else {
ElMessage.error(res.msg);
}
}).catch(() => {
ElMessage.error('生成图片失败');
}).finally(() => {
generateImageLoading.value = false;
})
}
const subscribe = (channel: string, res: any) => {
if (channel === 'generateactorimage') {
if (res.id === actorForm.id) {
actorForm.status_enum = res.status;
if (res.image) {
actorForm.headimg = res.image;
} else {
generateImageLoading.value = false;
}
}
} else if (channel === 'generateactorthreeviewimage') {
if (res.id === actorForm.id) {
actorForm.status_enum = res.status;
if (res.image) {
actorForm.three_view_image = res.image;
} else {
generateImageLoading.value = false;
}
}
}
}
const actorImageModel = ref('remarks');
const uploadImageModel = ref('headimg');
const voiceDialogRef = ref<any>();
const handleVoiceSuccess = (data: any) => {
actorForm.voice_channel = data.voice_channel;
actorForm.voice_id = data.voice_id;
actorForm.voice_name = data.name;
actorForm.voice_model_id = data.model_id;
}
defineExpose({
open: openActorCreateDialog,
upload: uploadActor,
close: cancelActorDialog,
subscribe: subscribe
})
</script>
<template>
<div>
<el-dialog v-model="actorDialogVisible" class="generate-scene-dialog" draggable :width="showForm ? 'min(100%,840px)' : 'min(100%,420px)'" append-to-body
@close="cancelActorDialog">
<template #header>
<span class="font-weight-600" v-if="!showForm">上传演员</span>
<span class="font-weight-600" v-else-if="!actorForm.id">创建演员</span>
<span class="font-weight-600" v-else>编辑演员</span>
</template>
<el-form label-position="top" :model="actorForm" :rules="actorFormRules" ref="actorFormRef"
class="actor-form" :disabled="actorForm.status_enum.value !== 'initializing'">
<div class="flex grid-gap-4 flex-y-flex-start">
<div class="flex-1 grid-columns-6 grid-gap-4" v-if="showForm">
<el-form-item label="演员名称" prop="title" class="grid-column-3">
<el-input v-model="actorForm.name" placeholder="请输入演员名称"
class="actor-form-input bg-overlay" />
</el-form-item>
<el-form-item label="演员音色" prop="title" class="grid-column-3">
<el-button text bg @click="voiceDialogRef?.open?.({ modelScene:'dialogue_voice',actor: actorForm })">
<span v-if="actorForm.voice_name">音色:{{ actorForm.voice_name }}</span>
<span v-else>点击选择音色</span>
</el-button>
</el-form-item>
<el-form-item label="物种" prop="title" class="grid-column-2">
<el-select v-model="actorForm.species_type" placeholder="请选择物种" :teleported="false"
class="actor-form-select">
<el-option v-for="item in WEBCONFIG?.enum?.actor_species_type" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="性别" prop="title" class="grid-column-2">
<el-select v-model="actorForm.gender" placeholder="请选择性别" :teleported="false"
class="actor-form-select">
<el-option v-for="item in WEBCONFIG?.enum?.actor_gender" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="年龄" prop="title" class="grid-column-2">
<el-select v-model="actorForm.age" placeholder="请选择年龄" :teleported="false"
class="actor-form-select">
<el-option v-for="item in WEBCONFIG?.enum?.actor_age" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="演员形象" class="grid-column-6">
<div class="flex flex-column grid-gap-4 w-100">
<el-segmented v-model="actorImageModel" class="tabs-segmented"
:options="[{ label: '文本生成', value: 'remarks' }, { label: '本地上传', value: 'upload' }]" />
<div class="bg-overlay rounded-4 p-4 w-100">
<el-input v-model="actorForm.remarks" placeholder="请输入角色描述" size="small"
class="actor-form-textarea" type="textarea"
:autosize="{ minRows: 6, maxRows: 20 }" />
<div class="flex flex-y-center grid-gap-2 line-height-1 mt-4"
v-if="actorImageModel === 'remarks'">
<div class="bg rounded-round p-3 flex flex-center grid-gap-2 pointer hover-bg-hover"
ref="actorHeadimgButtonRef" title="选择使用AI生成形象图">
<template v-if="actorHeadimgModel.id">
<el-avatar :src="actorHeadimgModel.icon" :alt="actorHeadimgModel.name"
shape="square" :size="16"></el-avatar>
<span class="h10 text-ellipsis-1" style="max-width: 60px;">{{
actorHeadimgModel.name
}}</span>
<el-icon size="16" class="pointer"
@click.stop="handleActorHeadimgSelect()">
<Close />
</el-icon>
</template>
<template v-else>
<el-icon size="16">
<IconModelSvg />
</el-icon>
<span class="h10 overflow-hidden text-nowrap"
style="max-width: 60px;">形象图</span>
<el-icon size="16">
<ArrowDown />
</el-icon>
</template>
</div>
<div class="bg rounded-round p-3 flex flex-center grid-gap-2 pointer hover-bg-hover"
ref="actorThreeViewModelButtonRef" title="选择使用AI生成三视图">
<template v-if="actorImageThreeViewModel.id">
<el-avatar :src="actorImageThreeViewModel.icon"
:alt="actorImageThreeViewModel.name" shape="square"
:size="16"></el-avatar>
<span class="h10 text-ellipsis-1" style="max-width: 60px;">{{
actorImageThreeViewModel.name }}</span>
<el-icon size="16" class="pointer"
@click.stop="handleActorThreeViewModelSelect()">
<Close />
</el-icon>
</template>
<template v-else>
<el-icon size="16">
<IconModelSvg />
</el-icon>
<span class="h10 overflow-hidden text-nowrap"
style="max-width: 60px;">三视图</span>
<el-icon size="16">
<ArrowDown />
</el-icon>
</template>
</div>
<el-upload ref="uploadReferenceImageRef" title="添加参考图"
:data="{ dir_name: 'actor/reference', dir_title: '演员形象参考照片' }"
:action="$http.getCompleteUrl('app/shortplay/api/Uploads/upload')"
:headers="$http.getHeaders()" accept="image/jpeg,image/png" :limit="1"
type="cover" :disabled="uploadLoading"
:before-upload="() => { uploadLoading = true; return true; }"
:on-success="handleUploadSuccess" :show-file-list="false"
:on-error="() => { uploadLoading = false; handleUploadError() }">
<div
class="bg rounded-round p-3 flex flex-center grid-gap-2 pointer hover-bg-hover">
<el-popover placement="top" :disabled="!actorForm.reference_headimg"
width="fit-content">
<el-avatar :src="actorForm.reference_headimg" fit="contain"
:size="130" shape="square"></el-avatar>
<template #reference>
<el-icon size="16"
v-if="!actorForm.reference_headimg || uploadLoading">
<Loading class="circular" v-if="uploadLoading" />
<Plus v-else />
</el-icon>
<el-icon size="16" v-else color="var(--el-color-success)">
<PictureRounded />
</el-icon>
</template>
</el-popover>
</div>
</el-upload>
<div class="flex-1"></div>
<div class="flex flex-center grid-gap-2">
<el-icon size="16">
<IconPointsSvg />
</el-icon>
<span class="h10 text-nowrap">{{ points }}</span>
</div>
<div class="rounded-round p-3 flex flex-center grid-gap-2 pointer"
style="background-color: #FFFFFF;color:#141414;"
@click="handleGenerateImage">
<el-icon size="20">
<Loading class="circular" v-if="generateImageLoading||actorForm.status_enum.value === 'pending'" />
<Top v-else />
</el-icon>
</div>
</div>
</div>
</div>
</el-form-item>
</div>
<div class="flex flex-column grid-gap-4 p-4 bg-overlay rounded-4"
v-if="actorImageModel === 'remarks'">
<el-avatar :src="actorForm.headimg" shape="square" :size="360"
style="--el-avatar-bg-color:var(--el-bg-color-overlay)">
<div class="flex flex-column flex-center grid-gap-2"
v-if="actorForm.status_enum.value === 'initializing'">
<el-icon size="64">
<Loading class="circular" v-if="generateImageLoading" />
<IconImageSvg v-else />
</el-icon>
<span class="h10">填写左侧表单使用AI生成演员形象图</span>
<div class="flex flex-center grid-gap-2 h10">
<span>点击</span>
<div class="rounded-round p-1 flex flex-center grid-gap-2"
style="background-color: #FFFFFF;color:#141414;">
<el-icon size="14">
<Top />
</el-icon>
</div>
<span>按钮生成</span>
</div>
</div>
<div class="flex flex-column flex-center grid-gap-2"
v-else-if="actorForm.status_enum.value === 'pending'">
<el-icon size="64">
<Loading class="circular" />
</el-icon>
<span class="h10">生成中...</span>
</div>
</el-avatar>
</div>
<div class="flex flex-column grid-gap-4 bg-overlay rounded-4 p-4" v-else>
<div>
<el-upload v-if="uploadImageModel === 'headimg'" ref="uploadImageRef"
class="input-upload rounded-4" drag
:data="{ dir_name: 'actor/image', dir_title: '演员形象照片' }"
:action="$http.getCompleteUrl('app/shortplay/api/Uploads/upload')"
:headers="$http.getHeaders()" accept="image/jpeg,image/png" :limit="1" type="cover"
:disabled="uploadHeadimgLoading"
:before-upload="() => { uploadHeadimgLoading = true; return true; }"
:on-success="handleUploadSuccess" :show-file-list="false"
:on-error="() => { uploadHeadimgLoading = false; handleUploadError() }">
<template v-if="!actorForm.headimg">
<el-icon class="el-icon--upload">
<IconUploadImageSvg />
</el-icon>
<div class="el-upload__text">
<span class="h10">拖拽演员形象照片到此处或</span>
<span class="h10">点击上传</span>
</div>
<div class="el-upload__text">
<span class="h10">支持上传格式</span>
<span class="h10">PNG, JPG, JPEG</span>
</div>
<div class="el-upload__text">
<span class="h10">形象照片建议比例</span>
<span class="h10">1:1</span>
</div>
</template>
<template v-else>
<el-image :src="actorForm.headimg" class="image-cover" fit="contain"></el-image>
</template>
</el-upload>
<el-upload v-else-if="uploadImageModel === 'three_view'" ref="uploadImageRef"
class="input-upload rounded-4" drag
:data="{ dir_name: 'actor/three_view', dir_title: '演员三视图' }"
:action="$http.getCompleteUrl('app/shortplay/api/Uploads/upload')"
:headers="$http.getHeaders()" accept="image/jpeg,image/png" :limit="1" type="cover"
:disabled="uploadThreeViewLoading"
:before-upload="() => { uploadThreeViewLoading = true; return true; }"
:on-success="handleUploadSuccess" :show-file-list="false"
:on-error="() => { uploadThreeViewLoading = false; handleUploadError() }">
<template v-if="!actorForm.three_view_image">
<el-icon class="el-icon--upload">
<IconUploadImageSvg />
</el-icon>
<div class="el-upload__text">
<span class="h10">拖拽演员三视图到此处或</span>
<span class="h10">点击上传</span>
</div>
<div class="el-upload__text">
<span class="h10">支持上传格式</span>
<span class="h10">PNG, JPG, JPEG</span>
</div>
<div class="el-upload__text">
<span class="h10">形象照片建议比例</span>
<span class="h10">1:1</span>
</div>
</template>
<template v-else>
<el-image :src="actorForm.three_view_image" class="image-cover"
fit="contain"></el-image>
</template>
</el-upload>
</div>
<div class="flex flex-center grid-gap-4">
<el-avatar :src="actorForm.headimg" shape="square" :size="60" class="pointer"
@click="uploadImageModel = 'headimg'">
<div class="flex flex-column flex-center grid-gap-1"
:class="{ 'text-secondary': uploadImageModel === 'three_view' }">
<el-icon size="20">
<Loading class="circular" v-if="uploadHeadimgLoading" />
<IconActorSvg v-else />
</el-icon>
<span class="h10">形象图</span>
</div>
</el-avatar>
<el-avatar :src="actorForm.three_view_image" shape="square" :size="60" class="pointer"
@click="uploadImageModel = 'three_view'">
<div class="flex flex-column flex-center grid-gap-1"
:class="{ 'text-secondary': uploadImageModel === 'headimg' }">
<el-icon size="20">
<Loading class="circular" v-if="uploadThreeViewLoading" />
<IconActorThreeViewSvg v-else />
</el-icon>
<span class="h10">三视图</span>
</div>
</el-avatar>
</div>
</div>
</div>
</el-form>
<template #footer>
<div class="flex flex-center grid-gap-2 w-100">
<el-button type="info" @click="cancelActorDialog" :disabled="actorLoading">取消</el-button>
<div class="flex-1"></div>
<el-button type="success" @click="submitActorDialog()" :disabled="actorLoading"
:loading="actorLoading">提交</el-button>
</div>
</template>
</el-dialog>
<el-popover ref="actorHeadimgPopoverRef" :virtual-ref="actorHeadimgButtonRef" virtual-triggering
:teleported="false" placement="bottom-start" width="min(100vw,380px)" trigger="click">
<xl-models @select="handleActorHeadimgSelect" scene="actor_image" no-init />
</el-popover>
<el-popover ref="actorThreeViewModelPopoverRef" :virtual-ref="actorThreeViewModelButtonRef" virtual-triggering
:teleported="false" placement="bottom-start" width="min(100vw,380px)" trigger="click">
<xl-models @select="handleActorThreeViewModelSelect" scene="actor_three_view_image" no-init />
</el-popover>
<xl-voice ref="voiceDialogRef" @success="handleVoiceSuccess" />
</div>
</template>
<style lang="scss" scoped>
.input-upload {
width: 360px;
border: dashed 1px var(--el-border-color);
:deep(.el-upload) {
--el-fill-color-blank: var(--el-bg-color-overlay);
--el-color-primary: var(--el-border-color-hover);
--el-upload-dragger-padding-horizontal: 0;
--el-upload-dragger-padding-vertical: 0;
.el-upload-dragger {
border: none;
height: 360px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 6px;
}
}
.image-cover {
width: 360px;
height: 360px;
}
}
.input-button {
background-color: var(--el-fill-color-darker);
cursor: pointer;
&:hover {
background-color: var(--el-fill-color-dark);
}
}
.icon-model {
font-size: 20px;
width: 22px;
height: 22px;
}
.actor-form {
&-input {
:deep(.el-input__wrapper) {
box-shadow: none;
}
}
&-select {
:deep(.el-select__wrapper) {
background-color: var(--el-bg-color);
}
}
&-textarea {
:deep(.el-textarea__inner) {
box-shadow: none;
padding: 0;
resize: none;
border-radius:0;
}
}
}
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
const props = defineProps<{
modelValue: string;
}>();
const emit = defineEmits(['update:modelValue']);
const handleSelect = (aspectRatio: string) => {
emit('update:modelValue', aspectRatio);
}
const showArrowDown = ref(false);
const aspectRatioList = ref<string[]>(['9:16', '16:9', '3:4', '4:3', '2:3', '3:2', '1:1']);
</script>
<template>
<el-popover :show-arrow="false" trigger="click" placement="bottom-start" width="fit-content" popper-class="model-popover" @show="showArrowDown = false"
@hide="showArrowDown = true">
<template #reference>
<slot>
<div class="flex flex-center grid-gap-2 input-button input-button-selected px-6">
<span class="icon-aspect-ratio" :view="props.modelValue"></span>
<span class="h10">{{ props.modelValue }}</span>
<el-icon v-if="showArrowDown">
<ArrowUpBold />
</el-icon>
<el-icon v-else>
<ArrowDownBold />
</el-icon>
</div>
</slot>
</template>
<!-- <span class="h10">选择比例</span> -->
<el-scrollbar height="300px">
<div class="grid-columns-2 grid-gap-4 text-center mt-4">
<div v-for="item in aspectRatioList" :key="item"
class="grid-column-2 grid-gap-2 input-button rounded-4 p-2" :class="{ 'input-button-selected': props.modelValue === item }" @click.stop="handleSelect(item)">
<span class="icon-aspect-ratio" :view="item"></span>
<span class="font-weight-600">{{ item }}</span>
<el-icon v-if="props.modelValue === item" class="ml-auto">
<Check />
</el-icon>
</div>
</div>
</el-scrollbar>
</el-popover>
</template>
<style scoped lang="scss">
</style>
@@ -0,0 +1,336 @@
<script setup lang="ts">
import { ResponseCode } from '@/common/const';
import { $http } from '@/common/http';
import { usePoints } from '@/composables/usePoints';
import IconModelSvg from '@/svg/icon/icon-model.vue';
import IconPointsSvg from '@/svg/icon/icon-points.vue';
import { useUserStore, useRefs } from '@/stores';
import { usePush } from '@/composables/usePush';
const userStore = useUserStore();
const { USERINFO } = useRefs(userStore);
const props = withDefaults(defineProps<{
query?: any
types?: any[]
}>(), {
query: () => ({}),
types: () => ([]),
});
const emit = defineEmits(['select']);
const CharacterLookSearch = reactive({
type: 'all',
description: '',
actor_id: '',
...props.query,
})
const characterLookList = ref<any[]>([]);
const characterLookDialogVisible = ref(false);
const loading = ref(false);
const getCharacterLookList = () => {
loading.value = true;
characterLookList.value = [];
$http.get('/app/shortplay/api/CharacterLook/index', { params: CharacterLookSearch }).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
currentCharacterLook.value = {};
characterLookList.value = res.data;
}
}).finally(() => {
loading.value = false;
})
}
const currentCharacterLook = ref<any>({});
const handleCharacterLookItemClick = (item: any) => {
if (item.status_enum.value !== 'generated') return ElMessage.info('该装扮还未初始化完成');
currentCharacterLook.value = item;
}
const handleCharacterLookConfirm = () => {
characterLookDialogVisible.value = false;
emit('select', {
...currentCharacterLook.value,
type: CharacterLookSearch.type,
actor_costume_model_id: actorCostumeModel.value.id,
actor_costume_three_view_model_id: actorCostumeThreeViewModel.value.id,
});
}
const modelLoading = ref(false);
const actorCostumeButtonRef = ref();
const actorCostumePopoverRef = ref();
const actorCostumeModel = ref<any>({});
const handleActorCostumeSelect = (item: any) => {
actorCostumeModel.value = item;
actorCostumePopoverRef.value?.hide();
}
const actorCostumeThreeViewButtonRef = ref();
const actorCostumeThreeViewPopoverRef = ref();
const actorCostumeThreeViewModel = ref<any>({});
const points = usePoints([actorCostumeModel, actorCostumeThreeViewModel]);
const handleActorCostumeThreeViewSelect = (item: any) => {
actorCostumeThreeViewModel.value = item;
actorCostumeThreeViewPopoverRef.value?.hide();
}
const openCharacterLookDialog = (options?: any) => {
if (options) {
if (options?.actor) {
CharacterLookSearch.actor_id = options.actor.id;
} else {
CharacterLookSearch.actor_id = '';
}
}
getCharacterLookList();
addListener();
characterLookDialogVisible.value = true;
}
const handleBeforeClose = (done: any) => {
if (modelLoading.value) return;
done();
}
const initCurrentCharacterLook = ref();
const initModelDialogVisible = ref(false);
const initLoading = ref(false);
const handleCharacterLookInit = (item: any) => {
initCurrentCharacterLook.value = item;
initModelDialogVisible.value = true;
}
const handleCharacterLookInitSubmit = () => {
if (initLoading.value) return;
initLoading.value = true;
$http.post('/app/shortplay/api/Generate/characterLookCostume', initCurrentCharacterLook.value).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
getCharacterLookList();
initModelDialogVisible.value = false;
} else {
ElMessage.info(res.msg);
}
})
.catch(() => {
ElMessage.error('初始化失败');
})
.finally(() => {
initLoading.value = false;
})
}
const { subscribe, unsubscribeAll } = usePush();
const addListener = () => {
subscribe('private-generatecharacterlookcostume-' + USERINFO.value?.user, (res: any) => {
console.log('channels complete info message', res);
const findItem = characterLookList.value.find((item: any) => item.id === res.id);
if (findItem) {
getCharacterLookList();
}
});
}
onUnmounted(() => {
unsubscribeAll();
})
const xlCharacterLookCreateRef = ref();
defineExpose({
open: openCharacterLookDialog,
})
</script>
<template>
<div>
<el-dialog v-model="characterLookDialogVisible" class="generate-scene-dialog" draggable width="min(100%,800px)"
:close-on-press-escape="false" :close-on-click-modal="false" :before-close="handleBeforeClose">
<template #header>
<span class="font-weight-600">演员装扮</span>
</template>
<div class="flex flex-column grid-gap-4" v-loading="modelLoading">
<el-form class="flex flex-center grid-gap-4" @submit.prevent="getCharacterLookList">
<el-form-item class="mb-0">
<xl-tabs v-model="CharacterLookSearch.type" class="text-info" @change="getCharacterLookList">
<xl-tabs-item value="all">全部</xl-tabs-item>
<xl-tabs-item value="drama">本剧</xl-tabs-item>
<xl-tabs-item value="episode">本集</xl-tabs-item>
<xl-tabs-item value="actor" v-if="CharacterLookSearch.actor_id">演员装扮</xl-tabs-item>
</xl-tabs>
</el-form-item>
<div class="flex-1"></div>
<el-form-item class="mb-0">
<el-input v-model="CharacterLookSearch.description" placeholder="搜索装扮" clearable
@change="getCharacterLookList">
<template #suffix>
<el-icon>
<Search />
</el-icon>
</template>
</el-input>
</el-form-item>
</el-form>
<el-scrollbar height="40vh" v-loading="loading" v-if="characterLookList.length > 0 || loading">
<div class="grid-columns-5 grid-gap-4">
<div v-if="CharacterLookSearch.type != 'actor'"
class="grid-column-1 flex flex-column flex-center grid-gap-2 character-look-item"
@click="xlCharacterLookCreateRef?.open?.();">
<div class="character-look-item-image flex flex-center bg-overlay rounded-4">
<el-icon size="50" class="border rounded-4 p-4 border-2">
<Plus />
</el-icon>
</div>
<span>新增装扮</span>
</div>
<div class="grid-column-1 flex flex-column flex-center grid-gap-2 character-look-item"
:class="{ 'character-look-item-active': currentCharacterLook.id === item.id }"
v-for="item in characterLookList" @click="handleCharacterLookItemClick(item)">
<template v-if="item.component === 'actor'">
<el-avatar :src="item.headimg" shape="square" fit="fill"
class="character-look-item-image" />
<span>{{ item.characterLook.title }}</span>
</template>
<template v-else>
<el-avatar :src="item.status_enum.value === 'generated' ? item.costume_url : ''"
shape="square" fit="fill" class="character-look-item-image bg-mosaic">
<template v-if="item.status_enum.value === 'pending'">
<div class="flex flex-column flex-center grid-gap-2">
<el-icon>
<Loading class="circular" />
</el-icon>
<span class="text-info h10">初始化中</span>
</div>
</template>
<template v-else-if="item.status_enum.value === 'initializing'">
<el-button type="success" size="small" text
@click.stop="handleCharacterLookInit(item)">立即初始化</el-button>
</template>
</el-avatar>
<span>{{ item.title }}</span>
</template>
</div>
</div>
</el-scrollbar>
<el-empty v-else description="暂无装扮">
<el-button type="success" bg text @click="xlCharacterLookCreateRef?.open?.();">新增装扮</el-button>
</el-empty>
</div>
<template #footer>
<el-button bg text @click="characterLookDialogVisible = false" :loading="modelLoading">取消</el-button>
<div class="flex-1"></div>
<template v-if="CharacterLookSearch.type !== 'actor'">
<div class="flex flex-center grid-gap-2 input-button rounded-4 py-2 px-6"
ref="actorCostumeButtonRef">
<template v-if="!actorCostumeModel.id">
<el-icon alt="角色换装" class="icon-model" color="var(--el-color-success)">
<IconModelSvg />
</el-icon>
<span class="h10">选择角色换装模型</span>
</template>
<template v-else>
<el-avatar :src="actorCostumeModel.icon" :alt="actorCostumeModel.name" shape="square"
class="icon-model"></el-avatar>
<span class="h10">{{ actorCostumeModel.name }}</span>
</template>
</div>
<div class="flex flex-center grid-gap-2 input-button rounded-4 py-2 px-6"
ref="actorCostumeThreeViewButtonRef">
<template v-if="!actorCostumeThreeViewModel.id">
<el-icon alt="角色换装三视图" class="icon-model" color="var(--el-color-success)">
<IconModelSvg />
</el-icon>
<span class="h10">选择角色换装三视图模型</span>
</template>
<template v-else>
<el-avatar :src="actorCostumeThreeViewModel.icon" :alt="actorCostumeThreeViewModel.name"
shape="square" class="icon-model"></el-avatar>
<span class="h10">{{ actorCostumeThreeViewModel.name }}</span>
</template>
</div>
<div class="flex flex-center grid-gap-2">
<el-icon size="16">
<IconPointsSvg />
</el-icon>
<span class="h10">{{ points }}</span>
</div>
</template>
<el-button type="success" @click="handleCharacterLookConfirm" :loading="modelLoading"
:disabled="!currentCharacterLook.id">
<span>确认选择</span>
</el-button>
</template>
</el-dialog>
<el-popover ref="actorCostumePopoverRef" :virtual-ref="actorCostumeButtonRef" virtual-triggering
placement="bottom-start" width="min(100vw,380px)" trigger="click">
<xl-models v-model="actorCostumeModel.id" @select="handleActorCostumeSelect" scene="actor_costume" />
</el-popover>
<el-popover ref="actorCostumeThreeViewPopoverRef" :virtual-ref="actorCostumeThreeViewButtonRef"
virtual-triggering placement="bottom-start" width="min(100vw,380px)" trigger="click">
<xl-models v-model="actorCostumeThreeViewModel.id" @select="handleActorCostumeThreeViewSelect"
scene="actor_costume_three_view" />
</el-popover>
<xl-character-look-create ref="xlCharacterLookCreateRef" :query="props.query"
@success="CharacterLookSearch.type = 'all'; getCharacterLookList();" />
<el-dialog v-model="initModelDialogVisible" class="generate-storyboard-dialog" draggable>
<template #header>
<span class="font-weight-600">初始化装扮</span>
</template>
<xl-models v-model="initCurrentCharacterLook.model_id" scene="character_look_costume" no-init />
<template #footer>
<div class="flex flex-center grid-gap-2">
<el-button type="info" @click="initModelDialogVisible = false"
:disabled="initLoading">取消</el-button>
<el-button type="success" icon="Check" @click="handleCharacterLookInitSubmit"
:disabled="!initCurrentCharacterLook.model_id || initLoading"
:loading="initLoading">初始化</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.character-look-item {
cursor: pointer;
&-image {
--el-avatar-border-radius: 8px;
width: 100%;
height: 137px;
border-style: solid;
border-width: 2px;
border-color: transparent;
}
&-active {
.character-look-item-image {
border-color: var(--el-color-success);
}
}
}
.input-upload {
width: 300px;
:deep(.el-upload) {
--el-fill-color-blank: var(--el-bg-color-overlay);
--el-color-primary: var(--el-border-color-hover);
--el-upload-dragger-padding-horizontal: 0;
--el-upload-dragger-padding-vertical: 0;
.el-upload-dragger {
border: none;
min-height: 300px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 6px;
}
}
.image-cover {
width: 300px;
height: 300px;
}
}
.input-button {
background-color: var(--el-fill-color-darker);
cursor: pointer;
&:hover {
background-color: var(--el-fill-color-dark);
}
}
.icon-model {
font-size: 20px;
width: 22px;
height: 22px;
}
</style>
@@ -0,0 +1,242 @@
<script setup lang="ts">
import { ResponseCode } from '@/common/const';
import { $http } from '@/common/http';
import { ElMessage } from 'element-plus';
import IconModelSvg from '@/svg/icon/icon-model.vue';
import IconPointsSvg from '@/svg/icon/icon-points.vue';
import { uniqid } from '@/common/functions';
import { usePoints } from '@/composables/usePoints';
const props = withDefaults(defineProps<{
query?: any
types?: any[]
}>(), {
query: () => ({}),
types: () => ([]),
});
const emit = defineEmits(['success']);
const CharacterLookSearch = reactive({
type: 'all',
description: '',
...props.query,
})
const characterLookList = ref<any[]>([]);
const characterLookDialogVisible = ref(false);
const loading = ref(false);
const getCharacterLookList = () => {
loading.value = true;
characterLookList.value = [];
$http.get('/app/shortplay/api/CharacterLook/index', { params: CharacterLookSearch }).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
characterLookList.value = res.data;
}
}).finally(() => {
loading.value = false;
})
}
const createForm = reactive({
...props.query,
alias_id: uniqid(),
title: '',
overall_style: '',
makeup: '',
hair_style: '',
costume: '',
costume_url: '',
status_note: '',
costume_reference_state: false,
model_id: ''
})
const characterLookFormRef = ref<any>();
const uploadImageRef = ref<any>(null);
const uploadLoading = ref(false);
const handleUploadSuccess = (response: any) => {
uploadLoading.value = false;
if (response.code === ResponseCode.SUCCESS) {
switch (response.data.dir_name) {
case 'actor/costume':
createForm.costume_url = response.data.url;
if (createForm.model_id) {
createForm.costume_reference_state = true;
}
uploadImageRef.value?.clearFiles();
break;
}
} else {
ElMessage.error(response.msg);
}
}
const handleUploadError = () => {
uploadLoading.value = false;
uploadImageRef.value?.clearFiles();
}
const createLoading = ref(false);
const submit = () => {
if (createLoading.value) return;
createLoading.value = true;
$http.post('/app/shortplay/api/CharacterLook/update', createForm).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
ElMessage.success(res.msg);
characterLookFormRef.value?.resetFields();
emit('success');
characterLookDialogVisible.value=false;
} else {
ElMessage.error(res.msg);
}
}).catch(() => {
ElMessage.error('创建失败');
}).finally(() => {
createLoading.value = false;
})
}
const actorCostumeButtonRef = ref();
const actorCostumePopoverRef = ref();
const actorCostumeModel = ref<any>({});
const handleModelSelect = (item: any) => {
actorCostumeModel.value = item;
if (createForm.costume_url) {
createForm.costume_reference_state = true;
}
actorCostumePopoverRef.value?.hide();
}
const points = usePoints([actorCostumeModel]);
const openCharacterLookDialog = () => {
getCharacterLookList();
characterLookDialogVisible.value = true;
}
const handleBeforeClose = (done: any) => {
if (createLoading.value) return;
done();
}
defineExpose({
open: openCharacterLookDialog,
})
</script>
<template>
<div>
<el-dialog v-model="characterLookDialogVisible" class="generate-scene-dialog" draggable width="min(100%,800px)"
:close-on-press-escape="false" :close-on-click-modal="false" :before-close="handleBeforeClose">
<template #header>
<span class="font-weight-600">演员装扮</span>
</template>
<el-form label-position="right" label-width="80px" :model="createForm" size="large"
ref="characterLookFormRef">
<div class="grid-columns-2 grid-gap-x-10 grid-gap-y-4 character-look-form">
<el-form-item label="装扮名称" prop="title" class="grid-column-2">
<el-input v-model="createForm.title" placeholder="请输入装扮名称" class="form-input" />
</el-form-item>
<el-form-item label="整体" prop="overall_style" class="grid-column-1">
<el-input v-model="createForm.overall_style" placeholder="整体(如:日常、精致、狼狈、正式)"
class="form-input" />
</el-form-item>
<el-form-item label="状态" prop="status_note" class="grid-column-1">
<el-input v-model="createForm.status_note" placeholder="状态(凌乱、破损)" class="form-input" />
</el-form-item>
<el-form-item label="妆容" prop="makeup" class="grid-column-1">
<el-input v-model="createForm.makeup" placeholder="妆容(淡妆/浓妆/素颜感等)" class="form-input" />
</el-form-item>
<el-form-item label="发型" prop="hair_style" class="grid-column-1">
<el-input v-model="createForm.hair_style" placeholder="发型描述" class="form-input" />
</el-form-item>
<div class="grid-column-2 flex flex-column grid-gap-4">
<span>服饰描述</span>
<div class="flex flex-column bg-overlay rounded-4 p-4">
<el-input type="textarea" v-model="createForm.costume" placeholder="服饰描述(颜色+款式+搭配)"
class="form-textarea" :autosize="{ minRows: 3, maxRows: 6 }" />
<el-upload ref="uploadImageRef" :data="{ dir_name: 'actor/costume', dir_title: '服饰图片' }"
:action="$http.getCompleteUrl('app/shortplay/api/Uploads/upload')"
:headers="$http.getHeaders()" accept="image/jpeg,image/png" :limit="1" type="cover"
:disabled="uploadLoading" :before-upload="() => { uploadLoading = true; return true; }"
:on-success="handleUploadSuccess" :show-file-list="false" :on-error="handleUploadError">
<div
class="bg rounded-round p-3 flex flex-center grid-gap-2 pointer hover-bg-hover">
<el-popover placement="top" :disabled="!createForm.costume_url" width="fit-content">
<el-avatar :src="createForm.costume_url" fit="contain" :size="130"
shape="square"></el-avatar>
<template #reference>
<el-icon v-if="!createForm.costume_url || uploadLoading" color="#FFFFFF">
<Loading class="circular" v-if="uploadLoading" />
<Plus v-else />
</el-icon>
<el-icon v-else color="var(--el-color-success)">
<PictureRounded />
</el-icon>
</template>
</el-popover>
</div>
</el-upload>
</div>
</div>
</div>
</el-form>
<template #footer>
<el-button @click="characterLookDialogVisible = false;" bg text :disabled="createLoading">取消</el-button>
<div class="flex-1"></div>
<div class="flex flex-center grid-gap-2 input-button rounded-4 py-2 px-6" ref="actorCostumeButtonRef">
<template v-if="!actorCostumeModel.id">
<el-icon alt="角色装扮" class="icon-model" color="var(--el-color-success)">
<IconModelSvg />
</el-icon>
<span class="h10">选择装扮换装模型</span>
</template>
<template v-else>
<el-avatar :src="actorCostumeModel.icon" :alt="actorCostumeModel.name" shape="square"
class="icon-model"></el-avatar>
<span class="h10">{{ actorCostumeModel.name }}</span>
<el-icon
@click.stop="actorCostumeModel = {}; createForm.model_id = ''; createForm.costume_reference_state = false;">
<Close />
</el-icon>
</template>
</div>
<div class="flex flex-center grid-gap-2">
<el-icon size="16">
<IconPointsSvg />
</el-icon>
<span class="h10">{{ points }}</span>
</div>
<el-button type="success" @click="submit()" :loading="createLoading">创建</el-button>
</template>
</el-dialog>
<el-popover ref="actorCostumePopoverRef" :virtual-ref="actorCostumeButtonRef" virtual-triggering
placement="bottom-start" width="min(100vw,380px)" trigger="click">
<xl-models v-model="createForm.model_id" @select="handleModelSelect" scene="character_look_costume"
no-init />
</el-popover>
</div>
</template>
<style lang="scss" scoped>
.character-look-form {
.form-input {
--el-input-bg-color: var(--el-bg-color-overlay);
:deep(.el-input__wrapper) {
box-shadow: none;
}
}
.form-textarea {
:deep(.el-textarea__inner) {
box-shadow: none;
padding: 0;
resize: none;
}
}
}
.input-button {
background-color: var(--el-fill-color-darker);
cursor: pointer;
&:hover {
background-color: var(--el-fill-color-dark);
}
}
.icon-model {
font-size: 20px;
width: 22px;
height: 22px;
}
</style>
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { ResponseCode } from '@/common/const';
import { $http } from '@/common/http';
import { useRefs, useWebConfigStore } from '@/stores';
import { ElMessage } from 'element-plus';
const webConfigStore = useWebConfigStore();
const { WEBCONFIG } = useRefs(webConfigStore);
const dialogueCreateDialogVisible = ref(false);
const storyboard = ref<any>();
const dialogueForm = ref<any>({
id: '',
drama_id: '',
storyboard_id: '',
actor_id: '',
prosody_speed: 1,
prosody_volume: 50,
emotion: '',
start_time: 0,
end_time: 1000,
content: '',
actor: {
name: "选择演员",
headimg: "",
}
});
const dialogueFormRules = ref<any>({
actor_id: [{ required: true, message: '请选择演员', trigger: 'change' }],
content: [{ required: true, message: '请输入对话内容', trigger: 'blur' }],
prosody_speed: [{ required: true, message: '请选择语速', trigger: 'change' }],
prosody_volume: [{ required: true, message: '请选择音量', trigger: 'change' }],
emotion: [{ required: true, message: '请选择情感', trigger: 'change' }],
start_time: [{ required: true, message: '请选择字幕&语音开始时间', trigger: 'change' }],
end_time: [{ required: true, message: '请选择字幕结束时间', trigger: 'change' }],
});
const emit = defineEmits(['success']);
const dialogueFormRef = ref<any>();
const openDialogueCreateDialog = (item: any, form?: any) => {
if (form) {
dialogueForm.value = Object.assign(dialogueForm.value, form);
}
storyboard.value = item;
dialogueForm.value.storyboard_id = item.id;
dialogueForm.value.drama_id = item.drama_id;
nextTick(() => {
dialogueCreateDialogVisible.value = true;
});
}
const closeDialogueCreateDialog = () => {
dialogueFormRef.value?.resetFields();
dialogueForm.value = {
id: '',
drama_id: '',
storyboard_id: '',
actor_id: '',
prosody_speed: 1,
prosody_volume: 50,
emotion: '',
start_time: 0,
end_time: 1000,
content: '',
actor: {
name: "选择演员",
headimg: "",
}
};
dialogueCreateDialogVisible.value = false;
}
const submitDialogueCreateLoading = ref(false);
const submitDialogueCreateDialog = () => {
dialogueFormRef.value?.validate().then((valid: boolean) => {
if (valid) {
if (submitDialogueCreateLoading.value) return;
submitDialogueCreateLoading.value = true;
$http.post('/app/shortplay/api/StoryboardDialogue/save', dialogueForm.value).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
ElMessage.success(res.msg);
closeDialogueCreateDialog();
emit('success', storyboard.value, res.data);
} else {
ElMessage.error(res.msg);
}
}).catch(() => {
ElMessage.error('创建失败');
}).finally(() => {
submitDialogueCreateLoading.value = false;
});
}
});
}
const actorPopoverRef = ref();
const actorButtonRef = ref();
const handleActorSelect = (actor: any) => {
dialogueForm.value.actor_id = actor.id;
dialogueForm.value.actor = actor;
actorPopoverRef.value?.hide();
}
const speedFormatTooltip = (value: number) => {
if (value === 1) {
return '正常';
}
return `${value}x倍速`;
}
const volumeFormatTooltip = (value: number) => {
if (value === 0) {
return '静音';
}
if (value === 100) {
return '最大';
}
if (value === 50) {
return '正常';
}
if (value < 50) {
return `-${value}%`;
}
return `+${value}%`;
}
const handleBeforeClose = () => {
if (submitDialogueCreateLoading.value) return;
closeDialogueCreateDialog();
}
defineExpose({
open: openDialogueCreateDialog,
close: closeDialogueCreateDialog
})
</script>
<template>
<div v-if="dialogueCreateDialogVisible">
<el-dialog v-model="dialogueCreateDialogVisible" class="generate-scene-dialog" draggable append-to-body
:close-on-press-escape="false" :close-on-click-modal="false" :before-close="handleBeforeClose"
width="min(100%,800px)">
<template #header>
<span class="font-weight-600" v-if="!dialogueForm.id">新增对话</span>
<span class="font-weight-600" v-else>编辑对话</span>
</template>
<el-form label-position="top" :model="dialogueForm" :rules="dialogueFormRules" ref="dialogueFormRef"
size="large">
<div class="flex grid-gap-4">
<el-form-item label="演员" prop="actor" class="w-30">
<div class="flex flex-y-center grid-gap-2 rounded-4 p-4 bg-overlay w-100 pointer bg-hover-bg"
ref="actorButtonRef">
<el-avatar :src="dialogueForm.actor.headimg" :alt="dialogueForm.actor.name" shape="square"
class="icon-model"></el-avatar>
<span class="h10">{{ dialogueForm.actor.name }}</span>
</div>
</el-form-item>
<el-form-item label="对话内容" prop="content" class="flex-1">
<el-input type="textarea" v-model="dialogueForm.content" placeholder="请输入对话内容"
:autosize="{ minRows: 3, maxRows: 10 }" />
</el-form-item>
</div>
<div class="flex grid-gap-4">
<el-form-item label="语速" prop="prosody_speed" class="flex-1">
<div class="w-100 px-6">
<el-slider v-model="dialogueForm.prosody_speed" :step="0.1" show-stops :min="0.5" :max="2"
:format-tooltip="speedFormatTooltip" />
</div>
</el-form-item>
<el-form-item label="音量" prop="prosody_volume" class="flex-1">
<div class="w-100 px-6 pb-7">
<el-slider v-model="dialogueForm.prosody_volume" :step="1" :min="0" :max="100"
:format-tooltip="volumeFormatTooltip" :marks="{ 0: '静音', 100: '最大', 50: '正常' }" />
</div>
</el-form-item>
</div>
<el-form-item label="情感" prop="emotion">
<el-radio-group v-model="dialogueForm.emotion">
<el-radio v-for="item in WEBCONFIG?.enum?.voice_emotion" :key="item.value" :value="item.value"
border>{{
item.label }}</el-radio>
</el-radio-group>
</el-form-item>
<div class="flex grid-gap-4">
<el-form-item label="字幕&语音开始时间(毫秒)" prop="start_time">
<el-input-number v-model="dialogueForm.start_time" :min="0" :max="1000000" :step="100" />
</el-form-item>
<el-form-item label="字幕结束时间(毫秒)" prop="end_time">
<el-input-number v-model="dialogueForm.end_time" :min="0" :max="1000000" :step="100" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="flex flex-center grid-gap-2 w-100">
<el-button type="info" @click="closeDialogueCreateDialog"
:disabled="submitDialogueCreateLoading">取消</el-button>
<div class="flex-1"></div>
<el-button type="success" @click="submitDialogueCreateDialog"
:loading="submitDialogueCreateLoading">提交</el-button>
</div>
</template>
</el-dialog>
<el-popover ref="actorPopoverRef" :virtual-ref="actorButtonRef" virtual-triggering placement="bottom-start"
width="min(100vw,880px)" trigger="click">
<xl-actor @select="handleActorSelect"
:types="[{ label: '本集', value: 'episode' }, { label: '本剧', value: 'drama' }]"
:query="{ drama_id: storyboard?.drama_id, episode_id: storyboard?.episode_id }" />
</el-popover>
</div>
</template>
<style lang="scss" scoped></style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
const props = defineProps<{
modelValue: number;
}>();
const emit = defineEmits(['update:modelValue']);
const handleSelect = (value: number) => {
emit('update:modelValue', value);
}
const episodeSumList = ref<number[]>([60, 90, 120, 150, 180, 210, 240, 270, 300]);
</script>
<template>
<el-popover trigger="click" :show-arrow="false" placement="bottom-start" width="fit-content" popper-class="model-popover">
<template #reference>
<slot>
<div class="flex flex-center grid-gap-2 input-button input-button-selected px-6 ">
<span>每集时长</span>
<span class="h10 font-weight-600 text-episode-sum">{{ props.modelValue }}</span>
<span></span>
</div>
</slot>
</template>
<span class="h10">选择每集时长</span>
<div class="grid-columns-4 grid-gap-4 text-center mt-4">
<div class="grid-column-2 btn rounded-4 p-4" v-for="item in episodeSumList" :key="item" :class="{'active': props.modelValue === item}"
@click.stop="handleSelect(item)">
<span class="font-weight-600">{{ item }}</span>
</div>
</div>
</el-popover>
</template>
<style scoped lang="scss">
.btn {
backdrop-filter: blur(10px);
overflow: hidden;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
border-radius: 20px;
padding-top: 2px;
padding-bottom: 2px;
&:hover {
background: rgba(255,255,255,0.08);
}
&.active {
background: rgba(255, 255, 255, 0.16);
}
}
.text-episode-sum {
height: 34px;
line-height: 34px;
}
</style>
@@ -0,0 +1,58 @@
<script setup lang="ts">
const props = defineProps<{
modelValue: number;
}>();
const emit = defineEmits(['update:modelValue']);
const handleSelect = (value: number) => {
emit('update:modelValue', value);
}
const episodeSumList = ref<number[]>([20, 40, 60, 80, 100, 150, 200, 300, 400, 500]);
</script>
<template>
<el-popover trigger="click" :show-arrow="false" placement="bottom-start" width="fit-content" popper-class="model-popover">
<template #reference>
<slot>
<div class="flex flex-center grid-gap-2 input-button input-button-selected px-6 ">
<span></span>
<span class="h10 font-weight-600 text-episode-sum">{{ props.modelValue }}</span>
<span></span>
</div>
</slot>
</template>
<span class="h10">选择集数</span>
<div class="grid-columns-4 grid-gap-4 text-center mt-4">
<div class="grid-column-2 btn rounded-4 p-4" v-for="item in episodeSumList" :key="item"
:class="{ 'active': props.modelValue === item }" @click.stop="handleSelect(item)">
<span class="font-weight-600">{{ item }}</span>
</div>
</div>
</el-popover>
</template>
<style scoped lang="scss">
.btn {
backdrop-filter: blur(10px);
overflow: hidden;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
border-radius: 20px;
padding-top: 2px;
padding-bottom: 2px;
&:hover {
background: rgba(255, 255, 255, 0.16);
}
&.active {
background: rgba(255, 255, 255, 0.16);
}
}
.text-episode-sum {
height: 34px;
line-height: 34px;
}
</style>
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { truncate } from '@/common/functions';
import { useLogin } from '@/composables/useLogin';
import { useRefs, useUserStore, useWalletStore } from '@/stores';
// import LanguageSvg from '@/svg/icon/language.vue';
import router from '@/routers';
import { useInvitationCode } from '@/composables/useInvitationCode';
const useInvitationCodeBox = useInvitationCode();
const login = useLogin();
const userStore = useUserStore();
const { USERINFO } = useRefs(userStore);
const walletStore = useWalletStore();
const { WALLET } = useRefs(walletStore);
const xlUserinfoRef = ref<any>(null);
const userinfoPopoverRef = ref<any>(null);
// const xlInvitationCodeRef = ref<any>(null);
</script>
<template>
<el-popover placement="bottom" ref="userinfoPopoverRef" :width="320" trigger="click"
popper-style="border-radius: 10px;" v-if="userStore.hasLogin()">
<template #reference>
<div class="xl-header-userinfo">
<div class="xl-header-userinfo-info">
<el-avatar :size="30" :src="USERINFO?.headimg">
{{ truncate(USERINFO.nickname, 1) }}
</el-avatar>
</div>
</div>
</template>
<template #default>
<div class="userinfo">
<div class="h7 font-weight-bold">{{ USERINFO?.nickname }}</div>
<div class="text-secondary mt-4">ID {{ USERINFO?.id }}</div>
<div class="btn" @click="router.push('/vip')">开通会员</div>
<el-divider />
<div class="h8">我的积分</div>
<div class="grid grid-columns-3 mt-7">
<div class="flex flex-column flex-center grid-gap-3">
<span class="h7 font-weight-bold">{{ WALLET?.points }}</span>
<span class="h9 text-secondary">会员积分</span>
</div>
<div class="flex flex-column flex-center grid-gap-3">
<span class="h7 font-weight-bold">0</span>
<span class="h9 text-secondary">每周积分</span>
</div>
<div class="flex flex-column flex-center grid-gap-3">
<span class="h7 font-weight-bold">{{ WALLET?.tmp_points }}</span>
<span class="h9 text-secondary">奖励积分</span>
</div>
</div>
<el-divider />
<div class="flex flex-y-center grid-gap-2 pointer"
@click="userinfoPopoverRef.hide(); useInvitationCodeBox.open()">
<el-icon color="#fff" size="20">
<Present />
</el-icon>
<span class="h8">邀请好友</span>
<div class="num">{{ USERINFO?.invitation_code_count || 0 }}</div>
</div>
<!-- <div class="flex flex-y-center grid-gap-2 mt-8">
<el-icon color="#fff" size="20">
<LanguageSvg />
</el-icon>
<span class=" h8">语言</span>
<el-divider direction="vertical" />
<span class="h9 text-secondary">简体中文</span>
<el-icon color="#B5B5B5">
<CaretBottom />
</el-icon>
</div> -->
<div class="flex flex-y-center grid-gap-2 mt-8 pointer"
@click="userinfoPopoverRef.hide(); xlUserinfoRef.open()">
<el-icon color="#fff" size="20">
<Setting />
</el-icon>
<span class=" h8">设置中心</span>
</div>
</div>
</template>
</el-popover>
<div class="xl-header-userinfo" v-else>
<div class="xl-header-userinfo-button" @click="login.open()">
<span>一键登录</span>
</div>
</div>
<xl-userinfo ref="xlUserinfoRef" />
</template>
<style scoped lang="scss">
.xl-header-userinfo {
cursor: pointer;
&-info {
display: flex;
align-items: center;
justify-content: center;
}
&-button {
min-width: 120px;
height: calc(var(--xl-header-height, 70px) - 30px);
border-radius: 100px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #FFFFFF;
cursor: pointer;
background: linear-gradient(to right, #FFFFFF, #FFFFFF);
background-position: left center;
background-size: 0% 100%;
background-repeat: no-repeat;
transition: background-size 0.3s ease-in;
&:hover {
background-size: 100% 100%;
}
span {
font-size: 14px;
font-weight: 500;
color: #FFFFFF;
mix-blend-mode: difference;
selection: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
}
}
.userinfo {
border-radius: 10px;
color: #FFFFFF;
.btn {
width: 100%;
padding: 10px 0px;
text-align: center;
border-radius: 20px;
background: linear-gradient(90deg, #F4CF77 0%, #FFF1D0 100%);
border: 0px solid rgba(255, 255, 255, 0.3);
color: #3D2D09;
margin-top: 16px;
font-weight: bold;
cursor: pointer;
}
.num {
width: 20px;
height: 20px;
border-radius: 50%;
background: linear-gradient(90deg, #79FFFF 0%, #0DF283 100%);
color: #000;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
}
.el-divider--horizontal {
--el-border-color: rgba(255, 255, 255, 0.1);
}
</style>
@@ -0,0 +1,361 @@
<template>
<div class="grid-columns-6 grid-gap-6 content" @click="handleContainerClick" @paste="handlePaste">
<div v-for="(char, index) in codeArray" :key="index" class="flex flex-center flex-column item"
:class="{ active: currentIndex === index }" @click="focusInput(index)">
<input ref="inputRefs" v-model="codeArray[index]" type="text" maxlength="1" class="code-input"
@input="handleInput(index, $event)" @keydown="handleKeydown(index, $event)" @paste="handlePaste"
@focus="currentIndex = index" @blur="handleBlur" />
<span class="code-char">{{ char || '' }}</span>
<span v-if="currentIndex === index && !char" class="cursor"></span>
</div>
</div>
<div class="flex grid-gap-2 px-8">
<el-button class="btn" round color="var(--el-color-white)" size="large" @click="wechatGroupDialogVisible = true">
申请邀请码
</el-button>
<el-button class="btn" round color="var(--el-color-success)" size="large" :loading="loading"
:disabled="!isCodeComplete" @click="handleSubmit">
确定
</el-button>
</div>
<el-dialog v-model="wechatGroupDialogVisible" title="加入微信群" width="512px" align-center>
<div class="flex flex-column flex-y-center grid-gap-4 py-8" v-if="WEBCONFIG?.wechat_group_qrcode_url">
<el-image :src="WEBCONFIG.wechat_group_qrcode_url" style="width: 230px; height: 230px;" fit="contain"
:preview-src-list="[WEBCONFIG.wechat_group_qrcode_url]" preview-teleported />
<p class="text-center">请使用微信扫描下方二维码加入群聊</p>
</div>
<p v-else class="text-center text-secondary">二维码暂未配置</p>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { $http } from '@/common/http'
import { ResponseCode } from '@/common/const'
import { useUserStore } from '@/stores'
import { useWebConfigStore,useRefs } from '@/stores'
const webConfigStore = useWebConfigStore();
const { WEBCONFIG } = useRefs(webConfigStore);
const wechatGroupDialogVisible = ref(false);
const emit = defineEmits(['success', 'close'])
const inputRefs = ref<(HTMLInputElement | null)[]>([])
const codeArray = ref<string[]>(['', '', '', '', '', ''])
const currentIndex = ref<number>(0)
const loading = ref(false)
const isPasting = ref(false)
const isCodeComplete = computed(() => {
return codeArray.value.every(char => char.trim() !== '')
})
const focusInput = (index: number) => {
currentIndex.value = index
nextTick(() => {
const input = inputRefs.value[index]
if (input) {
input.focus()
}
})
}
const handleContainerClick = (e: MouseEvent) => {
const target = e.target as HTMLElement
//
if (target.classList.contains('code-input')) {
return
}
// item
const item = target.closest('.item') as HTMLElement
if (item) {
const index = Array.from(item.parentElement?.children || []).indexOf(item)
if (index !== -1) {
focusInput(index)
}
}
}
const handleInput = (index: number, event: Event) => {
//
if (isPasting.value) {
return
}
const input = event.target as HTMLInputElement
let value = input.value.toUpperCase().replace(/[^A-Z0-9]/g, '')
if (value.length > 1) {
value = value.slice(0, 1)
}
codeArray.value[index] = value
input.value = value
//
if (value && index < 5) {
nextTick(() => {
focusInput(index + 1)
})
}
}
const handleKeydown = (index: number, event: KeyboardEvent) => {
const key = event.key
// Ctrl+V Cmd+V (Mac)
if ((event.ctrlKey || event.metaKey) && key === 'v') {
//
return
}
if (key === 'Backspace') {
if (codeArray.value[index]) {
codeArray.value[index] = ''
} else if (index > 0) {
focusInput(index - 1)
codeArray.value[index - 1] = ''
}
event.preventDefault()
} else if (key === 'ArrowLeft' && index > 0) {
focusInput(index - 1)
event.preventDefault()
} else if (key === 'ArrowRight' && index < 5) {
focusInput(index + 1)
event.preventDefault()
} else if (key === 'Delete') {
codeArray.value[index] = ''
event.preventDefault()
} else if (key.length === 1 && /[A-Z0-9]/i.test(key) && !event.ctrlKey && !event.metaKey) {
// Ctrl/Cmd
const value = key.toUpperCase()
codeArray.value[index] = value
if (index < 5) {
nextTick(() => {
focusInput(index + 1)
})
}
event.preventDefault()
}
}
const handlePaste = (event: ClipboardEvent) => {
event.preventDefault()
event.stopPropagation()
isPasting.value = true
const pastedText = event.clipboardData?.getData('text') || ''
if (!pastedText) {
isPasting.value = false
return
}
const cleanText = pastedText.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6)
if (!cleanText) {
isPasting.value = false
return
}
//
codeArray.value = ['', '', '', '', '', '']
//
for (let i = 0; i < cleanText.length && i < 6; i++) {
codeArray.value[i] = cleanText[i]
}
//
nextTick(() => {
inputRefs.value.forEach((input, index) => {
if (input) {
input.value = codeArray.value[index] || ''
}
})
//
const nextIndex = Math.min(cleanText.length, 5)
focusInput(nextIndex)
//
setTimeout(() => {
isPasting.value = false
}, 200)
})
}
const handleBlur = () => {
// 便
setTimeout(() => {
//
const activeElement = document.activeElement
if (!activeElement || !activeElement.classList.contains('code-input')) {
//
}
}, 100)
}
const handleSubmit = async () => {
if (!isCodeComplete.value) {
ElMessage.warning('请输入完整的邀请码')
return
}
const code = codeArray.value.join('')
loading.value = true
try {
const res: any = await $http.post('/app/user/api/User/bindInvitationCode', { code })
if (res.code === ResponseCode.SUCCESS) {
ElMessage.success(res.msg || '邀请码使用成功');
$http.get('/app/user/api/User/info').then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
const userStore = useUserStore();
userStore.setUserInfo(res.data as UserInfoInterface);
//
emit('success', res.data)
}
})
//
setTimeout(() => {
emit('close')
}, 500)
} else {
ElMessage.error(res.msg || '邀请码使用失败')
}
} catch (error: any) {
ElMessage.error(error?.msg || '邀请码使用失败,请稍后重试')
} finally {
loading.value = false
}
}
const handleGlobalPaste = (event: ClipboardEvent) => {
//
const activeElement = document.activeElement
const target = event.target as HTMLElement
const container = document.querySelector('.content')
//
if (container && (
(activeElement && container.contains(activeElement)) ||
(target && container.contains(target)) ||
(!activeElement && !target)
)) {
handlePaste(event)
}
}
onMounted(() => {
//
nextTick(() => {
focusInput(0)
})
//
document.addEventListener('paste', handleGlobalPaste)
})
onUnmounted(() => {
//
document.removeEventListener('paste', handleGlobalPaste)
})
</script>
<style scoped lang="scss">
.btn {
padding: 14px 0px !important;
transition: all 0.3s ease;
flex: 1;
font-size: 20px;
font-weight: 600;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.content {
width: 100%;
padding-top: 100px;
padding-bottom: 100px;
display: flex;
justify-content: center;
align-items: center;
}
.item {
position: relative;
width: 71px;
height: 71px;
background: #1E1E1E;
border-radius: 12px;
border: 1px solid #272727;
font-size: 29px;
font-weight: 600;
color: #FFFFFF;
text-align: center;
line-height: 71px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: #272727;
border-color: #3a3a3a;
}
&.active {
border-color: var(--el-color-success);
background: #272727;
box-shadow: 0 0 0 2px rgba(103, 194, 58, 0.2);
}
.code-input {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
opacity: 0;
cursor: pointer;
border: none;
background: transparent;
color: transparent;
caret-color: transparent;
font-size: 0;
z-index: 1;
}
.code-char {
position: relative;
z-index: 0;
user-select: none;
}
.cursor {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 2px;
height: 35px;
background: var(--el-color-success);
animation: blink 1s infinite;
z-index: 2;
pointer-events: none;
}
}
@keyframes blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
</style>
@@ -0,0 +1,280 @@
<template>
<div class="invitation-content">
<!-- 邀请码列表 -->
<div class="invitation-codes-section" v-if="invitationCodes.length > 0">
<div class="h8">您还剩 {{ invitationCodes.filter((item: any) => item.status === 'unused').length }} 个邀请名额拥有下方邀请码的任何人都可以加入</div>
<div class="h8 mt-3">好友成功注册可得 {{ WEBCONFIG?.register?.invite_reward_points || 0 }} 积分</div>
<div class="codes-grid">
<div v-for="(item, index) in invitationCodes" :key="index" class="code-item">
<div class="flex flex-1 grid-gap-3">
<div class="code-text">{{ item.code }}</div>
<div class="code-status">
<span v-if="item.status === 'used'" class="status-tag status-used">已使用</span>
<span v-else class="status-tag status-pending">待使用</span>
<!-- <div v-else-if="item.status === 'reward'" class="status-reward">
<el-icon class="star-icon">
<StarFilled />
</el-icon>
<span>+{{ item.points || 100 }}</span>
</div> -->
</div>
</div>
<span @click="copyCodeItem(item.code)" v-if="item.status === 'unused'">复制</span>
</div>
</div>
</div>
<div v-else class="invitation-codes-section">
<div class="h8">您的邀请名额已用完</div>
</div>
<!-- 邀请记录 -->
<div class="invitation-records-section">
<div class="records-title">邀请好友记录</div>
<div class="records-list" v-if="invitationRecords.length > 0">
<div v-for="(record, index) in invitationRecords" :key="index" class="record-item">
<div class="record-name">{{ record.useUser.nickname }}</div>
<div class="record-reward">
<span>+{{ record.points || WEBCONFIG?.register?.invite_reward_points }}</span>
</div>
<div class="record-date">{{ record.create_time }}</div>
</div>
</div>
<div v-else class="h9 text-center records-list-empty">
暂未邀请好友复制邀请码邀请好友得积分吧~
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { $http } from '@/common/http';
import { ResponseCode } from '@/common/const';
import { ElMessage } from 'element-plus';
import { useRefs, useWebConfigStore } from '@/stores';
// ========== Store ==========
const webConfigStore = useWebConfigStore();
const { WEBCONFIG } = useRefs(webConfigStore);
// ========== ==========
const list = ref<any>([])
const invitationCodes = ref<any[]>([])
const invitationRecords = ref<any[]>([])
// ========== ==========
const getList = () => {
$http.get('/app/user/api/User/getUnusedInvitationCode').then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
list.value = res.data
// TODO: invitationCodes
invitationCodes.value = res.data.map((item: any) => ({
code: item.code,
status: item.status,
sort: item.status === 'unused' ? 0 : 1,
})).sort((a: any, b: any) => a.sort - b.sort)
invitationRecords.value = res.data.map((item: any) => { return item.status === 'used' ? item : null }).filter(Boolean)
console.log(invitationRecords.value)
}
})
// TODO:
// getInvitationRecords()
}
// ========== ==========
const fallbackCopy = (text: string) => {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.top = '-9999px'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
// ========== ==========
const copyCodeItem = async (code: string) => {
if (!code) {
ElMessage.warning('暂无可复制内容')
return
}
//
const origin = window.location.origin
const url = `${origin}/fastmovie/#/?code=${code}`
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(url)
} else {
fallbackCopy(url)
}
ElMessage.success('复制成功')
} catch (err) {
ElMessage.error('复制失败')
}
}
// ========== ==========
onMounted(() => {
getList()
})
</script>
<style scoped lang="scss">
.invitation-content {
display: flex;
flex-direction: column;
gap: 24px;
width: calc(586px - 60px);
}
.invitation-codes-section {
text-align: center;
.h8 {
font-size: 14px;
color: rgba(255, 255, 255, 0.90);
line-height: 1.5;
}
.codes-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-top: 20px;
max-height: 240px;
overflow-y: auto;
}
.code-item {
display: flex;
gap: 8px;
background: #1E1E1E;
border: 1px solid #272727;
padding: 16px 20px;
border-radius: 12px;
cursor: pointer;
}
.code-text {
font-size: 18px;
color: #FFFFFF;
letter-spacing: 1px;
}
.code-status {
display: flex;
align-items: center;
gap: 4px;
}
.status-tag {
font-size: 11px;
padding: 4px 8px;
border-radius: 6px;
font-weight: 500;
display: inline-block;
}
.status-used {
background: rgba(140, 140, 140, 0.1);
color: #8C8C8C;
border: 1px solid #8C8C8C;
}
.status-pending {
background: rgba(103, 194, 58, 0.15);
color: var(--el-color-success);
border: 1px solid var(--el-color-success);
}
.status-reward {
display: flex;
align-items: center;
gap: 4px;
color: #FFD700;
font-weight: 600;
font-size: 14px;
.star-icon {
font-size: 16px;
}
}
.copy-btn {
width: 100%;
background-color: #fff;
color: #000;
border: 1px solid rgba(0, 0, 0, 0.1);
font-weight: 600;
font-size: 13px;
padding: 6px 0;
border-radius: 6px;
transition: all 0.3s;
&:hover {
background-color: #f5f5f5;
border-color: rgba(0, 0, 0, 0.15);
}
}
}
.invitation-records-section {
.records-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
}
.records-list {
display: flex;
flex-direction: column;
padding: 0px 16px;
max-height: 200px;
overflow-y: auto;
}
.records-list-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
}
.record-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
color: #FFFFFF;
&:last-child {
border-bottom: none;
}
}
.record-name {
font-size: 14px;
width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.record-reward {
font-size: 14px;
color: #FFD700;
margin: 0 12px;
white-space: nowrap;
}
.record-date {
font-size: 12px;
color: #B5B5B5;
white-space: nowrap;
}
}
</style>
@@ -0,0 +1,150 @@
<script setup lang="ts">
const props = defineProps<{
title?: string;
tips?: string;
list?: any[];
auto?: boolean;
showCancelButton?: boolean;
showConfirmButton?: boolean;
cancelButtonText?: string;
confirmButtonText?: string;
confirmButtonLoading?: boolean;
confirmButtonDisabled?: boolean;
confirmButtonClick?: () => void;
cancelButtonClick?: () => void;
}>();
const currentTime = ref(0);
const currentIndex = ref(0);
const currentItem = ref<any>(null);
const progress = ref<string>('0.00');
let interval: any;
const setProgress = (p: number) => {
progress.value = p.toFixed(2);
}
const setCurrentIndex = (index: number) => {
currentIndex.value = index;
currentItem.value = props.list?.[index];
}
const parseItem = () => {
currentIndex.value = props.list?.findIndex(item => currentTime.value >= item.start && currentTime.value <= item.end) || 0;
currentItem.value = props.list?.[currentIndex.value];
const itemDuration = currentItem.value?.end - currentItem.value?.start;
const itemProgress = (currentTime.value - currentItem.value?.start) / itemDuration || 0;
progress.value = (props.list?.filter(item => currentTime.value > item.end).reduce((acc, curr) => acc + curr.progress, 0) + (itemProgress * currentItem.value?.progress) || 0).toFixed(2);
}
const autoProgress = () => {
interval = setInterval(() => {
currentTime.value += 0.05;
parseItem();
}, 50);
}
onMounted(() => {
if (props.auto) {
autoProgress();
}
});
onUnmounted(() => {
clearInterval(interval);
});
defineExpose({
setProgress,
setCurrentIndex
});
</script>
<template>
<div class="xl-loading">
<span v-if="props.title" class="title">{{ props.title }}</span>
<span v-if="props.tips" class="tips text-info">{{ props.tips }}</span>
<div class="progress-bar mt-10">
<div class="progress-bar-inner flex flex-center px-4 grid-gap-1 h10">
<span class="text-success">{{ currentIndex + 1 }}.</span>
<span class="flex-1">{{ currentItem?.title }}</span>
<span class="text-title font-weight-600">{{ progress }}%</span>
</div>
</div>
<span v-if="props.list?.length">{{ currentIndex + 1 }}/{{ props.list?.length }}</span>
<div class="flex grid-gap-10 ">
<el-button bg text size="large" v-if="props.showCancelButton" @click="props.cancelButtonClick">{{
props.cancelButtonText }}</el-button>
<el-button type="success" size="large" v-if="props.showConfirmButton" @click="props.confirmButtonClick">{{
props.confirmButtonText }}</el-button>
</div>
</div>
</template>
<style lang="scss" scoped>
.xl-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
.title {
font-weight: 900;
font-size: 36px;
line-height: 55px;
text-align: center;
font-style: normal;
text-transform: none;
color: transparent;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-image: linear-gradient(to left, #79FFFF 0%, #0DF283 100%);
}
.progress-bar {
position: relative;
--border-width: 2px;
--width: 400px;
--height: 50px;
height: calc(var(--height) + var(--border-width) * 2);
width: calc(var(--width) + var(--border-width) * 2);
border-radius: 10px;
overflow: hidden;
&::after {
content: "";
position: absolute;
width: calc(var(--width) + var(--border-width) * 2);
height: calc(var(--width) + var(--border-width) * 2);
top: calc(calc(calc(var(--width) - var(--height)) / 2) * -1);
// inset: -100%;
border-radius: inherit;
/* 边框宽度 */
/* 跑马灯亮边(小段亮光,其余透明) */
background: linear-gradient(to right, #79FFFF, #0DF283, transparent, transparent, transparent);
background-repeat: no-repeat;
background-size: 100% 100%;
// background-position: center 0px;
transform-origin: center center;
animation: spin 2.5s linear infinite;
}
&-inner {
position: absolute;
top: var(--border-width);
left: var(--border-width);
right: var(--border-width);
bottom: var(--border-width);
background-color: var(--el-bg-color-overlay);
border-radius: 10px;
z-index: 10;
}
}
.text-title {
color: transparent;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-image: linear-gradient(to left, #79FFFF 0%, #0DF283 100%);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { truncate } from '@/common/functions';
import { useModelStore } from '@/stores';
import IconPointsSvg from '@/svg/icon/icon-points.vue';
const modelStore = useModelStore();
const props = withDefaults(defineProps<{
modelValue?: string | number
scene: keyof ModelInterface
noInit?: boolean
scrollProps?: any
title?: string
}>(), {
modelValue: '',
title: '选择模型',
noInit: false,
scrollProps: {
height: '30vh',
},
});
const emit = defineEmits(['select', 'update:modelValue']);
const ModelsSearch = reactive({
classify: 'all',
name: '',
})
const MODELS = ref<ModelInterface[keyof ModelInterface]>([]);
const getModelsList = () => {
MODELS.value = modelStore.get(props.scene);
if (!props.noInit && MODELS.value.length) {
handleModelsItemClick(MODELS.value[0]);
}
}
const handleModelsItemClick = (item: any) => {
emit('update:modelValue', item.id);
emit('select', item);
}
onMounted(() => {
getModelsList();
})
</script>
<template>
<div class="flex flex-column grid-gap-4" style="--el-color-primary:var(--el-color-success);">
<el-form class="flex flex-center grid-gap-4" @submit.prevent="() => { }">
<span>{{ props.title }}</span>
<div class="flex-1"></div>
<el-form-item class="mb-0">
<el-input v-model="ModelsSearch.name" placeholder="搜索模型" clearable>
<template #suffix>
<el-icon>
<Search />
</el-icon>
</template>
</el-input>
</el-form-item>
</el-form>
<el-scrollbar v-bind="scrollProps">
<div class="flex flex-column grid-gap-4">
<div class="flex input-button rounded-4 grid-gap-2 model-item"
:class="{ 'model-item-selected model-item-active': props.modelValue === item.id }"
v-for="item in MODELS.filter((item: any) => item.name.includes(ModelsSearch.name))"
@click="handleModelsItemClick(item)">
<el-image :src="item.icon" class="model-image rounded-4">
{{ truncate(item.name, 1) }}
</el-image>
<div class="flex-1 flex flex-column flex-y-flex-start grid-gap-2">
<span class="font-weight-600">{{ item.name }}</span>
<span class="text-secondary h10 text-ellipsis-3">{{ item.description }}</span>
<div class="flex flex-center grid-gap-2">
<el-icon size="16">
<IconPointsSvg />
</el-icon>
<span class="h10" v-if="item.point">{{ item.point }}/{{ item.unit }}</span>
<span class="h10" v-else>免费</span>
</div>
</div>
<el-icon size="16" color="var(--el-color-white)" class="model-item-selected-icon">
<Select />
</el-icon>
</div>
</div>
</el-scrollbar>
</div>
</template>
<style lang="scss" scoped>
.model-item {
cursor: pointer;
position: relative;
overflow: hidden;
background: rgba(0, 0, 0, 0.2);
padding: 10px;
line-height: normal;
&:hover {
background: rgba(0, 0, 0, 0.1);
}
&-active {
background: rgba(255, 255, 255, 0.1);
}
&.model-item-selected {
.model-item-selected-icon {
opacity: 1;
}
}
.model-image {
width: 60px;
height: 60px;
}
.model-item-selected-icon {
opacity: 0;
transition: opacity 0.3s ease-in-out;
// background-color: var(--el-bg-color);
// border-radius: 999px;
justify-self: center;
align-self: center;
}
}
</style>
@@ -0,0 +1,166 @@
<template>
<div>
<el-scrollbar height="90vh" @end-reached="handleScroll" :loading="loading">
<div class="flex flex-y-center grid-gap-6 p-4 rounded-4 active pointer" v-for="item in list" :key="item.id"
v-if="list.length > 0" @click="handleItemClick(item)">
<div class="flex-shrink-0 position-relative">
<div v-if="item.scene == 'announcement'" class="notice">
<el-icon color="#000" :size="26">
<BellFilled />
</el-icon>
</div>
<el-avatar :size="48" src="" v-else class="flex-shrink-0" alt="" />
<div class="unread" v-if="item.read_state == 0"></div>
</div>
<div class="flex flex-column grid-gap-2">
<span class="h7 font-weight-600">{{ item.title }}</span>
<span class="text-ellipsis-2 text-secondary">{{ item.subtitle }}</span>
</div>
</div>
<div class="flex flex-center flex-column" v-else>
<el-empty description="暂无消息" />
</div>
</el-scrollbar>
<el-dialog v-model="dialogVisible" append-to-body title="消息详情" width="600px" :close-on-click-modal="false">
<div v-loading="detailLoading" class="detail-content">
<div v-if="detailData" class="flex flex-column grid-gap-4">
<div class="detail-header">
<h3 class="detail-title">{{ detailData.title }}</h3>
<div class="detail-meta text-secondary">
<span v-if="detailData.create_time">{{ detailData.create_time }}</span>
</div>
</div>
<div class="detail-body">
<div class="detail-text" v-html="detailData.content.content || detailData.subtitle"></div>
</div>
</div>
<el-empty v-else description="暂无详情" />
</div>
<template #footer>
<div class="flex flex-center ">
<el-button color="var(--el-color-success)" @click="dialogVisible = false">我知道了</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { $http } from '@/common/http'
import { ResponseCode } from '@/common/const'
const list = ref<any[]>([])
const query = ref({
page: 1,
limit: 10,
})
const loading = ref(false)
const total = ref(0)
const dialogVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref<any>(null)
const getList = async () => {
loading.value = true
const res: any = await $http.get('/app/notification/api/Message/list', { params: query.value })
if (res.code === ResponseCode.SUCCESS) {
list.value = [...list.value, ...res.data.data]
total.value = res.data.total
}
loading.value = false
}
const handleScroll = () => {
if (query.value.page < total.value) {
query.value.page++;
getList();
}
}
const handleItemClick = async (item: any) => {
//
if (item.read_state == 0) {
const listItem = list.value.find(listItem => listItem.id === item.id)
if (listItem) {
listItem.read_state = 1
}
}
dialogVisible.value = true
detailData.value = null
await getDetail(item.id)
}
const getDetail = async (id: number | string) => {
detailLoading.value = true
try {
const res: any = await $http.get('/app/notification/api/Message/detail', { params: { id } })
if (res.code === ResponseCode.SUCCESS) {
detailData.value = res.data
}
} catch (error) {
console.error('获取详情失败:', error)
} finally {
detailLoading.value = false
}
}
defineExpose({
open: () => {
query.value.page = 1;
list.value = [];
getList();
}
})
</script>
<style scoped lang="scss">
.active:hover {
background: rgba(255, 255, 255, 0.1);
}
.notice {
background-color: var(--el-color-success);
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.unread {
background-color: var(--el-color-error);
width: 12px !important;
height: 12px !important;
border-radius: 50%;
position: absolute;
top: 0px;
right: 0px;
}
.detail-content {
min-height: 200px;
}
.detail-header {
padding-bottom: 8px;
}
.detail-title {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.detail-meta {
font-size: 14px;
}
.detail-text {
font-size: 14px;
line-height: 1.6;
color: var(--el-text-color-regular);
word-wrap: break-word;
white-space: pre-wrap;
}
</style>
@@ -0,0 +1,203 @@
<template>
<el-dialog v-model="payDialogVisible" width="420" title="扫码支付" top="35vh" @close="handleClose">
<div class="payment-list">
<div class="payment-item" v-for="item in paymentList" :key="item.id"
:class="{ 'active': form.pay_type === item.enum.value }">
<component :is="paymentListTemplate[item.enum.value]"
@click="form.pay_type = item.enum.value; form.payment_id = item.id" />
</div>
</div>
<div class="payment">
<div class="qrcode">
<xl-qrcode :text="paymentQrcode" v-if="paymentQrcode" />
<div v-else>请选择支付方式</div>
</div>
<div class="payment-info">
<div>
<span>支付金额</span>
<span class="price"> {{ price }} </span>
<span></span>
</div>
<div class="mt-4">请使用 微信支付</div>
<div class="mt-10">
<span>支付即视为同意</span>
<span class="privacy">付费服务协议</span>
</div>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ResponseCode } from '@/common/const';
import { $http } from '@/common/http';
import IconWxpaySvg from '@/svg/icon/icon-wxpay.vue';
import IconZfbpaySvg from '@/svg/icon/icon-alipay.vue';
import { ElMessage, ElLoading } from 'element-plus';
import router from '@/routers';
const emit = defineEmits(['update:modelValue']);
const props = withDefaults(defineProps<{
id: number | null,
price?: number | string,
type: string,
}>(), {
id: 0,
price: undefined,
type: 'points',
})
//
const form = ref<any>({
pay_type: 'wxpay',
id: 0,
payment_id: null,
type: props.type,
})
watch(() => props.id, (newVal) => {
form.value.id = newVal;
}, {
immediate: true,
})
//
const payDialogVisible = ref(false);
//
const paymentQrcode = ref<string | undefined>();
//
const paymentList = ref<any[]>([]);
//
const paymentListTemplate: Record<string, any> = {
'wxpay': IconWxpaySvg,
'zfbpay': IconZfbpaySvg
}
//
const getPaymentType = () => {
$http.get('/app/marketing/api/Payment/index').then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
paymentList.value = res.data;
const target =
paymentList.value.find((item: any) => item?.default === 1) ||
paymentList.value.find(
(item: any) => item.enum.value === form.value.pay_type
);
form.value.pay_type = target?.enum.value;
form.value.payment_id = target?.id;
}
})
}
//
const createOrder = async () => {
const loading = ElLoading.service({
lock: true,
text: '创建订单中...',
background: 'rgba(0, 0, 0, 0.7)',
});
form.value.id = props.id;
const res: any = await $http.post('/app/finance/api/Orders/create', { ...form.value });
loading.close();
if (res.code !== ResponseCode.SUCCESS) {
ElMessage.error(res.msg);
return;
}
paymentQrcode.value = res.data.pay_info.qrcode;
payDialogVisible.value = true;
trade.value = res.data.pay_info.trade;
timer = setInterval(() => {
getTradeStatus();
}, 1000);
return null;
}
const trade = ref<string | null>(null);
let timer: any;
//
const getTradeStatus = async () => {
if (!trade.value) return;
const res: any = await $http.get(`/app/finance/api/Orders/getOrderStatus?trade=${trade.value}`);
if (res.code !== ResponseCode.SUCCESS) {
ElMessage.error(res.msg);
return;
}
const status = res.data.status;
if (status) {
ElMessage.success('支付成功');
payDialogVisible.value = false;
clearInterval(timer);
router.push('/');
return;
}
}
const handleClose = () => {
clearInterval(timer);
timer = null;
}
defineExpose({
open: () => {
clearInterval(timer);
timer = null;
createOrder();
},
})
onUnmounted(() => {
clearInterval(timer);
})
onMounted(() => {
getPaymentType();
})
</script>
<style scoped lang="scss">
.payment-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-gap: 10px;
.payment-item {
cursor: pointer;
width: 100px;
height: 40px;
background-color: #fff;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
}
.active {
border: 1px solid var(--el-color-success);
}
}
.payment {
display: flex;
flex-direction: row;
margin-top: 20px;
gap: 20px;
.qrcode {
width: 131px;
height: 131px;
border-radius: 6px;
flex-shrink: 0;
background-color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.payment-info {
font-size: 14px;
.price {
font-size: 28px;
color: #F93434;
font-weight: 600;
padding: 0px 6px;
}
.privacy {
color: #379EFF;
}
}
}
</style>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { ResponseCode } from '@/common/const';
import { truncate } from '@/common/functions';
import { $http } from '@/common/http';
const props = withDefaults(defineProps<{
query?: any
types?: any[]
}>(), {
query: () => ({}),
types: () => ([]),
});
const emit = defineEmits(['select']);
const PropSearch = reactive({
type: 'all',
name: '',
...props.query,
})
const propList = ref<any[]>([]);
const loading = ref(false);
const getPropList = () => {
loading.value = true;
propList.value = [];
$http.get('/app/shortplay/api/Prop/index', { params: PropSearch }).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
propList.value = res.data;
}
}).finally(() => {
loading.value = false;
})
}
const handlePropItemClick = (item: any) => {
emit('select', item);
}
const propCreateRef = ref<any>(null);
onMounted(() => {
getPropList();
})
</script>
<template>
<div class="flex flex-column grid-gap-4" style="--el-color-primary:var(--el-color-success);">
<el-form class="flex flex-center grid-gap-4" @submit.prevent="getPropList">
<el-form-item class="mb-0">
<xl-tabs v-model="PropSearch.type" class="text-info" @change="getPropList">
<xl-tabs-item value="all">全部</xl-tabs-item>
<xl-tabs-item value="personal">个人</xl-tabs-item>
<xl-tabs-item v-for="item in props.types" :key="item.value" :value="item.value">
{{ item.label }}
</xl-tabs-item>
</xl-tabs>
</el-form-item>
<div class="flex-1"></div>
<el-form-item class="mb-0">
<el-input v-model="PropSearch.name" placeholder="搜索物品" clearable @change="getPropList">
<template #suffix>
<el-icon>
<Search />
</el-icon>
</template>
</el-input>
</el-form-item>
</el-form>
<el-scrollbar height="40vh" v-loading="loading">
<div class="grid-columns-8 grid-gap-4">
<div class="grid-column-2 rounded-4 p-4 border-2 border-dotted prop-item flex flex-center grid-gap-4 prop-item"
@click="propCreateRef?.open?.(null, props.query?.drama_id, props.query?.episode_id)"
v-if="PropSearch.type !== 'public'">
<el-icon class="rounded-4" size="20"
style="height: 40px; width: 40px;background-color: var(--el-fill-color-dark);">
<Plus />
</el-icon>
<span>添加物品</span>
</div>
<div class="grid-column-2 rounded-4 p-4 border-2 border-solid flex flex-center grid-gap-2 prop-item"
v-for="item in propList" @click="handlePropItemClick(item)">
<el-avatar :src="item.image" :size="40">
{{ truncate(item.name, 1) }}
</el-avatar>
<div class="flex-1 flex flex-column grid-gap-2">
<span>{{ item.name }}</span>
</div>
</div>
</div>
</el-scrollbar>
<xl-prop-create ref="propCreateRef" @success="getPropList" />
</div>
</template>
<style lang="scss" scoped>
.prop-item {
cursor: pointer;
height: 80px;
&:hover {
background-color: var(--el-fill-color-dark);
}
}
</style>
@@ -0,0 +1,543 @@
<script setup lang="ts">
import { $http } from '@/common/http';
import { ResponseCode } from '@/common/const';
import { ElMessage } from 'element-plus';
import IconModelSvg from '@/svg/icon/icon-model.vue';
import IconPointsSvg from '@/svg/icon/icon-points.vue';
import IconPropSvg from '@/svg/icon/icon-actor.vue';
import IconPropThreeViewSvg from '@/svg/icon/icon-actor-three-view.vue';
import IconImageSvg from '@/svg/icon/icon-image.vue';
import IconUploadImageSvg from '@/svg/icon/icon-upload-image.vue';
import { usePoints } from '@/composables/usePoints';
const emit = defineEmits(['success']);
const propDialogVisible = ref(false);
const propForm = reactive<any>({
id: '',
drama_id: '',
episode_id: '',
image_state: false,
image_model_id: '',
three_view_image_state: false,
three_view_model_id: '',
status_enum: { value: 'initializing', label: '待初始化' },
name: '',
image: '',
three_view_image: '',
description: '',
image_reference_state: false,
reference_image: '',
})
const propFormRef = ref<any>(null);
const propLoading = ref(false);
const propFormRules = reactive({
image_model_id: [{ required: true, message: '请选择图片模型', trigger: 'change' }],
three_view_model_id: [{ required: true, message: '请选择三维模型', trigger: 'change' }],
description: [{ required: true, message: '请输入备注', trigger: 'change' }],
})
const showForm = ref(false);
const openPropCreateDialog = (prop?: any, drama_id?: string | number, episode_id?: string | number) => {
propDialogVisible.value = true;
showForm.value = true;
propImageModel.value = 'description';
nextTick(() => {
if (prop) {
propForm.id = prop.id;
propForm.image = prop.image;
propForm.three_view_image = prop.three_view_image;
propForm.name = prop.name;
propForm.description = prop.description;
propForm.drama_id = drama_id;
propForm.episode_id = episode_id;
} else {
propForm.drama_id = drama_id;
propForm.episode_id = episode_id;
}
})
}
const uploadProp = (prop?: any, drama_id?: string | number, episode_id?: string | number) => {
propDialogVisible.value = true;
showForm.value = false;
propImageModel.value = 'upload';
nextTick(() => {
if (prop) {
propForm.id = prop.id;
propForm.image = prop.image;
propForm.three_view_image = prop.three_view_image;
propForm.name = prop.name;
propForm.description = prop.description;
propForm.drama_id = drama_id;
propForm.episode_id = episode_id;
} else {
propForm.drama_id = drama_id;
propForm.episode_id = episode_id;
}
})
}
const cancelPropDialog = () => {
propFormRef.value?.resetFields();
propDialogVisible.value = false;
propForm.id = '';
propForm.name = '';
propForm.description = '';
propForm.image_model_state = false;
propForm.image_model_id = '';
propForm.three_view_model_state = false;
propForm.three_view_model_id = '';
propForm.status_enum = { value: 'initializing', label: '待初始化' };
propForm.image = '';
propForm.three_view_image = '';
propForm.drama_id = '';
propForm.episode_id = '';
}
const submitPropDialog = (callback?: () => void) => {
$http.post('/app/shortplay/api/Prop/update', propForm).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
ElMessage.success(res.msg);
propForm.id = res.data.id;
if (callback) {
callback();
} else {
cancelPropDialog();
}
emit('success');
} else {
ElMessage.error(res.msg);
}
}).catch(() => {
ElMessage.error('更新失败');
})
}
const uploadImageRef = ref<any>(null);
const uploadReferenceImageRef = ref<any>(null);
const uploadHeadimgLoading = ref(false);
const uploadThreeViewLoading = ref(false);
const uploadLoading = ref(false);
const handleUploadSuccess = (response: any) => {
if (response.code === ResponseCode.SUCCESS) {
switch (response.data.dir_name) {
case 'prop/image':
uploadHeadimgLoading.value = false;
propForm.image = response.data.url;
uploadImageRef.value?.clearFiles();
break;
case 'prop/three_view':
uploadThreeViewLoading.value = false;
propForm.three_view_image = response.data.url;
uploadImageRef.value?.clearFiles();
break;
case 'prop/reference':
uploadLoading.value = false;
propForm.image_reference_state = true;
propForm.reference_image = response.data.url;
uploadReferenceImageRef.value?.clearFiles();
break;
}
} else {
ElMessage.error(response.msg);
}
}
const handleUploadError = () => {
uploadImageRef.value?.clearFiles();
uploadReferenceImageRef.value?.clearFiles();
}
const propHeadimgButtonRef = ref();
const propHeadimgPopoverRef = ref();
const propHeadimgModel = ref<any>({});
const handlePropHeadimgSelect = (item?: any) => {
if (item) {
propForm.image_model_id = item.id;
propForm.image_state = true;
propHeadimgModel.value = item;
} else {
propForm.image_model_id = '';
propForm.image_state = false;
propHeadimgModel.value = {};
}
propHeadimgPopoverRef.value?.hide();
}
const propThreeViewModelButtonRef = ref();
const propThreeViewModelPopoverRef = ref();
const propImageThreeViewModel = ref<any>({});
const handlePropThreeViewModelSelect = (item?: any) => {
if (item) {
propForm.three_view_model_id = item.id;
propImageThreeViewModel.value = item;
propForm.three_view_image_state = true;
} else {
propForm.three_view_model_id = '';
propImageThreeViewModel.value = {};
propForm.three_view_image_state = false;
}
propThreeViewModelPopoverRef.value?.hide();
}
const points = usePoints([propHeadimgModel, propImageThreeViewModel]);
const generateImageLoading = ref(false);
const handleGenerateImage = () => {
if (generateImageLoading.value || propForm.status_enum.value === 'pending') return;
if (!propForm.id) {
return submitPropDialog(handleGenerateImage);
}
generateImageLoading.value = true;
$http.post('/app/shortplay/api/Prop/initializing', propForm).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
propForm.status = res.data.status;
propForm.status_enum = res.data.status_enum;
} else {
ElMessage.error(res.msg);
}
}).catch(() => {
ElMessage.error('生成图片失败');
}).finally(() => {
generateImageLoading.value = false;
})
}
const subscribe = (channel: string, res: any) => {
if (channel === 'generatepropimage') {
if (res.id === propForm.id) {
propForm.status_enum = res.status;
if (res.image) {
propForm.image = res.image;
} else {
generateImageLoading.value = false;
}
}
} else if (channel === 'generatepropthreeviewimage') {
if (res.id === propForm.id) {
propForm.status_enum = res.status;
if (res.image) {
propForm.three_view_image = res.image;
} else {
generateImageLoading.value = false;
}
}
}
}
const propImageModel = ref('description');
const uploadImageModel = ref('image');
defineExpose({
open: openPropCreateDialog,
upload: uploadProp,
close: cancelPropDialog,
subscribe: subscribe
})
</script>
<template>
<div>
<el-dialog v-model="propDialogVisible" class="generate-scene-dialog" draggable :width="showForm?'min(100%,840px)':'min(100%,426px)'"
@close="cancelPropDialog">
<template #header>
<span class="font-weight-600" v-if="showForm">上传物品</span>
<span class="font-weight-600" v-else-if="!propForm.id">创建物品</span>
<span class="font-weight-600" v-else>编辑物品</span>
</template>
<el-form label-position="top" :model="propForm" :rules="propFormRules" ref="propFormRef" class="prop-form"
:disabled="propForm.status_enum.value !== 'initializing'">
<div class="flex grid-gap-4 flex-y-flex-start">
<div class="flex-1 grid-columns-6 grid-gap-4" v-if="showForm">
<el-form-item label="物品名称" prop="title" class="grid-column-6">
<el-input v-model="propForm.name" placeholder="请输入物品名称"
class="prop-form-input bg-overlay" />
</el-form-item>
<el-form-item label="物品图" class="grid-column-6">
<div class="flex flex-column grid-gap-4 w-100">
<el-segmented v-model="propImageModel" class="tabs-segmented"
:options="[{ label: '文本生成', value: 'description' }, { label: '本地上传', value: 'upload' }]" />
<div class="bg-overlay rounded-4 p-4 w-100">
<el-input v-model="propForm.description" placeholder="请输入物品描述" size="small"
class="prop-form-textarea" type="textarea"
:autosize="{ minRows: 6, maxRows: 20 }" />
<div class="flex flex-y-center grid-gap-2 line-height-1 mt-4"
v-if="propImageModel === 'description'">
<div class="bg rounded-round p-3 flex flex-center grid-gap-2 pointer hover-bg-hover"
ref="propHeadimgButtonRef" title="选择使用AI生成图">
<template v-if="propHeadimgModel.id">
<el-avatar :src="propHeadimgModel.icon" :alt="propHeadimgModel.name"
shape="square" :size="16"></el-avatar>
<span class="h10 text-ellipsis-1" style="max-width: 60px;">{{
propHeadimgModel.name
}}</span>
<el-icon size="16" class="pointer"
@click.stop="handlePropHeadimgSelect()">
<Close />
</el-icon>
</template>
<template v-else>
<el-icon size="16">
<IconModelSvg />
</el-icon>
<span class="h10 overflow-hidden text-nowrap"
style="max-width: 60px;">物品图</span>
<el-icon size="16">
<ArrowDown />
</el-icon>
</template>
</div>
<div class="bg rounded-round p-3 flex flex-center grid-gap-2 pointer hover-bg-hover"
ref="propThreeViewModelButtonRef" title="选择使用AI生成三视图">
<template v-if="propImageThreeViewModel.id">
<el-avatar :src="propImageThreeViewModel.icon"
:alt="propImageThreeViewModel.name" shape="square"
:size="16"></el-avatar>
<span class="h10 text-ellipsis-1" style="max-width: 60px;">{{
propImageThreeViewModel.name }}</span>
<el-icon size="16" class="pointer"
@click.stop="handlePropThreeViewModelSelect()">
<Close />
</el-icon>
</template>
<template v-else>
<el-icon size="16">
<IconModelSvg />
</el-icon>
<span class="h10 overflow-hidden text-nowrap"
style="max-width: 60px;">三视图</span>
<el-icon size="16">
<ArrowDown />
</el-icon>
</template>
</div>
<el-upload ref="uploadReferenceImageRef" title="添加参考图"
:data="{ dir_name: 'prop/reference', dir_title: '物品图参考照片' }"
:action="$http.getCompleteUrl('app/shortplay/api/Uploads/upload')"
:headers="$http.getHeaders()" accept="image/jpeg,image/png" :limit="1"
type="cover" :disabled="uploadLoading"
:before-upload="() => { uploadLoading = true; return true; }"
:on-success="handleUploadSuccess" :show-file-list="false"
:on-error="() => { uploadLoading = false; handleUploadError() }">
<div
class="bg rounded-round p-3 flex flex-center grid-gap-2 pointer hover-bg-hover">
<el-popover placement="top" :disabled="!propForm.reference_image"
width="fit-content">
<el-avatar :src="propForm.reference_image" fit="contain" :size="130"
shape="square"></el-avatar>
<template #reference>
<el-icon size="16"
v-if="!propForm.reference_image || uploadLoading">
<Loading class="circular" v-if="uploadLoading" />
<Plus v-else />
</el-icon>
<el-icon size="16" v-else color="var(--el-color-success)">
<PictureRounded />
</el-icon>
</template>
</el-popover>
</div>
</el-upload>
<div class="flex-1"></div>
<div class="flex flex-center grid-gap-2">
<el-icon size="16">
<IconPointsSvg />
</el-icon>
<span class="h10 text-nowrap">{{ points }}</span>
</div>
<div class="rounded-round p-3 flex flex-center grid-gap-2 pointer"
style="background-color: #FFFFFF;color:#141414;"
@click="handleGenerateImage">
<el-icon size="20">
<Loading class="circular" v-if="generateImageLoading||propForm.status_enum.value === 'pending'" />
<Top v-else />
</el-icon>
</div>
</div>
</div>
</div>
</el-form-item>
</div>
<div class="flex flex-column grid-gap-4 p-4 bg-overlay rounded-4"
v-if="propImageModel === 'description'">
<el-avatar :src="propForm.image" shape="square" :size="360"
style="--el-avatar-bg-color:var(--el-bg-color-overlay)">
<div class="flex flex-column flex-center grid-gap-2"
v-if="propForm.status_enum.value === 'initializing'">
<el-icon size="64">
<Loading class="circular" v-if="generateImageLoading" />
<IconImageSvg v-else />
</el-icon>
<span class="h10">填写左侧表单使用AI生成物品图</span>
<div class="flex flex-center grid-gap-2 h10">
<span>点击</span>
<div class="rounded-round p-1 flex flex-center grid-gap-2"
style="background-color: #FFFFFF;color:#141414;">
<el-icon size="14">
<Top />
</el-icon>
</div>
<span>按钮生成</span>
</div>
</div>
<div class="flex flex-column flex-center grid-gap-2"
v-else-if="propForm.status_enum.value === 'pending'">
<el-icon size="64">
<Loading class="circular" />
</el-icon>
<span class="h10">生成中...</span>
</div>
</el-avatar>
</div>
<div class="flex flex-column grid-gap-4 bg-overlay rounded-4 p-4" v-else>
<div>
<el-upload v-if="uploadImageModel === 'image'" ref="uploadImageRef"
class="input-upload rounded-4" drag
:data="{ dir_name: 'prop/image', dir_title: '物品图照片' }"
:action="$http.getCompleteUrl('app/shortplay/api/Uploads/upload')"
:headers="$http.getHeaders()" accept="image/jpeg,image/png" :limit="1" type="cover"
:disabled="uploadHeadimgLoading"
:before-upload="() => { uploadHeadimgLoading = true; return true; }"
:on-success="handleUploadSuccess" :show-file-list="false"
:on-error="() => { uploadHeadimgLoading = false; handleUploadError() }">
<template v-if="!propForm.image">
<el-icon class="el-icon--upload">
<IconUploadImageSvg />
</el-icon>
<div class="el-upload__text">
<span class="h10">拖拽物品图照片到此处或</span>
<span class="h10">点击上传</span>
</div>
<div class="el-upload__text">
<span class="h10">支持上传格式</span>
<span class="h10">PNG, JPG, JPEG</span>
</div>
<div class="el-upload__text">
<span class="h10">图照片建议比例</span>
<span class="h10">1:1</span>
</div>
</template>
<template v-else>
<el-image :src="propForm.image" class="image-cover" fit="contain"></el-image>
</template>
</el-upload>
<el-upload v-else-if="uploadImageModel === 'three_view'" ref="uploadImageRef"
class="input-upload rounded-4" drag
:data="{ dir_name: 'prop/three_view', dir_title: '物品三视图' }"
:action="$http.getCompleteUrl('app/shortplay/api/Uploads/upload')"
:headers="$http.getHeaders()" accept="image/jpeg,image/png" :limit="1" type="cover"
:disabled="uploadThreeViewLoading"
:before-upload="() => { uploadThreeViewLoading = true; return true; }"
:on-success="handleUploadSuccess" :show-file-list="false"
:on-error="() => { uploadThreeViewLoading = false; handleUploadError() }">
<template v-if="!propForm.three_view_image">
<el-icon class="el-icon--upload">
<IconUploadImageSvg />
</el-icon>
<div class="el-upload__text">
<span class="h10">拖拽物品三视图到此处或</span>
<span class="h10">点击上传</span>
</div>
<div class="el-upload__text">
<span class="h10">支持上传格式</span>
<span class="h10">PNG, JPG, JPEG</span>
</div>
<div class="el-upload__text">
<span class="h10">图照片建议比例</span>
<span class="h10">1:1</span>
</div>
</template>
<template v-else>
<el-image :src="propForm.three_view_image" class="image-cover"
fit="contain"></el-image>
</template>
</el-upload>
</div>
<div class="flex flex-center grid-gap-4">
<el-avatar :src="propForm.image" shape="square" :size="60" class="pointer"
@click="uploadImageModel = 'image'">
<div class="flex flex-column flex-center grid-gap-1"
:class="{ 'text-secondary': uploadImageModel === 'three_view' }">
<el-icon size="20">
<Loading class="circular" v-if="uploadHeadimgLoading" />
<IconPropSvg v-else />
</el-icon>
<span class="h10">物品图</span>
</div>
</el-avatar>
<el-avatar :src="propForm.three_view_image" shape="square" :size="60" class="pointer"
@click="uploadImageModel = 'three_view'">
<div class="flex flex-column flex-center grid-gap-1"
:class="{ 'text-secondary': uploadImageModel === 'image' }">
<el-icon size="20">
<Loading class="circular" v-if="uploadThreeViewLoading" />
<IconPropThreeViewSvg v-else />
</el-icon>
<span class="h10">三视图</span>
</div>
</el-avatar>
</div>
</div>
</div>
</el-form>
<template #footer>
<div class="flex flex-center grid-gap-2 w-100">
<el-button type="info" @click="cancelPropDialog" :disabled="propLoading">取消</el-button>
<div class="flex-1"></div>
<el-button type="success" @click="submitPropDialog()" :disabled="propLoading"
:loading="propLoading">提交</el-button>
</div>
</template>
</el-dialog>
<el-popover ref="propHeadimgPopoverRef" :virtual-ref="propHeadimgButtonRef" virtual-triggering
:teleported="false" placement="bottom-start" width="min(100vw,380px)" trigger="click">
<xl-models @select="handlePropHeadimgSelect" scene="prop_image" no-init />
</el-popover>
<el-popover ref="propThreeViewModelPopoverRef" :virtual-ref="propThreeViewModelButtonRef" virtual-triggering
:teleported="false" placement="bottom-start" width="min(100vw,380px)" trigger="click">
<xl-models @select="handlePropThreeViewModelSelect" scene="prop_three_view_image" no-init />
</el-popover>
</div>
</template>
<style lang="scss" scoped>
.input-upload {
width: 360px;
border: dashed 1px var(--el-border-color);
:deep(.el-upload) {
--el-fill-color-blank: var(--el-bg-color-overlay);
--el-color-primary: var(--el-border-color-hover);
--el-upload-dragger-padding-horizontal: 0;
--el-upload-dragger-padding-vertical: 0;
.el-upload-dragger {
border: none;
height: 360px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 6px;
}
}
.image-cover {
width: 360px;
height: 360px;
}
}
.icon-model {
font-size: 20px;
width: 22px;
height: 22px;
}
.prop-form {
&-input {
:deep(.el-input__wrapper) {
box-shadow: none;
}
}
&-select {
:deep(.el-select__wrapper) {
background-color: var(--el-bg-color);
}
}
&-textarea {
:deep(.el-textarea__inner) {
box-shadow: none;
padding: 0;
resize: none;
}
}
}
</style>
@@ -0,0 +1,138 @@
<script lang="ts" setup>
import { ResponseCode } from '@/common/const';
import { $http } from '@/common/http';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = withDefaults(defineProps<{
qrcode?: any,
url?: string,
check?: string,
size?: number | string
}>(), {
size: 200
});
const emit = defineEmits(['success', 'retry']);
const form = ref({
id: ''
});
const qrcode_content = ref();
let Inter: any;
const expire = ref();
const getQrcode = () => {
if (!props.url) {
return;
}
showError.value = undefined;
$http.get(props.url).then((res: any) => {
if (res.code === 200) {
form.value.id = res.data.id;
qrcode_content.value = res.data.qrcode;
if (res.data.expire) {
expire.value = new Date(res.data.expire * 1000).getTime();
}
} else {
setQrcodeExpire(res.msg);
}
}).catch(() => {
setQrcodeExpire(t('message.getQrcodeFail'));
});
}
let timer: any;
const check = ref(false);
const checkLogin = () => {
if (!props.check || check.value) {
return;
}
if (timer) {
clearTimeout(timer);
}
timer = undefined;
$http.post(props.check, form.value).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
check.value = true;
emit('success', res);
} else if (res.code === ResponseCode.WAIT) {
timer = setTimeout(() => {
checkLogin();
}, 1000);
} else {
check.value = true;
setQrcodeExpire(res.msg);
}
}).catch((error) => {
if (error.code != 'ERR_CANCELED') {
setQrcodeExpire(error.message);
}
});
}
const showError = ref();
const setQrcodeExpire = (msg: string) => {
expire.value = undefined;
showError.value = msg;
}
const retry = () => {
if (props.url) {
getQrcode();
} else {
emit('retry');
}
}
onMounted(() => {
if (props.url) {
getQrcode();
} else if (props.qrcode) {
form.value.id = props.qrcode.id;
qrcode_content.value = props.qrcode.qrcode;
if (props.qrcode.expire) {
expire.value = new Date(props.qrcode.expire * 1000).getTime();
}
}
});
onUnmounted(() => {
if (timer) {
clearTimeout(timer);
}
if (Inter) {
clearInterval(Inter);
}
qrcode_content.value = undefined;
});
</script>
<template>
<div class="flex flex-column flex-y-center">
<div class="qrcode" :style="[`width:${props.size}px;height:${props.size}px;`]">
<xl-qrcode :text="qrcode_content" :size="Number(props.size)" @update="checkLogin"></xl-qrcode>
<div class="error flex flex-column flex-center grid-gap-4" :class="{ 'show': showError }">
<el-icon size="40" color="var(--el-color-danger)">
<CircleClose />
</el-icon>
<div class="text-white text-break-all">{{ showError }}</div>
<el-link type="primary" @click="retry" underline="never">
{{ t('button.retryText') }}
</el-link>
</div>
</div>
<el-countdown class="flex flex-column flex-center" :title="t('message.qrcodeWillExpireTitle')" :value="expire"
@finish="() => setQrcodeExpire(t('message.qrcodeExpireContent'))" v-if="expire" />
</div>
</template>
<style lang="scss" scoped>
.qrcode {
position: relative;
}
.error {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.75);
display: none;
&.show {
display: flex;
}
}
</style>
@@ -0,0 +1,25 @@
<script lang="ts" setup>
import QRCode from "qrcode";
const props = withDefaults(defineProps<{
text?: string
size?: number
}>(), {
size: 200
});
const emit = defineEmits(['update'])
const qrcode = ref('');
watchEffect(() => {
qrcode.value = '';
if (props.text) {
QRCode.toDataURL(props.text, {
width: props.size
}, (_error: Error | null | undefined, url: string) => {
qrcode.value = url
emit('update');
});
}
})
</script>
<template>
<el-image v-if="qrcode" :src="qrcode" style="width: 100%;height: 100%;" />
</template>
@@ -0,0 +1,272 @@
<script setup lang="ts">
import { ResponseCode } from '@/common/const';
import { $http } from '@/common/http';
const emit = defineEmits(['success']);
const createSceneAction = ref<'form' | 'scene'>('form');
const createSceneForm = reactive({
id: '',
title: '',
scene_space: '',
scene_location: '',
scene_time: '',
scene_weather: '',
description: '',
atmosphere: '',
drama_id: '',
episode_id: '',
});
const createSceneFormRules = {
title: [{ required: true, message: '请输入场景名称', trigger: 'blur' }],
scene_space: [{ required: true, message: '请输入内景OR外景', trigger: 'blur' }],
scene_location: [{ required: true, message: '请输入地点', trigger: 'blur' }],
scene_time: [{ required: true, message: '请输入大概时间', trigger: 'blur' }],
scene_weather: [{ required: true, message: '请输入天气', trigger: 'blur' }],
description: [{ required: true, message: '请输入描述', trigger: 'blur' }],
};
const createSceneFormLoading = ref(false);
const episodes = ref<any[]>([]);
const selectedEpisodeId = ref('');
const getEpisodes = () => {
$http.get('/app/shortplay/api/DramaEpisode/episodes', {
params: {
drama_id: createSceneForm.drama_id,
}
}).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
episodes.value = res.data;
selectedEpisodeId.value = episodes.value[0]?.id || '';
} else {
ElMessage.error(res.msg);
}
}).catch(() => {
ElMessage.error('获取分集失败');
})
}
const handleSelectScene = (item: any) => {
if (createSceneFormLoading.value) return;
createSceneFormLoading.value = true;
$http.post('/app/shortplay/api/Scene/copyScene', {
episode_id: createSceneForm.episode_id,
drama_id: createSceneForm.drama_id,
scene_id: item.id,
}).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
emit('success', res.data);
createSceneDialogVisible.value = false;
} else {
ElMessage.error(res.msg);
}
}).catch(() => {
ElMessage.error('新增场景失败');
}).finally(() => {
createSceneFormLoading.value = false;
});
}
const createSceneDialogVisible = ref(false);
const createSceneFormRef = ref<any>(null);
const cancelCreateScene = () => {
createSceneFormRef.value?.resetFields();
createSceneDialogVisible.value = false;
createSceneForm.id = '';
createSceneForm.title = '';
createSceneForm.scene_location = '';
createSceneForm.scene_space = '';
createSceneForm.scene_time = '';
createSceneForm.scene_weather = '';
createSceneForm.description = '';
createSceneForm.atmosphere = '';
createSceneForm.drama_id = '';
createSceneForm.episode_id = '';
}
const handleCreateScene = () => {
if (createSceneFormLoading.value) return;
createSceneFormLoading.value = true;
$http.post('/app/shortplay/api/Scene/update', createSceneForm).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
emit('success', res.data);
cancelCreateScene();
} else {
ElMessage.error(res.msg);
}
}).catch(() => {
ElMessage.error('新增场景失败');
}).finally(() => {
createSceneFormLoading.value = false;
});
}
const openCreateScene = (options: any) => {
if (options.scene) {
createSceneForm.id = options.scene.id;
createSceneForm.title = options.scene.title;
createSceneForm.scene_location = options.scene.scene_location;
createSceneForm.scene_space = options.scene.scene_space;
createSceneForm.scene_time = options.scene.scene_time;
createSceneForm.scene_weather = options.scene.scene_weather;
createSceneForm.description = options.scene.description;
createSceneForm.atmosphere = options.scene.atmosphere;
}
createSceneForm.drama_id = options.drama_id;
createSceneForm.episode_id = options.episode_id;
getEpisodes();
createSceneDialogVisible.value = true;
}
defineExpose({
open: openCreateScene
})
</script>
<template>
<div>
<el-dialog v-model="createSceneDialogVisible" class="generate-scene-dialog" draggable>
<template #header>
<span class="font-weight-600" v-if="createSceneForm.id">编辑场景</span>
<span class="font-weight-600" v-else>新增场景</span>
</template>
<el-segmented v-model="createSceneAction" :disabled="createSceneFormLoading"
:options="[{ label: '填写场景信息', value: 'form' }, { label: '从其他分集选取', value: 'scene' }]"
class="tabs-segmented border" v-if="!createSceneForm.id" />
<el-form ref="createSceneFormRef" label-position="top" v-if="createSceneAction === 'form'"
:disabled="createSceneFormLoading" :model="createSceneForm" :rules="createSceneFormRules">
<el-form-item label="场景名称" prop="title">
<el-input v-model="createSceneForm.title" placeholder="请输入场景名称" class="scene-item-input" />
</el-form-item>
<el-form-item label="地点">
<div class="w-100 grid-columns-2 grid-gap-2">
<el-form-item prop="scene_space">
<el-input v-model="createSceneForm.scene_space" placeholder="内景OR外景"
class="scene-item-input grid-column-1 w-100" />
</el-form-item>
<el-form-item prop="scene_location">
<el-input v-model="createSceneForm.scene_location" placeholder="地点"
class="scene-item-input grid-column-1" />
</el-form-item>
<el-form-item prop="scene_time">
<el-input v-model="createSceneForm.scene_time" placeholder="大概时间"
class="scene-item-input grid-column-1" />
</el-form-item>
<el-form-item prop="scene_weather">
<el-input v-model="createSceneForm.scene_weather" placeholder="天气"
class="scene-item-input grid-column-1" />
</el-form-item>
</div>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="createSceneForm.description" type="textarea"
:autosize="{ minRows: 1, maxRows: 10 }" placeholder="请输入描述" class="scene-item-textarea" />
</el-form-item>
</el-form>
<div v-if="createSceneAction === 'scene'" class="flex flex-center grid-gap-4">
<el-scrollbar height="400px">
<div class="episode-tabs"
style="--el-anchor-active-color: var(--el-color-success);--el-anchor-marker-bg-color: var(--el-color-success);">
<span v-for="(item, index) in episodes" :key="item.id" @click="selectedEpisodeId = item.id"
class="episode-tabs-item" :class="{ 'is-active': item.id === selectedEpisodeId }">
#{{ index + 1 }} {{ item.title }}
</span>
</div>
</el-scrollbar>
<el-scrollbar height="400px" class="flex-1">
<div class="episode-tabs"
style="--el-anchor-active-color: var(--el-color-success);--el-anchor-marker-bg-color: var(--el-color-success);">
<span
v-for="item in episodes.find((item: any) => item.id === selectedEpisodeId)?.scenes"
:key="item.id" class="episode-tabs-item"
:class="{ 'is-active': item.id === selectedEpisodeId }" @click="handleSelectScene(item)">
#{{ item.id }} {{ item.title }}·{{ item.scene_location }}
</span>
</div>
</el-scrollbar>
</div>
<template #footer>
<div class="flex flex-center grid-gap-2" v-if="createSceneAction === 'form'">
<el-button type="info" @click="cancelCreateScene" :disabled="createSceneFormLoading">取消</el-button>
<el-button type="success" @click="handleCreateScene" :disabled="createSceneFormLoading"
:loading="createSceneFormLoading">提交</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.scene-item-textarea {
width: 100%;
--el-input-bg-color: var(--el-bg-color-overlay);
--el-input-border-color: var(--el-bg-color-overlay);
--el-input-border-radius: 6px;
--el-input-focus-border-color: var(--el-bg-color-overlay);
--el-input-text-color: var(--el-text-color-primary);
--el-input-placeholder-color: var(--el-text-color-placeholder);
--el-input-focus-text-color: var(--el-text-color-primary);
--el-input-focus-placeholder-color: var(--el-text-color-placeholder);
--el-input-focus-border-color: var(--el-bg-color-overlay);
--el-input-hover-border-color: var(--el-bg-color-overlay);
:deep(.el-textarea__inner) {
resize: none;
padding: 10px;
}
}
.scene-item-input {
--el-input-bg-color: var(--el-bg-color-overlay);
--el-input-border-color: var(--el-bg-color-overlay);
--el-input-border-radius: 6px;
--el-input-focus-border-color: var(--el-bg-color-overlay);
--el-input-text-color: var(--el-text-color-primary);
--el-input-placeholder-color: var(--el-text-color-placeholder);
--el-input-focus-text-color: var(--el-text-color-primary);
--el-input-focus-placeholder-color: var(--el-text-color-placeholder);
--el-input-focus-border-color: var(--el-bg-color-overlay);
--el-input-hover-border-color: var(--el-bg-color-overlay);
:deep(.el-textarea__inner) {
resize: none;
padding: 10px;
}
}
.scene-item-select {
width: 180px;
--el-select-bg-color: var(--el-bg-color-overlay);
--el-select-input-focus-border-color: var(--el-color-success);
}
.episode-tabs {
display: flex;
flex-direction: column;
gap: 10px;
--el-anchor-bg-color: transparent;
--el-anchor-padding-indent: 0;
--el-anchor-line-height: 50px;
:deep(.el-anchor__list) {
gap: 10px;
padding-bottom: 0;
}
:deep(.el-anchor__marker) {
top: 0;
bottom: 0;
height: var(--el-anchor-line-height);
border-radius: 6px;
}
.episode-tabs-item {
flex-shrink: 0;
cursor: pointer;
padding-left: 0;
background: #272727;
border-radius: 6px;
border-radius: 6px;
font-weight: 600;
padding: 10px;
z-index: 1;
color: var(--el-text-color-secondary);
&.is-active {
color: var(--el-bg-color);
background: var(--el-color-success);
}
}
}
</style>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { ResponseCode } from '@/common/const';
import { $http } from '@/common/http';
import { ElMessage } from 'element-plus';
const props = withDefaults(defineProps<{
query?: any
types?: any[]
}>(), {
query: () => ({}),
types: () => ([]),
});
const emit = defineEmits(['select']);
const StoryboardSearch = reactive({
type: 'episode',
description: '',
...props.query,
})
const storyboardList = ref<any[]>([]);
const loading = ref(false);
const getStoryboardList = () => {
loading.value = true;
storyboardList.value = [];
$http.get('/app/shortplay/api/Storyboard/index', { params: StoryboardSearch }).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
storyboardList.value = res.data;
}
}).finally(() => {
loading.value = false;
})
}
const handleStoryboardItemClick = (item: any) => {
if (!item.image) return ElMessage.info('该分镜还未生成图片');
emit('select', item);
}
defineExpose({
getStoryboardList,
})
</script>
<template>
<div class="flex flex-column grid-gap-4" style="--el-color-primary:var(--el-color-success);">
<el-form class="flex flex-center grid-gap-4" @submit.prevent="getStoryboardList">
<el-form-item class="mb-0">
<xl-tabs v-model="StoryboardSearch.type" class="text-info" @change="getStoryboardList">
<xl-tabs-item value="episode">本集</xl-tabs-item>
<xl-tabs-item value="all">全部</xl-tabs-item>
</xl-tabs>
</el-form-item>
<div class="flex-1"></div>
<el-form-item class="mb-0">
<el-input v-model="StoryboardSearch.description" placeholder="搜索分镜" clearable
@change="getStoryboardList">
<template #suffix>
<el-icon>
<Search />
</el-icon>
</template>
</el-input>
</el-form-item>
</el-form>
<el-scrollbar height="40vh" v-loading="loading" v-if="storyboardList.length > 0 || loading">
<div class="grid-columns-8 grid-gap-4">
<div class="grid-column-2 rounded-4 border-2 border-solid flex flex-column grid-gap-2 storyboard-item"
v-for="item in storyboardList" @click="handleStoryboardItemClick(item)">
<div class="p-2">
<span class="h10">场景{{ item.scene_id }} - #{{ item.sort }}</span>
</div>
<el-avatar :src="item.image" :size="40" shape="square" fit="contain"
class="storyboard-item-image bg-mosaic" />
</div>
</div>
</el-scrollbar>
<el-empty v-else description="暂无分镜"></el-empty>
</div>
</template>
<style lang="scss" scoped>
.storyboard-item {
cursor: pointer;
&:hover {
background-color: var(--el-fill-color-dark);
}
&-image {
width: 100%;
height: 200px;
}
}
</style>
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { ResponseCode } from '@/common/const';
import { truncate } from '@/common/functions';
import { $http } from '@/common/http';
import { useRefs, useWebConfigStore } from '@/stores';
const props = withDefaults(defineProps<{
modelValue: string | number
}>(), {
modelValue: '',
});
const webConfigStore = useWebConfigStore();
const { WEBCONFIG } = useRefs(webConfigStore);
const emit = defineEmits(['select', 'update:modelValue']);
const StyleSearch = reactive({
classify: 'all',
name: '',
})
const styleList = ref<any[]>([]);
const loading = ref(false);
const getStyleList = () => {
loading.value = true;
styleList.value = [];
$http.get('/app/shortplay/api/Style/index', { params: StyleSearch }).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
styleList.value = res.data;
}
}).finally(() => {
loading.value = false;
})
}
const handleStyleItemClick = (item: any) => {
emit('update:modelValue', item.id);
emit('select', item);
}
onMounted(() => {
getStyleList();
})
</script>
<template>
<div class="flex flex-column grid-gap-4" style="--el-color-primary:var(--el-color-success);">
<el-form class="flex flex-center grid-gap-4" @submit.prevent="getStyleList">
<el-form-item class="mb-0">
<xl-tabs v-model="StyleSearch.classify" class="text-info" @change="getStyleList">
<xl-tabs-item value="all">全部</xl-tabs-item>
<xl-tabs-item v-for="value in WEBCONFIG?.enum?.style_classify" :key="value.value"
:value="value.value">{{ value.label }}</xl-tabs-item>
</xl-tabs>
</el-form-item>
<div class="flex-1"></div>
<el-form-item class="mb-0">
<el-input v-model="StyleSearch.name" placeholder="搜索风格" clearable @change="getStyleList">
<template #suffix>
<el-icon>
<Search />
</el-icon>
</template>
</el-input>
</el-form-item>
</el-form>
<el-scrollbar height="300px" v-loading="loading">
<div class="grid-columns-10 grid-gap-3">
<div class="grid-column-2 input-button rounded-4 border-2 border-solid flex flex-center grid-gap-2 style-item"
:class="{ 'style-item-selected': props.modelValue === item.id }" v-for="item in styleList"
@click="handleStyleItemClick(item)">
<el-image :src="item.image" class="style-image" fit="cover">
{{ truncate(item.name, 1) }}
</el-image>
<div class="style-name">
<span>{{ item.name }}</span>
</div>
<el-icon size="20" color="var(--el-color-success)" class="style-item-selected-icon">
<SuccessFilled />
</el-icon>
</div>
</div>
</el-scrollbar>
</div>
</template>
<style lang="scss" scoped>
.style-item {
cursor: pointer;
height: 150px;
position: relative;
overflow: hidden;
border-color: transparent;
&:hover {
border-color: var(--el-fill-color-dark);
}
&.style-item-selected {
border-color: var(--el-color-success);
&:hover {
border-color: var(--el-color-success);
}
.style-item-selected-icon {
opacity: 1;
}
}
.style-image {
width: 100%;
height: 100%;
}
.style-name {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.00) 0%, rgba(0, 0, 0, 0.50) 100%);
color: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
}
.style-item-selected-icon {
position: absolute;
top: 5px;
right: 5px;
opacity: 0;
transition: opacity 0.3s ease-in-out;
background-color: var(--el-bg-color);
border-radius: 999px;
}
}
.el-input {
--el-input-border-radius: 20px;
}
</style>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed, inject, onMounted, onBeforeUnmount, watch, ref, nextTick, type Ref } from 'vue';
const props = defineProps({
value: {
type: [String, Number],
required: true
}
});
const activeTab = inject<Ref<string | number> | undefined>('activeTab', undefined);
const setActiveTab = inject<((value: string | number) => void) | undefined>('setActiveTab', undefined);
const registerTab = inject<((value: string | number, el: HTMLElement | null) => void) | undefined>('registerTab', undefined);
const unregisterTab = inject<((value: string | number) => void) | undefined>('unregisterTab', undefined);
const updateIndicator = inject<((el?: HTMLElement | null) => void) | undefined>('updateIndicator', undefined);
const itemRef = ref<HTMLElement | null>(null);
onMounted(() => {
nextTick(() => registerTab?.(props.value, itemRef.value));
});
onBeforeUnmount(() => {
unregisterTab?.(props.value);
});
const isActive = computed(() => activeTab?.value === props.value);
watch(isActive, (active) => {
if (active) {
nextTick(() => updateIndicator?.(itemRef.value));
}
});
const handleClick = () => {
setActiveTab?.(props.value);
};
</script>
<template>
<div class="xl-tabs-item" :class="{ 'xl-tabs-item-selected': isActive }" @click="handleClick" ref="itemRef">
<slot></slot>
</div>
</template>
<style lang="scss" scoped>
.xl-tabs-item {
min-width: 40px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
text-ellipsis: 1;
overflow: hidden;
white-space: nowrap;
&:hover {
color: var(--xl-tabs-selected-color);
}
&.xl-tabs-item-selected {
color: var(--xl-tabs-selected-color);
}
}
</style>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { ref, provide, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
const props = withDefaults(defineProps<{
modelValue: string | number
dotWidth?: number,
color?: string,
}>(), {
modelValue: '',
dotWidth: 20,
color: 'var(--el-color-success)'
});
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number): void;
(e: 'change', value: string | number): void;
}>();
const activeTab = ref(props.modelValue);
const tabs = ref<(string | number)[]>([]);
const tabRefs = new Map<string | number, HTMLElement | null>();
const dotLeft = ref(0);
const dotWidth = ref(props.dotWidth);
const containerRef = ref<HTMLElement | null>(null);
let frameId: number | null = null;
watch(activeTab, (value) => {
emit('change', value);
})
const setActiveTab = (value: string | number) => {
if (activeTab.value === value) {
return;
}
activeTab.value = value;
emit('update:modelValue', value);
nextTick(updateIndicator);
};
const updateIndicator = (el?: HTMLElement | null) => {
if (typeof window === 'undefined') {
return;
}
const target = el ?? tabRefs.get(activeTab.value) ?? null;
if (!target) {
return;
}
const { offsetWidth, offsetLeft } = target;
if (offsetWidth === 0) {
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
}
frameId = window.requestAnimationFrame(() => {
frameId = null;
updateIndicator(target);
});
return;
}
dotLeft.value = offsetLeft + offsetWidth / 2 - dotWidth.value / 2;
};
const registerTab = (value: string | number, el: HTMLElement | null) => {
if (!tabs.value.includes(value)) {
tabs.value.push(value);
}
tabRefs.set(value, el);
if (value === activeTab.value) {
nextTick(() => updateIndicator(el));
}
};
const unregisterTab = (value: string | number) => {
const index = tabs.value.indexOf(value);
if (index !== -1) {
tabs.value.splice(index, 1);
}
tabRefs.delete(value);
if (activeTab.value === value) {
const nextValue = tabs.value[0];
if (nextValue !== undefined) {
activeTab.value = nextValue;
// emit('update:modelValue', nextValue);
nextTick(updateIndicator);
}
}
};
watch(
() => props.modelValue,
(value) => {
if (activeTab.value !== value) {
activeTab.value = value;
nextTick(updateIndicator);
}
}
);
onMounted(() => {
nextTick(updateIndicator);
});
onBeforeUnmount(() => {
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
frameId = null;
}
});
provide('tabs', tabs);
provide('activeTab', activeTab);
provide('setActiveTab', setActiveTab);
provide('registerTab', registerTab);
provide('unregisterTab', unregisterTab);
provide('updateIndicator', updateIndicator);
</script>
<template>
<div class="xl-tabs" :style="{
'--xl-tabs-dot-left': `${dotLeft}px`,
'--xl-tabs-dot-width': `${dotWidth}px`,
'--xl-tabs-selected-color': color,
'--xl-tabs-dot-bg-color': color,
}" ref="containerRef">
<slot :activeTab="activeTab"></slot>
</div>
</template>
<style lang="scss" scoped>
.xl-tabs {
// --xl-tabs-selected-color: var(--el-color-success);
// --xl-tabs-dot-bg-color: var(--el-color-success);
--xl-tabs-dot-width: 20px;
--xl-tabs-dot-height: 2px;
display: flex;
gap: 10px;
position: relative;
overflow: hidden;
&::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
width: var(--xl-tabs-dot-width);
height: var(--xl-tabs-dot-height);
background-color: var(--xl-tabs-dot-bg-color);
border-radius: var(--xl-tabs-dot-height);
transform: translateX(var(--xl-tabs-dot-left, 0px));
transition: transform 0.3s ease-in-out, width 0.3s ease-in-out;
}
}
</style>
@@ -0,0 +1,128 @@
<template>
<el-dialog v-model="visible" width="590px" align-center append-to-body @close="list = [];query.page = 1">
<template #header>
<div class="flex flex-y-center grid-gap-7 flex-1">
<el-avatar :size="54" :src="USERINFO?.headimg">
{{ truncate(USERINFO?.nickname, 1) }}
</el-avatar>
<div class="flex flex-column flex-x-start grid-gap-1">
<span class="h8 font-weight-bold">{{ USERINFO?.nickname }}</span>
<div class="h9 text-secondary">剩余积分 <span class="text-success"> {{ WALLET?.points
}}</span> </div>
</div>
</div>
</template>
<div class="w-p-100 my-5 px-3">
<div class="flex flex-y-center">
<el-segmented v-model="query.type" :options="options" @change="onChangeType" />
</div>
<el-scrollbar height="400px" max-height="60vh" v-loading="loading" @end-reached="handleScroll">
<div class="flex flex-y-center grid-gap-4 py-5 line" v-for="item in list" :key="item.id"
v-if="list.length > 0">
<div class="flex flex-column flex-1 grid-gap-1">
<span class="h8">{{ item.remarks }}</span>
<span class="text-secondary h9">{{ item.create_time }}</span>
</div>
<span class="h7 text-success" v-if="item.action === 'increase'">+{{ item.num }}</span>
<span class="h7" v-if="item.action === 'decrease'">-{{ item.num }}</span>
</div>
<div v-else>
<el-empty description="暂无数据" />
</div>
</el-scrollbar>
</div>
<template #footer>
<div class="flex flex-x-center grid-gap-6">
<div class="flex-1 text-success flex flex-y-center grid-gap-2 pointer" @click="router.push('/article/points')">
<el-icon :size="18">
<Warning />
</el-icon>
<span>积分规则</span>
</div>
<el-button class="btn" color="var(--el-color-white)" size="large" @click="router.push('/points')">
充值积分
</el-button>
<el-button class="btn" color="var(--el-color-success)" size="large" @click="router.push('/vip')">
订阅服务
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { truncate } from '@/common/functions';
import { ref } from 'vue';
import { useRefs, useUserStore, useWalletStore } from '@/stores';
import { useRouter } from 'vue-router';
import { $http } from '@/common/http';
import { ResponseCode } from '@/common/const';
const router = useRouter();
const userStore = useUserStore();
const { USERINFO } = useRefs(userStore);
const walletStore = useWalletStore();
const { WALLET } = useRefs(walletStore);
const visible = ref(false);
const loading = ref(false);
const options = [
{ label: '全部', value: 'all' },
{ label: '消耗', value: 'consume' },
{ label: '充值', value: 'recharge' },
{ label: '获得', value: 'gain' },
];
const list = ref<any[]>([]);
const total = ref(0);
const query = ref({
page: 1,
type: 'all',
});
const getPointsList = async () => {
loading.value = true;
const res: any = await $http.get('/app/user/api/Bill/getPointsBill', { params: query.value })
if (res.code === ResponseCode.SUCCESS) {
list.value = [...list.value, ...(res.data.data || [])]
total.value = res.data.total || 0
}
loading.value = false;
}
const onChangeType = () => {
list.value = [];
query.value.page = 1;
getPointsList();
}
const handleScroll = () => {
console.log('handleScroll');
if (list.value.length >= total.value) return;
query.value.page++;
getPointsList();
}
defineExpose({
open: () => {
visible.value = true
getPointsList()
}
})
</script>
<style scoped lang="scss">
.btn {
padding: 8px 30px;
}
.el-segmented {
--el-segmented-item-selected-color: var(--el-color-black);
--el-segmented-item-selected-bg-color: var(--el-color-white);
--el-border-radius-base: 8px;
}
:deep(.el-segmented__item) {
padding: 4px 48px;
font-size: 18px;
}
:deep(.el-segmented__item.is-selected) {
font-weight: bold;
}
.line {
border-bottom: 1px solid var(--el-border-color-lighter);
}
</style>
@@ -0,0 +1,122 @@
<template>
<el-dialog v-model="visible" title="设置中心" width="700px" align-center>
<div class="flex grid-gap-10">
<div class="flex flex-column grid-gap-1">
<div v-for="item in options" class="flex flex-y-center grid-gap-2 menu-item"
:class="{ 'menu-item-active': activeValue == item.value }" @click="onChange(item.value)">
<el-icon :size="16">
<!-- <component :is="item.icon" /> -->
<HelpSvg v-if="item.value === 'help'" />
<UserSvg v-if="item.value === 'account'" />
<BookSvg v-if="item.value === 'terms'" />
<DunpaiSvg v-if="item.value === 'privacy'" />
<AboutSvg v-if="item.value === 'about'" />
<LogoutSvg v-if="item.value === 'logout'" />
</el-icon>
<span>{{ item.name }}</span>
</div>
</div>
<div class="flex-1 h-p-100 box">
<User v-if="activeValue === 'account'" ref="userRef" />
<div v-else v-html="html"></div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button color="var(--el-fill-color-lighter)" size="large" v-if="activeValue === 'account'"
@click="handleSave">
保存
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { $http } from '@/common/http';
import { ResponseCode } from '@/common/const';
import User from './modules/xl-user.vue';
import { useUserStore } from '@/stores';
import router from '@/routers';
import HelpSvg from '@/svg/icon/icon-help.vue';
import UserSvg from '@/svg/icon/icon-user.vue';
import BookSvg from '@/svg/icon/icon-book.vue';
import DunpaiSvg from '@/svg/icon/icon-dunpai.vue';
import AboutSvg from '@/svg/icon/icon-about.vue';
import LogoutSvg from '@/svg/icon/icon-logout.vue';
const html = ref(null);
const activeValue = ref('account');
const options = reactive([
{ name: '账户', icon: 'UserSvg', value: 'account' },
{ name: '帮助中心', icon: 'HelpSvg', value: 'help' },
{ name: '使用条款', icon: 'BookSvg', value: 'terms' },
{ name: '隐私协议', icon: 'DunpaiSvg', value: 'privacy' },
{ name: '关于我们', icon: 'AboutSvg', value: 'about' },
{ name: '退出登录', icon: 'LogoutSvg', value: 'logout' },
])
const visible = ref(false);
const userRef = ref<InstanceType<typeof User> | null>(null);
const userStore = useUserStore();
const getArticle = (key: string) => {
$http.get('/app/article/api/Article/index', { params: { key } }).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
html.value = res.data;
}
})
}
const onChange = (value: string) => {
if (value !== 'account' && value !== 'logout') {
getArticle(value);
}
if (value === 'logout') {
ElMessageBox.confirm('确定退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
userStore.clearUserInfo();
router.push('/');
visible.value = false;
ElMessage.success('退出登录成功');
});
return ;
}
activeValue.value = value;
}
const handleSave = () => {
if (userRef.value && typeof userRef.value.save === 'function') {
userRef.value.save();
}
}
defineExpose({
open: () => {
visible.value = true
}
})
</script>
<style scoped lang="scss">
.menu-item {
padding: 12px 36px 12px 12px;
border-radius: 8px;
cursor: pointer;
&-active {
background-color: var(--el-fill-color-lighter);
}
&:hover {
background-color: var(--el-fill-color-lighter);
}
}
.box {
min-height: 400px !important;
max-height: 400px !important;
overflow-y: auto;
}
.dialog-footer {
min-height: 40px;
}
</style>
@@ -0,0 +1,501 @@
<template>
<div class="h9 text-black">
<div class="flex flex-y-center ">
<div class="flex flex-y-center grid-gap-7 flex-1" v-if="!editStatus">
<el-avatar :size="54" :src="USERINFO?.headimg">
{{ truncate(USERINFO?.nickname, 1) }}
</el-avatar>
<div class="flex flex-column flex-x-start grid-gap-2">
<span class="h8 font-weight-bold">{{ USERINFO?.nickname }}</span>
<div class="h9 text-secondary">{{ USERINFO?.id }}</div>
</div>
</div>
<div class="flex flex-y-center grid-gap-7 flex-1" v-else>
<div class="position-relative">
<el-upload ref="uploadImageRef" class="avatar-uploader"
:data="{ dir_name: 'user/headimg', dir_title: '用户头像' }"
:action="$http.getCompleteUrl('app/shortplay/api/Uploads/upload')"
:headers="$http.getHeaders()" accept="image/jpeg,image/png" :limit="1" type="cover"
:disabled="uploadHeadimgLoading"
:before-upload="() => { uploadHeadimgLoading = true; return true; }"
:on-success="handleUploadSuccess" :show-file-list="false"
:on-error="() => { uploadHeadimgLoading = false; handleUploadError() }">
<template #default>
<div class="avatar-wrapper">
<el-avatar :size="54" :src="form.headimg || USERINFO?.headimg">
{{ truncate(form.nickname || USERINFO?.nickname, 1) }}
</el-avatar>
<div class="pointer position-absolute bottom--0 right-0 edit" @click.stop="triggerUpload">
<el-icon color="#000" :size="12" v-if="!uploadHeadimgLoading">
<IconEdit />
</el-icon>
<el-icon color="#000" :size="12" v-else class="is-loading">
<Loading />
</el-icon>
</div>
</div>
</template>
</el-upload>
</div>
<div class="flex flex-column flex-x-start">
<el-input v-model="form.nickname" placeholder="请输入昵称" maxlength="20" show-word-limit />
</div>
</div>
<el-button color="var(--el-fill-color-lighter)" @click="toggleEditStatus" >
{{ editStatus ? '取消' : '编辑信息' }}
</el-button>
</div>
<el-divider />
<div class="flex flex-x-space-between">
<span>手机号</span>
<div class="flex flex-y-center grid-gap-4">
<el-input v-if="editMobile" v-model="form.mobile" placeholder="请输入手机号" maxlength="11"
@blur="validateMobile" />
<span v-else>{{ USERINFO?.mobile || '未绑定' }}</span>
<el-icon :size="16" class="pointer" @click="toggleMobileEdit" v-if="!editStatus">
<Edit />
</el-icon>
<el-icon :size="16" class="pointer" @click="cancelMobileEdit" v-else-if="editMobile">
<Close />
</el-icon>
</div>
</div>
<div class="flex flex-x-space-between mt-10">
<span>微信</span>
<div class="flex flex-y-center grid-gap-4">
<span>{{ isWechatBound ? '已绑定' : '未绑定' }}</span>
<el-icon :size="16" class="pointer" @click="openWechatBindDialog" v-if="!isWechatBound">
<Edit />
</el-icon>
</div>
</div>
<div class="flex flex-x-space-between mt-10">
<span>密码</span>
<div class="flex flex-y-center grid-gap-4 pointer" @click="openPasswordDialog">
<span>{{ USERINFO?.password==1 ? '修改' : '设置' }}</span>
<el-icon :size="16" >
<Edit />
</el-icon>
</div>
</div>
</div>
<!-- 绑定微信二维码对话框 -->
<el-dialog v-model="wechatBindDialogVisible" title="绑定微信" width="400px" align-center @close="closeWechatBindDialog">
<div class="flex flex-column flex-y-center grid-gap-4">
<p class="text-center">请使用微信扫描下方二维码完成绑定</p>
<xl-qrcode-view
v-if="wechatBindDialogVisible"
url="/app/user/api/Login/qrcode"
check="/app/user/api/Login/checkBind"
@success="handleWechatBindSuccess" />
</div>
</el-dialog>
<!-- 修改密码对话框 -->
<el-dialog v-model="passwordDialogVisible" title="修改密码" width="400px" align-center @close="closePasswordDialog">
<div class="flex flex-column grid-gap-4">
<el-input v-model="passwordForm.password" type="password" placeholder="请输入新密码" show-password />
<el-input v-model="passwordForm.confirmPassword" type="password" placeholder="请确认新密码" show-password />
<el-input v-model="passwordForm.vcode" placeholder="请输入短信验证码" maxlength="6">
<template #append>
<el-button type="primary" :disabled="passwordVcode.btnDisabled.value" @click="getPasswordVcode">
{{ passwordVcode.btnText.value }}
</el-button>
</template>
</el-input>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="closePasswordDialog">取消</el-button>
<el-button color="var(--el-color-success)" :loading="passwordLoading" @click="handleSetPassword">确认</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { truncate } from '@/common/functions';
import { useRefs, useUserStore } from '@/stores';
import { $http } from '@/common/http';
import { ResponseCode } from '@/common/const';
import { useVcode } from '@/composables/useVcode';
import IconEdit from '@/svg/icon/icon-edit.vue';
import { Edit, Close, Loading } from '@element-plus/icons-vue';
const userStore = useUserStore();
const { USERINFO } = useRefs(userStore);
const editMobile = ref(false);
const editStatus = ref(false);
const uploadHeadimgLoading = ref(false);
const saving = ref(false);
const uploadImageRef = ref<any>(null);
const wechatBindDialogVisible = ref(false);
const passwordDialogVisible = ref(false);
const passwordLoading = ref(false);
const passwordVcode = useVcode();
//
const passwordForm = reactive({
password: '',
confirmPassword: '',
vcode: '',
token: null as string | null,
phoneCountryCode: '+86'
});
// USERINFO.wechat
const isWechatBound = computed(() => {
return !!(USERINFO.value?.wechat && USERINFO.value.wechat.openid.trim() !== '');
});
//
const originalData = ref({
nickname: '',
mobile: '',
headimg: '',
});
const form = reactive({
mobile: '',
nickname: '',
headimg: '',
});
//
const initForm = () => {
form.nickname = USERINFO.value?.nickname || '';
form.mobile = USERINFO.value?.mobile || '';
form.headimg = USERINFO.value?.headimg || '';
originalData.value = {
nickname: form.nickname,
mobile: form.mobile,
headimg: form.headimg,
};
};
//
watch(() => USERINFO.value, () => {
if (USERINFO.value) {
initForm();
}
}, { immediate: true });
//
const openWechatBindDialog = () => {
wechatBindDialogVisible.value = true;
};
//
const closeWechatBindDialog = () => {
wechatBindDialogVisible.value = false;
};
//
const handleWechatBindSuccess = async (response: any) => {
if (response.code === ResponseCode.SUCCESS) {
ElMessage.success('微信绑定成功');
wechatBindDialogVisible.value = false;
//
try {
const infoRes: any = await $http.get('/app/user/api/User/info');
if (infoRes.code === ResponseCode.SUCCESS) {
await userStore.setUserInfo(infoRes.data as UserInfoInterface);
}
} catch (error: any) {
ElMessage.error('刷新用户信息失败,请稍后重试');
}
}
};
//
const openPasswordDialog = () => {
passwordForm.password = '';
passwordForm.confirmPassword = '';
passwordForm.vcode = '';
passwordForm.token = null;
passwordDialogVisible.value = true;
};
//
const closePasswordDialog = () => {
passwordDialogVisible.value = false;
passwordForm.password = '';
passwordForm.confirmPassword = '';
passwordForm.vcode = '';
passwordForm.token = null;
};
//
const getPasswordVcode = () => {
if (!USERINFO.value?.mobile) {
ElMessage.warning('请先绑定手机号');
return;
}
passwordVcode.open({
username: USERINFO.value.mobile,
countryCode: passwordForm.phoneCountryCode,
scene: 'set_password',
headers: $http.getHeaders()
}).then((res: any) => {
passwordForm.vcode = '';
passwordForm.token = res.token;
}).catch(() => {
//
});
};
//
const validatePassword = () => {
if (!passwordForm.password || passwordForm.password.trim() === '') {
ElMessage.warning('请输入新密码');
return false;
}
if (passwordForm.password.length < 6) {
ElMessage.warning('密码长度不能少于6位');
return false;
}
if (passwordForm.password !== passwordForm.confirmPassword) {
ElMessage.warning('两次输入的密码不一致');
return false;
}
if (!passwordForm.vcode || passwordForm.vcode.trim() === '') {
ElMessage.warning('请输入短信验证码');
return false;
}
return true;
};
//
const handleSetPassword = async () => {
if (!validatePassword()) {
return;
}
if (passwordLoading.value) return;
passwordLoading.value = true;
try {
const res: any = await $http.post('/app/user/api/User/update', {
password: passwordForm.password,
vpassword: passwordForm.confirmPassword,
vcode: passwordForm.vcode,
token: passwordForm.token,
username: USERINFO.value?.mobile
});
if (res.code === ResponseCode.SUCCESS || res.code === ResponseCode.SUCCESS_EVENT_PUSH) {
ElMessage.success(res.msg || '密码修改成功');
closePasswordDialog();
} else {
ElMessage.error(res.msg || '密码修改失败');
}
} catch (error: any) {
ElMessage.error(error?.msg || '密码修改失败,请稍后重试');
} finally {
passwordLoading.value = false;
}
};
//
const toggleEditStatus = () => {
if (editStatus.value) {
//
form.nickname = originalData.value.nickname;
form.headimg = originalData.value.headimg;
editMobile.value = false;
form.mobile = originalData.value.mobile;
} else {
//
originalData.value = {
nickname: form.nickname,
mobile: form.mobile,
headimg: form.headimg,
};
}
editStatus.value = !editStatus.value;
};
//
const toggleMobileEdit = () => {
if (!editStatus.value) {
editStatus.value = true;
originalData.value = {
nickname: form.nickname,
mobile: form.mobile,
headimg: form.headimg,
};
}
editMobile.value = true;
};
//
const cancelMobileEdit = () => {
form.mobile = originalData.value.mobile;
editMobile.value = false;
};
//
const validateMobile = () => {
if (form.mobile && !/^1[3-9]\d{9}$/.test(form.mobile)) {
ElMessage.warning('请输入正确的手机号');
return false;
}
return true;
};
//
const handleUploadSuccess = (response: any) => {
if (response.code === ResponseCode.SUCCESS) {
uploadHeadimgLoading.value = false;
form.headimg = response.data.url;
uploadImageRef.value?.clearFiles();
ElMessage.success('头像上传成功');
} else {
uploadHeadimgLoading.value = false;
ElMessage.error(response.msg || '头像上传失败');
}
};
//
const handleUploadError = () => {
uploadHeadimgLoading.value = false;
uploadImageRef.value?.clearFiles();
ElMessage.error('头像上传失败,请重试');
};
//
const triggerUpload = () => {
if (!uploadHeadimgLoading.value) {
uploadImageRef.value?.$el?.querySelector('input[type="file"]')?.click();
}
};
//
const saveUserInfo = async () => {
if (saving.value) return;
//
if (!form.nickname || form.nickname.trim() === '') {
ElMessage.warning('请输入昵称');
return;
}
//
if (editMobile.value && form.mobile && !validateMobile()) {
return;
}
saving.value = true;
try {
//
const updateData: any = {};
if (form.nickname !== originalData.value.nickname) {
updateData.nickname = form.nickname;
}
if (form.headimg !== originalData.value.headimg) {
updateData.headimg = form.headimg;
}
if (editMobile.value && form.mobile !== originalData.value.mobile) {
updateData.mobile = form.mobile;
}
//
if (Object.keys(updateData).length === 0) {
ElMessage.info('没有需要保存的更改');
saving.value = false;
return;
}
//
const res: any = await $http.post('/app/user/api/User/update', updateData);
console.log(res);
if (res.code === ResponseCode.SUCCESS_EVENT_PUSH||res.code === ResponseCode.SUCCESS) {
ElMessage.success(res.msg || '保存成功');
//
const infoRes: any = await $http.get('/app/user/api/User/info');
if (infoRes.code === ResponseCode.SUCCESS) {
await userStore.setUserInfo(infoRes.data as UserInfoInterface);
initForm();
}
// 退
editStatus.value = false;
editMobile.value = false;
} else {
ElMessage.error(res.msg || '保存失败');
}
} catch (error: any) {
ElMessage.error(error?.msg || '保存失败,请稍后重试');
} finally {
saving.value = false;
}
};
//
defineExpose({
save: saveUserInfo,
});
</script>
<style scoped lang="scss">
.btn {
border-radius: 6px;
background-color: var(--el-fill-color-lighter);
font-size: 14px;
font-weight: 500;
cursor: pointer;
padding: 9px 16px;
}
.avatar-uploader {
height: 54px;
:deep(.el-upload) {
border: none;
border-radius: 50%;
cursor: pointer;
position: relative;
overflow: visible;
}
}
.avatar-wrapper {
position: relative;
display: inline-block;
height: 54px;
}
.edit {
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
cursor: pointer;
z-index: 10;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
&:hover {
background-color: #f5f5f5;
transform: scale(1.1);
}
.is-loading {
animation: rotating 2s linear infinite;
}
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { ResponseCode } from '@/common/const';
import { $http } from '@/common/http';
import { truncate } from '@/common/functions';
const voiceForm = ref<any>({
model_id: '',
voice_id: '',
voice_channel: 'yidevs',
});
watch(() => voiceForm.value.voice_channel, (newVal, oldVal) => {
console.log(newVal, oldVal);
});
const voiceDialogVisible = ref(false);
const emit = defineEmits(['success']);
const modelScene = ref('');
const openVoiceDialog = (options: any) => {
modelScene.value = options.modelScene;
if (options.voice) {
selectedVoice.value = options.voice;
voiceForm.value.voice_id = options.voice.voice_id;
voiceForm.value.voice_channel = options.voice.voice_channel;
voiceForm.value.model_id = options.voice.model_id;
}
nextTick(() => {
voiceDialogVisible.value = true;
});
}
const closeVoiceDialog = () => {
voiceForm.value = {
model_id: '',
voice_id: '',
voice_channel: 'yidevs',
};
voiceDialogVisible.value = false;
}
const voiceList = ref<any[]>([]);
const loading = ref(false);
const getVoiceList = () => {
voiceList.value = [];
if (!voiceForm.value.model_id || !voiceForm.value.voice_channel) return;
loading.value = true;
$http.get('/app/shortplay/api/Voice/list', {
params: {
model_id: voiceForm.value.model_id,
action: voiceForm.value.voice_channel,
scene: modelScene.value,
status:'success'
}
}).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
voiceList.value = res.data;
}
}).catch(() => {
ElMessage.error('获取配音列表失败');
}).finally(() => {
loading.value = false;
});
}
const handleModelSelect = (model: any) => {
voiceForm.value.model_id = model.id;
voiceForm.value.voice_id = '';
getVoiceList();
}
const handlePlayAudio = (audio: any) => {
if (!audio) return;
const audioElement = new Audio(audio);
audioElement.play();
}
const selectedVoice = ref<any>();
const handleVoiceItemClick = (item: any) => {
selectedVoice.value = item;
voiceForm.value.voice_id = item.voice_id;
}
const submitVoiceDialogLoading = ref(false);
const submitVoiceDialog = () => {
emit('success', { ...selectedVoice.value, voice_channel: voiceForm.value.voice_channel, model_id: voiceForm.value.model_id });
closeVoiceDialog();
}
const handleBeforeClose = () => {
if (submitVoiceDialogLoading.value) return;
closeVoiceDialog();
}
defineExpose({
open: openVoiceDialog,
close: closeVoiceDialog
})
</script>
<template>
<div class="voice-page">
<el-dialog v-model="voiceDialogVisible" class="generate-scene-dialog" draggable :close-on-press-escape="false"
append-to-body destroy-on-close :close-on-click-modal="false" :before-close="handleBeforeClose"
width="min(100%,1000px)">
<template #header>
<span class="font-weight-600">配音</span>
</template>
<div class="flex grid-gap-4">
<div class="flex flex-column grid-gap-4" style="width: 300px;">
<xl-models v-model="voiceForm.model_id" :scene="modelScene" :scrollProps="{ height: '320px' }"
@select="handleModelSelect" />
</div>
<div class="flex-1 flex flex-column grid-gap-4">
<xl-tabs v-model="voiceForm.voice_channel" class="text-info" @change="getVoiceList">
<xl-tabs-item value="yidevs" class="pb-2">公共音色</xl-tabs-item>
<xl-tabs-item value="self" class="pb-2">我的音色</xl-tabs-item>
</xl-tabs>
<el-scrollbar v-loading="loading" height="max(450px,50vh)">
<div class="grid-columns-3 grid-gap-4" v-if="voiceList.length > 0">
<div class="grid-column-1 rounded-4 p-4 flex flex-column grid-gap-4 actor-item actor-item-b"
v-for="item in voiceList" :key="item.id" @click="handleVoiceItemClick(item)"
:class="{ 'actor-item-selected': selectedVoice?.voice_id === item.voice_id }">
<div class="w-100 flex grid-gap-2 flex-center">
<el-avatar :src="item.headimg" :size="60" shape="square">
{{ truncate(item.name, 1) }}
</el-avatar>
<div class="flex-1 flex flex-column grid-gap-2">
<span v-if="item.name">{{ item.name }}</span>
<span v-else>未命名</span>
<div class="flex grid-gap-2" v-if="item.gender_enum && item.age_enum">
<span class="bg h10 rounded-2 py-1 px-2" v-if="item.gender_enum?.label">{{
item.gender_enum?.label
}}</span>
<span class="bg h10 rounded-2 py-1 px-2" v-if="item.age_enum?.label">{{
item.age_enum?.label }}</span>
</div>
</div>
</div>
<div class="flex-1 flex flex-column grid-gap-2"
v-if="voiceForm.voice_channel === 'yidevs'">
<span>支持语言</span>
<div class="flex grid-gap-2 flex-wrap">
<span v-for="lang in item.language_enum" :key="lang.value"
class="bg h10 rounded-2 py-1 px-2 flex grid-gap-2 flex-center"
@click.stop="handlePlayAudio(item.audios?.[lang.value])">
<el-icon v-if="item.audios?.[lang.value]">
<Headset />
</el-icon>
<span>{{ lang.label }}</span>
</span>
</div>
<span>支持情绪</span>
<div class="flex grid-gap-2 flex-wrap" v-if="item.emotions_enum.length > 0">
<span v-for="emotion in item.emotions_enum" :key="emotion.value"
class="bg h10 rounded-2 py-1 px-2">
{{ emotion.label }}
</span>
</div>
<span class="text-secondary h10" v-else>暂无支持情绪</span>
</div>
</div>
</div>
<el-empty v-else description="暂无音色数据" />
</el-scrollbar>
</div>
</div>
<template #footer>
<el-button bg text @click="closeVoiceDialog" :disabled="submitVoiceDialogLoading">取消</el-button>
<div class="flex-1"></div>
<el-button type="success" @click="submitVoiceDialog" :loading="submitVoiceDialogLoading">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.actor-item {
cursor: pointer;
border-width: 1px;
border-style: solid;
border-color: rgba(255, 255, 255, 0.1);
&:hover {
background-color: rgba(255, 255, 255, 0.08);
}
.bg {
background-color: rgba(255, 255, 255, 0.1);
}
&-selected {
border-color: var(--el-color-success);
}
}
.el-input {
--el-input-border-radius: 20px;
}
.el-select {
--el-border-radius-base: 20px;
}
</style>
@@ -0,0 +1,228 @@
import { onWindowResize } from "@/common/functions";
export const useAVCanvas = (cvsWrapEl: Ref<HTMLCanvasElement>, cvsWrapElWrapper: Ref<HTMLDivElement>) => {
let offscreen: OffscreenCanvas | undefined;
const isPlaying = ref(false);
let currentTrackResourceIndex: number = 0;
// 播放进度事件回调
const currentTimeEvents: ((currentTime: number) => void)[] = [];
const onCurrentTime = (callback: (currentTime: number) => void) => {
currentTimeEvents.push(callback);
}
const offCurrentTime = (callback: (currentTime: number) => void) => {
currentTimeEvents.splice(currentTimeEvents.indexOf(callback), 1);
}
// 切换素材事件
const switchToNextClipEvents: ((currentTrackResource: TrackResourceInterface) => void)[] = [];
const onSwitchToNextClip = (callback: (currentTrackResource: TrackResourceInterface) => void) => {
switchToNextClipEvents.push(callback);
}
const offSwitchToNextClip = (callback: (currentTrackResource: TrackResourceInterface) => void) => {
switchToNextClipEvents.splice(switchToNextClipEvents.indexOf(callback), 1);
}
const worker = new Worker(new URL('@/workers/loadStream.ts', import.meta.url), { type: 'module' });
worker.onmessage = (e) => {
const data = e.data;
switch (data.type) {
case 'stop':
isPlaying.value = false;
break;
case 'play':
currentTrackResourceIndex = data.index;
isPlaying.value = true;
const currentTrackResource = TrackResource[currentTrackResourceIndex];
initAudio(currentTrackResource);
switchToNextClipEvents.forEach((callback) => callback(currentTrackResource));
break;
case 'currentTime':
currentTimeEvents.forEach((callback) => callback(data.currentTime || 0));
break;
}
}
worker.onerror = (e) => {
console.log('worker error', e);
}
const audioEls: HTMLAudioElement[] = [];
const initAudio = (currentTrackResource: TrackResourceInterface) => {
const dialogueAudio = currentTrackResource.dialogue_audio;
const loopAudio = (index: number = 0) => {
const dialogue = dialogueAudio[index];
if (dialogue) {
const audio = new Audio();
audio.src = dialogue;
audio.play();
const audioElIndex = audioEls.push(audio);
audio.addEventListener('ended', () => {
index++;
audioEls.splice(audioElIndex - 1, 1);
if (isPlaying.value) {
loopAudio(index);
}
});
}
}
loopAudio(0);
const narrationAudio = currentTrackResource.narration_audio;
if (narrationAudio) {
const audio = new Audio();
audio.src = narrationAudio;
audio.play();
const audioElIndex = audioEls.push(audio);
audio.addEventListener('ended', () => {
audioEls.splice(audioElIndex - 1, 1);
});
}
const sfxAudio = currentTrackResource.sfx_audio;
if (sfxAudio) {
const audio = new Audio();
audio.src = sfxAudio;
audio.play();
const audioElIndex = audioEls.push(audio);
audio.addEventListener('ended', () => {
audioEls.splice(audioElIndex - 1, 1);
});
}
}
/**
*
* video: 视频资源
* dialogue_audio: 对话音频资源
* narration_audio: 旁白音频资源
* sfx_audio: 音效音频资源
*/
let TrackResource: TrackResourceInterface[] = [];
/**
*
* @param currentStoryboard
* @param storyboards
* @returns
*/
const parseTrackResource = async (storyboards: any[], currentStoryboard?: any) => {
if (currentStoryboard) {
currentTrackResourceIndex = storyboards.findIndex((item: any) => item.id === currentStoryboard.id) || 0;
}
TrackResource = [];
const storyboardsLen = storyboards.length;
for (let i = 0; i < storyboardsLen; i++) {
const storyboard = storyboards[i];
const resource: TrackResourceInterface = {
id: storyboard.id,
video: storyboard.video,
image: storyboard.image,
use_material_type: storyboard.use_material_type,
duration: storyboard.duration,
dialogue_audio: [],
narration: storyboard.narration,
narration_audio: storyboard.narration_audio,
sfx_audio: storyboard.sfx_audio,
dialogues: [],
};
if (storyboard.dialogues) {
for (const dialogue of storyboard.dialogues) {
resource.dialogue_audio.push(dialogue.audio);
resource.dialogues.push({
start_time: dialogue.start_time,
end_time: dialogue.end_time,
content: dialogue.actor.name + ':' + dialogue.content,
});
}
}
TrackResource.push(resource);
}
if (offscreen) {
worker.postMessage({
type: 'load',
TrackResource,
index: currentTrackResourceIndex,
});
} else {
offscreen = cvsWrapEl.value.transferControlToOffscreen();
offscreen.width = cvsWrapElWrapper.value.clientWidth;
offscreen.height = cvsWrapElWrapper.value.clientHeight;
worker.postMessage({
type: 'load',
offscreen,
TrackResource,
index: currentTrackResourceIndex,
}, [offscreen]);
}
}
const resize = () => {
worker.postMessage({
type: 'resize',
size: {
width: cvsWrapElWrapper.value.clientWidth,
height: cvsWrapElWrapper.value.clientHeight,
},
});
}
onWindowResize(resize, 300);
const play = (currentStoryboard?: any) => {
if (currentStoryboard) {
const find = TrackResource.findIndex((item: TrackResourceInterface) => item.id === currentStoryboard.id);
if (find !== -1) {
currentTrackResourceIndex = find;
}
}
worker.postMessage({
type: 'play',
index: currentTrackResourceIndex,
});
isPlaying.value = true;
}
const pause = () => {
isPlaying.value = false;
worker.postMessage({
type: 'pause',
});
audioEls.forEach((audio) => {
audio.pause();
});
audioEls.splice(0, audioEls.length);
}
const seek = (time: number) => {
worker.postMessage({
type: 'seek',
time,
});
}
const stop = () => {
worker.postMessage({
type: 'stop',
});
isPlaying.value = false;
audioEls.forEach((audio) => {
audio.pause();
});
audioEls.splice(0, audioEls.length);
}
const destroy = () => {
stop();
worker.terminate();
offscreen = undefined;
}
const switchClip = (currentStoryboard: any) => {
const find = TrackResource.findIndex((item: TrackResourceInterface) => item.id === currentStoryboard.id);
if (find !== -1) {
currentTrackResourceIndex = find;
}
worker.postMessage({
type: 'switchClip',
index: currentTrackResourceIndex,
});
}
return {
isPlaying,
play,
pause,
stop,
parseTrackResource,
destroy,
seek,
onCurrentTime,
offCurrentTime,
onSwitchToNextClip,
offSwitchToNextClip,
switchClip,
};
}
@@ -0,0 +1,405 @@
import { ClipExporter, ClipOptions } from '@/common/av-cliper/ClipExporter';
import { ResponseCode } from '@/common/const';
import { $http } from '@/common/http';
import { AxiosProgressEvent } from 'axios';
export const COMPSITE_EVENTS = {
PROGRESS: 'progress',
SAVE_FILE: 'saveFile',
PARSE_RESOURCE: 'parseResource',
COMPLETE: 'complete',
VIDEO: 'video',
DIALOGUES: 'dialogues',
DIALOGUES_AUDIO: 'dialogues_audio',
NARRATIONS: 'narrations',
NARRATIONS_AUDIO: 'narrations_audio',
CANCEL: 'cancel',
UPLOAD_VIDEO: 'uploadVideo',
}
export const useCompsite = (_options?: any) => {
const w = 720;
const h = 1280;
const cancelRef = ref<boolean>(false);
const abortControllers: AbortController[] = []; // 保存所有 AbortController,用于取消网络请求
const events = ref<any>({
[COMPSITE_EVENTS.SAVE_FILE]: () => { }
});
const createFileWriter = async (): Promise<{ writable: WritableStream, fileHandle: FileSystemFileHandle }> => {
events.value[COMPSITE_EVENTS.SAVE_FILE]?.();
try {
const handle: FileSystemFileHandle = await (window as any).showSaveFilePicker({
suggestedName: _options.output || 'output.mp4',
types: [
{
description: 'MP4 Video',
accept: { 'video/mp4': ['.mp4'] },
},
],
});
const writable: WritableStream = await handle.createWritable();
return { writable, fileHandle: handle };
} catch (err) {
console.error('创建文件失败:', err);
throw err;
}
};
const Tracks: any = {
video: [],
dialogues: [],
dialogues_audio: [],
narrations: [],
narrations_audio: [],
};
let composer: ClipExporter | null = null;
let currentStream: WritableStream | null = null; // 保存当前的文件流,用于取消时关闭
// 清理所有资源的辅助函数
const cleanup = () => {
// 取消所有网络请求
abortControllers.forEach(controller => {
try {
controller.abort();
} catch (error) {
console.warn('取消网络请求时出错:', error);
}
});
abortControllers.length = 0;
// 清理 composer
if (composer) {
try {
composer.destroy();
} catch (error) {
console.warn('销毁 composer 时出错:', error);
}
composer = null;
}
// 关闭文件流
if (currentStream) {
try {
// 尝试获取 writer 并 abort
const writer = currentStream.getWriter();
if (writer) {
writer.abort().catch(() => { });
writer.releaseLock();
}
} catch (error) {
// 如果无法获取 writer,尝试直接关闭
try {
if (currentStream && typeof (currentStream as any).abort === 'function') {
(currentStream as any).abort();
}
} catch (e) {
console.warn('关闭文件流时出错:', e);
}
}
currentStream = null;
}
// 清空 Tracks
Tracks.video = [];
Tracks.dialogues = [];
Tracks.dialogues_audio = [];
Tracks.narrations = [];
Tracks.narrations_audio = [];
};
// 替换原来的 uploadVideo 方法
const uploadVideo = async (file: File, chunkSize = 1 * 1024 * 1024) => {
const totalChunks = Math.ceil(file.size / chunkSize);
try {
await $http.post('/app/shortplay/api/drama/uploadChunkCheck', {
drama_id: _options.drama_id,
episode_id: _options.episode_id,
fileName: _options.output || 'output.mp4',
}).then((res: any) => {
if (res.code != ResponseCode.SUCCESS) {
throw new Error(res.msg);
}
}).catch((err: any) => {
console.error('分片上传检查出错:', err);
throw err;
});
for (let index = 0; index < totalChunks; index++) {
if (cancelRef.value) {
events.value[COMPSITE_EVENTS.CANCEL]?.();
return;
}
const start = index * chunkSize;
const end = Math.min(file.size, start + chunkSize);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('drama_id', _options.drama_id);
formData.append('episode_id', _options.episode_id);
formData.append('chunkIndex', index.toString());
formData.append('totalChunks', totalChunks.toString());
formData.append('chunkData', chunk);
await $http.post('/app/shortplay/api/drama/uploadChunk', formData, {
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
if (progressEvent.lengthComputable) {
const chunkPercent = (progressEvent.loaded / (progressEvent.total || 1)) * 100;
const totalPercent = ((index + chunkPercent / 100) / totalChunks) * 100;
events.value[COMPSITE_EVENTS.UPLOAD_VIDEO]?.(Math.round(totalPercent));
}
}
}).catch(err => {
console.error('分片上传出错:', err);
throw err;
});
}
// 所有分片上传完成,通知服务端合并
await $http.post('/app/shortplay/api/drama/mergeChunks', {
totalChunks,
drama_id: _options.drama_id,
episode_id: _options.episode_id,
});
events.value[COMPSITE_EVENTS.COMPLETE]?.(true);
} catch (err) {
console.error('上传失败:', err);
if (!cancelRef.value) {
events.value[COMPSITE_EVENTS.COMPLETE]?.(false);
}
}
};
const start = async (stream: WritableStream, fileHandle: FileSystemFileHandle, data: any) => {
currentStream = stream;
const len = data.length;
let offsetUs = 0;
events.value[COMPSITE_EVENTS.PARSE_RESOURCE]?.(0);
try {
for (let i = 0; i < len; i++) {
if (cancelRef.value) {
events.value[COMPSITE_EVENTS.CANCEL]?.();
cleanup();
return;
}
const item = data[i];
let temp: ClipOptions;
// 主轨道素材:不设置 offsetUs,让系统自动按顺序追加
if (item.use_material_type === 'video') {
const controller = new AbortController();
abortControllers.push(controller);
const response = await fetch(item.video, { signal: controller.signal });
if (cancelRef.value) {
cleanup();
return;
}
temp = {
type: 'video',
stream: response.body!,
// 主轨道不需要设置 offsetUs,会自动计算
offsetUs,
durationUs: item.duration * 1000,
};
} else {
if (item.image) {
const controller = new AbortController();
abortControllers.push(controller);
const response = await fetch(item.image, { signal: controller.signal });
if (cancelRef.value) {
cleanup();
return;
}
temp = {
type: 'image',
stream: response.body!,
// 主轨道不需要设置 offsetUs,会自动计算
offsetUs,
durationUs: item.duration * 1000,
};
} else {
const offscreen = new OffscreenCanvas(w, h);
const offscreenCtx = offscreen.getContext('2d')!;
offscreenCtx.fillStyle = 'black';
offscreenCtx.fillRect(0, 0, w, h);
const bitmap = offscreen.transferToImageBitmap();
temp = {
type: 'image',
stream: bitmap,
// 主轨道不需要设置 offsetUs,会自动计算
offsetUs,
durationUs: item.duration * 1000,
};
}
}
Tracks.video.push(temp);
// 收集对话字幕片段
if (item.dialogues && item.dialogues.length > 0) {
for (const dialogue of item.dialogues) {
Tracks.dialogues.push({
text: dialogue.content,
startTimeUs: offsetUs + dialogue.start_time * 1000,
endTimeUs: offsetUs + dialogue.end_time * 1000,
});
if (dialogue.audio) {
const controller = new AbortController();
abortControllers.push(controller);
const response = await fetch(dialogue.audio, { signal: controller.signal });
if (cancelRef.value) {
cleanup();
return;
}
const audioTemp: ClipOptions = {
type: 'audio',
stream: response.body!,
trackId: 'dialogue_audio', // 对话音频轨道
offsetUs: offsetUs + dialogue.start_time * 1000,
// durationUs 不设置,使用音频的实际时长
};
Tracks.dialogues_audio.push(audioTemp);
}
}
}
// 收集旁白字幕片段
if (item.narration) {
Tracks.narrations.push({
text: item.narration,
startTimeUs: offsetUs,
endTimeUs: offsetUs + item.duration * 1000,
});
if (item.narration_audio) {
const controller = new AbortController();
abortControllers.push(controller);
const response = await fetch(item.narration_audio, { signal: controller.signal });
if (cancelRef.value) {
cleanup();
return;
}
const audioTemp: ClipOptions = {
type: 'audio',
stream: response.body!,
trackId: 'narration_audio', // 旁白音频轨道
offsetUs: offsetUs,
// durationUs 不设置,使用音频的实际时长
};
Tracks.narrations_audio.push(audioTemp);
}
}
offsetUs += item.duration * 1000;
events.value[COMPSITE_EVENTS.PARSE_RESOURCE]?.(i / len * 100);
}
events.value[COMPSITE_EVENTS.PARSE_RESOURCE]?.(100);
events.value[COMPSITE_EVENTS.PROGRESS]?.(0);
if (cancelRef.value) {
events.value[COMPSITE_EVENTS.CANCEL]?.();
cleanup();
return;
}
try {
composer = new ClipExporter(w, h);
composer.setTracks(Tracks);
composer.on(COMPSITE_EVENTS.VIDEO, events.value[COMPSITE_EVENTS.VIDEO]);
composer.on(COMPSITE_EVENTS.DIALOGUES, events.value[COMPSITE_EVENTS.DIALOGUES]);
composer.on(COMPSITE_EVENTS.DIALOGUES_AUDIO, events.value[COMPSITE_EVENTS.DIALOGUES_AUDIO]);
composer.on(COMPSITE_EVENTS.NARRATIONS, events.value[COMPSITE_EVENTS.NARRATIONS]);
composer.on(COMPSITE_EVENTS.NARRATIONS_AUDIO, events.value[COMPSITE_EVENTS.NARRATIONS_AUDIO]);
await composer.initialization();
if (cancelRef.value) {
cleanup();
return;
}
composer.OutputProgress((progress: number) => {
if (!cancelRef.value) {
events.value[COMPSITE_EVENTS.PROGRESS]?.(progress);
}
});
// 导出
if (cancelRef.value) {
cleanup();
return;
}
await composer.export(stream);
if (cancelRef.value) {
cleanup();
return;
}
// 释放
composer.destroy();
composer = null;
currentStream = null;
// 清理已完成的网络请求控制器
abortControllers.length = 0;
const file = await fileHandle.getFile();
events.value[COMPSITE_EVENTS.UPLOAD_VIDEO]?.(0);
uploadVideo(file);
} catch (error) {
// 如果是取消导致的错误,不触发 complete(false)
if (!cancelRef.value) {
events.value[COMPSITE_EVENTS.COMPLETE]?.(false);
}
console.info(error);
cleanup();
}
} catch (error) {
// 如果是取消导致的错误,不触发 complete(false)
if (!cancelRef.value) {
events.value[COMPSITE_EVENTS.COMPLETE]?.(false);
}
console.info(error);
cleanup();
}
}
const synthesis = (data: any) => {
// 重置取消标志
cancelRef.value = false;
// 清理之前的资源
cleanup();
createFileWriter().then(({ writable, fileHandle }) => {
if (cancelRef.value) {
events.value[COMPSITE_EVENTS.CANCEL]?.();
cleanup();
return;
}
events.value[COMPSITE_EVENTS.SAVE_FILE]?.(true);
if (cancelRef.value) {
events.value[COMPSITE_EVENTS.CANCEL]?.();
cleanup();
return;
}
start(writable, fileHandle, data);
}).catch((err: any) => {
// 如果是用户取消文件选择,不触发错误事件
if (!cancelRef.value) {
events.value[COMPSITE_EVENTS.SAVE_FILE]?.(false);
}
console.info(err);
cleanup();
});
}
const cancel = () => {
cancelRef.value = true;
if (composer) {
composer.cancel();
}
// 清理所有资源
cleanup();
events.value[COMPSITE_EVENTS.CANCEL]?.();
}
const on = (event: typeof COMPSITE_EVENTS[keyof typeof COMPSITE_EVENTS], callback: (data: any) => void) => {
events.value[event] = callback;
}
const off = (event: typeof COMPSITE_EVENTS[keyof typeof COMPSITE_EVENTS]) => {
delete events.value[event];
}
return {
on,
off,
synthesis,
cancel,
cancelRef
}
}
@@ -0,0 +1,41 @@
import { ElMessageBox } from "element-plus"
import type { ElMessageBoxOptions } from "element-plus"
import { h } from "vue"
import XLInvitationCode from "@/components/xl-invitation-code/index.vue"
export const useInvitationCode = () => {
const open = () => {
const options: ElMessageBoxOptions = {
showClose: true,
showCancelButton: false,
showConfirmButton: false,
customStyle: {
'--el-messagebox-width': 'min(586px,100%)',
'--el-messagebox-border-radius': '20px',
'--el-messagebox-padding-primary': '30px',
'--el-color-primary':'var(--el-color-success)',
},
autofocus:false,
closeOnPressEscape: true,
closeOnClickModal: true,
title: '邀请好友',
message: () => h(XLInvitationCode),
}
ElMessageBox(options)
.then(() => {
console.log('打开邀请码成功')
})
.catch(() => {
console.log('打开邀请码失败')
})
}
const close = () => {
ElMessageBox.close()
}
return {
open,
close,
}
}
@@ -0,0 +1,34 @@
import { ElMessageBox, ElMessageBoxOptions } from "element-plus";
import XLLoading from "@/components/xl-loading/index.vue";
interface LoadingInstance {
open: () => Promise<boolean>;
close: () => void;
xlLoadingRef: Ref<any>;
}
export const useLoading = (props: any): LoadingInstance => {
const xlLoadingRef = ref<any>(null);
const options: ElMessageBoxOptions = {
showClose: false,
showCancelButton: false,
showConfirmButton: false,
customClass: 'x-loading-message-box',
modalClass: 'x-loading-message-box-modal',
closeOnPressEscape: false,
closeOnClickModal: false,
message: () => h(XLLoading, { ...props, ref: xlLoadingRef }),
}
const open = (): Promise<boolean> => {
return new Promise((resolve) => {
ElMessageBox(options).then((res: any) => {
console.log('打开loading成功', res);
}).catch(() => {
});
resolve(true);
});
}
const close = () => {
// @ts-ignore
options.onVanish?.()
}
return { open, close, xlLoadingRef };
}
+82
View File
@@ -0,0 +1,82 @@
import { ElMessageBox } from "element-plus"
import type { ElMessageBoxOptions } from "element-plus"
import { h } from "vue"
import XLogin from "@/components/x-login/index.vue"
import XLIcode from "@/components/xl-icode/index.vue"
export const useLogin = () => {
const open = () => {
const options: ElMessageBoxOptions = {
showClose: false,
showCancelButton: false,
showConfirmButton: false,
customClass: 'x-login-message-box',
closeOnPressEscape: false,
closeOnClickModal: false,
message: () => h(XLogin, {
onSuccess: (e: any) => {
// @ts-ignore
options.onVanish?.()
if (e.data.activation_time == null || e.data.activation_time == '') {
// 打开邀请码
openIcode()
}
},
onClose: () => {
console.log('关闭登录')
// @ts-ignore
options.onVanish?.()
}
}),
}
ElMessageBox(options)
.then(() => {
console.log('打开登录成功')
})
.catch(() => {
console.log('打开登录失败')
})
}
const close = () => {
console.log('关闭登录')
ElMessageBox.close()
}
const openIcode = () => {
const options: ElMessageBoxOptions = {
showClose: false,
showCancelButton: false,
showConfirmButton: false,
title: '填写邀请码',
customStyle: {
'--el-messagebox-width': 'min(586px,100%)',
'--el-messagebox-border-radius':'20px',
'--el-messagebox-padding-primary':'30px'
},
closeOnPressEscape: false,
closeOnClickModal: false,
message: () => h(XLIcode, {
onSuccess: (userInfo: any) => {
// @ts-ignore
options.onVanish?.()
console.log('邀请码绑定成功', userInfo)
},
onClose: () => {
console.log('关闭邀请码')
// @ts-ignore
options.onVanish?.()
}
}),
}
ElMessageBox(options)
.then(() => {
console.log('打开邀请码成功')
})
.catch(() => {
console.log('打开邀请码失败')
})
}
return {
open,
close,
openIcode
}
}
@@ -0,0 +1,8 @@
export const useNotify = () => {
const parseNotify = (notify: any) => {
console.log(notify);
}
return {
parse: parseNotify,
}
}
@@ -0,0 +1,24 @@
import { PricingCalculator } from "@/common/PricingCalculator";
export const usePoints = (models: any[], num: Ref<number> = ref(1), form: Ref<any> = ref()) => {
const points = computed(() => {
let totalPoints = 0;
for (const model of models) {
if (model && model.value && model.value.id) {
if (form.value) {
const calculator = new PricingCalculator(model.value.form, form.value, model.value.unit_point);
const point = calculator.calculate();
totalPoints += point || 0;
} else {
totalPoints += model.value.unit_point || 0;
}
}
}
totalPoints *= num.value;
if (totalPoints === 0) {
return '免费';
}
return totalPoints;
})
return points
}
+37
View File
@@ -0,0 +1,37 @@
export const usePush = () => {
const { appContext } = getCurrentInstance()!
const global = appContext.config.globalProperties;
const channels = new Map<string, any>();
const subscribe = (channel: string, callback: (data: any) => void) => {
if (global.$push) {
const channelItem = global.$push.subscribe(channel);
channelItem.on('message', callback);
channelItem.on('pusher_internal:subscription_succeeded', function () {
console.log('channels complete info subscription succeeded');
});
channelItem.on('subscription_error', function (message: any) {
console.log('channels complete info subscription error', message);
});
channels.set(channel, channelItem);
}
}
const unsubscribe = (channel: string) => {
if (global.$push) {
global.$push.unsubscribe(channel);
channels.delete(channel);
}
}
const unsubscribeAll = () => {
channels.forEach((_channelItem, channel) => {
if (global.$push) {
global.$push.unsubscribe(channel);
}
channels.delete(channel);
});
}
return {
subscribe,
unsubscribe,
unsubscribeAll,
}
}
@@ -0,0 +1,80 @@
const storage = globalThis.localStorage;
const storagePrefix = 'SHORT-PLAY';
export const useStorage = () => {
/**
*
* @param key
* @param data
* @param expire
* @returns Promise
*/
const set = (key: string, data: StorageInterface, expire?: number): Promise<boolean> => {
return new Promise((resolve, reject) => {
const obj = {
expire: 0,
data: data,
}
if (expire !== undefined) {
obj.expire = Date.now() + expire * 1000
}
storage.setItem(getKey(key), JSON.stringify(obj));
if (get(key) === null) {
reject();
} else {
resolve(true);
}
})
}
/**
*
* @param key
* @returns StorageInterface
*/
const get = (key: string): StorageInterface => {
const data = storage.getItem(getKey(key));
if (data === null) {
return null;
}
const ret = JSON.parse(data);
if (ret?.expire > 0 && ret?.expire < Date.now()) {
return null;
}
return ret?.data;
}
/**
*
* @param key
* @returns Promise
*/
const remove = (key: string): Promise<boolean> => {
return new Promise((resolve, reject) => {
storage.removeItem(getKey(key));
if (get(key) === null) {
resolve(true);
} else {
reject();
}
})
}
/**
*
* @param key
* @returns StorageInterface
*/
const getOnce = (key: string): StorageInterface => {
const data = get(key);
remove(key);
return data;
}
/**
*
* @param key
* @returns string
*/
const getKey = (key: string): string => {
return `${storagePrefix}.${key}`;
}
return { set, get, remove, getOnce, getKey };
}
+66
View File
@@ -0,0 +1,66 @@
import { ElMessageBox } from "element-plus"
import type { ElMessageBoxOptions } from "element-plus"
import XVcode from "@/components/x-vcode/index.vue"
export const useVcode = () => {
const btnText = ref('获取验证码')
const btnDisabled = ref(false)
let timer: any;
let timerCount = 60;
const open = (params: any) => {
return new Promise((resolve, reject) => {
const options: ElMessageBoxOptions = {
showClose: false,
showCancelButton: false,
showConfirmButton: false,
customClass: 'x-login-message-box',
customStyle: {
'--el-messagebox-width': 'min(375px,100%)',
'--el-messagebox-height': '300px',
},
closeOnPressEscape: false,
closeOnClickModal: false,
message: () => h(XVcode, {
data: params,
onSuccess: (res: any) => {
btnText.value = '重新获取'
btnDisabled.value = true
timer = setInterval(() => {
btnText.value = `重新获取(${timerCount--}s)`
if (timerCount <= 0) {
clearInterval(timer)
timer = undefined
btnText.value = '获取验证码'
btnDisabled.value = false
timerCount = 60
}
}, 1000)
// @ts-ignore
options.onVanish?.()
resolve(res)
},
onClose: () => {
// @ts-ignore
options.onVanish?.()
if (timer) {
clearInterval(timer)
timer = undefined
}
reject('关闭验证码')
}
}),
}
ElMessageBox(options)
.then(() => {
})
.catch((error) => {
reject(error)
})
})
}
return {
open,
btnText,
btnDisabled
}
}
+375
View File
@@ -0,0 +1,375 @@
<script setup lang="ts">
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import HomeSvg from '@/svg/tabs/home.vue'
import SquareSvg from '@/svg/tabs/square.vue'
import UserSvg from '@/svg/tabs/user.vue'
import HomeActiveSvg from '@/svg/tabs/home-active.vue'
import SquareActiveSvg from '@/svg/tabs/square-active.vue'
import UserActiveSvg from '@/svg/tabs/user-active.vue'
import IconLogoSvg from '@/svg/icon/icon-logo.vue'
import NoticeSvg from '@/svg/tabs/notice.vue'
import NoticeActiveSvg from '@/svg/tabs/notice-active.vue'
import router from '@/routers'
import { RouteLocationNormalized } from 'vue-router'
import { useStateStore, useRefs, useUserStore, useWebConfigStore } from '@/stores'
import { useLogin } from '@/composables/useLogin'
import XlNotice from '@/components/xl-notice/index.vue'
import { Close } from '@element-plus/icons-vue'
const iconMap: Record<string, any> = {
...ElementPlusIconsVue,
HomeSvg,
SquareSvg,
UserSvg,
HomeActiveSvg,
SquareActiveSvg,
UserActiveSvg,
IconLogoSvg,
NoticeSvg,
NoticeActiveSvg,
}
const menus = ref([
{
type: 'router',
name: '首页',
path: '/',
menu: 'index',
icon: 'HomeSvg',
activeIcon: 'HomeActiveSvg'
},
{
type: 'router',
name: '创意圈',
path: '/square',
menu: 'square',
icon: 'SquareSvg',
activeIcon: 'SquareActiveSvg'
},
{
type: 'router',
name: '通知',
path: '/notice',
menu: 'notice',
icon: 'NoticeSvg',
activeIcon: 'NoticeActiveSvg'
},
{
type: 'router',
name: '个人中心',
path: '/user',
menu: 'user',
icon: 'UserSvg',
activeIcon: 'UserActiveSvg'
}
])
const { WEBCONFIG } = useRefs(useWebConfigStore())
//
const getCurrentMenu = (route: RouteLocationNormalized): string => {
// 使 meta menu
if (route.meta?.menu) {
return route.meta.menu as string
}
// meta.menu
const matchedMenu = menus.value.find(menu => menu.path === route.path)
return matchedMenu?.menu || menus.value[0].menu
}
const currentMenu = ref(getCurrentMenu(router.currentRoute.value))
watch(() => router.currentRoute.value, (newRoute: RouteLocationNormalized) => {
currentMenu.value = getCurrentMenu(newRoute)
}, { immediate: true })
const stateStore = useStateStore()
const { STATE } = useRefs(stateStore)
const userStore = useUserStore()
const login = useLogin()
const noticePopoverVisible = ref(false)
const noticeRef = ref<any>(null)
//
const noLoginMenus = ['/']
//
const getVideoType = (url: string): string => {
if (!url) return ''
const extension = url.split('.').pop()?.toLowerCase()
switch (extension) {
case 'webm':
return 'video/webm'
case 'mp4':
case 'mov':
return 'video/mp4'
case 'ogg':
return 'video/ogg'
default:
return 'video/mp4'
}
}
//
const backgroundVideoUrl = computed(() => {
return WEBCONFIG.value?.project_background_video_url || '/fastmovie/static/image/bg.mov'
})
//
const videoType = computed(() => {
return getVideoType(backgroundVideoUrl.value)
})
//
const handleMenuClick = (menu: any, event: MouseEvent) => {
//
if (menu.menu === 'notice') {
event.preventDefault()
noticePopoverVisible.value = true
noticeRef.value?.open()
return
}
//
if (noLoginMenus.includes(menu.path) || userStore.hasLogin()) {
router.push(menu.path)
return
}
//
event.preventDefault()
login.open()
}
const videoRef = ref<HTMLVideoElement | null>(null)
onMounted(() => {
requestIdleCallback(() => {
const video = videoRef.value
if (video) {
video.play()
}
})
})
</script>
<template>
<div class="control-layouts" :class="{ 'bg ': $route.path === '/' }">
<!-- 背景视频 -->
<video ref="videoRef" v-show="$route.path === '/' && backgroundVideoUrl" preload="metadata"
class="control-layouts-bg-video" autoplay loop muted playsinline>
<source :src="backgroundVideoUrl" :type="videoType">
</video>
<transition name="blur-fade">
<div class="control-layouts-header-bg" v-if="$route.path === '/' && STATE.InputFocusState"></div>
</transition>
<div class="control-layouts-header">
<div class="control-layouts-header-logo pointer" @click="router.push('/')">
<!-- <el-icon alt="logo" size="30" class="control-layouts-header-logo-img"> -->
<IconLogoSvg />
<!-- </el-icon> -->
</div>
<div class="flex-1"></div>
<x-header-tools />
</div>
<div class="control-layouts-menu">
<ul class="control-layouts-menu-list">
<li v-for="menu in menus" :key="menu.path" class="control-layouts-menu-list-item"
:class="{ 'control-layouts-menu-list-item-selected': currentMenu === menu.menu }">
<template v-if="menu.type === 'router' && menu.path">
<el-tooltip class="box-item" effect="dark" :content="menu.name" placement="right">
<div class="control-layouts-menu-list-item-link" @click="handleMenuClick(menu, $event)">
<el-icon size="26">
<component
:is="currentMenu === menu.menu ? iconMap[menu.activeIcon] : iconMap[menu.icon]" />
</el-icon>
</div>
</el-tooltip>
</template>
<template v-else-if="menu.type === 'link' && menu.path">
<a :href="menu.path" class="control-layouts-menu-list-item-link" target="_blank">
<el-icon>
<component :is="iconMap[menu.icon]" />
</el-icon>
<!-- <span>{{ menu.name }}</span> -->
</a>
</template>
<!-- <template v-else>
<div class="control-layouts-menu-list-item-divider"></div>
</template> -->
</li>
</ul>
</div>
<div v-if="noticePopoverVisible" class="notice-popover-mask" @click="noticePopoverVisible = false"></div>
<el-popover :visible="noticePopoverVisible" width="430px" placement="right-end" :show-arrow="false" virtual-triggering
trigger="click" title="消息中心" popper-style="height:90dvh;margin-top:10dvh;inset:auto auto 5dvh 88px;">
<div class="position-relative">
<XlNotice ref="noticeRef" />
<div class="position-absolute top--10 right-4 pointer z-10">
<el-icon :size="20" @click="noticePopoverVisible = false">
<Close />
</el-icon>
</div>
</div>
</el-popover>
<router-view />
</div>
</template>
<style scoped lang="scss">
.control-layouts {
--xl-header-height: 72px;
--xl-menu-width: 94px;
padding: var(--xl-header-height) 0 0 var(--xl-menu-width);
transition: padding 0.3s ease-in-out;
position: relative;
&-bg-video {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
z-index: 0;
pointer-events: none;
}
&-header {
width: 100%;
height: var(--xl-header-height);
// background-color: rgba(var(--el-bg-color-rgb-r), var(--el-bg-color-rgb-g), var(--el-bg-color-rgb-b), 0.35);
// backdrop-filter: blur(20px);
position: fixed;
top: 0;
left: 0;
z-index: 10;
// box-shadow: 0 0 0 2px var(--el-border-color);
padding: 0 20px;
display: flex;
align-items: center;
gap: 20px;
&-logo {
height: 100%;
display: flex;
align-items: center;
justify-content: start;
gap: 10px;
&-img {
font-size: 30px;
width: 100%;
height: 22px;
}
&-text {
font-size: 16px;
font-weight: 500;
}
}
}
&-menu {
width: var(--xl-menu-width);
height: calc(100vh - var(--xl-header-height));
// background-color: var(--el-bg-color);
position: fixed;
top: calc(var(--xl-header-height) + 2px);
left: 0;
z-index: 10;
padding: 20px 0;
display: flex;
align-items: center;
justify-content: center;
&-list {
width: 60px;
margin: 0;
padding: 24px 0px;
list-style: none;
display: flex;
flex-direction: column;
gap: 28px;
background: rgba(30, 30, 30, 0.4);
border-radius: 200px 200px 200px 200px;
border: 0.1px solid rgba(255, 255, 255, 0.2);
&-item {
width: 100%;
list-style: none;
padding: 0px;
margin: 0;
border-radius: 10px;
// &-divider {
// width: 100%;
// height: 1px;
// background: linear-gradient(to right, transparent, transparent, var(--el-border-color), transparent, transparent);
// }
&-selected {
// background-color: rgba(13, 242, 131, 0.10);
.control-layouts-menu-list-item-link {
color: var(--el-color-white);
}
}
&-link {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
text-decoration: none;
color: #8C8C8C;
padding: 6px 0;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
}
}
}
}
.control-layouts-header-bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(8px);
z-index: 1;
pointer-events: none;
}
.blur-fade-enter-active,
.blur-fade-leave-active {
transition: opacity 0.5s ease, backdrop-filter 0.5s ease;
}
.blur-fade-enter-from,
.blur-fade-leave-to {
opacity: 0;
backdrop-filter: blur(0px);
}
.blur-fade-enter-to,
.blur-fade-leave-from {
opacity: 1;
backdrop-filter: blur(8px);
}
.notice-popover-mask {
position: fixed;
inset: 0;
z-index: 1999;
}
</style>
<style>
.el-popover {
--el-popover-bg-color: rgba(30, 30, 30, 0.4);
--el-popover-border-color: rgba(3255, 255, 255, 0.3);
--el-box-shadow-light: none;
--el-popover-border-radius: 20px;
--el-popover-padding: 20px;
backdrop-filter: blur(8px);
overflow: hidden;
}
</style>
+50
View File
@@ -0,0 +1,50 @@
const en={
button:{
confirmText:'Submit',
resetText:'Reset',
cancelText:'Cancel',
queryText:'Query',
allSelectText:'Select All',
redirectText:'Redirect',
loginText:'Login',
logoutText:'Logout',
registerText:'Register',
applyText:'Apply for Access',
myPayText:'I have paid',
retryText:'Click to retry',
cancelAuthorizationText:'Cancel Authorization',
addAuthorizationText:'Add Authorization',
uploadText:'Upload',
completeText:'Complete',
manageText:'Manage',
moveText:'Move',
deleteText:'Delete',
copyText:'Copy',
insertText:'Insert',
byBundleSelectText:'Select from the gallery'
},
message:{
tips:'Tips',
title:'Tips',
error:'Error',
error_code:'Error Code',
error_text:'Error Message',
requestFullscreenError:'Fullscreen Failed, Press F11 to Fullscreen',
exitFullscreenError:'Exit Fullscreen Failed, Press F11 to Exit Fullscreen',
confirmActionContent:'Are you sure you want to perform this action?',
confirmLogoutContent:'Are you sure you want to logout?',
noApplyContent:'No Apply Channel',
rejectContent:'Rejection Reason: {message}',
scanPayContent:'Scan and Pay',
qrcodeExpireContent:'QR Code Expired',
getQrcodeFail:'Get QR Code Failed',
userCannel:'User Cancel',
qrcodeWillExpireTitle:'QR Code Validity Period',
qrcodeWillExpireContent:'QR Code Will Expire in {time} Seconds',
searchMenuNotContent:'No Related Content Found',
uploadSizeError:'File Size Cannot Exceed {size}MB',
copySuccess:'Copy Success',
copyFail:'Copy Failed'
},
};
export default en;
+19
View File
@@ -0,0 +1,19 @@
import { createI18n } from 'vue-i18n';
import { default as en } from './en'
import { default as zhCn } from './zh-cn';
import { App } from 'vue';
export const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
globalInjection: true,
messages: {
en,
zh: zhCn,
'zh-CN': zhCn
}
});
export const setupI18n = {
install(app: App) {
app.use(i18n);
},
};
+50
View File
@@ -0,0 +1,50 @@
const zhCn={
button:{
confirmText:'提交',
resetText:'重置',
cancelText:'取消',
queryText:'查询',
allSelectText:'全选',
redirectText:'跳转',
loginText:'登录',
logoutText:'退出登录',
registerText:'注册',
applyText:'申请开通',
myPayText:'我已支付',
retryText:'点击重试',
cancelAuthorizationText:'取消授权',
addAuthorizationText:'添加授权',
uploadText:'上传',
completeText:'完成',
manageText:'管理',
moveText:'移动',
deleteText:'删除',
copyText:'复制',
insertText:'插入',
byBundleSelectText:'从图库中选择'
},
message:{
tips:'提示',
title:'温馨提示',
error:'错误',
error_code:'错误码',
error_text:'错误信息',
requestFullscreenError:'全屏失败,按F11全屏',
exitFullscreenError:'退出全屏失败,按F11退出全屏',
confirmActionContent:'您确定要执行此操作吗?',
confirmLogoutContent:'确定退出登录吗?',
noApplyContent:'暂未开通申请通道',
rejectContent:'驳回理由:{message}',
scanPayContent:'扫码支付',
qrcodeExpireContent:'二维码已过期',
getQrcodeFail:'获取二维码失败',
userCannel:'用户取消',
qrcodeWillExpireTitle:'二维码有效期',
qrcodeWillExpireContent:'二维码将在{time}秒后过期',
searchMenuNotContent:'没有找到相关内容',
uploadSizeError:'文件大小不能超过{size}MB',
copySuccess:'复制成功',
copyFail:'复制失败'
},
};
export default zhCn;
+22
View File
@@ -0,0 +1,22 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import "@/assets/css/theme.min.css";
import "@/assets/css/common.min.css";
import "@/assets/css/style.min.css";
import App from './App.vue'
import { createPinia } from 'pinia'
const pinia = createPinia()
import router from "@/routers";
import zhCn from 'element-plus/es/locale/lang/zh-cn';
const app = createApp(App)
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus, { locale: zhCn });
import { i18n } from '@/locale';
app.use(i18n);
app.use(pinia);
app.use(router);
app.mount('#app')
+9
View File
@@ -0,0 +1,9 @@
<template>
<div class="actor-page">
<div class="actor-page-header">
<div class="actor-page-header-title">
<h1>演员库</h1>
</div>
</div>
</div>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { ResponseCode } from '@/common/const';
import { $http } from '@/common/http';
import { useRoute } from 'vue-router';
const route = useRoute();
const article = route.params.article as string | number
const details = ref<any>();
const getArticle = () => {
$http.get('/app/article/api/Article/details', { params: { id:article } }).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
details.value = res.data;
}
})
}
onMounted(() => {
getArticle();
})
</script>
<template>
<div v-html="details?.content.content"></div>
</template>
@@ -0,0 +1,9 @@
<template>
<div class="creative-page">
<div class="creative-page-header">
<div class="creative-page-header-title">
<h1>创意圈</h1>
</div>
</div>
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
<template>
<div class="fail-page">
</div>
</template>
@@ -0,0 +1,920 @@
<script setup lang="ts">
import { ResponseCode } from '@/common/const';
import { $http } from '@/common/http';
import IconPrevStepSvg from '@/svg/icon/icon-prev-step.vue';
import IconNextStepSvg from '@/svg/icon/icon-next-step.vue';
import IconBatchSvg from '@/svg/icon/icon-batch.vue';
import IconModelSvg from '@/svg/icon/icon-model.vue';
import IconPointsSvg from '@/svg/icon/icon-points.vue';
import IconReplaceSvg from '@/svg/icon/icon-replace.vue';
import { useWebConfigStore, useUserStore, useRefs } from '@/stores';
import { useRoute } from 'vue-router';
import router from '@/routers';
import { Loading, UploadFilled } from '@element-plus/icons-vue';
import { usePoints } from '@/composables/usePoints';
import { usePush } from '@/composables/usePush';
const route = useRoute();
const userStore = useUserStore();
const { USERINFO } = useRefs(userStore);
const webConfigStore = useWebConfigStore();
const { WEBCONFIG } = useRefs(webConfigStore);
const drama_id = ref<string | number>(route.params.drama_id as string | number)
const episode_id = ref<string | number>(route.params.episode_id as string | number)
const emit = defineEmits(['update:drama']);
const dramaInfo = ref<any>({});
const getDramaInfo = () => {
if (!drama_id.value) return;
$http.get('/app/shortplay/api/Works/details', { params: { id: drama_id.value } }).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
dramaInfo.value = res.data;
emit('update:drama', dramaInfo.value);
} else {
ElMessage.error(res.msg);
}
}).catch(() => {
ElMessage.error('获取短剧详情失败');
})
}
const ActorSearch = reactive({
type: 'episode',
name: '',
actor_id: '',
species_type: null,
gender: null,
age: null,
drama_id: drama_id.value,
episode_id: episode_id.value
})
const actorList = ref<any[]>([]);
const loading = ref(false);
const actorCreateRef = ref<any>(null);
const currentActor = ref<any>();
const currentActorForm = ref<any>({
actor_id: '',
drama_id: drama_id.value,
episode_id: episode_id.value,
name: '',
species_type: null,
gender: null,
age: null,
remarks: '',
image_model_id: null,
three_view_model_id: null,
image_state: false,
three_view_image_state: false,
image_reference_state: false,
reference_headimg: '',
image: '',
three_view_image: '',
});
watch(actorList, () => {
try {
const find = actorList.value.find((item: any) => item.id === currentActor.value?.id);
if (find) {
handleCurrentActor(find)
} else {
const defaultActor = actorList.value.find((item: any) => item.is_edit);
if (defaultActor) {
handleCurrentActor(defaultActor);
} else {
currentActor.value = undefined;
currentActorForm.value = {
id: '',
drama_id: drama_id.value,
episode_id: episode_id.value,
name: '',
species_type: null,
gender: null,
age: null,
remarks: '',
image_model_id: null,
three_view_model_id: null,
image_state: false,
three_view_image_state: false,
image_reference_state: false,
reference_headimg: '',
image: '',
three_view_image: '',
};
}
}
} catch (error) {
console.error('watch actorList error', error);
}
})
const getActorList = () => {
loading.value = true;
$http.get('/app/shortplay/api/Actor/index', { params: ActorSearch }).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
actorList.value = res.data;
}
}).catch((error) => {
console.error('getActorList error', error);
}).finally(() => {
loading.value = false;
})
}
const previewImageVisible = ref(false);
const imageList = ref<any[]>([]);
/* const handlePreviewImage = (currentItem: any) => {
if (!currentItem.headimg && !currentItem.three_view_image) return;
imageList.value = [currentItem.headimg, currentItem.three_view_image];
if (currentItem.character_look_id) {
imageList.value = [currentItem.headimg, currentItem.three_view_image, currentItem.origin_headimg, currentItem.origin_three_view_image];
}
nextTick(() => {
previewImageVisible.value = true;
})
} */
const taskList = ref<any[]>([]);
const taskLoading = ref(false);
const taskSearch = reactive({
alias_id: '',
scene: 'actor_image',
page: 1,
page_size: 100,
});
let taskController: any = null;
const getTaskList = () => {
if (taskController) {
taskController.abort();
}
taskController = new AbortController();
taskLoading.value = true;
taskList.value = [];
$http.get('/app/model/api/Task/index', { params: taskSearch, signal: taskController?.signal }).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
taskList.value = res.data.data;
}
}).finally(() => {
taskLoading.value = false;
})
}
const handleCurrentActor = (item: any) => {
if (!item.is_edit) {
return;
}
currentActor.value = item;
currentActorForm.value.id = item.id;
currentActorForm.value.name = item.name;
currentActorForm.value.species_type = item.species_type;
currentActorForm.value.gender = item.gender;
currentActorForm.value.age = item.age;
currentActorForm.value.remarks = item.remarks;
currentActorForm.value.image = item.headimg;
currentActorForm.value.three_view_image = item.three_view_image;
currentActorForm.value.image_reference_state = false;
currentActorForm.value.reference_headimg = '';
taskSearch.alias_id = item.id;
getTaskList();
}
const modelPopoverRef = ref<any>(null);
const modelButtonRef = ref<any>(null);
const model = ref<any>({});
const handleModelSelect = (item?: any) => {
if (item) {
model.value = item;
currentActorForm.value.image_state = true;
} else {
model.value = {};
currentActorForm.value.image_model_id = null;
currentActorForm.value.image_state = false;
}
modelPopoverRef.value?.hide();
}
const threeViewModelPopoverRef = ref<any>(null);
const threeViewModelButtonRef = ref<any>(null);
const threeViewModel = ref<any>({});
const handleThreeViewModelSelect = (item?: any) => {
if (item) {
threeViewModel.value = item;
currentActorForm.value.three_view_image_state = true;
} else {
threeViewModel.value = {};
currentActorForm.value.three_view_model_id = null;
currentActorForm.value.three_view_image_state = false;
}
threeViewModelPopoverRef.value?.hide();
}
const points = usePoints([model, threeViewModel]);
const generateImageLoading = ref(false);
const handleGenerateImage = () => {
if (generateImageLoading.value || currentActor.value.status_enum.value === 'pending') return;
if(!currentActorForm.value.image_model_id&&!currentActorForm.value.three_view_model_id) {
ElMessage.error('请先选择形象图模型或三视图模型');
return;
}
generateImageLoading.value = true;
$http.post('/app/shortplay/api/Actor/initializing', {
...currentActorForm.value,
image: '',
three_view_image: ''
}).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
currentActor.value.status = res.data.status;
currentActor.value.status_enum = res.data.status_enum;
} else {
ElMessage.error(res.msg);
}
}).catch(() => {
ElMessage.error('生成图片失败');
}).finally(() => {
generateImageLoading.value = false;
})
}
const uploadImageRef = ref<any>(null);
const uploadLoading = ref(false);
const handleUploadReferenceImageSuccess = (response: any) => {
uploadLoading.value = false;
if (response.code === ResponseCode.SUCCESS) {
currentActorForm.value.image_reference_state = true;
currentActorForm.value.reference_headimg = response.data.url;
} else {
ElMessage.error(response.msg);
}
uploadImageRef.value?.clearFiles();
}
const handleUploadReferenceImageError = () => {
uploadLoading.value = false;
uploadImageRef.value?.clearFiles();
}
const replaceActorLoading = ref(false);
const handleReplaceActor = (item: any) => {
if (replaceActorLoading.value) return;
replaceActorLoading.value = true;
$http.post('/app/shortplay/api/Actor/ReplaceActor', {
id: currentActor.value.id,
task_id: item.id
}).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
currentActor.value.headimg = item.result.image_path;
if (!currentActor.value.character_look_id) {
currentActor.value.origin_headimg = item.result.image_path;
}
currentActor.value.status = res.data.status;
currentActor.value.status_enum = res.data.status_enum;
currentActorForm.value.image = item.result.image_path;
} else {
ElMessage.error(res.msg);
}
}).catch(() => {
ElMessage.error('替换演员失败');
}).finally(() => {
replaceActorLoading.value = false;
})
}
const batchGenerateDialogVisible = ref(false);
const batchGenerateLoading = ref(false);
const batchGenerateLength = ref(0);
const batchGenerateList = ref<any[]>([]);
const handleBatchGenerate = () => {
batchGenerateDialogVisible.value = true;
batchGenerateList.value = actorList.value.filter((item: any) => item.status_enum.value === 'initializing')
batchGenerateLength.value = batchGenerateList.value.length
}
const batchPoints = usePoints([model, threeViewModel], batchGenerateLength);
const submitBatchGenerate = () => {
batchGenerateLoading.value = true;
Promise.all(batchGenerateList.value.map((item: any) => {
return $http.post('/app/shortplay/api/Actor/initializing', {
id: item.id,
image_reference_state: item.headimg ? true : false,
reference_headimg: item.headimg ? item.headimg : '',
image_state: true,
three_view_image_state: true,
image_model_id: model.value.id,
three_view_model_id: threeViewModel.value.id,
})
})).then(() => {
getActorList();
}).catch((error) => {
console.error('submitBatchGenerate error', error);
}).finally(() => {
batchGenerateLoading.value = false;
batchGenerateDialogVisible.value = false;
})
}
const handleDeleteActor = (item: any) => {
$http.post('/app/shortplay/api/DramaEpisode/deleteActor', {
id: item.id,
drama_id: drama_id.value,
episode_id: episode_id.value,
}).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
ElMessage.success(res.msg);
getActorList();
} else {
ElMessage.error(res.msg);
}
})
}
const voiceDialogRef = ref<any>();
const handleVoiceActor = (item: any) => {
currentActor.value = item;
voiceDialogRef.value?.open({
modelScene: 'dialogue_voice',
voice: item.voice
});
}
const handleVoiceSuccess = (data: any) => {
$http.post('/app/shortplay/api/Actor/voice', {
id: currentActor.value.id,
drama_id: drama_id.value,
episode_id: episode_id.value,
apply_scope: 'episode',
voice: data
}).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
ElMessage.success(res.msg);
getActorList();
} else {
ElMessage.error(res.msg);
}
}).catch((error) => {
console.error('handleVoiceSuccess error', error);
ElMessage.error('设置演员音色失败');
})
}
const characterLookRef = ref();
const handleCharacterLookActor = (item: any) => {
currentActor.value = item;
characterLookRef.value?.open?.({ actor: item });
}
const handleCharacterLookSelect = (item: any) => {
let url = '/app/shortplay/api/Generate/characterLook';
if (item.type === 'actor') {
url = '/app/shortplay/api/DramaEpisode/CharacterLook';
}
$http.post(url, {
drama_id: drama_id.value,
episode_id: episode_id.value,
actor_id: currentActor.value.id,
character_look_id: item.id,
actor_costume_model_id: item.actor_costume_model_id,
actor_costume_three_view_model_id: item.actor_costume_three_view_model_id,
}).then((res: any) => {
if (res.code === ResponseCode.SUCCESS) {
ElMessage.success(res.msg);
const find = actorList.value.find((n: any) => n.id === res.data.id);
if (item.type != 'actor' && find) {
find.character_look_state = 1;
} else {
getActorList();
}
} else {
ElMessage.error(res.msg);
}
})
}
const { subscribe, unsubscribeAll } = usePush();
const addListener = () => {
subscribe('private-generateactorimage-' + USERINFO.value?.user, (res: any) => {
console.log('channels complete info message', res);
const findItem = actorList.value.find((item: any) => item.id === res.id);
if (findItem) {
findItem.status_enum = res.status;
if (res.image) {
findItem.headimg = res.image;
}
}
actorCreateRef.value?.subscribe?.('generateactorimage', res);
});
subscribe('private-generateactorthreeviewimage-' + USERINFO.value?.user, (res: any) => {
console.log('channels complete info message', res);
const findItem = actorList.value.find((item: any) => item.id === res.id);
if (findItem) {
findItem.status_enum = res.status;
if (res.image) {
findItem.three_view_image = res.image;
}
}
actorCreateRef.value?.subscribe?.('generateactorthreeviewimage', res);
});
}
const prevStep = () => {
router.push(`/generate/drama/${route.params.drama_id}/${route.params.episode_id}`);
}
const nextStep = () => {
router.push(`/generate/props/${route.params.drama_id}/${route.params.episode_id}`);
}
onMounted(() => {
getActorList();
getDramaInfo();
addListener();
})
onUnmounted(() => {
console.log('actors unmounted');
unsubscribeAll();
})
</script>
<template>
<div class="flex flex-column draw-module">
<div class="flex-1 flex grid-gap-10 overflow-hidden">
<div class="flex-1 flex flex-column grid-gap-4 overflow-hidden">
<el-form class="flex flex-center grid-gap-4" @submit.prevent="getActorList">
<el-form-item class="mb-0">
<el-input v-model="ActorSearch.name" placeholder="搜索演员" clearable @change="getActorList">
<template #suffix>
<el-icon>
<Search />
</el-icon>
</template>
</el-input>
</el-form-item>
<div class="flex-1"></div>
<el-form-item class="mb-0" style="width: 80px;">
<el-select v-model="ActorSearch.species_type" placeholder="物种" clearable :teleported="false"
@change="getActorList">
<el-option v-for="item in WEBCONFIG?.enum?.actor_species_type" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item class="mb-0" style="width: 80px;">
<el-select v-model="ActorSearch.gender" placeholder="性别" clearable :teleported="false"
@change="getActorList">
<el-option v-for="item in WEBCONFIG?.enum?.actor_gender" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item class="mb-0" style="width: 80px;">
<el-select v-model="ActorSearch.age" placeholder="年龄" clearable :teleported="false"
@change="getActorList">
<el-option v-for="item in WEBCONFIG?.enum?.actor_age" :key="item.value" :label="item.label"
:value="item.value" />
</el-select>
</el-form-item>
</el-form>
<el-scrollbar class="flex-1">
<div
class="grid-columns-xxl-7 grid-columns-xl-6 grid-columns-lg-5 grid-columns-md-4 grid-columns-sm-3 grid-columns-xs-2 grid-columns-p-1 grid-gap-4">
<div class="grid-column-1 rounded-4 p-4 actor-item flex flex-column flex-center grid-gap-4 pointer bg-overlay"
@click="actorCreateRef?.open?.(null, ActorSearch.drama_id, ActorSearch.episode_id)">
<el-icon class="rounded-4" size="20"
style="height: 40px; width: 40px;background-color: var(--el-fill-color-dark);">
<Plus />
</el-icon>
<span>添加演员</span>
</div>
<div class="grid-column-1 rounded-4 flex flex-column flex-center grid-gap-2 actor-item bg-overlay border"
:class="{ 'border-success': item.id === currentActorForm.id }" v-for="item in actorList"
:key="item.id">
<el-avatar :src="item.headimg" class="actor-avatar bg-mosaic" fit="cover" :title="item.is_edit?item.name:'公共角色不可编辑'">
{{ item.name }}
</el-avatar>
<div class="actor-edit-mask flex flex-column grid-gap-4 flex-center pointer"
v-if="item.is_edit" @click="handleCurrentActor(item)">
<div class="flex flex-center bg-overlay rounded-round p-2 pointer grid-gap-2"
@click.stop="actorCreateRef?.upload?.(item, ActorSearch.drama_id, ActorSearch.episode_id)">
<el-icon size="16">
<UploadFilled />
</el-icon>
<span class="h10">手动上传</span>
</div>
<el-popconfirm title="确定删除该演员吗?" width="fit-content" @confirm="handleDeleteActor(item)"
placement="bottom-end">
<template #reference>
<div class="flex flex-center bg-overlay rounded-round p-2 pointer grid-gap-2"
@click.stop>
<el-icon size="16">
<Delete />
</el-icon>
<span class="h10">删除演员</span>
</div>
</template>
</el-popconfirm>
</div>
<div class="flex grid-gap-2 actor-status">
<!-- <span class="actor-tag pointer" title="复制演员"
@click.stop="setClipboard(`@${item.name}(${item.actor_id}) `)">{{
item.actor_id
}}</span> -->
<div class="flex-1"></div>
<div class="flex flex-column flex-y-flex-end grid-gap-2">
<span class="actor-tag" :class="[`actor-tag--` + item.status_enum.props.type]">
{{ item.status_enum.label }}
</span>
</div>
</div>
<div class="flex flex-column grid-gap-2 actor-info">
<div class="actor-name flex flex-center grid-gap-1"
:class="{ 'pointer': item.is_edit, 'actor-name--success': item.id === currentActorForm.id }"
@click.stop="actorCreateRef?.open?.(item, ActorSearch.drama_id, ActorSearch.episode_id)">
<el-icon size="16">
<UserFilled />
</el-icon>
<span>{{ item.name }}</span>
</div>
<!-- <div class="flex grid-gap-2">
<span class="actor-tag">{{ item.species_type_enum?.label
}}</span>
<span class="actor-tag">{{ item.gender_enum?.label }}</span>
<span class="actor-tag">{{ item.age_enum?.label }}</span>
</div> -->
</div>
<div class="actor-action flex flex-center px-4">
<el-button text bg @click="handleVoiceActor(item)" class="flex-1 text-ellipsis-1">
<span v-if="item.voice">音色:{{ item.voice.name }}</span>
<span v-else>演员音色</span>
</el-button>
<el-button text bg @click="handleCharacterLookActor(item)"
class="flex-1 text-ellipsis-1" :disabled="!!item.character_look_state">
<span v-if="item.character_look_state">换装中</span>
<span v-else-if="item.characterLook">{{ item.characterLook.title }}</span>
<span v-else>演员装扮</span>
</el-button>
</div>
</div>
</div>
</el-scrollbar>
</div>
<div class="actor-form-wrapper bg-overlay border rounded-4 actor-form flex flex-column grid-gap-4">
<template v-if="currentActorForm.id">
<div class="border-bottom flex flex-center">
<el-input v-model="currentActorForm.name" placeholder="角色演员" size="large"
class="actor-form-input flex-1">
</el-input>
</div>
<div class="flex flex-column grid-gap-2 px-4">
<span>基础信息</span>
<div class="flex grid-gap-2">
<el-select v-model="currentActorForm.species_type" placeholder="请选择物种" size="small"
class="flex-1 actor-form-select">
<el-option v-for="item in WEBCONFIG?.enum?.actor_species_type" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
<el-select v-model="currentActorForm.gender" placeholder="请选择性别" size="small"
class="flex-1 actor-form-select">
<el-option v-for="item in WEBCONFIG?.enum?.actor_gender" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
<el-select v-model="currentActorForm.age" placeholder="请选择年龄" size="small"
class="flex-1 actor-form-select">
<el-option v-for="item in WEBCONFIG?.enum?.actor_age" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
</div>
</div>
<div class="flex flex-column grid-gap-2 px-4">
<span>角色描述</span>
<div class="bg rounded-4 p-4 border">
<el-input v-model="currentActorForm.remarks" placeholder="请输入角色描述" size="small"
class="actor-form-textarea" type="textarea" :autosize="{ minRows: 6, maxRows: 20 }" />
<div class="flex flex-y-center grid-gap-2">
<div class="bg-overlay rounded-round p-3 flex flex-center grid-gap-2 pointer hover-bg-hover"
ref="modelButtonRef" title="选择使用AI生成形象图">
<template v-if="model.id">
<el-avatar :src="model.icon" :alt="model.name" shape="square"
:size="16"></el-avatar>
<span class="h10 text-ellipsis-1" style="max-width: 60px;">{{ model.name
}}</span>
<el-icon size="16" class="pointer" @click.stop="handleModelSelect()">
<Close />
</el-icon>
</template>
<template v-else>
<el-icon size="16">
<IconModelSvg />
</el-icon>
<span class="h10">形象图</span>
<el-icon size="16">
<ArrowDown />
</el-icon>
</template>
</div>
<div class="bg-overlay rounded-round p-3 flex flex-center grid-gap-2 pointer hover-bg-hover"
ref="threeViewModelButtonRef" title="选择使用AI生成三视图">
<template v-if="threeViewModel.id">
<el-avatar :src="threeViewModel.icon" :alt="threeViewModel.name" shape="square"
:size="16"></el-avatar>
<span class="h10 text-ellipsis-1" style="max-width: 60px;">{{
threeViewModel.name }}</span>
<el-icon size="16" class="pointer" @click.stop="handleThreeViewModelSelect()">
<Close />
</el-icon>
</template>
<template v-else>
<el-icon size="16">
<IconModelSvg />
</el-icon>
<span class="h10">三视图</span>
<el-icon size="16">
<ArrowDown />
</el-icon>
</template>
</div>
<el-upload ref="uploadImageRef" title="添加参考图"
:data="{ dir_name: 'actor/reference', dir_title: '演员形象参考照片' }"
:action="$http.getCompleteUrl('app/shortplay/api/Uploads/upload')"
:headers="$http.getHeaders()" accept="image/jpeg,image/png" :limit="1" type="cover"
:disabled="uploadLoading"
:before-upload="() => { uploadLoading = true; return true; }"
:on-success="handleUploadReferenceImageSuccess" :show-file-list="false"
:on-error="handleUploadReferenceImageError">
<div
class="bg-overlay rounded-round p-3 flex flex-center grid-gap-2 pointer hover-bg-hover">
<el-popover placement="top" :disabled="!currentActorForm.reference_headimg"
width="fit-content">
<el-avatar :src="currentActorForm.reference_headimg" fit="contain"
:size="130" shape="square"></el-avatar>
<template #reference>
<el-icon size="16"
v-if="!currentActorForm.reference_headimg || uploadLoading">
<Loading class="circular" v-if="uploadLoading" />
<Plus v-else />
</el-icon>
<el-icon size="16" v-else color="var(--el-color-success)">
<PictureRounded />
</el-icon>
</template>
</el-popover>
</div>
</el-upload>
<div class="flex-1"></div>
<div class="flex flex-center grid-gap-2">
<el-icon size="16">
<IconPointsSvg />
</el-icon>
<span class="h10">{{ points }}</span>
</div>
<div class="rounded-round p-3 flex flex-center grid-gap-2 pointer"
style="background-color: #FFFFFF;color:#141414;" @click="handleGenerateImage">
<el-icon size="20">
<Loading class="circular"
v-if="generateImageLoading || currentActor.status_enum.value === 'pending'" />
<Top v-else />
</el-icon>
</div>
</div>
</div>
</div>
<el-popover ref="modelPopoverRef" popper-class="model-popover" :virtual-ref="modelButtonRef"
virtual-triggering placement="bottom" width="min(100vw,380px)" trigger="click">
<xl-models v-model="currentActorForm.image_model_id" @select="handleModelSelect" no-init
scene="actor_image" />
</el-popover>
<el-popover ref="threeViewModelPopoverRef" popper-class="model-popover"
:virtual-ref="threeViewModelButtonRef" virtual-triggering placement="bottom"
width="min(100vw,380px)" trigger="click">
<xl-models v-model="currentActorForm.three_view_model_id" @select="handleThreeViewModelSelect"
no-init scene="actor_three_view_image" />
</el-popover>
<div class="flex flex-column grid-gap-2 px-4 pt-4">
<span>角色原始形象记录</span>
</div>
<el-scrollbar class="flex-1">
<div class="grid-columns-2 grid-gap-4 p-4" v-if="taskList.length > 0">
<div class="grid-column-1 task-item" v-for="item in taskList" :key="item.id">
<el-avatar :src="item.result.image_path" fit="contain" shape="square" :size="206">
</el-avatar>
<div class="flex flex-center grid-gap-2 task-item-replace pointer"
v-if="item.status==='success'&&item.result.image_path !== currentActor.headimg && item.result.image_path !== currentActor.origin_headimg"
@click="handleReplaceActor(item)">
<el-icon>
<Loading class="circular" v-if="replaceActorLoading" />
<IconReplaceSvg v-else />
</el-icon>
<span class="h10 text-nowrap">替换原始形象</span>
</div>
</div>
</div>
<el-empty v-else description="暂无原始形象记录" />
</el-scrollbar>
</template>
<el-empty v-else description="点击角色名称选择角色" />
</div>
</div>
<div class="p-4 w-100 flex grid-gap-4 flex-center">
<el-button bg text size="large" @click="prevStep">
<el-icon size="16">
<IconPrevStepSvg />
</el-icon>
<span>上一步</span>
</el-button>
<el-button type="success" size="large"
:disabled="actorList.filter((item: any) => item.status_enum.value === 'initializing').length <= 0"
@click="handleBatchGenerate">
<el-icon size="16">
<IconBatchSvg />
</el-icon>
<span>批量生成</span>
</el-button>
<el-button type="success" size="large" @click="nextStep">
<span>下一步</span>
<el-icon size="16" class="ml-2">
<IconNextStepSvg />
</el-icon>
</el-button>
</div>
<el-image-viewer :url-list="imageList" v-if="previewImageVisible" @close="previewImageVisible = false" />
<xl-actor-create ref="actorCreateRef" @success="getActorList" />
<el-dialog v-model="batchGenerateDialogVisible" class="generate-storyboard-dialog" draggable width="800px">
<template #header>
<span class="font-weight-600">批量生成演员</span>
</template>
<div class="flex grid-gap-10">
<xl-models title="形象模型" v-model="currentActorForm.image_model_id" @select="handleModelSelect" no-init
class="flex-1 bg-overlay rounded-4 p-4" scene="actor_image" />
<xl-models title="三视图模型" v-model="currentActorForm.three_view_model_id"
@select="handleThreeViewModelSelect" class="flex-1 bg-overlay rounded-4 p-4" no-init
scene="actor_three_view_image" />
</div>
<template #footer>
<el-button type="info" @click="batchGenerateDialogVisible = false"
:disabled="batchGenerateLoading">取消</el-button>
<div class="flex-1"></div>
<div class="flex flex-center grid-gap-2">
<el-icon size="16">
<IconPointsSvg />
</el-icon>
<span class="h10">{{ batchPoints }}</span>
</div>
<el-button type="success" icon="Check" @click="submitBatchGenerate"
:disabled="!currentActorForm.image_model_id || !currentActorForm.three_view_model_id"
:loading="batchGenerateLoading">生成</el-button>
</template>
</el-dialog>
<xl-voice ref="voiceDialogRef" @success="handleVoiceSuccess" />
<xl-character-look ref="characterLookRef" @select="handleCharacterLookSelect"
:query="{ drama_id: drama_id, episode_id: episode_id }" />
</div>
</template>
<style lang="scss" scoped>
.draw-module {
height: calc(100vh - var(--xl-header-height));
margin: 0 auto;
padding: 20px;
overflow: hidden;
.actor-form-wrapper {
width: 450px;
height: 100%;
overflow: hidden;
}
.actor-item {
height: 310px;
position: relative;
overflow: hidden;
box-shadow: inset 0 0 0px 2px transparent;
&:hover {
background-color: var(--el-fill-color-dark);
.actor-delete {
opacity: 1;
}
}
&.active {
box-shadow: inset 0 0 0px 2px var(--el-color-success);
}
.actor-avatar {
height: 260px;
width: 100%;
border-radius: 0px;
}
.bg-overlay {
background-color: rgba(0, 0, 0, 0.5);
}
.actor-edit-mask {
position: absolute;
top: 0;
left: 0;
height: 260px;
width: 100%;
opacity: 0;
transition: opacity 0.3s ease-in-out;
&:hover {
opacity: 1;
}
}
.actor-status {
position: absolute;
top: 0;
right: 0;
width: 100%;
padding: 10px;
justify-content: flex-start;
align-items: flex-start;
z-index: 1;
}
.actor-info {
position: absolute;
bottom: 50px;
left: 0;
width: 100%;
padding: 10px;
justify-content: flex-start;
align-items: flex-start;
z-index: 1;
}
.actor-tag,
.actor-name {
background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(10px);
color: #FFFFFF;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
&--primary {
color: var(--el-color-primary);
}
&--success {
color: var(--el-color-success);
}
&--warning {
color: var(--el-color-warning);
}
&--danger {
color: var(--el-color-danger);
}
&--info {
color: var(--el-color-info);
}
&.pointer:hover {
background-color: rgba(0, 0, 0, 0.5);
}
}
.actor-delete {
width: 28px;
height: 28px;
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
.actor-name {
padding: 4px 8px;
border-radius: 20px;
}
.actor-action {
height: 50px;
width: 100%;
}
}
.actor-form {
&-input {
:deep(.el-input__wrapper) {
box-shadow: none;
}
}
&-select {
:deep(.el-select__wrapper) {
background-color: var(--el-bg-color);
}
}
&-textarea {
:deep(.el-textarea__inner) {
box-shadow: none;
padding: 0;
resize: none;
}
}
}
.task-item {
position: relative;
.task-item-replace {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.5);
padding: 5px 10px;
border-radius: 99px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
&:hover {
.task-item-replace {
opacity: 1;
}
}
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More