FastMovieAI 二开基线 v1.0 (完整源码)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
VITE_REQUEST_BASE_URL = http://localhost:36300
|
||||
@@ -0,0 +1 @@
|
||||
# VITE_REQUEST_BASE_URL = https://fastmovie.admin.xhadmin.com.cn
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
@@ -0,0 +1 @@
|
||||
# 请将伪静态规则或自定义Apache配置填写到此处
|
||||
@@ -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).
|
||||
Vendored
+75
@@ -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')
|
||||
}
|
||||
@@ -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}`);
|
||||
Vendored
+90
@@ -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']
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Generated
+6431
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
@@ -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 |
@@ -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
|
||||
);
|
||||
@@ -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
@@ -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
@@ -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 */
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
+2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4
@@ -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"
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(':');
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user