Commit 4c170cb1 4c170cb18f5205639366cb65b33ebf12650beb7d by 杨俊

Init

0 parents
Showing 161 changed files with 10234 additions and 0 deletions
1 /*.json
2 /*.js
3 dist
...\ No newline at end of file ...\ No newline at end of file
1 const path = require('path');
2
3 module.exports = {
4 root: true,
5 parser: 'vue-eslint-parser',
6 parserOptions: {
7 // Parser that checks the content of the <script> tag
8 parser: '@typescript-eslint/parser',
9 sourceType: 'module',
10 ecmaVersion: 2020,
11 ecmaFeatures: {
12 jsx: true,
13 },
14 },
15 env: {
16 browser: true,
17 node: true,
18 },
19 plugins: ['@typescript-eslint'],
20 extends: [
21 // Airbnb JavaScript Style Guide https://github.com/airbnb/javascript
22 'airbnb-base',
23 'plugin:@typescript-eslint/recommended',
24 'plugin:import/recommended',
25 'plugin:import/typescript',
26 'plugin:vue/vue3-recommended',
27 'plugin:prettier/recommended',
28 ],
29 settings: {
30 'import/resolver': {
31 typescript: {
32 project: path.resolve(__dirname, './tsconfig.json'),
33 },
34 },
35 },
36 globals: {
37 defineProps: 'readonly',
38 defineEmits: 'readonly',
39 defineExpose: 'readonly',
40 withDefaults: 'readonly'
41 },
42 rules: {
43 'prettier/prettier': 1,
44 // Vue: Recommended rules to be closed or modify
45 'vue/require-default-prop': 0,
46 'vue/singleline-html-element-content-newline': 0,
47 'vue/max-attributes-per-line': 0,
48 // Vue: Add extra rules
49 'vue/custom-event-name-casing': [2, 'camelCase'],
50 'vue/no-v-text': 1,
51 'vue/padding-line-between-blocks': 1,
52 'vue/require-direct-export': 1,
53 'vue/multi-word-component-names': 0,
54 // Allow @ts-ignore comment
55 '@typescript-eslint/ban-ts-comment': 0,
56 '@typescript-eslint/no-unused-vars': 1,
57 '@typescript-eslint/no-empty-function': 1,
58 '@typescript-eslint/no-explicit-any': 0,
59 'camelcase': 'off',
60 '@typescript-eslint/camelcase': 0,
61 'import/extensions': [
62 2,
63 'ignorePackages',
64 {
65 js: 'never',
66 jsx: 'never',
67 ts: 'never',
68 tsx: 'never',
69 },
70 ],
71 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
72 'no-param-reassign': 0,
73 'prefer-regex-literals': 0,
74 'import/no-extraneous-dependencies': 0,
75 },
76 };
1 node_modules
2 .DS_Store
3 dist
4 dist-ssr
5 *.local
6 /node_modules
7 /dist-ssr
8 .idea/*
9 yarn.lock
10 /.yarn
1 module.exports = {
2 tabWidth: 2,
3 semi: true,
4 printWidth: 140,
5 singleQuote: true,
6 quoteProps: 'consistent',
7 htmlWhitespaceSensitivity: 'strict',
8 };
1 module.exports = {
2 extends: [
3 'stylelint-config-standard',
4 'stylelint-config-rational-order',
5 'stylelint-config-prettier',
6 ],
7 defaultSeverity: 'warning',
8 plugins: ['stylelint-order'],
9 rules: {
10 'at-rule-no-unknown': [
11 true,
12 {
13 ignoreAtRules: ['plugin'],
14 },
15 ],
16 'rule-empty-line-before': [
17 'always',
18 {
19 except: ['after-single-line-comment', 'first-nested'],
20 },
21 ],
22 'selector-pseudo-class-no-unknown': [
23 true,
24 {
25 ignorePseudoClasses: ['deep'],
26 },
27 ],
28 },
29 };
1 nodeLinker: node-modules
1 module.exports = {
2 plugins: ['@vue/babel-plugin-jsx'],
3 };
1 module.exports = {
2 extends: ['@commitlint/config-conventional'],
3 };
1 *
2 !.gitignore
3 !vite.config.develop.ts
4 !vite.config.prod.ts
5 !vite.config.cdn.ts
1 import { resolve } from 'path';
2 import { defineConfig } from 'vite';
3 import vue from '@vitejs/plugin-vue';
4 import vueJsx from '@vitejs/plugin-vue-jsx';
5 import svgLoader from 'vite-svg-loader';
6 import vueSetupExtend from 'vite-plugin-vue-setup-extend';
7 import VitePluginOss from 'vite-plugin-oss';
8
9 const config = {
10 VITE_OSS_ACCESS_KEY: 'LTAI5t7LPdTdw9bSY7JUJmyG',
11 VITE_OSS_ACCESS_SECRET: 'qBiMksMt7DakR5cLQc03LCcsdwhPH1',
12 VITE_OSS_BUCKET: 'hising',
13 VITE_OSS_REGION: 'oss-cn-hangzhou',
14 VITE_OSS_HOST: 'https://hising-cdn.hikoon.com',
15 VITE_OSS_ENDPOINT: 'https://hising.oss-cn-hangzhou.aliyuncs.com',
16 };
17
18 const ossPath = `vendor/manage/asset${new Date()
19 .toLocaleString('zh')
20 .slice(0, 20)
21 .replace(/[\s/:]/g, '')}`;
22
23 export default defineConfig({
24 mode: 'production',
25 base: config.VITE_OSS_HOST,
26 server: { https: true },
27 plugins: [
28 vue(),
29 vueJsx(),
30 vueSetupExtend(),
31 svgLoader({ svgoConfig: {} }),
32 VitePluginOss({
33 from: `./dist/${ossPath}/**`,
34 accessKeyId: config.VITE_OSS_ACCESS_KEY,
35 accessKeySecret: config.VITE_OSS_ACCESS_SECRET,
36 bucket: config.VITE_OSS_BUCKET,
37 region: config.VITE_OSS_REGION,
38 quitWpOnError: true,
39 }),
40 ],
41 resolve: {
42 alias: [
43 {
44 find: '@',
45 replacement: resolve(__dirname, '../src'),
46 },
47 {
48 find: 'assets',
49 replacement: resolve(__dirname, '../src/assets'),
50 },
51 {
52 find: 'vue-i18n',
53 replacement: 'vue-i18n/dist/vue-i18n.cjs.js', // Resolve the i18n warning issue
54 },
55 {
56 find: 'vue',
57 replacement: 'vue/dist/vue.esm-bundler.js', // compile template. you can remove it, if you don't need.
58 },
59 ],
60 extensions: ['.ts', '.js'],
61 },
62 define: {
63 'process.env': {
64 VITE_ENV: 'production',
65 VITE_API_HOST: 'https://hising.hikoon.com',
66 VITE_SHOW_LOGIN_ACCOUNT: false,
67 ...config,
68 },
69 },
70 build: {
71 assetsDir: ossPath,
72 rollupOptions: {
73 output: {
74 manualChunks: {
75 arco: ['@arco-design/web-vue'],
76 chart: ['echarts', 'vue-echarts'],
77 vue: ['vue', 'vue-router', 'pinia', '@vueuse/core', 'vue-i18n'],
78 },
79 },
80 },
81 chunkSizeWarningLimit: 2000,
82 },
83 });
1 import { resolve } from 'path';
2 import { defineConfig } from 'vite';
3 import vue from '@vitejs/plugin-vue';
4 import vueJsx from '@vitejs/plugin-vue-jsx';
5 import svgLoader from 'vite-svg-loader';
6 import vueSetupExtend from 'vite-plugin-vue-setup-extend';
7
8 export default defineConfig({
9 mode: 'production',
10 server: { https: true },
11 plugins: [vue(), vueJsx(), vueSetupExtend(), svgLoader({ svgoConfig: {} })],
12 // build:{
13 // rollupOptions: {
14 // output: {
15 // manualChunks: {
16 // echarts: ['echarts']
17 // }
18 // }
19 // }
20 // },
21 resolve: {
22 alias: [
23 {
24 find: '@',
25 replacement: resolve(__dirname, '../src'),
26 },
27 {
28 find: 'assets',
29 replacement: resolve(__dirname, '../src/assets'),
30 },
31 {
32 find: 'vue-i18n',
33 replacement: 'vue-i18n/dist/vue-i18n.cjs.js', // Resolve the i18n warning issue
34 },
35 {
36 find: 'vue',
37 replacement: 'vue/dist/vue.esm-bundler.js', // compile template. you can remove it, if you don't need.
38 },
39 ],
40 extensions: ['.ts', '.js'],
41 },
42 define: {
43 'process.env': {
44 VITE_ENV: 'develop',
45 VITE_API_HOST: 'https://hi-sing-service-dev.hikoon.com',
46 VITE_SHOW_LOGIN_ACCOUNT: true,
47 VITE_OSS_ACCESS_KEY: 'LTAI4GKtcA6yTV6wnapivq7Y',
48 VITE_OSS_ACCESS_SECRET: 'QPEt0HPEuRe7wIk2wZNtKPF6L0xMmQ',
49 VITE_OSS_BUCKET: 'hisin-dev',
50 VITE_OSS_REGION: 'oss-cn-beijing',
51 VITE_OSS_HOST: 'https://hi-sing-cdn-dev.hikoon.com',
52 VITE_OSS_ENDPOINT: 'https://hisin-dev.oss-cn-beijing.aliyuncs.com',
53 },
54 },
55 });
1 import { resolve } from 'path';
2 import { defineConfig } from 'vite';
3 import vue from '@vitejs/plugin-vue';
4 import vueJsx from '@vitejs/plugin-vue-jsx';
5 import svgLoader from 'vite-svg-loader';
6 import vueSetupExtend from 'vite-plugin-vue-setup-extend';
7
8 export default defineConfig({
9 mode: 'production',
10 server: { https: true },
11 plugins: [vue(), vueJsx(), vueSetupExtend(), svgLoader({ svgoConfig: {} })],
12 resolve: {
13 alias: [
14 {
15 find: '@',
16 replacement: resolve(__dirname, '../src'),
17 },
18 {
19 find: 'assets',
20 replacement: resolve(__dirname, '../src/assets'),
21 },
22 {
23 find: 'vue-i18n',
24 replacement: 'vue-i18n/dist/vue-i18n.cjs.js', // Resolve the i18n warning issue
25 },
26 {
27 find: 'vue',
28 replacement: 'vue/dist/vue.esm-bundler.js', // compile template. you can remove it, if you don't need.
29 },
30 ],
31 extensions: ['.ts', '.js'],
32 },
33 define: {
34 'process.env': {
35 VITE_ENV: 'production',
36 VITE_API_HOST: 'https://hising.hikoon.com',
37 VITE_SHOW_LOGIN_ACCOUNT: false,
38 VITE_OSS_ACCESS_KEY: 'LTAI4GKtcA6yTV6wnapivq7Y',
39 VITE_OSS_ACCESS_SECRET: 'QPEt0HPEuRe7wIk2wZNtKPF6L0xMmQ',
40 VITE_OSS_BUCKET: 'hisin',
41 VITE_OSS_REGION: 'oss-cn-hangzhou',
42 VITE_OSS_HOST: 'https://hi-sing-cdn.hikoon.com',
43 VITE_OSS_ENDPOINT: 'https://hisin.oss-cn-hangzhou.aliyuncs.com',
44 },
45 },
46 });
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4 <meta charset="UTF-8"/>
5 <link rel="shortcut icon" type="image/x-icon" href="https://hising-cdn.hikoon.com/file/20231201/nyaagyzd92c1701419974050pv0hv2xlsme.ico">
6 <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
7 <title>海星试唱</title>
8 </head>
9 <body>
10 <div id="app"></div>
11 <script type="module" src="/src/main.ts"></script>
12 </body>
13 </html>
This diff could not be displayed because it is too large.
1 {
2 "name": "arco-design-pro-vue",
3 "description": "Arco Design Pro for Vue",
4 "version": "1.0.0",
5 "private": true,
6 "author": "ArcoDesign Team",
7 "license": "MIT",
8 "scripts": {
9 "dev": "vite --config ./config/vite.config.dev.ts",
10 "build": "vite build --config ./config/vite.config.prod.ts",
11 "build:cdn": "vite build --config ./config/vite.config.cdn.ts",
12 "develop": "vite build --config ./config/vite.config.develop.ts",
13 "lint-staged": "npx lint-staged"
14 },
15 "lint-staged": {
16 "*.{js,ts,jsx,tsx}": [
17 "prettier --write",
18 "eslint --fix"
19 ],
20 "*.vue": [
21 "stylelint --fix",
22 "prettier --write",
23 "eslint --fix"
24 ],
25 "*.{less,css}": [
26 "stylelint --fix",
27 "prettier --write"
28 ]
29 },
30 "dependencies": {
31 "@arco-design/web-vue": "^2.36.0",
32 "@types/file-saver": "^2.0.5",
33 "@types/mockjs": "^1.0.4",
34 "@vueuse/core": "^7.3.0",
35 "@vueuse/router": "^10.3.0",
36 "ali-oss": "^6.17.1",
37 "arco-design-pro-vue": "^2.2.3",
38 "axios": "^0.24.0",
39 "dayjs": "^1.11.2",
40 "echarts": "^5.3.3",
41 "file-saver": "^2.0.5",
42 "i": "^0.3.7",
43 "js-file-download": "^0.4.12",
44 "lodash": "^4.17.21",
45 "nanoid": "^3.3.4",
46 "npm": "^9.6.6",
47 "nprogress": "^0.2.0",
48 "pinia": "^2.0.9",
49 "query-string": "^7.0.1",
50 "rabbit-lyrics": "^2.1.1",
51 "vue": "^3.2.31",
52 "vue-echarts": "^6.2.3",
53 "vue-i18n": "^9.2.0-beta.17",
54 "vue-router": "4",
55 "vue3-video-play": "1.3.1-beta.6"
56 },
57 "devDependencies": {
58 "@arco-design/web-vue": "^2.36.0",
59 "@commitlint/cli": "^11.0.0",
60 "@commitlint/config-conventional": "^12.0.1",
61 "@types/ali-oss": "^6.16.3",
62 "@types/lodash": "^4.14.177",
63 "@types/nprogress": "^0.2.0",
64 "@typescript-eslint/eslint-plugin": "^5.10.0",
65 "@typescript-eslint/parser": "^5.10.0",
66 "@vitejs/plugin-vue": "^1.9.4",
67 "@vitejs/plugin-vue-jsx": "^1.2.0",
68 "@vue/babel-plugin-jsx": "^1.1.1",
69 "eslint": "^8.7.0",
70 "eslint-config-airbnb-base": "^14.2.1",
71 "eslint-config-prettier": "^8.3.0",
72 "eslint-import-resolver-typescript": "^2.4.0",
73 "eslint-plugin-import": "^2.22.1",
74 "eslint-plugin-prettier": "^3.3.1",
75 "eslint-plugin-vue": "^8.3.0",
76 "less": "^4.1.2",
77 "lint-staged": "^11.2.6",
78 "mockjs": "^1.1.0",
79 "prettier": "^2.2.1",
80 "stylelint": "^13.8.0",
81 "stylelint-config-prettier": "^8.0.2",
82 "stylelint-config-rational-order": "^0.1.2",
83 "stylelint-config-standard": "^20.0.0",
84 "stylelint-order": "^4.1.0",
85 "typescript": "^4.5.5",
86 "vite": "^2.6.4",
87 "vite-plugin-eslint": "^1.3.0",
88 "vite-plugin-oss": "^1.2.13",
89 "vite-plugin-vue-setup-extend": "^0.4.0",
90 "vite-svg-loader": "^3.1.0",
91 "vue-tsc": "^0.30.5"
92 },
93 "volta": {
94 "node": "18.3.0",
95 "yarn": "1.22.19"
96 }
97 }
1 <template>
2 <a-config-provider size="small">
3 <router-view />
4 </a-config-provider>
5 </template>
6
7 <script lang="ts" setup></script>
1 // eslint-disable-next-line max-classes-per-file
2 import { AnyObject, QueryForParams, ServiceResponse } from '@/types/global';
3 import axios, { AxiosRequestConfig } from 'axios';
4 import { ActivityApply } from '@/types/activity-apply';
5 import FileSaver from 'file-saver';
6 import { Activity, ActivityViewUser } from '@/types/activity';
7 import { ActivityWork } from '@/types/activity-work';
8
9 export default class useActivityApi {
10 static statusOption = [
11 { label: '处理中', value: 0 },
12 { label: '已上架', value: 1 },
13 { label: '已下架', value: 2 },
14 { label: '已匹配', value: 3 },
15 { label: '已发行', value: 5 },
16 { label: '处理失败', value: 4 },
17 ];
18
19 static weightOption = [
20 { label: '无', value: 0 },
21 { label: '低', value: 30 },
22 { label: '中', value: 60 },
23 { label: '高', value: 90 },
24 ];
25
26 static workSingTypeOption = [
27 { label: '自主上传', value: 1 },
28 { label: '唱整首', value: 2 },
29 { label: '唱片段', value: 3 },
30 ];
31
32 static workSingStatusOption = [
33 { label: '待采纳', value: 0 },
34 { label: '已确认', value: 1 },
35 { label: '不合适', value: 2 },
36 { label: '未采纳', value: 3 },
37 { label: '其他', value: 4 },
38 ];
39
40 static songTypeOption = [
41 { label: '歌曲', value: 1 },
42 { label: 'Demo', value: 2 },
43 ];
44
45 static async get(params: QueryForParams): Promise<ServiceResponse<Activity[]>> {
46 return axios.get('audition/activities', { params });
47 }
48
49 static async getExport(fileName: string, params?: QueryForParams) {
50 const config = { params: { ...params, fetchType: 'excel' }, timeout: 60000, responseType: 'blob' } as AxiosRequestConfig;
51 return axios.get('audition/activities', config).then(({ data }) => FileSaver.saveAs(data, `${fileName}.xlsx`));
52 }
53
54 static async show(id: number, params?: QueryForParams): Promise<Activity> {
55 return axios.get(`/audition/activities/${id}`, { params }).then((res) => Promise.resolve(res.data));
56 }
57
58 static async changeStatus(id: number, data: AnyObject) {
59 return axios.put<Activity>(`/audition/activities/${id}/change-status`, data).then((res) => Promise.resolve(res.data));
60 }
61
62 static async update(id: number, data: AnyObject): Promise<Activity> {
63 return axios.put(`/audition/activities/${id}`, data).then((res) => Promise.resolve(res.data));
64 }
65
66 static async destroy(id: number) {
67 return axios.delete(`/audition/activities/${id}`).then((res) => Promise.resolve(res.data));
68 }
69
70 static async getManageUser(activityId: number, params: QueryForParams): Promise<any> {
71 return axios.get(`/audition/activities/${activityId}/managers`, { params });
72 }
73
74 static async createManage(data: AnyObject): Promise<any> {
75 return axios.post('/audition/activity-managers', data).then((res) => Promise.resolve(res.data));
76 }
77
78 static async updateManage(id: number, data: AnyObject): Promise<any> {
79 return axios.put(`/audition/activity-managers/${id}`, data).then((res) => Promise.resolve(res.data));
80 }
81
82 static async deleteManage(id: number): Promise<any> {
83 return axios.delete(`/audition/activity-managers/${id}`).then((res) => Promise.resolve(res.data));
84 }
85
86 static async getViewUser(activityId: number, params: QueryForParams): Promise<ServiceResponse<ActivityViewUser[]>> {
87 return axios.get(`/audition/activities/${activityId}/views`, { params });
88 }
89
90 static async getLikeUser(activityId: number, params: QueryForParams): Promise<ServiceResponse<ActivityViewUser[]>> {
91 return axios.get(`/audition/activities/${activityId}/likes`, { params });
92 }
93 }
94
95 export class useApply {
96 static auditStatusOption = [
97 { label: '审核不通过', value: 2 },
98 { label: '审核中', value: 0 },
99 ];
100
101 static async get(params: QueryForParams): Promise<ServiceResponse<ActivityApply[]>> {
102 return axios.get('audition/applies', { params });
103 }
104
105 static async create(data: AnyObject) {
106 return axios.post('audition/applies', data).then((res) => Promise.resolve(res.data));
107 }
108
109 static async update(id: number, data: AnyObject) {
110 return axios.put(`audition/applies/${id}`, data).then((res) => Promise.resolve(res.data));
111 }
112
113 static async destroy(id: number) {
114 return axios.delete(`/audition/applies/${id}`);
115 }
116 }
117
118 export class useWorkApi {
119 static async get(params?: QueryForParams): Promise<ServiceResponse<ActivityWork[]>> {
120 return axios.get(`/audition/activity-works`, { params });
121 }
122
123 static async getExport(fileName: string, params?: QueryForParams) {
124 const config = { params: { ...params, fetchType: 'excel' }, timeout: 60000, responseType: 'blob' } as AxiosRequestConfig;
125 return axios.get('audition/activity-works', config).then(({ data }) => FileSaver.saveAs(data, `${fileName}.xlsx`));
126 }
127
128 static async changeStatus(workId: number, data: AnyObject) {
129 return axios.put(`/audition/activity-works/${workId}/change-status`, data).then((res) => Promise.resolve(res.data));
130 }
131 }
1 import axios from 'axios';
2 import { SystemPermission } from '@/types/system-permission';
3 import { AttributeData } from '@/types/global';
4 import FileSaver from 'file-saver';
5
6 type AuthResponse = {
7 user: { id?: number; nick_name?: string; real_name?: string; avatar?: string; email?: string };
8 permissions: string[];
9 menus: SystemPermission[];
10 };
11
12 export default class useAuthApi {
13 static async info(): Promise<AuthResponse> {
14 return axios.get('/auth/info').then((res) => Promise.resolve(res.data));
15 }
16
17 static async changePwd(data: AttributeData) {
18 return axios.patch('/auth/change-pwd', data).then((res) => Promise.resolve(res.data));
19 }
20
21 static async downloadFile(url: string, fileName: string) {
22 // ?response-content-type=Blob
23 FileSaver.saveAs(`${url}`, fileName + url.substring(url.lastIndexOf('.')));
24 }
25 }
1 import { QueryForParams, ServiceResponse } from '@/types/global';
2 import axios, { AxiosRequestConfig } from 'axios';
3 import FileSaver from 'file-saver';
4
5 export default class useDashboardApi {
6 static async userStyle() {
7 return axios.get('/dashboard/user-style').then((res) => Promise.resolve(res.data));
8 }
9
10 static async activityStyle() {
11 return axios.get('/dashboard/activity-style').then((res) => Promise.resolve(res.data));
12 }
13
14 static async todo(params?: QueryForParams): Promise<ServiceResponse> {
15 return axios.get('/dashboard/todo', { params });
16 }
17
18 static async submitWork(params?: QueryForParams) {
19 return axios.get('/dashboard/submit-work', { params });
20 }
21
22 static async getSubmitWorkExport(fileName: string, params?: QueryForParams) {
23 const config = { params: { ...params, fetchType: 'excel' }, timeout: 60000, responseType: 'blob' } as AxiosRequestConfig;
24 return axios.get('dashboard/submit-work', config).then(({ data }) => FileSaver.saveAs(data, `${fileName}.xlsx`));
25 }
26 }
1 import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
2 import { Message } from '@arco-design/web-vue';
3 import { clearToken, getRefreshToken, getToken } from '@/utils/auth';
4 import { apiHost } from '@/utils/env';
5 import { ServiceResponse } from '@/types/global';
6
7 axios.defaults.baseURL = apiHost;
8 axios.defaults.timeout = 30000;
9
10 const isRefreshing = false;
11
12 axios.interceptors.request.use(
13 (config: AxiosRequestConfig) => {
14 if (!config.url?.startsWith('/provider')) {
15 config.baseURL = `${config.baseURL}/manage`;
16 }
17
18 config.headers = {
19 Authorization: `Bearer ${isRefreshing ? getRefreshToken() : getToken()}`,
20 Accept: 'application/json',
21 Route: sessionStorage.getItem('route') || 'dashboard',
22 ...config.headers,
23 };
24
25 if (config.params) {
26 Object.keys(config.params).forEach((key) => {
27 if (config.params[key] === undefined || config.params[key] === '') {
28 delete config.params[key];
29 }
30 });
31 }
32
33 return config;
34 },
35 (error) => {
36 return Promise.reject(error);
37 }
38 );
39
40 axios.interceptors.response.use(
41 async (response: AxiosResponse<ServiceResponse>) => {
42 if (response.data instanceof Blob) {
43 return Promise.resolve(response);
44 }
45
46 return Promise.resolve(response.data);
47 },
48 async (error: any): Promise<any> => {
49 if (error.response?.status === 401) {
50 clearToken();
51 window.location.reload();
52 Message.warning({ content: '登陆信息已失效,请重新登陆...' });
53 }
54
55 if (error.response?.status === 403) {
56 window.location.href = '/exception/403';
57 }
58
59 Message.error({
60 id: 'service_error',
61 content: error.response.data.msg || error.msg || 'Request Error',
62 duration: 5 * 1000,
63 });
64
65 return Promise.reject(error);
66 }
67 );
1 import { AttributeData, QueryForParams, ServiceResponse } from '@/types/global';
2 import axios, { AxiosRequestConfig } from 'axios';
3 import FileSaver from 'file-saver';
4 import { Project } from '@/types/project';
5 import { User } from '@/types/user';
6
7 type ProjectList = Project & {
8 activity_count: number;
9 activity_up_count: number;
10 activity_match_count: number;
11 activity_down_count: number;
12 activity_send_count: number;
13 manage_count: number;
14 };
15
16 export default class useProjectApi {
17 static promoteStatusOption = [
18 { label: '否', value: 0 },
19 { label: '是', value: 1 },
20 ];
21
22 static statusOption = [
23 { label: '启用', value: 1 },
24 { label: '禁用', value: 0 },
25 ];
26
27 static async get(params?: QueryForParams): Promise<ServiceResponse<ProjectList[]>> {
28 return axios.get('audition/projects', { params });
29 }
30
31 static async getExport(fileName: string, params?: QueryForParams) {
32 const config = { params: { ...params, fetchType: 'excel' }, timeout: 60000, responseType: 'blob' } as AxiosRequestConfig;
33 return axios.get('audition/projects', config).then(({ data }) => FileSaver.saveAs(data, `${fileName}.xlsx`));
34 }
35
36 static async show(id: number): Promise<Project> {
37 return axios.get(`/audition/projects/${id}`).then((res) => Promise.resolve(res.data));
38 }
39
40 static async update(id: number, data: AttributeData): Promise<Project> {
41 return axios.put(`audition/projects/${id}`, data).then((res) => Promise.resolve(res.data));
42 }
43
44 static async manageUsers(projectId: number, params?: QueryForParams): Promise<ServiceResponse<User[]>> {
45 return axios.get(`/audition/projects/${projectId}/managers`, { params });
46 }
47
48 static async memberUsers(projectId: number, params?: QueryForParams): Promise<ServiceResponse<User[]>> {
49 return axios.get(`/audition/projects/${projectId}/members`, { params });
50 }
51
52 static async outManageUsers(projectId: number, params?: QueryForParams): Promise<ServiceResponse<User[]>> {
53 return axios.get(`/audition/projects/${projectId}/out-managers`, { params });
54 }
55
56 static destroyOutManageUsers(projectId: number, data = {}) {
57 return axios.post(`/audition/projects/${projectId}/out-managers`, data);
58 }
59
60 static async dynamics(id: number, params?: QueryForParams): Promise<ServiceResponse> {
61 return axios.get(`/audition/projects/${id}/dynamics`, { params });
62 }
63 }
1 import axios from 'axios';
2
3 type LoginData = { access_token: string; refresh_token: string; nick_name: string };
4
5 export default class useProviderApi {
6 static async area() {
7 return axios.get('/provider/area');
8 }
9
10 static sms(type: string, phone: string, area?: string) {
11 return axios.post('/provider/sms', { type, phone, area, platform: 'manage', scope: 2 });
12 }
13
14 static async login(type: 'phone' | 'email', data: object) {
15 return axios.post<LoginData>('/provider/login', { platform: 'manage', scope: 2, type, ...data });
16 }
17 }
1 import { QueryForParams } from '@/types/global';
2 import axios from 'axios';
3
4 export default class useUserApi {
5 static statusOption = [
6 { label: '启用', value: 1 },
7 { label: '禁用', value: 0 },
8 { label: '注销', value: 2 },
9 ];
10
11 static officialStatusOption = [
12 { label: '已关注', value: 1 },
13 { label: '未关注', value: 0 },
14 ];
15
16 static sexOption = [
17 { label: '男', value: 1 },
18 { label: '女', value: 2 },
19 { label: '无', value: 0 },
20 ];
21
22 static scopeOption = [
23 { label: '无权限', value: 0 },
24 { label: '平台管理员', value: 1 },
25 { label: '厂牌管理员', value: 2 },
26 ];
27
28 static async manageSongs(id: number, params?: QueryForParams) {
29 return axios.get(`users/${id}/manage-songs`, { params });
30 }
31 }
1 <svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
2 <g clip-path="url(#clip0)">
3 <path fill-rule="evenodd" clip-rule="evenodd" d="M5.37754 16.9795L12.7498 9.43027C14.7163 7.41663 17.9428 7.37837 19.9564 9.34482C19.9852 9.37297 20.0137 9.40145 20.0418 9.43027L20.1221 9.51243C22.1049 11.5429 22.1049 14.7847 20.1221 16.8152L12.7498 24.3644C10.7834 26.378 7.55686 26.4163 5.54322 24.4498C5.5144 24.4217 5.48592 24.3932 5.45777 24.3644L5.37754 24.2822C3.39468 22.2518 3.39468 19.0099 5.37754 16.9795Z" fill="#12D2AC"/>
4 <path fill-rule="evenodd" clip-rule="evenodd" d="M20.0479 9.43034L27.3399 16.8974C29.3674 18.9735 29.3674 22.2883 27.3399 24.3644C25.3735 26.3781 22.147 26.4163 20.1333 24.4499C20.1045 24.4217 20.076 24.3933 20.0479 24.3644L12.7558 16.8974C10.7284 14.8213 10.7284 11.5065 12.7558 9.43034C14.7223 7.4167 17.9488 7.37844 19.9624 9.34489C19.9912 9.37304 20.0197 9.40152 20.0479 9.43034Z" fill="#307AF2"/>
5 <path fill-rule="evenodd" clip-rule="evenodd" d="M20.1321 9.52163L23.6851 13.1599L16.3931 20.627L9.10103 13.1599L12.6541 9.52163C14.6707 7.45664 17.9794 7.4174 20.0444 9.434C20.074 9.46286 20.1032 9.49207 20.1321 9.52163Z" fill="#0057FE"/>
6 </g>
7 <defs>
8 <clipPath id="clip0">
9 <rect width="26" height="19" fill="white" transform="translate(3.5 7)"/>
10 </clipPath>
11 </defs>
12 </svg>
1 * {
2 box-sizing: border-box;
3 }
4
5 html,
6 body {
7 width: 100%;
8 height: 100%;
9 margin: 0;
10 padding: 0;
11 font-size: 14px;
12 background-color: var(--color-bg-1);
13 -moz-osx-font-smoothing: grayscale;
14 -webkit-font-smoothing: antialiased;
15 }
16
17 .echarts-tooltip-diy {
18 background: linear-gradient(304.17deg,
19 rgba(253, 254, 255, 0.6) -6.04%,
20 rgba(244, 247, 252, 0.6) 85.2%) !important;
21 border: none !important;
22 backdrop-filter: blur(10px) !important;
23 /* Note: backdrop-filter has minimal browser support */
24
25 border-radius: 6px !important;
26
27 .content-panel {
28 display: flex;
29 justify-content: space-between;
30 width: auto;
31 height: 32px;
32 margin-bottom: 4px;
33 padding: 0 9px;
34 line-height: 32px;
35 background: rgba(255, 255, 255, 0.8);
36 border-radius: 4px;
37 box-shadow: 6px 0 20px rgba(34, 87, 188, 0.1);
38 }
39
40 .tooltip-title {
41 margin: 0 0 10px 0;
42 }
43
44 p {
45 margin: 0;
46 }
47
48 .tooltip-title,
49 .tooltip-value {
50 font-size: 13px;
51 line-height: 15px;
52 display: flex;
53 align-items: center;
54 text-align: right;
55 color: #1d2129;
56 font-weight: bold;
57 }
58
59 .tooltip-value {
60 margin-left: 10px;
61 }
62
63 .tooltip-item-icon {
64 display: inline-block;
65 margin-right: 8px;
66 width: 10px;
67 height: 10px;
68 border-radius: 50%;
69 }
70 }
71
72 .general-card {
73 border-radius: 4px;
74 border: none;
75
76 & > .arco-card-header {
77 height: auto;
78 padding: 10px 20px 0 20px;
79 border: none;
80
81 & > .arco-card-header-title {
82 font-size: 15px;
83 }
84 }
85
86 & > .arco-card-body {
87 padding: 0 20px 20px 20px;
88 }
89 }
90
91 .split-line {
92 border-color: rgb(var(--gray-2));
93 }
94
95 .arco-table-cell {
96 .circle {
97 display: inline-block;
98 margin-right: 4px;
99 width: 6px;
100 height: 6px;
101 border-radius: 50%;
102 background-color: rgb(var(--blue-6));
103
104 &.pass {
105 background-color: rgb(var(--green-6));
106 }
107
108 &.warn {
109 background-color: rgb(var(--yellow-6));
110 }
111
112 &.err {
113 background-color: rgb(var(--red-6));
114 }
115 }
116 }
117
118 .mt-0 {
119 margin-bottom: 0;
120 }
121
122 .mt-20 {
123 margin-top: 20px;
124 }
125
126 .mb-0 {
127 margin-bottom: 0;
128 }
129
130 .color-grey {
131 color: rgba(44, 44, 44, 0.5)
132 }
133
134 .justify-center {
135 justify-content: center !important;
136 }
137
138
139 .retry {
140 div.arco-modal-header {
141 border-bottom: unset !important;
142 }
143
144 div.arco-modal-body {
145 padding: 0 24px 20px;
146 }
147 }
148
149 .link-hover:hover {
150 cursor: pointer !important;
151 }
152
This diff could not be displayed because it is too large.
1 <template>
2 <audio v-if="url" :id="uuid" :src="url" class="player" controls controlsList="nodownload noplaybackrate" @play="onPay" />
3 </template>
4
5 <script lang="ts" setup>
6 import { nanoid } from 'nanoid';
7 import { computed } from 'vue';
8
9 defineProps<{ url: string; name: string }>();
10
11 const uuid = computed(() => `audio-${nanoid()}`);
12
13 const onPay = (e: any) => {
14 const audios = document.getElementsByTagName('audio');
15 [].forEach.call(audios, (i: HTMLAudioElement) => i !== e.target && i.pause());
16 };
17 </script>
18
19 <style lang="less" scoped>
20 .player {
21 height: 30px;
22 width: 100%;
23 }
24 </style>
1 <template>
2 <Upload :accept="accept" :custom-request="onUpload" :on-before-upload="onBeforeUpload" :file-list="[]" :show-file-list="false">
3 <template #upload-button>
4 <Avatar :shape="shape" :size="size" trigger-type="mask">
5 <template #trigger-icon>
6 <icon-camera />
7 </template>
8 <img v-if="modelValue" :src="modelValue" alt="avatar" style="width: 100%; height: 100%" />
9 <div v-else class="no-avatar">
10 <icon-plus />
11 </div>
12 </Avatar>
13 </template>
14 </Upload>
15 </template>
16
17 <script lang="ts" setup>
18 import { IconCamera, IconPlus } from '@arco-design/web-vue/es/icon';
19 import useOss from '@/hooks/oss';
20 import { Avatar, Message, Upload, UploadRequest, useFormItem } from '@arco-design/web-vue';
21
22 const props = withDefaults(
23 defineProps<{
24 modelValue: string;
25 size: number;
26 shape: 'circle' | 'square';
27 accept: string;
28 limit: number;
29 }>(),
30 {
31 modelValue: '',
32 size: 80,
33 shape: 'circle',
34 accept: 'image/*',
35 limit: 5,
36 }
37 );
38
39 const { eventHandlers } = useFormItem();
40
41 const emits = defineEmits(['update:modelValue']);
42
43 const { upload } = useOss();
44
45 const onBeforeUpload = (file: File) => {
46 if (file.size > props.limit * 1024 * 1024) {
47 Message.warning(`${file.name} 文件超过${props.limit}MB,无法上传`);
48 return Promise.resolve(false);
49 }
50
51 return Promise.resolve(file);
52 };
53
54 const onUpload = (option: any): UploadRequest => {
55 const { fileItem } = option;
56
57 if (fileItem.file) {
58 upload(fileItem.file, 'image').then((res) => {
59 emits('update:modelValue', res.url);
60 eventHandlers.value?.onChange?.();
61 });
62 }
63
64 return {};
65 };
66 </script>
67
68 <style lang="less" scoped>
69 :deep(.arco-avatar-text) {
70 width: 100%;
71 height: 100%;
72 }
73
74 .no-avatar {
75 position: absolute;
76 display: flex;
77 align-content: center;
78 align-items: center;
79 justify-content: center;
80 width: 100%;
81 height: 100%;
82 top: 0;
83 left: 0;
84 }
85 </style>
1 <template>
2 <a-breadcrumb class="container-breadcrumb">
3 <a-breadcrumb-item>
4 <icon-apps />
5 </a-breadcrumb-item>
6 <a-breadcrumb-item v-for="item in items" :key="item">
7 <router-link v-if="getRouteMeta(item)?.isRedirect" :to="{ name: item }">
8 {{ getRouteName(item) }}
9 </router-link>
10 <span v-else> {{ getRouteName(item) }}</span>
11 </a-breadcrumb-item>
12 </a-breadcrumb>
13 </template>
14
15 <script lang="ts" setup>
16 import { IconApps } from '@arco-design/web-vue/es/icon';
17 import { computed } from 'vue';
18 import { RouteMeta, RouteRecordNormalized, useRouter } from 'vue-router';
19 import { useAppStore } from '@/store';
20 import { storeToRefs } from 'pinia';
21
22 defineProps<{ items: string[] }>();
23
24 const router = useRouter();
25 const appStore = useAppStore();
26
27 const { appMenu } = storeToRefs(appStore);
28
29 const routes = computed(() => {
30 return router.getRoutes() as RouteRecordNormalized[];
31 });
32
33 const getRoute = (name: any) => {
34 return routes.value.find((el) => el.name === name);
35 };
36
37 const getRouteMeta = (name: any): RouteMeta | undefined => {
38 return getRoute(name)?.meta;
39 };
40
41 const getRouteName = (name: any) => {
42 return getRouteMeta(name)?.title || appMenu.value.find((item) => item.name === name)?.label || '';
43 };
44 </script>
45
46 <style lang="less" scoped>
47 .container-breadcrumb {
48 margin: 16px 0;
49
50 :deep(.arco-breadcrumb-item) {
51 color: rgb(var(--gray-6));
52
53 &:last-child {
54 color: rgb(var(--gray-8));
55 }
56 }
57 }
58 </style>
1 <template>
2 <VCharts v-if="renderChart" :option="options" :autoresize="autoresize" :style="{ width, height }" />
3 </template>
4
5 <script lang="ts">
6 import { defineComponent, ref, computed, nextTick } from 'vue';
7 import VCharts from 'vue-echarts';
8 import { useAppStore } from '@/store';
9
10 export default defineComponent({
11 components: {
12 VCharts,
13 },
14 props: {
15 options: {
16 type: Object,
17 default() {
18 return {};
19 },
20 },
21 autoresize: {
22 type: Boolean,
23 default: true,
24 },
25 width: {
26 type: String,
27 default: '100%',
28 },
29 height: {
30 type: String,
31 default: '100%',
32 },
33 },
34 setup() {
35 const appStore = useAppStore();
36 const theme = computed(() => {
37 if (appStore.theme === 'dark') return 'dark';
38 return '';
39 });
40 const renderChart = ref(false);
41 // wait container expand
42 nextTick(() => {
43 renderChart.value = true;
44 });
45 return {
46 theme,
47 renderChart,
48 };
49 },
50 });
51 </script>
52
53 <style scoped lang="less"></style>
1 <template>
2 <a-button :loading="loading" @click="onClick">
3 <template #icon>
4 <icon-download />
5 </template>
6 {{ label }}
7 </a-button>
8 </template>
9
10 <script lang="ts" setup>
11 import { IconDownload } from '@arco-design/web-vue/es/icon';
12 import useLoading from '@/hooks/loading';
13
14 const props = withDefaults(
15 defineProps<{
16 label: string;
17 onDownload: () => Promise<void>;
18 }>(),
19 {
20 label: '导出',
21 }
22 );
23
24 const { loading, setLoading } = useLoading(false);
25
26 const onClick = () => {
27 setLoading(true);
28 props.onDownload().finally(() => setLoading(false));
29 };
30 </script>
31
32 <style scoped></style>
1 <script setup lang="ts">
2 import TableColumn from '@/components/filter/table-column.vue';
3 import { Layout, LayoutSider, LayoutContent, Image, Form, FormItem, TypographyText, TableData } from '@arco-design/web-vue';
4 import { get } from 'lodash';
5 import { isString, isUndefined } from '@/utils';
6
7 const props = withDefaults(
8 defineProps<{
9 title?: string;
10 dataIndex?: string;
11 row?: string;
12 coverIndex?: string;
13 nameIndex?: string;
14 subIndex?: string;
15 tagIndex?: string;
16 projectIndex?: string;
17 hideSubTitle?: boolean;
18 }>(),
19 {
20 coverIndex: 'cover',
21 nameIndex: 'song_name',
22 subIndex: 'sub_title',
23 tagIndex: 'tags',
24 projectIndex: 'project',
25 }
26 );
27
28 const getRow = (record: TableData): TableData | undefined => {
29 if (isUndefined(props.row)) {
30 return record;
31 }
32
33 if (isString(props.row)) {
34 return get(record, props.row);
35 }
36
37 return props.row;
38 };
39
40 const getRowColumn = (record: TableData, key: string) => getRow(record)?.[key];
41 </script>
42
43 <template>
44 <TableColumn v-bind="$attrs" :title="title" :data-index="dataIndex">
45 <template #default="{ record }">
46 <Layout>
47 <LayoutSider :width="104" class="left">
48 <Image show-loader :height="100" :width="100" fit="cover" :src="getRowColumn(record, coverIndex)" />
49 </LayoutSider>
50 <LayoutContent>
51 <Form auto-label-width label-align="left" :model="record" style="height: 100%; justify-content: space-between">
52 <FormItem label="歌曲名称" :show-colon="true" row-class="mb-0">
53 <TypographyText :ellipsis="{ rows: 1, showTooltip: true }">
54 {{ getRowColumn(record, nameIndex) }}
55 </TypographyText>
56 </FormItem>
57 <FormItem v-if="!hideSubTitle" label="推荐语" :show-colon="true" row-class="mb-0">
58 <TypographyText :ellipsis="{ rows: 1, showTooltip: true }">
59 {{ getRowColumn(record, subIndex) }}
60 </TypographyText>
61 </FormItem>
62 <FormItem label="歌曲标签" :show-colon="true" row-class="mb-0">
63 <TypographyText :ellipsis="{ rows: 1, showTooltip: true }">
64 {{
65 getRowColumn(record, tagIndex)
66 .map((item: any) => item.name)
67 .join('、')
68 }}
69 </TypographyText>
70 </FormItem>
71 <FormItem label="关联厂牌" :show-colon="true" row-class="mb-0">
72 <TypographyText :ellipsis="{ rows: 1, showTooltip: true }">
73 {{ getRowColumn(record, projectIndex)?.name }}
74 </TypographyText>
75 </FormItem>
76 </Form>
77 </LayoutContent>
78 </Layout>
79 </template>
80 </TableColumn>
81 </template>
82
83 <style lang="less" scoped>
84 :deep(.arco-typography) {
85 margin-bottom: 0;
86 width: 100%;
87 text-align: left;
88 }
89
90 .left {
91 margin-top: 5px;
92 box-shadow: unset;
93 margin-right: 5px;
94 }
95 </style>
1 <script setup lang="ts">
2 import { Avatar, TableColumn } from '@arco-design/web-vue';
3 import { get } from 'lodash';
4
5 const props = defineProps<{ title?: string; dataIndex?: string; width?: number }>();
6
7 const getValue = (record: object) => {
8 return get(record, props.dataIndex || '', '');
9 };
10 </script>
11
12 <template>
13 <TableColumn v-bind="$attrs" :title="title" :data-index="dataIndex" :width="width || 60">
14 <template #cell="{ record }">
15 <Avatar :size="40" shape="circle">
16 <img alt="" :src="getValue(record)" :width="40" :height="40" />
17 </Avatar>
18 </template>
19 </TableColumn>
20 </template>
21
22 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { get } from 'lodash';
3 import TableColumn from '@/components/filter/table-column.vue';
4 import dayjs from 'dayjs';
5
6 defineProps<{ title?: string; dataIndex?: string; split?: boolean }>();
7
8 const getValue = (record: object, path: string) => {
9 return get(record, path, '');
10 };
11 </script>
12
13 <template>
14 <TableColumn v-bind="$attrs" :title="title" :data-index="dataIndex">
15 <template #default="{ record }">
16 <template v-if="dataIndex && !getValue(record, dataIndex)">
17 <span style="color: rgba(0, 0, 0, 0.3)"></span>
18 </template>
19 <template v-else-if="split">
20 <div>{{ dayjs(getValue(record, dataIndex))?.format('YYYY-MM-DD') || '' }}</div>
21 <div>{{ dayjs(getValue(record, dataIndex))?.format('HH:mm:ss') || '' }}</div>
22 </template>
23 <template v-else>{{ getValue(record, dataIndex) }}</template>
24 </template>
25 </TableColumn>
26 </template>
27
28 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { Options } from '@/types/global';
3 import { eq, get } from 'lodash';
4 import TableColumn from '@/components/filter/table-column.vue';
5
6 const props = defineProps<{ title?: string; dataIndex?: string; option: Options[]; darkValue?: string | number }>();
7
8 const getValue = (record: object) => {
9 return get(record, props.dataIndex || '', '');
10 };
11 </script>
12
13 <template>
14 <TableColumn v-bind="$attrs" :title="title" :data-index="dataIndex">
15 <template #default="{ record }">
16 <template v-if="darkValue !== undefined && eq(getValue(record), darkValue)">
17 <span style="color: rgba(44, 44, 44, 0.5)"> {{ option.find((item) => item.value === getValue(record))?.label || '未知' }}</span>
18 </template>
19 <template v-else> {{ option.find((item) => item.value === getValue(record))?.label || '未知' }}</template>
20 </template>
21 </TableColumn>
22 </template>
23
24 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import TableColumn from '@/components/filter/table-column.vue';
3 import { Link } from '@arco-design/web-vue';
4 import { AnyObject } from '@/types/global';
5
6 defineProps<{
7 title?: string;
8 dataIndex?: string;
9 linkStyle?: object;
10 formatter: (record: AnyObject) => string;
11 to: (record: AnyObject) => void;
12 }>();
13 </script>
14
15 <template>
16 <TableColumn v-bind="$attrs" :title="title" :data-index="dataIndex">
17 <template #default="{ record }">
18 <Link v-if="!!formatter(record)" class="link" :style="linkStyle" :hoverable="false" @click.stop="to(record)">{{
19 formatter(record)
20 }}</Link>
21 <span v-else style="color: rgba(44, 44, 44, 0.5)"></span>
22 </template>
23 </TableColumn>
24 </template>
25
26 <style scoped lang="less">
27 .link:hover {
28 cursor: pointer;
29 }
30 </style>
1 <script setup lang="ts">
2 import { get } from 'lodash';
3 import TableColumn from '@/components/filter/table-column.vue';
4
5 const props = defineProps<{ title?: string; dataIndex?: string; nickIndex?: string; realIndex?: string }>();
6
7 const fullName = (record: object) => {
8 let name = '';
9
10 if (props.nickIndex && get(record, props.nickIndex, '')) {
11 name += get(record, props.nickIndex, '');
12 }
13
14 if (props.realIndex && get(record, props.realIndex)) {
15 name += `(${get(record, props.realIndex, '')})`;
16 }
17
18 return name;
19 };
20 </script>
21
22 <template>
23 <TableColumn v-bind="$attrs" :title="title" :data-index="dataIndex">
24 <template #default="{ record }"> {{ fullName(record) }}</template>
25 </TableColumn>
26 </template>
27
28 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { eq, get } from 'lodash';
3 import TableColumn from '@/components/filter/table-column.vue';
4
5 const props = withDefaults(
6 defineProps<{ title?: string; dataIndex?: string; darkValue?: string | number; prefix?: string; suffix?: string }>(),
7 { prefix: '', suffix: '' }
8 );
9
10 const getValue = (record: object) => {
11 return get(record, props.dataIndex || '', '');
12 };
13 </script>
14
15 <template>
16 <TableColumn v-bind="$attrs" :title="title" :data-index="dataIndex">
17 <template #default="{ record }">
18 <template v-if="darkValue !== undefined && eq(getValue(record), darkValue)">
19 <span style="color: rgba(44, 44, 44, 0.5)">{{ `${prefix} 0 ${suffix}` }}</span>
20 </template>
21 <template v-else> {{ `${prefix} ${getValue(record)} ${suffix}` }} </template>
22 </template>
23 </TableColumn>
24 </template>
25
26 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { get } from 'lodash';
3 import TableColumn from '@/components/filter/table-column.vue';
4
5 defineProps<{ title?: string; dataIndex?: string; areaIndex?: string }>();
6
7 const getValue = (record: object, path: string) => {
8 return get(record, path, '');
9 };
10 </script>
11
12 <template>
13 <TableColumn v-bind="$attrs" :title="title" :data-index="dataIndex">
14 <template #default="{ record }"> {{ `(+${getValue(record, areaIndex)}) ${getValue(record, dataIndex)}` }}</template>
15 </TableColumn>
16 </template>
17
18 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { GridItem, FormItem } from '@arco-design/web-vue';
3 </script>
4
5 <template>
6 <GridItem>
7 <FormItem class="form-item" row-class="mb-0" v-bind="$attrs" :show-colon="true">
8 <slot />
9 </FormItem>
10 </GridItem>
11 </template>
12
13 <style scoped lang="less">
14 .form-item {
15 :deep(.arco-picker) {
16 width: 100%;
17 }
18 }
19 </style>
1 <script setup lang="ts">
2 import { AnyObject } from '@/types/global';
3 import { Layout, LayoutContent, LayoutSider, Form, Space, Grid } from '@arco-design/web-vue';
4 import IconButton from '@/components/icon-button/index.vue';
5
6 type PropType = {
7 model?: AnyObject;
8 loading?: boolean;
9 searchLabel?: string;
10 searchIcon?: string;
11 resetLabel?: string;
12 resetIcon?: string;
13 hideSearch?: boolean;
14 hideReset?: boolean;
15 inline?: boolean;
16 split?: number;
17 size?: 'mini' | 'small' | 'medium' | 'large';
18 hideDivider?: boolean;
19 };
20
21 const props = withDefaults(defineProps<PropType>(), {
22 loading: false,
23 searchLabel: '搜索',
24 searchIcon: 'search',
25 resetLabel: '重置',
26 resetIcon: 'refresh',
27 hideSearch: false,
28 hideReset: false,
29 hideDivider: false,
30 inline: false,
31 split: 3,
32 size: 'small',
33 });
34
35 defineEmits<{ (e?: 'search'): void; (e?: 'reset'): void }>();
36
37 const layoutStyle = { marginBottom: '12px' };
38 const layoutRightStyle = props.hideSearch && props.hideReset ? {} : { borderLeft: '1px solid var(--color-neutral-3)' };
39
40 if (!props.hideDivider) {
41 Object.assign(layoutStyle, { paddingBottom: '12px', borderBottom: '1px solid var(--color-neutral-3)' });
42 }
43 </script>
44
45 <template>
46 <Layout :style="layoutStyle">
47 <LayoutContent>
48 <Form ref="formRef" auto-label-width :model="model" label-align="right">
49 <Grid :cols="split as number" :col-gap="12" :row-gap="12">
50 <slot />
51 </Grid>
52 </Form>
53 </LayoutContent>
54 <LayoutSider class="right" width="auto" :style="layoutRightStyle">
55 <Space :size="12" :direction="inline ? 'horizontal' : 'vertical'">
56 <IconButton
57 v-if="!hideSearch"
58 :size="size"
59 :icon="searchIcon"
60 :label="searchLabel"
61 type="primary"
62 :loading="loading"
63 @click="$emit('search')"
64 />
65 <IconButton v-if="!hideReset" :size="size" :icon="resetIcon" :label="resetLabel" @click="$emit('reset')" />
66 <slot name="button" :size="size" />
67 </Space>
68 </LayoutSider>
69 </Layout>
70 </template>
71
72 <style scoped lang="less">
73 .right {
74 border-left: 1px solid var(--color-neutral-3);
75 margin-left: 12px;
76 padding-left: 12px;
77 box-shadow: unset;
78 }
79 </style>
1 <script setup lang="ts">
2 import { Space, TableData } from '@arco-design/web-vue';
3 import TableColumn from '@/components/filter/table-column.vue';
4
5 withDefaults(defineProps<{ title?: string; dataIndex?: string; direction?: 'vertical' | 'horizontal' }>(), {
6 direction: 'horizontal',
7 });
8 </script>
9
10 <template>
11 <TableColumn v-bind="$attrs" :title="title" :data-index="dataIndex" :tooltip="false">
12 <template #default="{ record, rowIndex }">
13 <Space :direction="direction" fill>
14 <slot name="default" :record="record as TableData" :index="rowIndex as Number" />
15 </Space>
16 </template>
17 </TableColumn>
18 </template>
19
20 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { TableColumn, TableSortable } from '@arco-design/web-vue';
3
4 type PropType = { title?: string; dataIndex?: string; tooltip?: boolean; ellipsis?: boolean; hasSort?: boolean };
5
6 const sortable = { sortDirections: ['ascend', 'descend'], sorter: true } as TableSortable;
7
8 withDefaults(defineProps<PropType>(), { tooltip: true, hasSort: false });
9 </script>
10
11 <template>
12 <TableColumn
13 v-bind="$attrs"
14 :title="title"
15 :data-index="dataIndex"
16 :tooltip="tooltip as boolean"
17 :ellipsis="ellipsis || tooltip as boolean"
18 :sortable="hasSort ? sortable : undefined as TableSortable"
19 >
20 <template v-if="$slots.default" #cell="{ record, rowIndex }">
21 <slot name="default" :record="record" :index="rowIndex" />
22 </template>
23 </TableColumn>
24 </template>
25
26 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { computed, ref } from 'vue';
3 import { Col, Row, Table } from '@arco-design/web-vue';
4 import { AnyObject, sizeType } from '@/types/global';
5 import usePagination from '@/hooks/pagination';
6
7 type PropType = {
8 loading?: boolean;
9 rowKey?: string;
10 hoverType?: string;
11 simplePage?: boolean;
12 pageSize?: number;
13 size?: sizeType;
14 onQuery: (params?: AnyObject) => Promise<any>;
15 };
16
17 defineEmits<{ (e: 'rowSort', dataIndex: string, direction: string): void }>();
18
19 const props = withDefaults(defineProps<PropType>(), { loading: false, rowKey: 'id', size: 'small', pageSize: 20 });
20 const { pagination, setPage, setPageSize, setTotal } = usePagination({
21 simple: props.simplePage,
22 size: props.size,
23 pageSize: props.pageSize,
24 });
25 const list = ref<any[]>([]);
26 const tableRef = ref();
27
28 const pageQueryParams = computed(() => {
29 return { page: pagination.value.current, pageSize: pagination.value.pageSize };
30 });
31
32 const hoverType = computed(() => props.hoverType || 'default');
33
34 const onFetch = () =>
35 props.onQuery(pageQueryParams.value).then(({ data, meta }) => {
36 list.value = data;
37 setPage(meta.current);
38 setTotal(meta.total);
39 setPageSize(meta.limit);
40 });
41
42 const onPageChange = (page: number) => {
43 setPage(page || 1);
44 onFetch();
45 };
46
47 const onSizeChange = (size: number) => {
48 setPageSize(size);
49 setPage(1);
50 onFetch();
51 };
52
53 const onPush = (row: unknown) => {
54 list.value.unshift(row);
55 // eslint-disable-next-line no-plusplus
56 ++pagination.value.total;
57 };
58
59 const onRemove = (index: number) => {
60 list.value.splice(index, 1);
61 // eslint-disable-next-line no-plusplus
62 --pagination.value.total;
63 };
64
65 const resetSort = () => tableRef.value?.clearSorters();
66
67 const getCount = () => pagination.value.total;
68
69 const formatSortType = (type: string): string => type?.replace('end', '') ?? '';
70
71 defineExpose({ onFetch, onPageChange, onSizeChange, resetSort, getCount, onPush, onRemove });
72 </script>
73
74 <template>
75 <Row justify="space-between" align="center">
76 <Col class="table-tool-item" :span="16" style="text-align: left">
77 <slot name="tool" :size="size" />
78 </Col>
79 <Col class="table-tool-item" :span="8" style="text-align: right">
80 <slot name="tool-right" :size="size" />
81 </Col>
82 </Row>
83 <Table
84 ref="tableRef"
85 v-bind="$attrs"
86 :row-key="rowKey as string"
87 :loading="loading as boolean"
88 :size="size as sizeType"
89 :data="list"
90 :pagination="pagination"
91 :bordered="false"
92 :table-layout-fixed="true"
93 @page-change="onPageChange"
94 @page-size-change="onSizeChange"
95 @sorter-change="(dataIndex, direction) => $emit('rowSort', dataIndex, formatSortType(direction))"
96 >
97 <template #columns>
98 <slot />
99 </template>
100 </Table>
101 </template>
102
103 <style lang="less" scoped>
104 :deep(.arco-table-cell) {
105 padding: 5px 8px !important;
106
107 :hover {
108 cursor: v-bind(hoverType);
109 }
110
111 & > .arco-table-td-content .arco-btn-size-small {
112 padding: 5px !important;
113 }
114 }
115
116 :deep(.table-tool-item) {
117 > * {
118 margin-bottom: 12px !important;
119 }
120 }
121 </style>
1 <script setup lang="ts">
2 import TableColumn from '@/components/filter/table-column.vue';
3 import { Avatar } from '@arco-design/web-vue';
4
5 import { get } from 'lodash';
6 import { User } from '@/types/user';
7 import { isString, isUndefined } from '@/utils';
8
9 const props = withDefaults(
10 defineProps<{
11 title?: string;
12 dataIndex?: string;
13 user?: string | Pick<User, 'id' | 'nick_name' | 'real_name' | 'identity'>;
14 showHref?: boolean;
15 showAvatar?: boolean;
16 showRealName?: boolean;
17 darkValue?: string;
18 nickIndex?: string;
19 realIndex?: string;
20 avatarIndex?: string;
21 roleIndex?: string;
22 linkStyle?: object;
23 }>(),
24 {
25 dataIndex: 'id',
26 nickIndex: 'nick_name',
27 realIndex: 'real_name',
28 avatarIndex: 'avatar',
29 roleIndex: 'identity',
30 }
31 );
32
33 const getUser = (record: object): Pick<User, 'id' | 'nick_name' | 'real_name' | 'identity'> | undefined => {
34 if (isUndefined(props.user)) {
35 return record as User;
36 }
37
38 if (isString(props.user)) {
39 return get(record, props.user);
40 }
41
42 return get(props, 'user') as User;
43 };
44
45 const getName = (record: object): string => {
46 const user = getUser(record);
47
48 if (!user) {
49 return '';
50 }
51
52 if (props.showRealName) {
53 return `${get(user, props.nickIndex, '')}(${get(user, props.realIndex)})`;
54 }
55
56 return get(user, props.nickIndex, '');
57 };
58 </script>
59
60 <template>
61 <TableColumn v-bind="$attrs" :title="title" :data-index="dataIndex">
62 <template #default="{ record }">
63 <template v-if="darkValue !== undefined && getName(record) === darkValue">
64 <span style="color: rgba(44, 44, 44, 0.5)"></span>
65 </template>
66 <template v-else>
67 <Avatar v-if="showAvatar" style="margin-right: 8px" :size="34" shape="circle" :image-url="getUser(record)?.[avatarIndex]" />
68 {{ getName(record) }}
69 </template>
70 </template>
71 </TableColumn>
72 </template>
73
74 <style scoped lang="less"></style>
1 <template>
2 <Button v-bind="$attrs" :size="size" :type="type" :loading="loading" :status="status" @click="() => emits('click')">
3 <template v-if="icon && iconAlign === 'left'" #icon>
4 <component :is="Icon[iconName]" />
5 </template>
6 {{ label }}
7 <component :is="Icon[iconName]" v-if="icon && iconAlign === 'right'" style="margin-left: 6px" />
8 </Button>
9 </template>
10
11 <script lang="ts" setup>
12 import { Button } from '@arco-design/web-vue';
13 import * as Icon from '@arco-design/web-vue/es/icon';
14 import { computed } from 'vue';
15 import { camelCase, upperFirst } from 'lodash';
16
17 const props = withDefaults(
18 defineProps<{
19 loading?: boolean;
20 label?: string;
21 icon?: string;
22 iconAlign?: 'left' | 'right';
23 type?: 'primary' | 'secondary' | 'outline' | 'dashed' | 'text';
24 shape?: 'square' | 'round' | 'circle';
25 status?: 'normal' | 'warning' | 'success' | 'danger';
26 size?: 'mini' | 'small' | 'medium' | 'large';
27 }>(),
28 {
29 loading: false,
30 iconAlign: 'left',
31 type: 'secondary',
32 shape: 'square',
33 status: 'normal',
34 size: 'small',
35 }
36 );
37
38 const emits = defineEmits(['click']);
39
40 const iconName = computed(() => upperFirst(camelCase(`icon-${props.icon}`)));
41 </script>
42
43 <style scoped></style>
1 <template>
2 <a-upload
3 :accept="accept"
4 :custom-request="onUpload"
5 :file-list="[]"
6 :show-file-list="false"
7 >
8 <template #upload-button>
9 <div class="arco-upload-list-item">
10 <a-image
11 v-if="modelValue"
12 :height="height"
13 :preview="false"
14 :src="modelValue"
15 :width="width"
16 />
17 <div v-else :style="style" class="arco-upload-picture-card">
18 <div class="arco-upload-picture-card-text">
19 <IconPlus />
20 </div>
21 </div>
22 </div>
23 </template>
24 </a-upload>
25 </template>
26
27 <script lang="ts" setup>
28 import useOss from '@/hooks/oss';
29 import { computed } from 'vue';
30 import { IconPlus } from '@arco-design/web-vue/es/icon';
31 import { UploadRequest, useFormItem } from '@arco-design/web-vue';
32
33 const props = defineProps({
34 modelValue: {
35 type: String,
36 default: '',
37 },
38 size: {
39 type: Number,
40 default: 80,
41 },
42 accept: {
43 type: String,
44 default: 'image/*',
45 },
46 width: {
47 type: Number,
48 default: 80,
49 },
50 height: {
51 type: [Number, String],
52 default: 'auto',
53 },
54 });
55
56 const emits = defineEmits(['update:modelValue']);
57 const { eventHandlers } = useFormItem();
58
59 const style = computed(() => {
60 return {
61 width: `${props.width}px`,
62 height: props.height.constructor === String ? 'auto' : `${props.height}px`,
63 maxWidth: '100%',
64 maxHeight: '100%',
65 minHeight: '80px',
66 };
67 });
68
69 const { upload } = useOss();
70
71 const onUpload = (option: any): UploadRequest => {
72 const { fileItem } = option;
73
74 if (fileItem.file) {
75 upload(fileItem.file, 'image').then((res) => {
76 emits('update:modelValue', res.url);
77 eventHandlers.value?.onChange?.();
78 });
79 }
80
81 return {};
82 };
83 </script>
84
85 <style lang="less" scoped>
86 :deep(.arco-avatar-text) {
87 width: 100%;
88 height: 100%;
89 }
90
91 .no-avatar {
92 position: absolute;
93 display: flex;
94 align-content: center;
95 align-items: center;
96 justify-content: center;
97 width: 100%;
98 height: 100%;
99 top: 0;
100 left: 0;
101 }
102 </style>
1 import { App } from 'vue';
2 import { use } from 'echarts/core';
3 import { CanvasRenderer } from 'echarts/renderers';
4 import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
5 import { DataZoomComponent, GraphicComponent, GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
6 import AudioPlayer from '@/components/audio-player/index.vue';
7 import Chart from './chart/index.vue';
8 import Breadcrumb from './breadcrumb/index.vue';
9 import AvatarUpload from './avatar-upload/index.vue';
10 import InputUpload from './input-upload/index.vue';
11 import ImageUpload from './image-upload/index.vue';
12 import RouterButton from './router-button/index.vue';
13 import ExportButton from './export-button/index.vue';
14 import IconButton from './icon-button/index.vue';
15 import ProjectSelect from './project-select/index.vue';
16 import TagSelect from './tag-select/index.vue';
17 import UserSelect from './user-select/index.vue';
18 import FilterSearch from './filter/search.vue';
19 import FilterSearchItem from './filter/search-item.vue';
20 import FilterTable from './filter/table.vue';
21 import FilterTableColumn from './filter/table-column.vue';
22 import PageView from './page-view/index.vue';
23
24 // import SvgIcon from './svg-icon/index.vue';
25
26 // Manually introduce ECharts modules to reduce packing size
27
28 use([
29 CanvasRenderer,
30 BarChart,
31 LineChart,
32 PieChart,
33 RadarChart,
34 GridComponent,
35 TooltipComponent,
36 LegendComponent,
37 DataZoomComponent,
38 GraphicComponent,
39 ]);
40
41 export default {
42 install(Vue: App) {
43 Vue.component('Chart', Chart);
44 Vue.component('Breadcrumb', Breadcrumb);
45 Vue.component('AvatarUpload', AvatarUpload);
46 Vue.component('InputUpload', InputUpload);
47 Vue.component('ImageUpload', ImageUpload);
48 Vue.component('RouterButton', RouterButton);
49 Vue.component('ExportButton', ExportButton);
50 Vue.component('IconButton', IconButton);
51 Vue.component('AudioPlayer', AudioPlayer);
52 Vue.component('ProjectSelect', ProjectSelect);
53 Vue.component('TagSelect', TagSelect);
54 Vue.component('UserSelect', UserSelect);
55 Vue.component('FilterSearch', FilterSearch);
56 Vue.component('FilterSearchItem', FilterSearchItem);
57 Vue.component('FilterTable', FilterTable);
58 Vue.component('FilterTableColumn', FilterTableColumn);
59 Vue.component('PageView', PageView);
60 // Vue.component('SvgIcon', SvgIcon);
61 },
62 };
1 <template>
2 <Upload
3 v-bind="$attrs"
4 :on-before-upload="onBeforeUpload"
5 :custom-request="onUpload"
6 :file-list="[]"
7 :show-file-list="false"
8 style="width: 100%"
9 >
10 <template #upload-button>
11 <Input :model-value="modelValue" :readonly="true" :placeholder="placeholder">
12 <template #suffix>
13 <Progress v-if="loading" :percent="percent" size="mini" />
14 <IconUpload v-else />
15 </template>
16 </Input>
17 </template>
18 </Upload>
19 </template>
20
21 <script lang="ts" setup>
22 import { ref, watch } from 'vue';
23 import { IconUpload } from '@arco-design/web-vue/es/icon';
24 import useLoading from '@/hooks/loading';
25 import useOss from '@/hooks/oss';
26 import { Input, Message, Progress, Upload, UploadRequest, useFormItem } from '@arco-design/web-vue';
27
28 type FileType = { name: string; url: string; size: number; type: string };
29
30 const props = defineProps({
31 modelValue: { type: String, default: '' },
32 prefix: { type: String, default: 'file' },
33 limit: { type: Number, default: 0 },
34 placeholder: { type: String, default: '请选择' },
35 });
36
37 const emits = defineEmits<{
38 (e: 'update:modelValue', value: string): void;
39 (e: 'update:loading', value: boolean): void;
40 (e: 'success', value: FileType): void;
41 (e: 'choose-file', value: File): void;
42 }>();
43 const { eventHandlers } = useFormItem();
44
45 const { loading, setLoading } = useLoading(false);
46 const { upload } = useOss();
47 const percent = ref<number>(0);
48
49 const onBeforeUpload = (file: File) => {
50 if (props.limit !== 0 && file.size > props.limit * 1024 * 1024) {
51 Message.warning(`${file.name} 文件超过${props.limit}MB,无法上传`);
52 return Promise.resolve(false);
53 }
54
55 // if (startsWith(file.type, 'audio/') || startsWith(file.type, 'video/')) {
56 // const audioElement = new Audio(URL.createObjectURL(file));
57 // audioElement.addEventListener('loadedmetadata', () => emits('update:duration', audioElement.duration * 1000));
58 // }
59
60 return Promise.resolve(file);
61 };
62
63 const onProgress = (p: number) => {
64 percent.value = p;
65 };
66
67 const onUpload = (option: any): UploadRequest => {
68 const { fileItem } = option;
69
70 if (fileItem.file) {
71 setLoading(true);
72 // eslint-disable-next-line vue/custom-event-name-casing
73 emits('choose-file', fileItem.file as File);
74 upload(fileItem.file, props.prefix, onProgress)
75 .then((res) => {
76 emits('update:modelValue', res?.url || '');
77 // emits('change', res?.url || '');
78 eventHandlers.value?.onChange?.();
79 fileItem.percent = 100;
80 fileItem.url = res?.url || '';
81 fileItem.status = 'done';
82 emits('success', { name: fileItem.name, url: fileItem.url, size: fileItem.file.size, type: fileItem.file.type });
83 })
84 .finally(() => {
85 setLoading(false);
86 });
87 }
88
89 return {};
90 };
91
92 watch(
93 () => loading.value,
94 (value) => emits('update:loading', value)
95 );
96 </script>
97
98 <style lang="less" scoped>
99 ::v-deep(.arco-input-append) {
100 padding: 0;
101 }
102 </style>
1 <script setup lang="ts">
2 withDefaults(defineProps<{ hasCard?: boolean; hasBread?: boolean; loading?: boolean }>(), {
3 loading: false,
4 });
5 </script>
6
7 <template>
8 <div class="container">
9 <breadcrumb v-if="hasBread && $route.meta?.breadcrumb" :items="$route.meta?.breadcrumb" />
10 <a-spin style="width: 100%" :loading="loading as boolean">
11 <a-card v-if="hasCard" :bordered="false">
12 <slot />
13 </a-card>
14 <slot v-else />
15 </a-spin>
16 </div>
17 </template>
18
19 <style scoped lang="less">
20 .container {
21 padding: 0 30px 20px 20px;
22 }
23 </style>
1 <template>
2 <Select
3 v-bind="$attrs"
4 :model-value="modelValue"
5 :options="projectOptions"
6 :fallback-option="false"
7 :placeholder="placeholder"
8 :field-names="{ value: 'id', label: 'name' }"
9 @update:model-value="onUpdate"
10 />
11 </template>
12
13 <script lang="ts" setup>
14 import { Select } from '@arco-design/web-vue';
15 import { toRefs } from 'vue';
16 import { storeToRefs } from 'pinia';
17 import { useSelectionStore } from '@/store';
18
19 type propType = {
20 modelValue?: number | string | number[];
21 multiple?: boolean;
22 placeholder?: string;
23 maxTagCount?: number;
24 };
25
26 const props = withDefaults(defineProps<propType>(), {
27 multiple: false,
28 maxTagCount: 0,
29 placeholder: '请选择',
30 modelValue: '',
31 });
32
33 const emits = defineEmits<{ (e: 'update:modelValue', value: number | number[]): void }>();
34
35 const { multiple } = toRefs(props);
36
37 const { projectOptions } = storeToRefs(useSelectionStore());
38
39 const onUpdate = (val?: number) => emits('update:modelValue', val || (multiple.value ? [] : 0));
40 </script>
41
42 <style scoped></style>
1 <template>
2 <a-button v-if="type === 'Button'" type="text" size="small" @click="onClick">
3 <slot>详情</slot>
4 </a-button>
5 <a-link v-else class="link" :hoverable="false" @click="onClick">
6 <a-typography-text type="primary" :style="textStyle" :ellipsis="{ rows: 1, showTooltip: showTooltip }">
7 <slot>详情</slot>
8 </a-typography-text>
9 </a-link>
10 </template>
11
12 <script lang="ts" setup>
13 import { RouteParamsRaw, useRouter } from 'vue-router';
14 import { AnyObject } from '@/types/global';
15
16 const props = withDefaults(
17 defineProps<{
18 name: string;
19 params?: AnyObject;
20 type: 'Button' | 'link';
21 showTooltip: boolean;
22 }>(),
23 {
24 name: '',
25 type: 'Button',
26 params: undefined,
27 showTooltip: true,
28 }
29 );
30
31 const textStyle = { marginBottom: 0 };
32
33 const router = useRouter();
34 const onClick = () => {
35 router.push({
36 name: props.name,
37 params: (props.params as RouteParamsRaw) || {},
38 });
39 };
40 </script>
41
42 <style lang="less" scoped>
43 .link {
44 width: 100%;
45 padding: 1px 0;
46 display: block !important;
47 }
48 </style>
1 <template>
2 <Select
3 v-bind="$attrs"
4 v-model="value"
5 :options="options"
6 :fallback-option="false"
7 :placeholder="placeholder"
8 :virtual-list-props="{ height: 200 }"
9 :field-names="{ value: 'id', label: 'name' }"
10 @exceed-limit="onTagExceedLimitError"
11 />
12 </template>
13
14 <script lang="ts" setup>
15 import { Message, Select } from '@arco-design/web-vue';
16 import { computed, onMounted } from 'vue';
17 import { Tag } from '@/types/tag';
18 import { storeToRefs } from 'pinia';
19 import { useSelectionStore } from '@/store';
20 import { isArray } from '@arco-design/web-vue/es/_utils/is';
21
22 interface propType {
23 modelValue?: number | string | number[];
24 placeholder?: string;
25 }
26
27 const props = withDefaults(defineProps<propType>(), { placeholder: '请选择' });
28 const emits = defineEmits<{ (e: 'update:modelValue', value: unknown): void }>();
29
30 const onTagExceedLimitError = () => Message.warning({ content: '关联标签最多选中3个', duration: 1500 });
31
32 const value = computed({
33 get: () => props.modelValue,
34 set: (val) => emits('update:modelValue', val),
35 });
36
37 const { getTagOptions } = storeToRefs(useSelectionStore());
38 const options = computed(() => getTagOptions.value.filter((item: Tag) => item.type === 1));
39
40 const tagIds = computed(() => options.value?.map((item: Tag) => item.id));
41
42 onMounted(() => {
43 if (isArray(props.modelValue)) {
44 value.value = props.modelValue?.filter((item: number) => tagIds.value?.indexOf(item) !== -1) || [];
45 }
46 });
47 </script>
48
49 <style scoped></style>
1 <script setup lang="ts">
2 import { ref } from 'vue';
3
4 import { Select } from '@arco-design/web-vue';
5 import { useSelectionStore } from '@/store';
6 import { storeToRefs } from 'pinia';
7
8 const { getUserOptions } = storeToRefs(useSelectionStore());
9
10 const loading = ref<boolean>(false);
11 const fieldName = { value: 'id', label: 'nick_name' };
12 </script>
13
14 <template>
15 <Select
16 v-bind="$attrs"
17 placeholder="请选择"
18 :loading="loading"
19 :multiple="true"
20 :options="getUserOptions"
21 :field-names="fieldName"
22 :allow-search="true"
23 :virtual-list-props="{ height: 200 }"
24 />
25 </template>
26
27 <style scoped lang="less"></style>
1 {
2 "theme": "light",
3 "colorWeek": false,
4 "navbar": true,
5 "menu": true,
6 "menuCollapse": false,
7 "footer": false,
8 "themeColor": "#165DFF",
9 "menuWidth": 220,
10 "globalSettings": false
11 }
1 import { App } from 'vue';
2 import permission from './permission';
3
4 export default {
5 install(Vue: App) {
6 Vue.directive('permission', permission);
7 },
8 };
1 import { DirectiveBinding } from 'vue';
2 import { useAuthorizedStore } from '@/store';
3
4 function checkPermission(el: HTMLElement, binding: DirectiveBinding) {
5 const { value } = binding;
6 const userStore = useAuthorizedStore();
7 const { permissions } = userStore;
8 if (Array.isArray(value)) {
9 if (value.length > 0) {
10 const hasPermission = value.filter((item: string) => permissions.includes(item));
11 if (hasPermission.length === 0 && el.parentNode) {
12 if (el.parentNode.childElementCount === 1) {
13 // @ts-ignore
14 el.parentNode.remove();
15 } else {
16 el.parentNode.removeChild(el);
17 }
18 }
19 }
20 } else {
21 throw new Error(`need roles! Like v-permission="['admin','user']"`);
22 }
23 }
24
25 export default {
26 mounted(el: HTMLElement, binding: DirectiveBinding) {
27 checkPermission(el, binding);
28 },
29 updated(el: HTMLElement, binding: DirectiveBinding) {
30 checkPermission(el, binding);
31 },
32 };
1 /// <reference types="vite/client" />
2
3 declare module '*.vue' {
4 import { DefineComponent } from 'vue';
5 // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
6 const component: DefineComponent<{}, {}, any>;
7 export default component;
8 }
1 import { Activity } from '@/types/activity';
2 import { createVNode, ref } from 'vue';
3 import { Button, Form, FormItem, Input, Modal } from '@arco-design/web-vue';
4 import { clone, compact, trim } from 'lodash';
5 import { IconDelete, IconPlus } from '@arco-design/web-vue/es/icon';
6 import { AnyObject } from '@/types/global';
7
8 export default function useActivityHook() {
9 const updateStatusToSend = (activity: Activity, callback: (done: (closed: boolean) => void, attribute: AnyObject) => void) => {
10 const oldLink = clone(activity.send_url?.[0]?.url || '');
11 const link = ref(oldLink);
12 const title = link.value.length === 0 ? '发行' : '编辑发行';
13
14 return Modal.open({
15 title,
16 content: () =>
17 createVNode(
18 Form,
19 { model: link, autoLabelWidth: true },
20 {
21 default: () => [
22 createVNode(
23 'div',
24 {
25 style: {
26 fontFamily: 'PingFangSC-Regular, serif',
27 paddingBottom: '16px',
28 color: '#696969',
29 },
30 },
31 '将歌曲发行平台的url链接回填,会展示在App或小程序应用端。将会给您带来更多播放量 ~'
32 ),
33 createVNode(
34 FormItem,
35 {
36 label: 'QQ音乐或酷狗平台链接',
37 rowClass: 'mb-0',
38 required: true,
39 },
40 {
41 default: () =>
42 createVNode(Input, {
43 'modelValue': link.value,
44 'size': 'small',
45 'onUpdate:modelValue': (val?: string) => {
46 link.value = val || '';
47 },
48 }),
49 }
50 ),
51 ],
52 }
53 ),
54 closable: false,
55 width: 'auto',
56 onBeforeOk: (done) => {
57 if (trim(link.value).length === 0) {
58 Modal.open({ title: '提醒', content: '请输入:QQ音乐或酷狗平台链接', closable: false, hideCancel: true, escToClose: false });
59 return done(false);
60 }
61
62 if (link.value.toString() === oldLink.toString()) {
63 return done(true);
64 }
65
66 if (!/^(((ht|f)tps?):\/\/)?([^!@#$%^&*?.\s-]([^!@#$%^&*?.\s]{0,63}[^!@#$%^&*?.\s])?\.)+[a-z]{2,6}\/?/.test(link.value)) {
67 Modal.open({
68 title: '提醒',
69 content: 'url链接无法被识别,请检查后重试',
70 closable: false,
71 hideCancel: true,
72 escToClose: false,
73 });
74
75 return done(false);
76 }
77 return callback(done, { status: 5, link: link.value });
78 },
79 });
80 };
81
82 const updateStatusToReUp = (activity: Activity, callback: (done: (closed: boolean) => void, attribute: AnyObject) => void) => {
83 return Modal.open({
84 title: '是否确认重新上架',
85 titleAlign: 'start',
86 content: '注意:重新上架会保留原参与试唱的信息',
87 closable: false,
88 escToClose: false,
89 okText: '上架',
90 onBeforeOk: (done) => {
91 return callback(done, { status: 1 });
92 },
93 });
94 };
95
96 const updateStatusToDown = (activity: Activity, callback: (done: (closed: boolean) => void, attribute: AnyObject) => void) => {
97 const msg = ref<string>('');
98
99 return Modal.open({
100 title: '变更状态',
101 titleAlign: 'start',
102 content: () =>
103 createVNode(Input, {
104 'placeholder': '请输入下架原因',
105 'maxLength': 20,
106 'show-word-limit': true,
107 'modelValue': msg.value,
108 'onUpdate:modelValue': (val?: string) => {
109 msg.value = val || '';
110 },
111 }),
112 simple: false,
113 closable: false,
114 escToClose: false,
115 maskClosable: false,
116 okText: '下架',
117 onBeforeOk: (done) => {
118 return callback(done, { status: 2, msg: msg.value });
119 },
120 });
121 };
122
123 const updateStatusToUp = (activity: Activity, callback: (done: (closed: boolean) => void, attribute: AnyObject) => void) => {
124 return Modal.open({
125 title: '变更状态',
126 titleAlign: 'start',
127 content: `请确认是否上架活动:${activity.song_name}`,
128 simple: false,
129 closable: false,
130 escToClose: false,
131 okText: '上架',
132 onBeforeOk: (done) => {
133 return callback(done, { status: 1 });
134 },
135 });
136 };
137
138 const updateRecommendIntro = (activity: Activity, callback: (done: (closed: boolean) => void, attribute: AnyObject) => void) => {
139 const oldIntro = activity.recommend_intros?.map((item) => item.content) || [];
140
141 const intro = ref<string[]>(oldIntro.length === 0 ? [''] : clone(oldIntro));
142
143 const InputVNode = (index: number) =>
144 createVNode(Input, {
145 'modelValue': intro.value[index],
146 'onUpdate:modelValue': (val?: string) => {
147 intro.value[index] = val || '';
148 },
149 });
150
151 const DeleteBtnVNode = (index: number) =>
152 createVNode(
153 Button,
154 {
155 style: { marginLeft: '10px' },
156 onClick: () => intro.value.splice(index, 1),
157 },
158 { icon: () => createVNode(IconDelete) }
159 );
160
161 const InputItemVNode = (index: number) =>
162 createVNode(
163 FormItem,
164 { label: `推荐语${index + 1}`, required: true },
165 {
166 default: () => (intro.value.length === 1 ? InputVNode(index) : [InputVNode(index), DeleteBtnVNode(index)]),
167 }
168 );
169
170 const AddItemVNode = createVNode(
171 FormItem,
172 {},
173 {
174 default: () =>
175 createVNode(
176 Button,
177 {
178 long: false,
179 type: 'primary',
180 size: 'small',
181 onClick: () => intro.value.push(''),
182 },
183 {
184 icon: () => createVNode(IconPlus),
185 default: () => '添加',
186 }
187 ),
188 }
189 );
190
191 return Modal.open({
192 title: '推荐',
193 content: () =>
194 createVNode(
195 Form,
196 { model: intro, autoLabelWidth: true },
197 {
198 default: () => {
199 const children = intro.value.map((value: any, index: number) => InputItemVNode(index));
200 if (children.length < 3) {
201 children.push(AddItemVNode);
202 }
203 return children;
204 },
205 }
206 ),
207 closable: false,
208 // @ts-ignore
209 bodyStyle: { padding: '24px 20px 0' },
210 onBeforeOk: (done) => {
211 if (oldIntro.toString() === intro.value.toString()) {
212 return done(true);
213 }
214
215 if (intro.value.length !== compact(intro.value).length) {
216 return done(false);
217 }
218
219 return callback(done, { intro: intro.value });
220 },
221 });
222 };
223
224 // const activity
225
226 return {
227 updateStatusToReUp,
228 updateStatusToSend,
229 updateStatusToDown,
230 updateStatusToUp,
231 updateRecommendIntro,
232 };
233 }
1 import { computed } from 'vue';
2 import { EChartsOption } from 'echarts';
3 import { useAppStore } from '@/store';
4
5 // for code hints
6 // import { SeriesOption } from 'echarts';
7 // Because there are so many configuration items, this provides a relatively convenient code hint.
8 // When using vue, pay attention to the reactive issues. It is necessary to ensure that corresponding functions can be triggered, Typescript does not report errors, and code writing is convenient.
9 interface optionsFn {
10 (isDark: boolean): EChartsOption;
11 }
12
13 export default function useChartOption(sourceOption: optionsFn) {
14 const appStore = useAppStore();
15 const isDark = computed(() => {
16 return appStore.theme === 'dark';
17 });
18 // echarts support https://echarts.apache.org/zh/theme-builder.html
19 // It's not used here
20 // TODO echarts themes
21 const chartOption = computed<EChartsOption>(() => {
22 return sourceOption(isDark.value);
23 });
24 return {
25 chartOption,
26 };
27 }
1 import { ref } from 'vue';
2
3 export default function useLoading(initValue = false) {
4 const loading = ref(initValue);
5 const setLoading = (value: boolean) => {
6 loading.value = value;
7 };
8 const toggle = () => {
9 loading.value = !loading.value;
10 };
11 return {
12 loading,
13 setLoading,
14 toggle,
15 };
16 }
1 import { computed } from 'vue';
2 import { useI18n } from 'vue-i18n';
3 import { Message } from '@arco-design/web-vue';
4
5 export default function useLocale() {
6 const i18 = useI18n();
7 const currentLocale = computed(() => {
8 return i18.locale.value;
9 });
10 const changeLocale = (value: string) => {
11 i18.locale.value = value;
12 localStorage.setItem('arco-locale', value);
13 Message.success(i18.t('navbar.action.locale'));
14 };
15 return {
16 currentLocale,
17 changeLocale,
18 };
19 }
1 import OSS from 'ali-oss';
2 import { ossConfig } from '@/utils/env';
3
4 const oss = new OSS({
5 accessKeyId: ossConfig.accessKeyId,
6 accessKeySecret: ossConfig.accessKeySecret,
7 bucket: ossConfig.bucket,
8 region: ossConfig.region,
9 endpoint: ossConfig.endpoint,
10 cname: true,
11 });
12
13 export default function useOss() {
14 const getFileName = () => {
15 function rx() {
16 return Math.random().toString(36).substring(2);
17 }
18
19 return `${rx()}${new Date().getTime()}${rx()}`;
20 };
21
22 const getFileDir = () => {
23 return new Date().toISOString().slice(0, 10).replace(/-/g, '');
24 };
25
26 const getFileType = (file: File) => {
27 return file.name?.split('.')?.pop()?.toLowerCase();
28 };
29
30 const getHost = () => {
31 if (ossConfig.host && ossConfig.host.length !== 0) {
32 return ossConfig.host;
33 }
34
35 return ossConfig.endpoint;
36 };
37
38 return {
39 upload(file: File, prefix = 'file', onProgress?: (percent: number) => void) {
40 return oss
41 .multipartUpload(`${prefix}/${getFileDir()}/${getFileName()}.${getFileType(file)}`, file, { progress: onProgress })
42 .then((res) => {
43 return { response: res.res, url: `${getHost()}/${res.name}` };
44 });
45 },
46 };
47 }
1 import { ref } from 'vue';
2 import { Pagination } from '@/types/global';
3
4 interface Config {
5 pageSize?: number;
6 showTotal?: boolean;
7 showPageSize?: boolean;
8 size?: 'mini' | 'small' | 'medium' | 'large';
9 simple?: boolean;
10 hideOnSinglePage?: boolean;
11 }
12
13 export default function usePagination(config: Config = {}) {
14 const defaultPagination = {
15 total: 0,
16 current: 1,
17 pageSize: config.pageSize ?? 20,
18 pageSizeOptions: [config.pageSize ?? 20, (config.pageSize ?? 20) * 2, (config.pageSize ?? 20) * 3, (config.pageSize ?? 20) * 5],
19 showTotal: config.showTotal ?? true,
20 showPageSize: config.showPageSize ?? true,
21 size: config.size ?? 'medium',
22 simple: config.simple ?? false,
23 hideOnSinglePage: config.hideOnSinglePage ?? false,
24 };
25
26 const pagination = ref<Pagination>(defaultPagination);
27
28 // eslint-disable-next-line no-return-assign
29 const setPage = (page: number) => (pagination.value.current = page);
30 // eslint-disable-next-line no-return-assign
31 const setPageSize = (size: number) => (pagination.value.pageSize = size);
32 // eslint-disable-next-line no-return-assign
33 const setTotal = (total: number) => (pagination.value.total = total);
34 // eslint-disable-next-line no-return-assign
35 const resetPagination = () => (pagination.value = defaultPagination);
36
37 const incrementTotal = (increment = 1) => setTotal(pagination.value.total + increment);
38
39 const decrementTotal = (increment = 1) => setTotal(pagination.value.total - increment);
40
41 return {
42 pagination,
43 setPage,
44 setPageSize,
45 setTotal,
46 resetPagination,
47 incrementTotal,
48 decrementTotal,
49 };
50 }
1 import { RouteLocationNormalized, RouteRecordRaw } from 'vue-router';
2 import { useAuthorizedStore } from '@/store';
3
4 export default function usePermission() {
5 const authorizedStore = useAuthorizedStore();
6
7 return {
8 accessRouter(route: RouteLocationNormalized | RouteRecordRaw) {
9 return (
10 !route.meta?.requiresAuth ||
11 !route.meta?.roles ||
12 route.meta?.roles?.includes('*') ||
13 authorizedStore.permissions?.filter((item) => route.meta?.roles?.includes(item))?.length !== 0
14 );
15 },
16 checkPermission(binding: string[] | string): boolean {
17 const userStore = useAuthorizedStore();
18 const { permissions } = userStore;
19
20 if (Array.isArray(binding) && binding.length > 0) {
21 const hasPermission = binding.filter((item: string) => permissions.includes(item));
22 return hasPermission.length !== 0;
23 }
24 return permissions.includes(binding as string);
25 },
26 };
27 }
1 import { computed } from 'vue';
2 import { useAppStore } from '@/store';
3
4 export default function useThemes() {
5 const appStore = useAppStore();
6 const isDark = computed(() => {
7 return appStore.theme === 'dark';
8 });
9 return {
10 isDark,
11 };
12 }
1 import { LocationQueryRaw, useRouter } from 'vue-router';
2 import { useAuthorizedStore } from '@/store';
3 import { createVNode, h, ref } from 'vue';
4 import { FormInstance } from '@arco-design/web-vue/es/form';
5 import { Form, FormItem, InputPassword, Message, Modal } from '@arco-design/web-vue';
6 import useAuthApi from '@/api/auth';
7
8 export default function useUser() {
9 const router = useRouter();
10 const userStore = useAuthorizedStore();
11
12 const logout = async () => {
13 await userStore.logout();
14 const currentRoute = router.currentRoute.value;
15 await router.push({
16 name: 'login',
17 query: { path: currentRoute.path } as LocationQueryRaw,
18 });
19 };
20
21 const resetPwd = () => {
22 const pwdRef = ref<FormInstance>();
23 const pwdVal = ref({ password: '', password_confirmation: '' });
24 const pwdRules = {
25 password: [{ required: true, message: '请输入密码' }],
26 password_confirmation: [{ required: true, message: '请输入密码' }],
27 };
28
29 const createPwdVNode = (label: string, field: 'password' | 'password_confirmation') =>
30 createVNode(
31 FormItem,
32 { label, field, rowClass: field === 'password_confirmation' ? 'mb-0' : '' },
33 {
34 default: () =>
35 h(InputPassword, {
36 'modelValue': pwdVal.value[field],
37 // eslint-disable-next-line no-return-assign
38 'onUpdate:modelValue': (val?: string) => (pwdVal.value[field] = val || ''),
39 }),
40 }
41 );
42
43 return Modal.open({
44 title: '设置密码',
45 content: () =>
46 h(
47 Form,
48 { ref: pwdRef, model: pwdVal, rules: pwdRules, autoLabelWidth: true },
49 { default: () => [createPwdVNode('新密码', 'password'), createPwdVNode('确认密码', 'password_confirmation')] }
50 ),
51 closable: false,
52 maskClosable: false,
53 escToClose: false,
54 // eslint-disable-next-line no-shadow
55 onBeforeOk: (done: (closed: boolean) => void) => {
56 useAuthApi
57 .changePwd(pwdVal.value)
58 .then(() => {
59 Message.success('更新成功');
60 done(true);
61 })
62 .catch(() => {
63 done(false);
64 });
65 },
66 onClose: () => {
67 pwdVal.value = { password: '', password_confirmation: '' };
68 },
69 });
70 };
71
72 return {
73 logout,
74 resetPwd,
75 };
76 }
1 <script lang="tsx">
2 import { compile, computed, defineComponent, h, ref, watch } from 'vue';
3
4 import { RouteRecordRaw, useRoute, useRouter } from 'vue-router';
5 import { useAppStore } from '@/store';
6 import usePermission from '@/hooks/permission';
7 import { last, orderBy } from 'lodash';
8 import { storeToRefs } from 'pinia';
9
10 export default defineComponent({
11 emit: ['collapse'],
12 setup() {
13 const appStore = useAppStore();
14 const permission = usePermission();
15 const router = useRouter();
16 const route = useRoute();
17
18 const { appMenu } = storeToRefs(appStore);
19
20 const collapsed = ref(false);
21 const selectedKey = ref<string[]>([]);
22 const openKey = ref<string[]>([]);
23 const goto = (item: RouteRecordRaw) => router.push({ name: item.name });
24
25 const syncServicePermission = (item: RouteRecordRaw) => {
26 if (item.meta) {
27 const serverConfig = appMenu.value.find((menu) => item.name === menu.name);
28 item.meta.title = serverConfig?.label || item.meta?.title || '';
29 item.meta.order = serverConfig?.weight || item.meta?.order || 0;
30 item.meta.icon = serverConfig?.icon || item.meta?.icon || '';
31 }
32 item.children?.map((child) => syncServicePermission(child));
33 return item;
34 };
35
36 const appRoute = computed((): RouteRecordRaw[] => {
37 return router
38 .getRoutes()
39 .find((el) => el.name === 'root')
40 ?.children.filter((element) => element.meta?.hideInMenu !== true)
41 .map((item: RouteRecordRaw) => syncServicePermission(item)) as RouteRecordRaw[];
42 });
43
44 const menuTree = computed((): RouteRecordRaw[] => {
45 function travel(_routes: RouteRecordRaw[], layer: number) {
46 if (!_routes) return null;
47 const collector: any = orderBy(_routes, 'meta.order', 'desc').map((element) => {
48 // no access
49 if (!permission.accessRouter(element)) {
50 return null;
51 }
52
53 // leaf node
54 if (!element.children) {
55 return element;
56 }
57
58 // route filter hideInMenu true
59 element.children = element.children.filter((x) => x.meta?.hideInMenu !== true);
60
61 // Associated child node
62 const subItem = travel(element.children, layer);
63 if (subItem.length) {
64 element.children = subItem;
65 return element;
66 }
67 // the else logic
68 if (layer > 1) {
69 element.children = subItem;
70 return element;
71 }
72
73 if (element.meta?.hideInMenu === false) {
74 return element;
75 }
76
77 return null;
78 });
79 return collector.filter(Boolean);
80 }
81
82 return travel(appRoute.value, 0);
83 });
84
85 watch(
86 route,
87 (newVal) => {
88 if (newVal.meta.requiresAuth) {
89 const key = newVal.meta.hideInMenu ? last(newVal.matched)?.meta?.menuSelectKey : last(newVal.matched)?.name;
90 selectedKey.value = [key as string];
91 openKey.value = [...(newVal.meta.breadcrumb || [])];
92 }
93 },
94 { immediate: true }
95 );
96 watch(
97 () => appStore.menuCollapse,
98 (newVal) => {
99 collapsed.value = newVal;
100 },
101 { immediate: true }
102 );
103 const setCollapse = (val: boolean) => {
104 appStore.updateSettings({ menuCollapse: val });
105 };
106
107 const renderSubMenu = () => {
108 function travel(_route: RouteRecordRaw[], nodes = []) {
109 if (_route) {
110 _route.forEach((element) => {
111 // This is demo, modify nodes as needed
112 const icon = element?.meta?.icon ? `<${element?.meta?.icon}/>` : ``;
113 let r;
114
115 if (element && element.children && element.children.length !== 0) {
116 r = (
117 <a-sub-menu key={element?.name} title={element.meta?.title} v-slots={{ icon: () => h(compile(icon)) }}>
118 {element?.children?.map((elem) => {
119 return (
120 <a-menu-item key={elem.name} onClick={() => goto(elem)}>
121 {elem.meta?.title}
122 {travel(elem.children ?? [])}
123 </a-menu-item>
124 );
125 })}
126 </a-sub-menu>
127 );
128 } else {
129 r = (
130 <a-menu-item key={element.name} v-slots={{ icon: () => h(compile(icon)) }} onClick={() => goto(element)}>
131 {element.meta?.title}
132 </a-menu-item>
133 );
134 }
135 nodes.push(r as never);
136 });
137 }
138 return nodes;
139 }
140
141 return travel(menuTree.value);
142 };
143
144 return () => (
145 <a-menu
146 v-model:collapsed={collapsed.value}
147 show-collapse-button
148 auto-open={false}
149 v-model:selected-keys={selectedKey.value}
150 v-model:open-keys={openKey.value}
151 auto-open-selected={true}
152 auto-scroll-into-view={true}
153 level-indent={34}
154 style="height: 100%"
155 onCollapse={setCollapse}
156 >
157 {renderSubMenu()}
158 </a-menu>
159 );
160 },
161 });
162 </script>
163
164 <style lang="less" scoped>
165 :deep(.arco-menu-inner) {
166 .arco-menu-inline-header {
167 display: flex;
168 align-items: center;
169 }
170
171 .arco-icon {
172 &:not(.arco-icon-down) {
173 font-size: 18px;
174 }
175 }
176 }
177 </style>
1 <template>
2 <div class="navbar">
3 <div class="left-side">
4 <a-space>
5 <img alt="logo" src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/dfdba5317c0c20ce20e64fac803d52bc.svg~tplv-49unhts6dw-image.image" />
6 <a-typography-title :heading="5" :style="{ margin: 0, fontSize: '18px' }"> 海星试唱 </a-typography-title>
7 </a-space>
8 </div>
9 <ul class="right-side">
10 <!-- <li>-->
11 <!-- <toggle-theme-button />-->
12 <!-- </li>-->
13 <!-- <li>-->
14 <!-- <a-tooltip :content="$t('settings.navbar.alerts')">-->
15 <!-- <div class="message-box-trigger">-->
16 <!-- <a-badge :count="9" dot>-->
17 <!-- <a-button-->
18 <!-- :shape="'circle'"-->
19 <!-- class="nav-btn"-->
20 <!-- type="outline"-->
21 <!-- @click="setPopoverVisible"-->
22 <!-- >-->
23 <!-- <icon-notification />-->
24 <!-- </a-button>-->
25 <!-- </a-badge>-->
26 <!-- </div>-->
27 <!-- </a-tooltip>-->
28 <!-- <a-popover-->
29 <!-- :arrow-style="{ display: 'none' }"-->
30 <!-- :content-style="{ padding: 0, minWidth: '400px' }"-->
31 <!-- content-class="message-popover"-->
32 <!-- trigger="click"-->
33 <!-- >-->
34 <!-- <div ref="refBtn" class="ref-btn"></div>-->
35 <!-- <template #content>-->
36 <!-- <message-box />-->
37 <!-- </template>-->
38 <!-- </a-popover>-->
39 <!-- </li>-->
40 <li>
41 <a-dropdown trigger="hover">
42 <a-space align="center">
43 <a-avatar v-if="avatar" :size="32">
44 <img :src="avatar" alt="avatar" />
45 </a-avatar>
46
47 <span
48 :style="{
49 color: theme === 'dark' ? 'white' : 'black',
50 lineHeight: '31px',
51 }"
52 >
53 {{ name }}
54 <icon-down />
55 </span>
56 </a-space>
57 <template #content>
58 <!-- <a-doption>-->
59 <!-- <a-space @click="switchRoles">-->
60 <!-- <icon-tag />-->
61 <!-- <span>-->
62 <!-- {{ $t('messageBox.switchRoles') }}-->
63 <!-- </span>-->
64 <!-- </a-space>-->
65 <!-- </a-doption>-->
66 <a-doption>
67 <a-space @click="handleChangePwd">
68 <icon-lock />
69 <span>修改密码</span>
70 </a-space>
71 </a-doption>
72 <a-doption>
73 <a-space @click="handleLogout">
74 <icon-export />
75 <span> 退出登录 </span>
76 </a-space>
77 </a-doption>
78 </template>
79 </a-dropdown>
80 </li>
81 </ul>
82 </div>
83 </template>
84
85 <script lang="ts" setup>
86 import { computed } from 'vue';
87 import { useAppStore, useAuthorizedStore } from '@/store';
88 import useUser from '@/hooks/user';
89 // import ToggleThemeButton from '@/layout/components/toggle-theme-button.vue';
90 import { IconDown, IconExport, IconLock } from '@arco-design/web-vue/es/icon';
91
92 const appStore = useAppStore();
93 const authorized = useAuthorizedStore();
94 const { logout } = useUser();
95
96 const avatar = computed(() => {
97 return authorized.avatar;
98 });
99
100 const name = computed(() => {
101 return authorized.nick_name;
102 });
103
104 const theme = computed(() => {
105 return appStore.theme;
106 });
107
108 // const setVisible = () => {
109 // appStore.updateSettings({ globalSettings: true });
110 // };
111 // const refBtn = ref();
112 // const triggerBtn = ref();
113 // const setPopoverVisible = () => {
114 // const event = new MouseEvent('click', {
115 // view: window,
116 // bubbles: true,
117 // cancelable: true,
118 // });
119 // refBtn.value.dispatchEvent(event);
120 // };
121 const handleLogout = () => {
122 logout();
123 };
124
125 const handleChangePwd = () => {
126 useUser().resetPwd();
127 };
128 // const setDropDownVisible = () => {
129 // const event = new MouseEvent('click', {
130 // view: window,
131 // bubbles: true,
132 // cancelable: true,
133 // });
134 // triggerBtn.value.dispatchEvent(event);
135 // };
136 </script>
137
138 <style lang="less" scoped>
139 .navbar {
140 display: flex;
141 justify-content: space-between;
142 height: 100%;
143 background-color: var(--color-bg-2);
144 border-bottom: 1px solid var(--color-border);
145 }
146
147 .left-side {
148 display: flex;
149 align-items: center;
150 padding-left: 20px;
151 }
152
153 .right-side {
154 display: flex;
155 padding-right: 10px;
156 list-style: none;
157
158 :deep(.locale-select) {
159 border-radius: 20px;
160 }
161
162 li {
163 display: flex;
164 align-items: center;
165 padding: 0 10px;
166 }
167
168 a {
169 color: var(--color-text-1);
170 text-decoration: none;
171 }
172
173 .nav-btn {
174 border-color: rgb(var(--gray-2));
175 color: rgb(var(--gray-8));
176 font-size: 16px;
177 }
178
179 .trigger-btn,
180 .ref-btn {
181 position: absolute;
182 bottom: 14px;
183 }
184
185 .trigger-btn {
186 margin-left: 14px;
187 }
188 }
189 </style>
190
191 <style lang="less">
192 .message-popover {
193 .arco-popover-content {
194 margin-top: 0;
195 }
196 }
197
198 .arco-dropdown-open .arco-icon-down {
199 transform: rotate(180deg);
200 }
201 </style>
1 <template>
2 <a-tooltip :content="theme === 'light' ? '切换为暗黑模式' : '切换为亮色模式'">
3 <a-button
4 :shape="'circle'"
5 class="nav-btn"
6 type="outline"
7 @click="() => onChange()"
8 >
9 <template #icon>
10 <icon-moon-fill v-if="theme === 'dark'" />
11 <icon-sun-fill v-else />
12 </template>
13 </a-button>
14 </a-tooltip>
15 </template>
16
17 <script lang="ts">
18 import { computed, defineComponent } from 'vue';
19 import { useAppStore } from '@/store';
20 import { useDark, useToggle } from '@vueuse/core';
21
22 import { IconMoonFill, IconSunFill } from '@arco-design/web-vue/es/icon';
23
24 export default defineComponent({
25 name: 'ToggleThemeButton',
26 components: {
27 IconSunFill,
28 IconMoonFill,
29 },
30 setup() {
31 const appStore = useAppStore();
32
33 const theme = computed(() => {
34 return appStore.theme;
35 });
36
37 const isDark = useDark({
38 selector: 'body',
39 attribute: 'arco-theme',
40 valueDark: 'dark',
41 valueLight: 'light',
42 storageKey: 'arco-theme',
43 onChanged(dark: boolean) {
44 appStore.toggleTheme(dark);
45 },
46 });
47 const onChange = useToggle(isDark);
48
49 return {
50 theme,
51 onChange,
52 };
53 },
54 });
55 </script>
56
57 <style lang="less" scoped>
58 .nav-btn {
59 border-color: rgb(var(--gray-2));
60 color: rgb(var(--gray-8));
61 font-size: 16px;
62 }
63
64 .trigger-btn,
65 .ref-btn {
66 position: absolute;
67 bottom: 14px;
68 }
69
70 .trigger-btn {
71 margin-left: 14px;
72 }
73 </style>
1 <template>
2 <a-layout class="layout">
3 <div class="layout-navbar">
4 <Navbar />
5 </div>
6 <a-layout>
7 <a-layout>
8 <a-layout-sider
9 v-if="menu"
10 :breakpoint="'xl'"
11 :collapsed="collapse"
12 :collapsible="true"
13 :hide-trigger="true"
14 :style="{ paddingTop: '60px' }"
15 :width="menuWidth"
16 class="layout-sider"
17 @collapse="setCollapsed"
18 >
19 <div class="menu-wrapper">
20 <Menu />
21 </div>
22 </a-layout-sider>
23 <a-layout :style="paddingStyle" class="layout-content">
24 <a-layout-content>
25 <router-view />
26 </a-layout-content>
27 </a-layout>
28 </a-layout>
29 </a-layout>
30 </a-layout>
31 </template>
32
33 <script lang="ts" name="layout">
34 import { computed, defineComponent, watch } from 'vue';
35 import { useRoute, useRouter } from 'vue-router';
36 import { useAppStore, useAuthorizedStore } from '@/store';
37 import Menu from '@/layout/components/menu.vue';
38 import usePermission from '@/hooks/permission';
39 import Navbar from '@/layout/components/navbar.vue';
40
41 export default defineComponent({
42 components: {
43 Navbar,
44 Menu,
45 },
46 setup() {
47 const appStore = useAppStore();
48 const authorizedStore = useAuthorizedStore();
49 const router = useRouter();
50 const route = useRoute();
51 const permission = usePermission();
52 const navbarHeight = `60px`;
53 const menu = computed(() => appStore.menu);
54 const menuWidth = computed(() => {
55 return appStore.menuCollapse ? 48 : appStore.menuWidth;
56 });
57 const collapse = computed(() => {
58 return appStore.menuCollapse;
59 });
60 const paddingStyle = computed(() => {
61 const paddingLeft = menu.value ? { paddingLeft: `${menuWidth.value}px` } : {};
62 const paddingTop = { paddingTop: navbarHeight };
63 return { ...paddingLeft, ...paddingTop };
64 });
65 const setCollapsed = (val: boolean) => {
66 appStore.updateSettings({ menuCollapse: val });
67 };
68 watch(
69 () => authorizedStore.permissions,
70 (roleValue) => {
71 if (roleValue && !permission.accessRouter(route)) router.push({ name: 'exception-403' });
72 }
73 );
74 return {
75 menu,
76 menuWidth,
77 paddingStyle,
78 collapse,
79 setCollapsed,
80 };
81 },
82 });
83 </script>
84
85 <style lang="less" scoped>
86 @nav-size-height: 60px;
87 @layout-max-width: 1100px;
88
89 .layout {
90 width: 100%;
91 height: 100%;
92 }
93
94 .layout-navbar {
95 position: fixed;
96 top: 0;
97 left: 0;
98 z-index: 100;
99 width: 100%;
100 min-width: @layout-max-width;
101 height: @nav-size-height;
102 }
103
104 .layout-sider {
105 position: fixed;
106 top: 0;
107 left: 0;
108 z-index: 99;
109 height: 100%;
110
111 &::after {
112 position: absolute;
113 top: 0;
114 right: -1px;
115 display: block;
116 width: 1px;
117 height: 100%;
118 background-color: var(--color-border);
119 content: '';
120 }
121
122 > :deep(.arco-layout-sider-children) {
123 overflow-y: hidden;
124 }
125 }
126
127 .menu-wrapper {
128 height: 100%;
129 overflow: auto;
130 overflow-x: hidden;
131
132 :deep(.arco-menu) {
133 ::-webkit-scrollbar {
134 width: 12px;
135 height: 4px;
136 }
137
138 ::-webkit-scrollbar-thumb {
139 border: 4px solid transparent;
140 background-clip: padding-box;
141 border-radius: 7px;
142 background-color: var(--color-text-4);
143 }
144
145 ::-webkit-scrollbar-thumb:hover {
146 background-color: var(--color-text-3);
147 }
148 }
149 }
150
151 .layout-content {
152 min-width: @layout-max-width;
153 min-height: 100vh;
154 overflow-y: hidden;
155 background-color: var(--color-fill-2);
156 transition: padding-left 0.2s;
157 }
158 </style>
1 import { createApp } from 'vue';
2 import ArcoVue from '@arco-design/web-vue';
3 import ArcoVueIcon from '@arco-design/web-vue/es/icon';
4 import globalComponents from '@/components';
5 import router from './router';
6 import store from './store';
7 import directive from './directive';
8 import App from './App.vue';
9 import '@arco-design/web-vue/dist/arco.css';
10 import '@/assets/style/global.less';
11 import '@/api/interceptor';
12
13 const app = createApp(App);
14
15 app.use(ArcoVue, {});
16 app.use(ArcoVueIcon);
17
18 app.use(store);
19 app.use(router);
20 app.use(globalComponents);
21 app.use(directive);
22
23 app.mount('#app');
1 import { createRouter, createWebHistory, LocationQueryRaw } from 'vue-router';
2 import NProgress from 'nprogress'; // progress bar
3 import 'nprogress/nprogress.css';
4 import usePermission from '@/hooks/permission';
5 import { clearToken, isLogin } from '@/utils/auth';
6 import PageLayout from '@/layout/index.vue';
7 import { useAuthorizedStore } from '@/store';
8 import appRoutes from './modules';
9
10 NProgress.configure({ showSpinner: false }); // NProgress Configuration
11
12 const router = createRouter({
13 history: createWebHistory(),
14 routes: [
15 {
16 path: '/login',
17 name: 'login',
18 component: () => import('@/views/login/index.vue'),
19 meta: {
20 title: '登陆',
21 requiresAuth: false,
22 },
23 },
24 {
25 name: 'root',
26 path: '/',
27 component: PageLayout,
28 redirect: 'dashboard',
29 children: appRoutes,
30 },
31 {
32 path: '/:pathMatch(.*)*',
33 name: 'notFound',
34 redirect: '/exception/404',
35 meta: {
36 requiresAuth: true,
37 },
38 },
39 ],
40 scrollBehavior() {
41 return { top: 0 };
42 },
43 });
44
45 router.beforeEach(async (to, from, next) => {
46 NProgress.start();
47 const authorizedStore = useAuthorizedStore();
48
49 if (from.name !== undefined) {
50 to.meta.from = from.name;
51 }
52
53 async function crossroads() {
54 const Permission = usePermission();
55
56 if (Permission.accessRouter(to)) {
57 next();
58 } else {
59 next({ name: 'exception-403' });
60 }
61 NProgress.done();
62 }
63
64 if (isLogin()) {
65 if (authorizedStore.permissions.length) {
66 await crossroads();
67 } else {
68 try {
69 await authorizedStore.syncInfo();
70 await crossroads();
71 } catch (error) {
72 clearToken();
73 next({ name: 'login', query: { path: to.path } as LocationQueryRaw });
74 NProgress.done();
75 }
76 }
77 } else {
78 if (to.name === 'login') {
79 next();
80 NProgress.done();
81 return;
82 }
83
84 if (to.name === 'root') {
85 next({ name: 'login' });
86 } else {
87 next({ name: 'login', query: { path: to.path } as LocationQueryRaw });
88 }
89 NProgress.done();
90 }
91 });
92
93 router.afterEach(async (to) => {
94 sessionStorage.setItem(
95 'route',
96 to.meta?.breadcrumb?.toString() ||
97 to.matched
98 ?.slice(1)
99 .map((item) => item.name)
100 .toString() ||
101 'dashboard'
102 );
103 });
104
105 export default router;
1 export default {
2 path: 'audition',
3 name: 'audition',
4 component: () => import('@/views/audition/index.vue'),
5 meta: {
6 requiresAuth: true,
7 roles: ['*'],
8 },
9 children: [
10 {
11 path: 'applies',
12 name: 'audition-apply',
13 component: () => import('@/views/audition/activity-apply/index.vue'),
14 meta: {
15 requiresAuth: true,
16 hideInMenu: false,
17 isRedirect: true,
18 reload: true,
19 menuSelectKey: 'audition',
20 roles: ['audition-apply'],
21 breadcrumb: ['audition', 'audition-apply'],
22 },
23 },
24 {
25 path: 'activities',
26 name: 'audition-activity',
27 component: () => import('@/views/audition/activity/index.vue'),
28 meta: {
29 icon: 'icon-apps',
30 requiresAuth: true,
31 hideInMenu: false,
32 isRedirect: true,
33 reload: true,
34 roles: ['audition-activity'],
35 menuSelectKey: 'audition',
36 breadcrumb: ['audition', 'audition-activity'],
37 },
38 },
39 {
40 path: 'activities/:id(\\d+)',
41 name: 'audition-activity-show',
42 component: () => import('@/views/audition/activity-show/index.vue'),
43 meta: {
44 requiresAuth: true,
45 hideInMenu: true,
46 reload: true,
47 menuSelectKey: 'audition-activity',
48 roles: ['audition-activity-show'],
49 breadcrumb: ['audition', 'audition-activity', 'audition-activity-show'],
50 },
51 },
52 // {
53 // path: 'demo-applies',
54 // name: 'audition-demo-apply',
55 // component: () => import('@/views/audition/demo-apply/index.vue'),
56 // meta: {
57 // requiresAuth: true,
58 // hideInMenu: false,
59 // isRedirect: true,
60 // reload: true,
61 // menuSelectKey: 'audition',
62 // roles: ['audition-demo-apply'],
63 // breadcrumb: ['audition', 'audition-demo-apply'],
64 // },
65 // },
66 {
67 path: 'demos',
68 name: 'audition-demo',
69 component: () => import('@/views/audition/demo/index.vue'),
70 meta: {
71 icon: 'icon-apps',
72 requiresAuth: true,
73 hideInMenu: false,
74 isRedirect: true,
75 reload: true,
76 roles: ['audition-demo'],
77 menuSelectKey: 'audition',
78 breadcrumb: ['audition', 'audition-demo'],
79 },
80 },
81 {
82 path: 'demos/:id(\\d+)',
83 name: 'audition-demo-show',
84 component: () => import('@/views/audition/demo-show/index.vue'),
85 meta: {
86 requiresAuth: true,
87 hideInMenu: true,
88 reload: true,
89 menuSelectKey: 'audition-demo',
90 roles: ['audition-demo-show'],
91 breadcrumb: ['audition', 'audition-demo', 'audition-demo-show'],
92 },
93 },
94 ],
95 };
1 export default {
2 path: 'dashboard',
3 name: 'dashboard',
4 component: () => import('@/views/dashboard/index.vue'),
5 meta: {
6 title: '信息概览',
7 requiresAuth: true,
8 icon: 'icon-dashboard',
9 roles: ['*'],
10 order: 99999999,
11 breadcrumb: ['dashboard'],
12 },
13 };
1 export default {
2 path: 'exception',
3 name: 'exception',
4 component: () => import('@/views/exception/index.vue'),
5 meta: {
6 title: '异常页',
7 requiresAuth: true,
8 icon: 'icon-exclamation-circle',
9 hideInMenu: true,
10 },
11 children: [
12 {
13 path: '403',
14 name: 'exception-403',
15 component: () => import('@/views/exception/403/index.vue'),
16 meta: {
17 title: '403',
18 requiresAuth: true,
19 roles: ['*'],
20 hideInMenu: true,
21 },
22 },
23 {
24 path: '404',
25 name: 'exception-404',
26 component: () => import('@/views/exception/404/index.vue'),
27 meta: {
28 title: '404',
29 requiresAuth: true,
30 roles: ['*'],
31 hideInMenu: true,
32 },
33 },
34 {
35 path: '500',
36 name: 'exception-500',
37 component: () => import('@/views/exception/500/index.vue'),
38 meta: {
39 title: '500',
40 requiresAuth: true,
41 roles: ['*'],
42 },
43 },
44 ],
45 };
1 import Project from '@/router/modules/project';
2 import Dashboard from '@/router/modules/dashboard';
3 import Exception from '@/router/modules/exception';
4 import Audition from '@/router/modules/audition';
5
6 export default [Dashboard, Exception, Project, Audition];
1 export default {
2 path: 'projects',
3 name: 'project',
4 component: () => import('@/views/project/index.vue'),
5 meta: {
6 requiresAuth: true,
7 hideInMenu: false,
8 isRedirect: true,
9 roles: ['project'],
10 breadcrumb: ['project'],
11 },
12 children: [
13 {
14 path: ':id(\\d+)',
15 name: 'project-show',
16 component: () => import('@/views/project-show/index.vue'),
17 meta: {
18 requiresAuth: true,
19 hideInMenu: true,
20 roles: ['project-show'],
21 menuSelectKey: 'project',
22 breadcrumb: ['project', 'project-show'],
23 },
24 },
25 ],
26 };
1 import 'vue-router';
2
3 declare module 'vue-router' {
4 interface RouteMeta {
5 // options
6 roles?: string[];
7 // every route must declare
8 requiresAuth: boolean; // need login
9 icon?: string;
10 // menu select key
11 menuSelectKey?: string;
12 breadcrumb?: string[];
13 hideInMenu?: boolean;
14 isRedirect?: boolean;
15 title?: string;
16 order?: number;
17 }
18 }
1 import { createPinia } from 'pinia';
2
3 import useAppStore from '@/store/modules/app';
4 import useAuthorizedStore from '@/store/modules/authorized';
5
6 import useSelectionStore from '@/store/modules/selection';
7
8 const pinia = createPinia();
9
10 export { useAppStore, useSelectionStore, useAuthorizedStore };
11
12 export default pinia;
1 import { defineStore } from 'pinia';
2 import { SystemPermission } from '@/types/system-permission';
3 import { AppState } from './types';
4
5 const useAppStore = defineStore('app', {
6 state: (): AppState => ({
7 theme: 'light',
8 colorWeek: false,
9 navbar: true,
10 menu: true,
11 menuCollapse: false,
12 footer: false,
13 themeColor: '#165DFF',
14 menuWidth: 220,
15 globalSettings: false,
16 permissions: [],
17 }),
18
19 getters: {
20 appCurrentSetting(state: AppState): AppState {
21 return { ...state };
22 },
23 appMenu(state: AppState): SystemPermission[] {
24 return state.permissions?.filter((item) => item.guard === 'Manage' && item.type === 'Menu') || [];
25 },
26 },
27
28 actions: {
29 // Update app settings
30 updateSettings(partial: Partial<AppState>) {
31 // @ts-ignore-next-line
32 this.$patch(partial);
33 },
34
35 // Change theme color
36 toggleTheme(dark: boolean) {
37 if (dark) {
38 this.theme = 'dark';
39 document.body.setAttribute('arco-theme', 'dark');
40 } else {
41 this.theme = 'light';
42 document.body.removeAttribute('arco-theme');
43 }
44 },
45
46 setPermissions(permissions: SystemPermission[]) {
47 this.$patch({ permissions });
48 },
49 },
50 });
51
52 export default useAppStore;
1 import { SystemPermission } from '@/types/system-permission';
2
3 export interface AppState {
4 theme: string;
5 navbar: boolean;
6 menu: boolean;
7 menuCollapse: boolean;
8 themeColor: string;
9 menuWidth: number;
10 globalSettings: boolean;
11 permissions?: SystemPermission[];
12 [key: string]: unknown;
13 }
1 import { defineStore } from 'pinia';
2 import { clearToken } from '@/utils/auth';
3 import useAuthApi from '@/api/auth';
4 // eslint-disable-next-line import/no-cycle
5 import { useAppStore } from '@/store';
6 import { AuthorizedState } from './type';
7
8 const useAuthorizedStore = defineStore('authorized', {
9 state: (): AuthorizedState => ({
10 id: undefined,
11 nick_name: undefined,
12 real_name: undefined,
13 avatar: undefined,
14 email: undefined,
15 phone: undefined,
16 permissions: [],
17 }),
18 getters: {
19 authorizedInfo(state: AuthorizedState): AuthorizedState {
20 return { ...state };
21 },
22 },
23 actions: {
24 setInfo(partial: Partial<AuthorizedState>) {
25 this.$patch(partial);
26 },
27 async syncInfo() {
28 const { user, permissions, menus } = await useAuthApi.info();
29 this.setInfo({ ...user, permissions });
30 useAppStore().setPermissions(menus);
31 },
32 async logout() {
33 this.$reset();
34 clearToken();
35 },
36 async syncToken() {
37 // const { data } = await refreshToken();
38 // setToken(data.access_token);
39 },
40 },
41 });
42
43 export default useAuthorizedStore;
1 export interface AuthorizedState {
2 id?: number;
3 nick_name?: string;
4 real_name?: string;
5 avatar?: string;
6 email?: string;
7 phone?: string;
8 permissions: string[];
9 }
1 import { defineStore } from 'pinia';
2 import { Selection } from '@/store/modules/selection/type';
3 import { AnyObject } from '@/types/global';
4 import { SystemConfig } from '@/types/system-config';
5 import axios from 'axios';
6 import { Tag } from '@/types/tag';
7
8 const useSelectionStore = defineStore('selection', {
9 state: (): Selection => ({
10 user: [],
11 project: [],
12 tag: [],
13 config: [],
14 }),
15 getters: {
16 getUserOptions(state) {
17 return state.user;
18 },
19 getTagOptions(state) {
20 return state.tag;
21 },
22 projectOptions(state) {
23 return state.project;
24 },
25 lyricTool(state): string {
26 return state.config.find((item) => item.identifier === 'activity_lyric_tool')?.content || '';
27 },
28 activityLang(state): SystemConfig | undefined {
29 return state.config.find((item) => item.identifier === 'activity_lang');
30 },
31 activityLangOptions(state): SystemConfig[] {
32 return state.config.filter((item) => item.parent_id === this.activityLang?.id);
33 },
34 activitySpeed(state): SystemConfig | undefined {
35 return state.config.find((item) => item.identifier === 'activity_speed');
36 },
37 activitySpeedOptions(state): SystemConfig[] {
38 return state.config.filter((item) => item.parent_id === this.activitySpeed?.id);
39 },
40 activitySex(state): SystemConfig | undefined {
41 return state.config.find((item) => item.identifier === 'activity_sex');
42 },
43 activitySexOptions(state): SystemConfig[] {
44 return state.config.filter((item) => item.parent_id === this.activitySex?.id);
45 },
46 activityTagOptions(state): Pick<Tag, 'id' | 'name' | 'type'>[] {
47 return state.tag.filter((item) => item.type === 1);
48 },
49 activityAudioAccept(state): string {
50 return state.config.find((item) => item.identifier === 'activity_audio_accept')?.content || 'audio/*';
51 },
52 activityTrackAccept(state): string {
53 return state.config.find((item) => item.identifier === 'activity_track_accept')?.content || '*';
54 },
55 appleDemoCover(state): string {
56 return state.config.find((item) => item.identifier === 'activity_demo_cover')?.content || '';
57 },
58 },
59 actions: {
60 queryUser(params?: AnyObject) {
61 axios.get('selection/user', { params }).then(({ data }) => {
62 this.user = data;
63 });
64 },
65 queryProject(params?: AnyObject) {
66 axios.get('selection/project', { params }).then(({ data }) => {
67 this.project = data;
68 });
69 },
70 queryTag() {
71 axios.get('selection/tag').then(({ data }) => {
72 this.tag = data;
73 });
74 },
75 queryConfig() {
76 axios.get('selection/config').then(({ data }) => {
77 this.config = data;
78 });
79 },
80
81 queryAll() {
82 this.queryConfig();
83 this.queryUser();
84 this.queryProject();
85 this.queryTag();
86 },
87 },
88 });
89
90 export default useSelectionStore;
1 import { User } from '@/types/user';
2 import { Project } from '@/types/project';
3 import { Tag } from '@/types/tag';
4 import { SystemConfig } from '@/types/system-config';
5
6 export interface Selection {
7 user: User[];
8 project: Project[];
9 tag: Tag[];
10 config: SystemConfig[];
11 }
1 import { Project } from '@/types/project';
2 import { Tag } from '@/types/tag';
3 import { User } from '@/types/user';
4
5 export interface ActivityExpand {
6 tag_ids: number[];
7 lyricist: { ids: number[]; supplement: string[] };
8 composer: { ids: number[]; supplement: string[] };
9 arranger: { ids: number[]; supplement: string[] };
10 guide_source: { name: string; url: string; size: number };
11 karaoke_source: { name: string; url: string; size: number };
12 track_source: { name: string; url: string; size: number };
13 }
14
15 export interface ActivityApplyRecord {
16 id: number;
17 created_at: string;
18 audit_msg: string;
19 }
20
21 export interface ActivityApply {
22 id: number;
23 cover: string;
24 song_name: string;
25 sub_title?: string;
26 lang: string[];
27 speed: string;
28 lyric: string;
29 clip_lyric: string;
30 sex: string;
31 project_id: number;
32 estimate_release_at: string;
33 audit_status: number;
34 expand: ActivityExpand;
35 project?: Project;
36 tags?: Tag[];
37 user?: User;
38 apply_records?: ActivityApplyRecord[];
39 }
40
41 export type ActivityApplyFormStep1 = Pick<ActivityApply, 'cover' | 'song_name' | 'sub_title' | 'lang' | 'speed'> &
42 Pick<ActivityExpand, 'tag_ids'>;
43
44 export type ActivityApplyFormStep2 = Pick<ActivityApply, 'project_id' | 'sex' | 'estimate_release_at'> &
45 Pick<ActivityExpand, 'lyricist' | 'composer' | 'arranger'>;
46
47 export type ActivityApplyFormStep3 = Pick<ActivityApply, 'song_name' | 'lyric' | 'clip_lyric'> &
48 Pick<ActivityExpand, 'guide_source' | 'karaoke_source' | 'track_source'>;
49
50 export type ActivityApplyForm = ActivityApplyFormStep1 & ActivityApplyFormStep2 & ActivityApplyFormStep3;
1 import { Activity } from '@/types/activity';
2 import { Singer } from '@/types/singer';
3 import { Business } from '@/types/business';
4
5 export interface ActivityWork {
6 id: number;
7 demo_url: string;
8 version: number;
9 type: '' | 'Submit' | 'Save';
10 status: 0 | 1 | 2;
11 mode: 0 | 1;
12 activity_id: number;
13 user_id: number;
14 open_id: string;
15 user?: Singer;
16 activity?: Activity;
17 activity_name: string;
18 activity_status: number;
19 business?: Business;
20 submit_at: string;
21 created_at: string;
22 business_id: 0;
23 business_nick_name?: string;
24 business_real_name?: string;
25 business_role?: string;
26 children: [];
27
28 user_email: string;
29 user_nick_name: string;
30 user_real_name: string;
31 user_company: string;
32 user_province: string;
33 user_city: string;
34 user_rate: string;
35 user_role: string;
36
37 share_id?: number;
38 share_nick_name?: string;
39 share_real_name?: string;
40 share_role?: string;
41 }
1 import { Project } from '@/types/project';
2 import { Tag } from '@/types/tag';
3 import { FileStatus } from '@arco-design/web-vue/es/upload/interfaces';
4 import { User } from '@/types/user';
5 import { ActivityExpand } from '@/types/activity-apply';
6
7 export type ActivityMaterialType = 'GuideDinging' | 'Accompany';
8 export type ActivityMaterialTone = -3 | -2 | -1 | 0 | 1 | 2 | 3;
9
10 export interface ActivityMaterialOption {
11 type: ActivityMaterialType;
12 tone: ActivityMaterialTone;
13 label: string;
14 }
15
16 export interface ActivityMaterial {
17 id: string;
18 type: ActivityMaterialType;
19 url: string;
20 name: string;
21 tone: ActivityMaterialTone;
22 file?: File;
23 percent?: number;
24 status?: FileStatus;
25 response?: string;
26 created_at?: string;
27 }
28
29 export interface ActivitySendLink {
30 type: string;
31 url: string;
32 }
33
34 export interface ActivityRecommendIntro {
35 id: number;
36 content: string;
37 }
38
39 export interface ActivityApplyRecord {
40 id: number;
41 audit_user_id: number;
42 audit_msg: string;
43 created_at?: string;
44 updated_at?: string;
45 }
46
47 export interface Activity {
48 id: number;
49 song_name: string;
50 song_type?: number;
51 sub_title: string;
52 cover: string;
53 user_id: number;
54 project_id: number;
55 guide: string;
56 karaoke: string;
57 guide_source?: string;
58 karaoke_source?: string;
59 lyric: string;
60 clip_lyric: string;
61 weight: number;
62 audit_status: number;
63 status: number;
64 match_at: string;
65 created_at: string;
66 updated_at: string;
67 submit_works_count?: number;
68 views_count?: number;
69 collections_count?: number;
70 is_official?: number;
71 send_url?: ActivitySendLink[];
72 user?: Pick<User, 'id' | 'nick_name' | 'real_name' | 'role'>;
73 project?: Project;
74 tags?: Tag[];
75 materials?: ActivityMaterial[];
76 recommend_intros?: ActivityRecommendIntro[];
77 apply_records?: ActivityApplyRecord[];
78 estimate_release_at: string;
79 expand?: ActivityExpand;
80 links?: User[];
81 }
82
83 export type ActivityViewUser = Pick<User, 'id' | 'avatar' | 'nick_name' | 'real_name' | 'sex' | 'role'> & {
84 listen_count: number;
85 collection_count: number;
86 submit_work: number;
87 last_listen_at: number;
88 };
1 // eslint-disable-next-line import/no-cycle
2 import { User } from '@/types/user';
3
4 export interface Admin extends User {
5 creator?: User;
6 role?: 'ProjectUser' | 'SystemUser' | 'Admin';
7 company: string;
8 activities_count?: number;
9 singers_count?: number;
10 submit_activities_count?: number;
11 accept_activities_count?: number;
12 checked_activities_count?: number;
13 }
1 export interface AppVersion {
2 id: number;
3 os: string;
4 app_ver: string;
5 app_no: number;
6 url: string;
7 remark?: string;
8 is_force: 1 | 0;
9 }
1 import { User } from '@/types/user';
2
3 export interface Business extends User {
4 role?: 'Business';
5 company?: string;
6 audit_status?: number;
7 singers_count?: number;
8 nick_name: string;
9 real_name: string;
10 email: string;
11 phone: string;
12 submit_activities_count?: number;
13 checked_activities_count?: number;
14 }
1 import { CallbackDataParams } from 'echarts/types/dist/shared';
2
3 export interface ToolTipFormatterParams extends CallbackDataParams {
4 axisDim: string;
5 axisIndex: number;
6 axisType: string;
7 axisId: string;
8 axisValue: string;
9 axisValueLabel: string;
10 }
1 export interface AnyObject {
2 [key: string]: unknown;
3 }
4
5 export interface Options {
6 value: unknown;
7 label: string;
8 }
9
10 export interface MoreRowValue<T = object> {
11 type: string;
12 row: T;
13 index?: number;
14 }
15
16 export interface QueryForParams {
17 [key: string]: unknown;
18
19 sortBy?: string;
20 sortType?: 'desc' | 'asc' | '';
21 }
22
23 export interface QueryForPaginationParams extends QueryForParams {
24 page?: number;
25 pageSize?: number;
26 }
27
28 export type sizeType = 'mini' | 'small' | 'medium' | 'large';
29
30 export interface AttributeData {
31 [key: string]: unknown;
32 }
33
34 export interface Pagination {
35 current: number;
36 pageSize: number;
37 total: number;
38 showTotal?: boolean;
39 showPageSize?: boolean;
40 pageSizeOptions?: number[];
41 }
42
43 export type SortType = 'descend' | 'ascend' | '' | string;
44
45 export interface Sort {
46 column: string;
47 type: SortType | string;
48 }
49
50 export interface GeneralChart {
51 xAxis: string[];
52 data: Array<{ name: string; value: number[] }>;
53 }
54
55 export interface HttpResponse<T = unknown> {
56 status: 'success' | 'fail' | 'error';
57 msg: string;
58 code: number;
59 data: T;
60 }
61
62 export interface ServiceResponse<T = unknown> extends HttpResponse<T> {
63 meta: { current: number; limit: number; total: number };
64 }
1 // eslint-disable-next-line import/no-cycle
2 import { Admin } from '@/types/admin';
3 import { User } from '@/types/user';
4
5 export interface Project {
6 id: number;
7 name: string;
8 user?: Admin;
9 master?: User;
10 status: number;
11 created_at?: string;
12 updated_at?: string;
13 up_count?: number;
14 down_count?: number;
15 finish_count?: number;
16 is_promote?: number;
17 is_official?: number;
18
19 is_can_apply?: number;
20 is_can_manage?: number;
21 is_can_demo_apply?: number;
22
23 head_cover: string;
24 cover: string;
25 intro: string;
26 managers_count?: number;
27 activities_count?: number;
28 send_count?: number;
29 managers?: Admin[];
30 }
1 import { AttributeData } from '@/types/global';
2 import { Business } from '@/types/business';
3 import { User } from '@/types/user';
4
5
6 export interface Singer extends User {
7 avatar: string;
8 remarks: string;
9 province: string;
10 city: string;
11 company: string;
12 rate: string;
13 is_subscribe?: 1 | 0;
14 role?: 'Singer';
15 audit_status?: number;
16 submit_activities_count?: number;
17 accept_activities_count?: number;
18 }
19
20 export interface UpdateAttribute extends AttributeData {
21 nick_name: string;
22 real_name: string;
23 phone: string;
24 business_id: number;
25 }
1 export interface SystemConfig {
2 id: number;
3 parent_id: number;
4 name: string;
5 content: string;
6 remark: string;
7 identifier: string;
8 children?: SystemConfig[];
9 }
1 export interface SystemPermission {
2 id: number;
3 name: string;
4 guard: 'Admin' | 'Manage';
5 type: 'Menu' | 'Button';
6 label: string;
7 icon: string;
8 parent_id: number;
9 weight: number;
10 parent?: SystemPermission;
11 children?: SystemPermission[];
12 created_at: string;
13 updated_at: string;
14 }
1 // eslint-disable-next-line import/no-cycle
2 import { Admin } from '@/types/admin';
3
4 export interface Tag {
5 id: number;
6 name: string;
7 type: number;
8 created_at: string;
9 updated_at: string;
10 user?: Admin;
11 }
1 // eslint-disable-next-line import/no-cycle
2 import { Project } from '@/types/project';
3 // eslint-disable-next-line import/no-cycle
4 import { Tag } from '@/types/tag';
5 // eslint-disable-next-line import/no-cycle
6 import { Business } from '@/types/business';
7
8 export interface User {
9 id: number;
10 avatar?: string;
11 nick_name: string;
12 real_name: string;
13 phone: string;
14 email: string;
15 business_id: 0;
16 business?: Business;
17 province?: string;
18 city?: string;
19 role?: 'Singer' | 'Business' | 'ProjectUser' | 'SystemUser' | 'Admin';
20 remarks?: string;
21 like_activities_count?: number;
22 sound?: string;
23 status?: number;
24 last_login?: string;
25 created_at?: string;
26 updated_at?: string;
27 projects?: Project[];
28 tags?: Tag[];
29 styles?: Tag[];
30 voices?: Tag[];
31 skills?: Tag[];
32 identity: number;
33
34 [key: string]: unknown;
35 }
1 import RabbitLyrics from 'rabbit-lyrics';
2 import parseLyrics from 'rabbit-lyrics/src/parseLyrics';
3
4 // @ts-ignore
5 export default class AudioSyncLyric extends RabbitLyrics {
6 public startTime = 0;
7
8 public setStartTime(time: number) {
9 this.startTime = time || 0;
10 this.render();
11 this.mediaElement.addEventListener('timeupdate', this.synchronize);
12 }
13
14 private render(): void {
15 // Add class names
16 this.lyricsElement.classList.add('rabbit-lyrics');
17 this.lyricsElement.classList.add(`rabbit-lyrics--${this.viewMode}`);
18 this.lyricsElement.classList.add(`rabbit-lyrics--${this.alignment}`);
19 this.lyricsElement.textContent = null;
20
21 // Render lyrics lines
22 this.lyricsLines = parseLyrics(this.lyrics).map((line) => {
23 const lineElement = document.createElement('div');
24 lineElement.className = 'rabbit-lyrics__line';
25 lineElement.addEventListener('click', () => {
26 this.mediaElement.currentTime = line.startsAt - this.startTime;
27 this.synchronize();
28 });
29 const lineContent = line.content.map((inline) => {
30 const inlineElement = document.createElement('span');
31 inlineElement.className = 'rabbit-lyrics__inline';
32 inlineElement.textContent = inline.content;
33 lineElement.append(inlineElement);
34 return { ...inline, element: inlineElement };
35 });
36 this.lyricsElement.append(lineElement);
37 return { ...line, content: lineContent, element: lineElement };
38 });
39 this.synchronize();
40 }
41
42 private synchronize = () => {
43 const time = this.startTime + this.mediaElement.currentTime;
44 let changed = false; // If here are active lines changed
45 const activeLines = this.lyricsLines.filter((line) => {
46 if (time >= line.startsAt && time < line.endsAt) {
47 // If line should be active
48 if (!line.element.classList.contains('rabbit-lyrics__line--active')) {
49 // If it hasn't been activated
50 changed = true;
51 line.element.classList.add('rabbit-lyrics__line--active');
52 }
53 line.content.forEach((inline) => {
54 if (time >= inline.startsAt) {
55 inline.element.classList.add('rabbit-lyrics__inline--active');
56 } else {
57 inline.element.classList.remove('rabbit-lyrics__inline--active');
58 }
59 });
60 return true;
61 }
62 // If line should be inactive
63 if (line.element.classList.contains('rabbit-lyrics__line--active')) {
64 // If it hasn't been deactivated
65 changed = true;
66 line.element.classList.remove('rabbit-lyrics__line--active');
67 line.content.forEach((inline) => {
68 inline.element.classList.remove('rabbit-lyrics__inline--active');
69 });
70 }
71 return false;
72 });
73
74 if (changed && activeLines.length > 0) {
75 // Calculate scroll top. Vertically align active lines in middle
76 const activeLinesOffsetTop =
77 (activeLines[0].element.offsetTop +
78 activeLines[activeLines.length - 1].element.offsetTop +
79 activeLines[activeLines.length - 1].element.offsetHeight) /
80 2;
81 this.lyricsElement.scrollTop = activeLinesOffsetTop - this.lyricsElement.clientHeight / 2;
82 }
83 };
84 }
1 const isLogin = () => {
2 return !!localStorage.getItem('access_token');
3 };
4
5 const getToken = () => {
6 return localStorage.getItem('access_token') || localStorage.getItem('refresh_token');
7 };
8
9 const setToken = (access_token: string, refresh_token: string) => {
10 localStorage.setItem('access_token', access_token);
11 localStorage.setItem('refresh_token', refresh_token);
12 };
13
14 const getRefreshToken = () => {
15 return localStorage.getItem('refresh_token');
16 };
17
18 const clearToken = () => {
19 localStorage.removeItem('access_token');
20 localStorage.removeItem('refresh_token');
21 };
22
23 export { isLogin, getToken, setToken, getRefreshToken, clearToken };
1 import { AnyObject } from '@/types/global';
2 import { createVNode, Ref, VNode } from 'vue';
3 import { CheckboxGroup, Form, FormItem, Input, InputNumber, Modal, Select, Textarea } from '@arco-design/web-vue';
4 import InputUpload from '@/components/input-upload/index.vue';
5 import ImageUpload from '@/components/image-upload/index.vue';
6
7 import { RenderContent } from '@arco-design/web-vue/es/_utils/types';
8 import { ModalConfig } from '@arco-design/web-vue/es/modal/interface';
9
10 export function createSelectVNode(value: Ref, options: AnyObject[], props?: AnyObject): VNode {
11 return createVNode(Select, {
12 options,
13 'modelValue': value.value,
14 'placeholder': '请选择',
15 'onUpdate:modelValue': (val?: unknown) => {
16 value.value = val;
17 },
18 ...props,
19 });
20 }
21
22 export function createInputVNode(value: Ref, props?: AnyObject): VNode {
23 return createVNode(Input, {
24 'modelValue': value.value,
25 'placeholder': '请输入',
26 'onUpdate:modelValue': (val?: string) => {
27 value.value = val || '';
28 },
29 ...props,
30 });
31 }
32
33 export function createInputNumberVNode(value: Ref, props?: AnyObject): VNode {
34 return createVNode(InputNumber, {
35 'min': 0,
36 'max': 200,
37 'step': 1,
38 'modelValue': value.value,
39 'onUpdate:modelValue': (val?: number) => {
40 value.value = val || 0;
41 },
42 ...props,
43 });
44 }
45
46 export function createTextareaVNode(value: Ref, props?: AnyObject): VNode {
47 return createVNode(Textarea, {
48 'modelValue': value.value,
49 'maxLength': 200,
50 'showWordLimit': true,
51 'autoSize': { minRows: 4, maxRows: 4 },
52 'placeholder': '请选择',
53 'onUpdate:modelValue': (val?: string) => {
54 value.value = val;
55 },
56 ...props,
57 });
58 }
59
60 export function createInputUploadVNode(value: Ref, props?: AnyObject) {
61 return createVNode(InputUpload, {
62 'modelValue': value.value,
63 'onUpdate:modelValue': (val?: string) => {
64 value.value = val;
65 },
66 ...props,
67 });
68 }
69
70 export function createImageUploadVNode(value: Ref, props?: AnyObject) {
71 return createVNode(ImageUpload, {
72 'size': 100,
73 'modelValue': value.value,
74 'onUpdate:modelValue': (val?: string) => {
75 value.value = val;
76 },
77 ...props,
78 });
79 }
80
81 export function createCheckboxGroupVNode(value: unknown, options?: AnyObject[], props?: AnyObject) {
82 return createVNode(CheckboxGroup, {
83 'modelValue': value,
84 'onUpdate:modelValue': (val?: string) => {
85 value = val;
86 },
87 options,
88 ...props,
89 });
90 }
91
92 export function createFormVNode(props?: AnyObject, children?: VNode | VNode[] | undefined): VNode {
93 return createVNode(Form, { autoLabelWidth: true, ...props }, () => children);
94 }
95
96 export function createFormItemVNode(props?: AnyObject, children?: string | VNode | VNode[]): VNode {
97 return createVNode(FormItem, { showColon: true, ...props }, () => children);
98 }
99
100 export function createSelectionFormItemVNode(value: Ref, options: AnyObject[], itemProps?: AnyObject, props?: AnyObject): VNode {
101 return createFormItemVNode(itemProps, createSelectVNode(value, options, props));
102 }
103
104 export function createImageFormItemVNode(value: Ref, itemProps?: AnyObject, props?: AnyObject): VNode {
105 return createFormItemVNode(itemProps, createImageUploadVNode(value, props));
106 }
107
108 export function createInputFormItemVNode(value: Ref, itemProps?: AnyObject, props?: AnyObject): VNode {
109 return createFormItemVNode(itemProps, createInputVNode(value, props));
110 }
111
112 export function createInputNumberFormItemVNode(value: Ref, itemProps?: AnyObject, props?: AnyObject): VNode {
113 return createFormItemVNode(itemProps, createInputNumberVNode(value, props));
114 }
115
116 export function createTextareaFormItemVNode(value: Ref, itemProps?: AnyObject, props?: AnyObject) {
117 return createFormItemVNode(itemProps, createTextareaVNode(value, props));
118 }
119
120 export function createInputUploadFormItemVNode(value: Ref, itemProps?: AnyObject, props?: AnyObject) {
121 return createFormItemVNode(itemProps, createInputUploadVNode(value, props));
122 }
123
124 export function createCheckboxGroupFormItemVNode(value: Ref, options?: AnyObject[], itemProps?: AnyObject, props?: AnyObject) {
125 return createFormItemVNode(itemProps, createCheckboxGroupVNode(value, options, props));
126 }
127
128 export function createModalVNode(content: RenderContent, props?: Omit<ModalConfig, 'content'> & { bodyStyle?: object }) {
129 return Modal.open({ content, titleAlign: 'center', closable: false, escToClose: false, maskClosable: false, ...props });
130 }
1 export const debug = process.env.VITE_ENV !== 'production';
2
3 export const ossConfig = {
4 accessKeyId: process.env.VITE_OSS_ACCESS_KEY as string,
5 accessKeySecret: process.env.VITE_OSS_ACCESS_SECRET as string,
6 bucket: process.env.VITE_OSS_BUCKET,
7 region: process.env.VITE_OSS_REGION,
8 host: process.env.VITE_OSS_HOST,
9 endpoint: process.env.VITE_OSS_ENDPOINT,
10 };
11
12 export const apiHost = process.env.VITE_API_HOST;
13
14 export const showLoginAccount = process.env.VITE_SHOW_LOGIN_ACCOUNT;
15
16 export const isProduction = process.env.VITE_ENV === 'production';
1 import { compact } from 'lodash';
2
3 const opt = Object.prototype.toString;
4
5 export const bytesForHuman = (bytes: number, decimals = 2) => {
6 const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
7
8 let i = 0;
9
10 // eslint-disable-next-line no-plusplus
11 for (i; bytes > 1024; i++) {
12 bytes /= 1024;
13 }
14
15 return `${parseFloat(bytes.toFixed(decimals))} ${units[i]}`;
16 };
17
18 export const audioBufferToWav = (audioBuffer: AudioBuffer, len: number) => {
19 const numOfChan = audioBuffer.numberOfChannels;
20 const length = len * numOfChan * 2 + 44;
21 const buffer = new ArrayBuffer(length);
22 const view = new DataView(buffer);
23 const channels = [];
24 let i;
25 let sample;
26 let offset = 0;
27 let pos = 0;
28
29 // write WAVE header
30 // eslint-disable-next-line no-use-before-define
31 setUint32(0x46464952); // "RIFF"
32 // eslint-disable-next-line no-use-before-define
33 setUint32(length - 8); // file length - 8
34 // eslint-disable-next-line no-use-before-define
35 setUint32(0x45564157); // "WAVE"
36
37 // eslint-disable-next-line no-use-before-define
38 setUint32(0x20746d66); // "fmt " chunk
39 // eslint-disable-next-line no-use-before-define
40 setUint32(16); // length = 16
41 // eslint-disable-next-line no-use-before-define
42 setUint16(1); // PCM (uncompressed)
43 // eslint-disable-next-line no-use-before-define
44 setUint16(numOfChan);
45 // eslint-disable-next-line no-use-before-define
46 setUint32(audioBuffer.sampleRate);
47 // eslint-disable-next-line no-use-before-define
48 setUint32(audioBuffer.sampleRate * 2 * numOfChan); // avg. bytes/sec
49 // eslint-disable-next-line no-use-before-define
50 setUint16(numOfChan * 2); // block-align
51 // eslint-disable-next-line no-use-before-define
52 setUint16(16); // 16-bit (hardcoded in this demo)
53
54 // eslint-disable-next-line no-use-before-define
55 setUint32(0x61746164); // "data" - chunk
56 // eslint-disable-next-line no-use-before-define
57 setUint32(length - pos - 4); // chunk length
58
59 // write interleaved data
60 // eslint-disable-next-line no-plusplus
61 for (i = 0; i < audioBuffer.numberOfChannels; i++) channels.push(audioBuffer.getChannelData(i));
62
63 while (pos < length) {
64 // eslint-disable-next-line no-plusplus
65 for (i = 0; i < numOfChan; i++) {
66 // interleave channels
67 sample = Math.max(-1, Math.min(1, channels[i][offset])); // clamp
68 // eslint-disable-next-line no-bitwise
69 sample = (0.5 + sample < 0 ? sample * 32768 : sample * 32767) | 0; // scale to 16-bit signed int
70 view.setInt16(pos, sample, true); // write 16-bit sample
71 pos += 2;
72 }
73 // eslint-disable-next-line no-plusplus
74 offset++; // next source sample
75 }
76
77 // create Blob
78 return new Blob([buffer], { type: 'audio/wav' });
79
80 function setUint16(data: number) {
81 view.setUint16(pos, data, true);
82 pos += 2;
83 }
84
85 function setUint32(data: number) {
86 view.setUint32(pos, data, true);
87 pos += 4;
88 }
89 };
90
91 export const getLyricTimeArr = (lyric: string): string[] => {
92 const times: string[] = [];
93
94 lyric?.split('\n').forEach((item) => {
95 item = item.replace(/(^\s*)|(\s*$)/g, '');
96 times.push(item.substring(item.indexOf('[') + 1, item.indexOf(']')));
97 });
98
99 return compact(times);
100 };
101
102 export function isUndefined(obj: any): obj is undefined {
103 return obj === undefined;
104 }
105
106 export function isString(obj: any): obj is string {
107 return opt.call(obj) === '[object String]';
108 }
109
110 export const promiseToBoolean = (callback: Promise<any>) => callback.then(() => true).catch(() => false);
1 import { App, ComponentPublicInstance } from 'vue';
2 import axios from 'axios';
3
4 export default function handleError(Vue: App, baseUrl: string) {
5 if (!baseUrl) {
6 return;
7 }
8 Vue.config.errorHandler = (
9 err: unknown,
10 instance: ComponentPublicInstance | null,
11 info: string
12 ) => {
13 // send error info
14 axios.post(`${baseUrl}/report-error`, {
15 err,
16 instance,
17 info,
18 // location: window.location.href,
19 // message: err.message,
20 // stack: err.stack,
21 // browserInfo: getBrowserInfo(),
22 // user info
23 // dom info
24 // url info
25 // ...
26 });
27 };
28 }
1 <script setup lang="ts">
2 import { Space } from '@arco-design/web-vue';
3 import { computed, onMounted, ref } from 'vue';
4 import axios from 'axios';
5 import { audioBufferToWav } from '@/utils';
6 import AudioSyncLyric from '@/utils/audioSyncLyric';
7
8 const props = defineProps<{
9 src: string | File | ArrayBuffer;
10 lyric: string;
11 startWithLyric?: boolean;
12 startTime?: string;
13 endTime?: string;
14 }>();
15
16 const audioRef = ref();
17 const lyricRef = ref();
18
19 const source = ref();
20
21 const onPay = (e: any) => {
22 const audios = document.getElementsByTagName('audio');
23 [].forEach.call(audios, (i: HTMLAudioElement) => i !== e.target && i.pause());
24 };
25
26 const convertDurationToSeconds = (duration: string) => {
27 const timeArray = duration.split(':'); // 将时间字符串拆分为时、分、秒的数组
28
29 switch (timeArray.length) {
30 case 1:
31 return parseFloat(timeArray[0]);
32 case 2:
33 return parseInt(timeArray[0], 10) * 60 + parseFloat(timeArray[1]);
34 case 3:
35 return parseInt(timeArray[0], 10) * 3600 + parseInt(timeArray[1], 10) * 60 + parseFloat(timeArray[2]);
36 default:
37 return 0;
38 }
39 };
40
41 const startTime = computed((): number => convertDurationToSeconds(props.startTime ?? '00:00.00'));
42 const endTime = computed((): number | undefined => (props.endTime ? convertDurationToSeconds(props.endTime) : undefined));
43
44 const getResult = async (): Promise<ArrayBuffer> => {
45 if (props.src instanceof Blob) {
46 return props.src.arrayBuffer();
47 }
48 if (props.src instanceof ArrayBuffer) {
49 return Promise.resolve(props.src);
50 }
51
52 return axios
53 .get(`${props.src}?response-content-type=Blob`, { responseType: 'blob', timeout: 60000 })
54 .then(({ data }) => Promise.resolve(data.arrayBuffer()));
55 };
56
57 onMounted(async () => {
58 // eslint-disable-next-line no-new
59 new AudioSyncLyric(lyricRef.value, audioRef.value).setStartTime(startTime.value);
60 const result: ArrayBuffer = await getResult();
61
62 const audioCtx = new AudioContext();
63 const audioBuffer = await audioCtx.decodeAudioData(result);
64 const { numberOfChannels, sampleRate, duration } = audioBuffer;
65 const start = startTime.value; // 从第几秒开始复制
66 const end = endTime.value || duration; // 复制到第几秒结束
67
68 // eslint-disable-next-line no-bitwise
69 const startOffset = (start * sampleRate) >> 0; // 起始位置 = 开始时间 * 采样率
70 // eslint-disable-next-line no-bitwise
71 const endOffset = (end * sampleRate) >> 0; // 结束位置 = 结束时间 * 采样率
72 const frameCount = endOffset - startOffset; // 音频帧数/长度 = 结束位置 - 起始位置
73 const newAudioBuffer = audioCtx.createBuffer(numberOfChannels, frameCount, sampleRate);
74
75 // eslint-disable-next-line no-plusplus
76 for (let i = 0; i < numberOfChannels; i++) {
77 newAudioBuffer.getChannelData(i).set(audioBuffer.getChannelData(i).slice(startOffset, endOffset));
78 }
79
80 const blob = audioBufferToWav(newAudioBuffer, frameCount);
81 source.value = URL.createObjectURL(blob);
82 });
83 </script>
84
85 <template>
86 <Space direction="vertical" fill>
87 <audio ref="audioRef" :src="source" class="audio" controls controlsList="nodownload noplaybackrate" @play="onPay" />
88 <div ref="lyricRef" class="lyric">{{ lyric }}</div>
89 </Space>
90 </template>
91
92 <style scoped lang="less">
93 .audio {
94 height: 30px;
95 width: 100%;
96 outline: none;
97 }
98
99 .lyric {
100 border: none !important;
101 background-color: #f7f8fa;
102
103 :deep(.rabbit-lyrics__line) {
104 padding: 0.3em 1em !important;
105 }
106
107 :deep(.rabbit-lyrics__inline) {
108 color: #818181;
109 }
110
111 :deep(.rabbit-lyrics__inline.rabbit-lyrics__inline--active) {
112 font-size: 16px;
113 font-weight: 500;
114 color: black;
115 }
116 }
117 </style>
1 <template>
2 <Layout>
3 <Layout has-sider>
4 <LayoutSider :width="140" class="aside">
5 <Steps :current="currentStep" direction="vertical">
6 <Step v-for="item in stepItems" :key="item.value">{{ item.label }}</Step>
7 </Steps>
8 </LayoutSider>
9 <LayoutContent class="main">
10 <component :is="currentContent" ref="formRef" v-model="formValue" :loading="loading" :filter-project="filterProject" />
11 </LayoutContent>
12 </Layout>
13 <LayoutFooter>
14 <Divider style="margin-top: 8px; margin-bottom: 20px" />
15 <Link :href="useSelectionStore().lyricTool" :hoverable="false" class="link-hover" icon>歌词制作工具</Link>
16 <Space style="float: right">
17 <IconButton v-show="currentStep !== 1" icon="left" label="上一步" @click="onPrev" />
18 <IconButton icon="right" icon-align="right" :loading="loading" :label="nextBtnLabel" @click="onNext" />
19 </Space>
20 </LayoutFooter>
21 </Layout>
22 </template>
23
24 <script setup lang="ts">
25 import { Layout, LayoutSider, LayoutContent, LayoutFooter, Step, Steps, Space, Divider, Link } from '@arco-design/web-vue';
26 import { computed, markRaw, ref } from 'vue';
27 import Step1FormContent from '@/views/audition/activity-apply/components/step1-form-content.vue';
28 import Step2FormContent from '@/views/audition/activity-apply/components/step2-form-content.vue';
29 import Step3FormContent from '@/views/audition/activity-apply/components/step3-form-content.vue';
30 import IconButton from '@/components/icon-button/index.vue';
31
32 import { useSelectionStore } from '@/store';
33
34 import { AnyObject } from '@/types/global';
35 import useLoading from '@/hooks/loading';
36 import { cloneDeep } from 'lodash';
37
38 const props = defineProps<{
39 initValue?: AnyObject;
40 filterProject?: (value: unknown) => boolean;
41 onSubmit: (data: AnyObject) => Promise<any>;
42 }>();
43
44 const { loading, setLoading } = useLoading(false);
45
46 const formRef = ref();
47 const formValue = ref({ ...cloneDeep(props.initValue) });
48
49 const stepItems = [
50 { value: 1, label: '基本信息', template: markRaw(Step1FormContent) },
51 { value: 2, label: '补充信息', template: markRaw(Step2FormContent) },
52 { value: 3, label: '上传文件', template: markRaw(Step3FormContent) },
53 ];
54
55 const currentStep = ref(1);
56 const currentContent = computed(() => stepItems.find((item) => item.value === currentStep.value)?.template);
57 const nextBtnLabel = computed(() => (currentStep.value === 3 ? '提交' : '下一步'));
58
59 const onPrev = (): void => {
60 currentStep.value = Math.max(1, currentStep.value - 1);
61 };
62
63 const onNext = () => {
64 formRef.value.onValid(async () => {
65 if (currentStep.value === stepItems.length) {
66 setLoading(true);
67 props.onSubmit(formValue.value).finally(() => setLoading(false));
68 }
69 currentStep.value = Math.min(stepItems.length, currentStep.value + 1);
70 });
71 };
72 </script>
73
74 <style scoped lang="less">
75 .aside {
76 box-shadow: unset !important;
77 border-right: 1px solid var(--color-border);
78 margin-right: 20px;
79 background-color: transparent;
80 }
81
82 .main {
83 min-width: 640px;
84 }
85 </style>
1 <script setup lang="ts">
2 import { Form, FormItem, Input, Message, Select } from '@arco-design/web-vue';
3 import { computed, ref } from 'vue';
4 import AvatarUpload from '@/components/avatar-upload/index.vue';
5 import { FieldRule, FormInstance } from '@arco-design/web-vue/es/form';
6 import { useSelectionStore } from '@/store';
7 import { get, set } from 'lodash';
8 import { useVModels } from '@vueuse/core';
9
10 const props = withDefaults(defineProps<{ loading?: boolean; modelValue?: any; filterProject?: (value: any) => boolean }>(), {
11 filterProject: () => true,
12 });
13
14 const emits = defineEmits(['update:modelValue', 'update:loading']);
15
16 const formRef = ref<FormInstance>();
17 const { modelValue: formValue } = useVModels(props, emits);
18
19 const formRule = {
20 'cover': [{ type: 'string', required: true, message: '请上传活动封面' }],
21 'song_name': [{ type: 'string', required: true, message: '请输入歌曲名称' }],
22 'sub_title': [{ type: 'string', required: true, message: '请输入简介' }],
23 'expand.tag_ids': [
24 { type: 'array', required: true, message: '请选择关联标签' },
25 { type: 'array', maxLength: 3, message: '关联标签最多选中3个' },
26 ],
27 'lang': [
28 { type: 'string', required: true, message: '请选择语种' },
29 { type: 'array', maxLength: 2, message: '语种最多选中2个' },
30 ],
31 'speed': [{ type: 'string', required: true, message: '请选择语速' }],
32 'project_id': [{ type: 'number', min: 1, required: true, message: '请选择关联厂牌' }],
33 } as Record<string, FieldRule[]>;
34
35 const tagIds = computed({
36 get: () => get(formValue.value, 'expand.tag_ids', []),
37 set: (val) => set(formValue.value, 'expand.tag_ids', val),
38 });
39
40 const { activityTagOptions, activityLangOptions, activitySpeedOptions, projectOptions } = useSelectionStore();
41
42 const onTagExceedLimitError = (content: string, duration = 1000) => Message.warning({ content, duration });
43
44 defineExpose({
45 onValid: (callback?: () => void) => formRef.value?.validate((errors) => !errors && callback?.()),
46 });
47 </script>
48
49 <template>
50 <Form ref="formRef" :model="formValue" :rules="formRule" :auto-label-width="true">
51 <FormItem label="封面图片" field="cover" :show-colon="true">
52 <AvatarUpload v-model="formValue.cover" :size="100" shape="square" />
53 </FormItem>
54 <FormItem label="歌曲名称" field="song_name" :show-colon="true">
55 <Input v-model="formValue.song_name" :max-length="100" :show-word-limit="true" placeholder="请输入" />
56 </FormItem>
57 <FormItem label="曲风标签" field="expand.tag_ids" :show-colon="true">
58 <Select
59 v-model="tagIds"
60 :options="activityTagOptions"
61 :limit="3"
62 :field-names="{ value: 'id', label: 'name' }"
63 :fallback-option="false"
64 :virtual-list-props="{ height: 200 }"
65 :multiple="true"
66 placeholder="请选择"
67 @exceed-limit="onTagExceedLimitError('关联标签最多选中3个')"
68 />
69 </FormItem>
70 <FormItem label="歌曲语种" field="lang" :show-colon="true">
71 <Select
72 v-model="formValue.lang"
73 :options="activityLangOptions"
74 :limit="2"
75 :field-names="{ value: 'identifier', label: 'name' }"
76 :fallback-option="false"
77 :multiple="true"
78 placeholder="请选择"
79 @exceed-limit="onTagExceedLimitError('关联语种最多选中2个')"
80 />
81 </FormItem>
82 <FormItem label="歌曲速度" field="speed" :show-colon="true">
83 <Select
84 v-model="formValue.speed"
85 :options="activitySpeedOptions"
86 :field-names="{ value: 'identifier', label: 'name' }"
87 :fallback-option="false"
88 placeholder="请选择"
89 />
90 </FormItem>
91 <FormItem label="歌曲简介" field="sub_title" :show-colon="true">
92 <Input v-model.trim="formValue.sub_title" :max-length="20" :show-word-limit="true" placeholder="举例:影视OST歌曲,急寻歌手" />
93 </FormItem>
94 <FormItem label="关联厂牌" field="project_id" :show-colon="true">
95 <Select
96 v-model="formValue.project_id"
97 :options="projectOptions.filter((item) => filterProject(item))"
98 :field-names="{ value: 'id', label: 'name' }"
99 :fallback-option="false"
100 :allow-search="true"
101 placeholder="请选择"
102 />
103 </FormItem>
104 </Form>
105 </template>
106
107 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { DatePicker, Form, FormItem, InputTag, Select } from '@arco-design/web-vue';
3 import { ref, computed } from 'vue';
4 import { FieldRule, FormInstance } from '@arco-design/web-vue/es/form';
5 import { useVModels } from '@vueuse/core';
6 import { useSelectionStore } from '@/store';
7 import { get, set } from 'lodash';
8 import UserSelect from '@/components/user-select/index.vue';
9
10 const props = defineProps<{ loading?: boolean; modelValue?: any }>();
11 const emits = defineEmits(['update:modelValue', 'update:loading']);
12
13 const formRef = ref<FormInstance>();
14 const { modelValue: formValue } = useVModels(props, emits);
15
16 const { activitySexOptions } = useSelectionStore();
17
18 const lyricistIds = computed({
19 get: () => get(formValue.value, 'expand.lyricist.ids', []),
20 set: (val) => set(formValue.value, 'expand.lyricist.ids', val),
21 });
22
23 const lyricistSupplement = computed({
24 get: () => get(formValue.value, 'expand.lyricist.supplement', []),
25 set: (val) => set(formValue.value, 'expand.lyricist.supplement', val),
26 });
27
28 const composerIds = computed({
29 get: () => get(formValue.value, 'expand.composer.ids', []),
30 set: (val) => set(formValue.value, 'expand.composer.ids', val),
31 });
32
33 const composerSupplement = computed({
34 get: () => get(formValue.value, 'expand.composer.supplement', []),
35 set: (val) => set(formValue.value, 'expand.composer.supplement', val),
36 });
37
38 const arrangerIds = computed({
39 get: () => get(formValue.value, 'expand.arranger.ids', []),
40 set: (val) => set(formValue.value, 'expand.arranger.ids', val),
41 });
42
43 const arrangerSupplement = computed({
44 get: () => get(formValue.value, 'expand.arranger.supplement', []),
45 set: (val) => set(formValue.value, 'expand.arranger.supplement', val),
46 });
47
48 const checkMaxUser = (value: any, cb: (error?: string) => void) => (value && value.length > 2 ? cb('最大选择2人') : {});
49
50 const checkNeedOne = (value: any, cb: (error?: string) => void, condition: boolean, message: string) => {
51 checkMaxUser(value, cb);
52 if (condition) {
53 cb(message);
54 }
55 };
56
57 const formRule = {
58 'sex': [{ required: true, message: '请选择性别要求' }],
59 'expand.lyricist.supplement': [
60 {
61 type: 'array',
62 validator: (value, cb) =>
63 checkNeedOne(
64 value,
65 cb,
66 lyricistIds.value.length === 0 && lyricistSupplement.value.length === 0,
67 '词作者(用户)或词作者(未注册),必填一个'
68 ),
69 },
70 ],
71 'expand.composer.supplement': [
72 {
73 type: 'array',
74 validator: (value, cb) =>
75 checkNeedOne(
76 value,
77 cb,
78 composerIds.value.length === 0 && composerSupplement.value.length === 0,
79 '曲作者(用户)或曲作者(未注册),必填一个'
80 ),
81 },
82 ],
83 'expand.arranger.supplement': [
84 {
85 type: 'array',
86 validator: (value, cb) =>
87 checkNeedOne(
88 value,
89 cb,
90 arrangerIds.value.length === 0 && arrangerSupplement.value.length === 0,
91 '编曲(用户)或编曲(未注册),必填一个'
92 ),
93 },
94 ],
95 'estimate_release_at': [{ required: true, message: '请选择预计发布时间' }],
96 } as Record<string, FieldRule[]>;
97
98 defineExpose({
99 onValid: (callback?: () => void) => formRef.value?.validate((errors) => !errors && callback?.()),
100 });
101 </script>
102
103 <template>
104 <Form ref="formRef" :model="formValue" :rules="formRule" :auto-label-width="true">
105 <FormItem label="词作者(用户)" field="expand.lyricist.ids" :show-colon="true">
106 <UserSelect v-model="lyricistIds" placeholder="请选择用户" :limit="2" />
107 </FormItem>
108 <FormItem label="词作者(未注册)" field="expand.lyricist.supplement" :show-colon="true">
109 <InputTag v-model="lyricistSupplement" placeholder="输入完成回车键分隔" :max-tag-count="2" :unique-value="true" />
110 <template #extra>
111 <div>注意:上方填1项即可,歌曲信息中【用户】可点击跳转用户主页,【未注册】仅显示;</div>
112 </template>
113 </FormItem>
114 <FormItem label="曲作者(用户)" field="expand.composer.ids" :show-colon="true">
115 <UserSelect v-model="composerIds" placeholder="请选择用户" :limit="2" />
116 </FormItem>
117 <FormItem label="曲作者(未注册)" field="expand.composer.supplement" :show-colon="true">
118 <InputTag v-model="composerSupplement" placeholder="输入完成回车键分隔" :max-tag-count="2" :unique-value="true" />
119 <template #extra>
120 <div>注意:上方填1项即可,歌曲信息中【用户】可点击跳转用户主页,【未注册】仅显示;</div>
121 </template>
122 </FormItem>
123 <FormItem label="编曲(用户)" field="expand.arranger.ids" :show-colon="true">
124 <UserSelect v-model="arrangerIds" placeholder="请选择用户" :limit="2" />
125 </FormItem>
126 <FormItem label="编曲(未注册)" field="expand.arranger.supplement" :show-colon="true">
127 <InputTag v-model="arrangerSupplement" placeholder="输入完成回车键分隔" :max-tag-count="2" :unique-value="true" />
128 <template #extra>
129 <div>注意:上方填1项即可,歌曲信息中【用户】可点击跳转用户主页,【未注册】仅显示;</div>
130 </template>
131 </FormItem>
132 <FormItem label="性别要求" field="sex" :show-colon="true">
133 <Select
134 v-model="formValue.sex"
135 :options="activitySexOptions"
136 :field-names="{ value: 'identifier', label: 'name' }"
137 :fallback-option="false"
138 placeholder="请选择"
139 />
140 </FormItem>
141 <FormItem label="预计发行日期" field="estimate_release_at" :show-colon="true">
142 <DatePicker
143 v-model="formValue.estimate_release_at"
144 placeholder="请选择"
145 style="width: 100%"
146 :show-now-btn="false"
147 :allow-clear="false"
148 />
149 </FormItem>
150 </Form>
151 </template>
152
153 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { ref, computed, createVNode } from 'vue';
3 import { FieldRule, FormInstance } from '@arco-design/web-vue/es/form';
4 import { useVModels } from '@vueuse/core';
5 import { Form, FormItem, Link, Textarea } from '@arco-design/web-vue';
6 import InputUpload from '@/components/input-upload/index.vue';
7 import { useSelectionStore } from '@/store';
8 import { first, get, last, set } from 'lodash';
9 import { bytesForHuman, getLyricTimeArr } from '@/utils';
10 import useAuthApi from '@/api/auth';
11 import axios from 'axios';
12 import AudioPreview from '@/views/audition/activity-apply/components/audio-preview.vue';
13 import AudioPlayer from '@/components/audio-player/index.vue';
14 import { createModalVNode } from '@/utils/createVNode';
15
16 const props = defineProps<{ loading?: boolean; modelValue?: any }>();
17 const emits = defineEmits(['update:modelValue', 'update:loading']);
18
19 const formRef = ref<FormInstance>();
20 const { loading, modelValue: formValue } = useVModels(props, emits);
21
22 const guideSourceUrl = computed({
23 get: () => get(formValue.value, 'expand.guide_source.url', ''),
24 set: (val) => set(formValue.value, 'expand.guide_source.url', val),
25 });
26
27 const karaokeSourceUrl = computed({
28 get: () => get(formValue.value, 'expand.karaoke_source.url', ''),
29 set: (val) => set(formValue.value, 'expand.karaoke_source.url', val),
30 });
31
32 const trackSourceUrl = computed({
33 get: () => get(formValue.value, 'expand.track_source.url', ''),
34 set: (val) => set(formValue.value, 'expand.track_source.url', val),
35 });
36
37 const trackSourceName = computed(() => get(formValue.value, 'expand.track_source.name', ''));
38 const trackSourceSize = computed((): number => get(formValue.value, 'expand.track_source.size', 0));
39
40 const fullLyricTime = computed(() => getLyricTimeArr(formValue.value.lyric));
41 const clipLyricTime = computed(() => getLyricTimeArr(formValue.value.clip_lyric));
42 const checkClip = computed(
43 () => clipLyricTime.value.length !== 0 && fullLyricTime.value.toString().indexOf(clipLyricTime.value.toString()) !== -1
44 );
45
46 const onUpdateGuide = (file: { name: string; url: string; size: number }) => {
47 set(formValue.value, 'expand.guide_source', { name: file.name, url: file.url, size: file.size });
48 };
49
50 const onUpdateKaraoke = (file: { name: string; url: string; size: number }) => {
51 set(formValue.value, 'expand.karaoke_source', { name: file.name, url: file.url, size: file.size });
52 };
53
54 const onUpdateTrack = (file: { name: string; url: string; size: number }) => {
55 set(formValue.value, 'expand.track_source', { name: file.name, url: file.url, size: file.size });
56 };
57
58 const { activityAudioAccept, activityTrackAccept } = useSelectionStore();
59
60 const formRule = {
61 'expand.guide_source.url': [{ required: true, message: '请上传导唱文件' }],
62 'expand.karaoke_source.url': [{ required: true, message: '请上传伴奏文件' }],
63 'lyric': [
64 { type: 'string', required: true, message: '请填写歌词内容' },
65 {
66 type: 'string',
67 validator: (value: any, cb: (error?: string) => void) => (fullLyricTime.value.length === 0 ? cb('歌词文本格式不正确') : {}),
68 },
69 ],
70 'clip_lyric': [
71 { type: 'string', required: true, message: '请填写推荐歌词内容' },
72 { type: 'string', validator: (value: any, cb: (error?: string) => void) => (checkClip.value ? {} : cb('推荐片段不在歌词中')) },
73 ],
74 } as Record<string, FieldRule[]>;
75
76 const onDownload = (url: string, fileName: string) => useAuthApi.downloadFile(url, `${formValue.value.song_name}(${fileName})`);
77
78 const guideFile = ref<File | undefined>();
79
80 const getGuideFile = async () => {
81 if (!guideFile.value) {
82 guideFile.value = await axios
83 .get(`${guideSourceUrl.value}`, { responseType: 'blob', timeout: 60000 })
84 .then(({ data }) => Promise.resolve(data));
85 }
86
87 return guideFile.value;
88 };
89
90 const onAudioPreview = async () => {
91 const src = await getGuideFile();
92 createModalVNode(() => createVNode(AudioPreview, { src, lyric: formValue.value.lyric }), {
93 title: '预览歌词-整首',
94 footer: false,
95 closable: true,
96 });
97 };
98
99 const onAudioClipPreview = async () => {
100 const src = await getGuideFile();
101 const startTime = first(clipLyricTime.value) as string;
102 const fullEndIndex = fullLyricTime.value.indexOf(last(clipLyricTime.value) as string);
103 const endTime = fullEndIndex === -1 ? undefined : fullLyricTime.value[fullEndIndex + 1] || undefined;
104
105 createModalVNode(() => createVNode(AudioPreview, { src, lyric: formValue.value.clip_lyric, startTime, endTime }), {
106 title: '预览歌词-片段',
107 footer: false,
108 closable: true,
109 });
110 };
111
112 defineExpose({
113 onValid: (callback?: () => void) => formRef.value?.validate((errors) => !errors && callback?.()),
114 });
115 </script>
116
117 <template>
118 <Form ref="formRef" :model="formValue" :rules="formRule" :auto-label-width="true">
119 <FormItem label="导唱文件(带伴奏)" field="expand.guide_source.url" :show-colon="true">
120 <InputUpload
121 v-model="guideSourceUrl"
122 :accept="activityAudioAccept"
123 :limit="100"
124 @success="onUpdateGuide"
125 @choose-file="(file) => (guideFile = file)"
126 @update:loading="(value) => (loading = value)"
127 />
128 </FormItem>
129 <FormItem v-if="guideSourceUrl">
130 <template #label>
131 <Link icon @click="onDownload(guideSourceUrl, '导唱文件')">下载导唱</Link>
132 </template>
133 <AudioPlayer name="导唱文件" :url="guideSourceUrl" />
134 </FormItem>
135 <FormItem label="伴奏文件" field="expand.karaoke_source.url" :show-colon="true">
136 <InputUpload
137 v-model="karaokeSourceUrl"
138 :accept="activityAudioAccept"
139 :limit="100"
140 @success="onUpdateKaraoke"
141 @update:loading="(value) => (loading = value)"
142 />
143 </FormItem>
144 <FormItem v-if="karaokeSourceUrl">
145 <template #label>
146 <Link icon @click="onDownload(karaokeSourceUrl, '伴奏文件')">下载伴奏</Link>
147 </template>
148 <AudioPlayer name="伴奏文件" :url="karaokeSourceUrl" />
149 </FormItem>
150 <FormItem label="分轨文件" :show-colon="true">
151 <InputUpload v-model="trackSourceUrl" :accept="activityTrackAccept" @success="onUpdateTrack" />
152 </FormItem>
153 <FormItem v-if="trackSourceUrl">
154 <template #label>
155 <Link icon @click="onDownload(karaokeSourceUrl, '分轨文件')">下载分轨文件</Link>
156 </template>
157 {{ trackSourceName }} {{ bytesForHuman(trackSourceSize) }}
158 </FormItem>
159 <FormItem class="lyric" label="歌词文本" field="lyric" :show-colon="true">
160 <Textarea
161 v-model="formValue.lyric"
162 :auto-size="{ minRows: 6, maxRows: 6 }"
163 placeholder="请粘贴带时间的lrc歌词文本至输入框"
164 @blur="() => (formValue.lyric = formValue.lyric?.replace(/(\n[\s\t]*\r*\n)/g, '\n').replace(/^[\n\r\t]*|[\n\r\t]*$/g, ''))"
165 />
166 <template #extra>
167 <Link :hoverable="false" :disabled="!guideSourceUrl || !formValue.lyric" @click="onAudioPreview()"> 预览歌词</Link>
168 </template>
169 </FormItem>
170 <FormItem class="lyric" label="片段歌词" field="clip_lyric" :show-colon="true">
171 <Textarea
172 v-model="formValue.clip_lyric"
173 :auto-size="{ minRows: 6, maxRows: 6 }"
174 placeholder="请粘贴带时间的lrc歌词文本至输入框"
175 @blur="
176 () => (formValue.clip_lyric = formValue.clip_lyric?.replace(/(\n[\s\t]*\r*\n)/g, '\n').replace(/^[\n\r\t]*|[\n\r\t]*$/g, ''))
177 "
178 />
179 <template #extra>
180 <Link :hoverable="false" :disabled="!guideSourceUrl || !checkClip" @click="onAudioClipPreview()"> 预览歌词</Link>
181 </template>
182 </FormItem>
183 </Form>
184 </template>
185
186 <style scoped lang="less">
187 .lyric {
188 :deep(.arco-form-item-extra) {
189 width: 100%;
190 text-align: right;
191 }
192 }
193 </style>
1 <template>
2 <page-view has-card has-bread>
3 <filter-search :loading="loading" @search="onSearch" @reset="onReset">
4 <filter-search-item label="歌曲名称">
5 <a-input v-model="filter.songName" :allow-clear="true" placeholder="请输入搜索歌曲名称" />
6 </filter-search-item>
7 <filter-search-item label="厂牌名称">
8 <a-input v-model="filter.projectName" :allow-clear="true" placeholder="请输入搜索厂牌名称" />
9 </filter-search-item>
10 <filter-search-item label="标签名称">
11 <a-input v-model="filter.tagName" :allow-clear="true" placeholder="请输入搜索标签名称" />
12 </filter-search-item>
13 <filter-search-item label="创建人">
14 <a-input v-model="filter.userName" :allow-clear="true" placeholder="请输入搜索创建人名称" />
15 </filter-search-item>
16 <filter-search-item label="创建时间">
17 <a-range-picker v-model="filter.createBetween" :allow-clear="true" />
18 </filter-search-item>
19 <filter-search-item label="状态">
20 <a-select v-model.number="filter.auditStatus" :options="useApply.auditStatusOption" placeholder="请选择搜索审核状态" />
21 </filter-search-item>
22 </filter-search>
23 <filter-table ref="tableRef" :loading="loading" :on-query="onQuery" @row-sort="onSort">
24 <template #tool="{ size }">
25 <a-button v-if="canCreate" :size="size" type="primary" @click="handleCreate">歌曲上架</a-button>
26 </template>
27 <activity-table-column :width="540" data-index="id" title="歌曲" />
28 <user-table-column :width="180" data-index="user_id" title="创建人" user="user" />
29 <date-table-column :width="120" data-index="created_at" title="创建时间" split />
30 <enum-table-column :width="120" data-index="audit_status" title="状态" :option="useApply.auditStatusOption" />
31 <space-table-column :width="80" data-index="operations" title="操作" direction="vertical">
32 <template #default="{ record }">
33 <span v-if="record.audit_status === 0" style="color: rgba(44, 44, 44, 0.5)"></span>
34 <template v-else>
35 <a-link class="link-hover" :hoverable="false" @click="handleUpdate(record)">编辑</a-link>
36 <a-link class="link-hover" :hoverable="false" @click="handleDelete(record)">删除</a-link>
37 </template>
38 </template>
39 </space-table-column>
40 </filter-table>
41 </page-view>
42 </template>
43
44 <script lang="ts" name="audition-apply" setup>
45 import useLoading from '@/hooks/loading';
46 import { computed, createVNode, onActivated, ref } from 'vue';
47 import { AnyObject, QueryForParams } from '@/types/global';
48 import { useApply } from '@/api/activity';
49
50 import FilterTable from '@/components/filter/table.vue';
51 import UserTableColumn from '@/components/filter/user-table-column.vue';
52 import EnumTableColumn from '@/components/filter/enum-table-column.vue';
53 import SpaceTableColumn from '@/components/filter/space-table-column.vue';
54 import DateTableColumn from '@/components/filter/date-table-column.vue';
55 import ActivityTableColumn from '@/components/filter/activity-table-column.vue';
56 import { Message, Table, TableData, TabPane, Tabs } from '@arco-design/web-vue';
57
58 import { useRouteQuery } from '@vueuse/router';
59 import { useRoute } from 'vue-router';
60 import { createModalVNode } from '@/utils/createVNode';
61 import { promiseToBoolean } from '@/utils';
62
63 import FormContent from '@/views/audition/activity-apply/components/form-content.vue';
64 import { Project } from '@/types/project';
65 import { useSelectionStore } from '@/store';
66
67 const { loading, setLoading } = useLoading(false);
68 const tableRef = ref();
69 const filter = ref<AnyObject>({});
70
71 const canCreate = computed(() => useSelectionStore().projectOptions.findIndex((item) => item.is_can_apply === 1) !== -1);
72
73 const queryParams = computed(() => {
74 return {
75 ...filter.value,
76 createdForm: 0,
77 songType: 1,
78 setColumn: [
79 'id',
80 'song_name',
81 'cover',
82 'lang',
83 'speed',
84 'sub_title',
85 'sex',
86 'user_id',
87 'project_id',
88 'lyric',
89 'clip_lyric',
90 'expand',
91 'audit_status',
92 'created_at',
93 'estimate_release_at',
94 ],
95 setWith: [
96 'project:id,name',
97 'user:id,real_name,nick_name,role',
98 'tags:id,name',
99 'applyRecords:id,activity_id,current,audit_msg,created_at',
100 ],
101 } as QueryForParams;
102 });
103
104 const onQuery = async (params: AnyObject): Promise<any> => {
105 setLoading(true);
106 return useApply.get({ ...queryParams.value, ...params }).finally(() => setLoading(false));
107 };
108
109 const onSearch = () => tableRef.value?.onPageChange();
110
111 const initAuditStatus = useRouteQuery('auditStatus', '');
112
113 const onReset = (initParams: AnyObject) => {
114 filter.value = {
115 songName: '',
116 projectName: '',
117 tagName: '',
118 userName: '',
119 auditStatus: '',
120 createBetween: [],
121 ...initParams,
122 setSort: ['-audit_status', '-updated_at', '-id'],
123 };
124 onSearch();
125 };
126
127 const onSort = (column: string, type: string) => {
128 filter.value.setSort = type ? [(type === 'desc' ? '-' : '') + column, '-updated_at', '-id'] : ['-audit_status', '-updated_at', '-id'];
129 tableRef.value?.onFetch();
130 };
131
132 onActivated(() => {
133 if (useRoute()?.meta?.reload) {
134 onReset({ auditStatus: initAuditStatus.value ? Number(initAuditStatus.value) : '' });
135 } else {
136 tableRef.value?.onFetch();
137 }
138 });
139
140 const handleCreate = () => {
141 const dialog = createModalVNode(
142 () =>
143 createVNode(FormContent, {
144 initValue: { song_type: 1, created_form: 0, is_push: 1, expand: { push_type: ['tag'] } },
145 filterProject: (value: Project) => value.is_can_apply === 1,
146 onSubmit: (data: AnyObject) =>
147 useApply.create(data).then(() => {
148 Message.success(`申请上架活动:${data.song_name}`);
149 tableRef.value?.onFetch();
150 dialog.close();
151 }),
152 }),
153 { title: '创建歌曲', footer: false, closable: true, width: 'auto' }
154 );
155 };
156
157 const handleUpdate = (row: TableData) => {
158 const column = [
159 // { title: '操作人', dataIndex: 'id', width: 100, ellipsis: true, tooltip: true, render: () => '平台运营' },
160 { title: '操作时间', dataIndex: 'created_at', width: 170, ellipsis: true, tooltip: true },
161 { title: '驳回说明', dataIndex: 'audit_msg', width: 500, ellipsis: true, tooltip: true },
162 ];
163
164 const dialog = createModalVNode(
165 () =>
166 createVNode(Tabs, { type: 'rounded', size: 'mini' }, [
167 createVNode(TabPane, { title: '试唱歌曲上架', key: 'applyRef' }, () =>
168 createVNode(FormContent, {
169 initValue: row,
170 filterProject: (value: Project) => value.is_can_apply === 1 || value.id === row.project_id,
171 onSubmit: (data: AnyObject) =>
172 useApply.update(row.id, Object.assign(data, { song_type: 1 })).then(() => {
173 Message.success(`重新申请上架活动:${row.song_name}`);
174 tableRef.value?.onFetch();
175 dialog.close();
176 }),
177 })
178 ),
179 createVNode(TabPane, { title: `审核驳回记录(${row.apply_records?.length || 0}`, key: 'record' }, () => {
180 return createVNode(Table, {
181 columns: column,
182 data: row.apply_records || [],
183 bordered: false,
184 tableLayoutFixed: true,
185 pagination: false,
186 scroll: { y: 370 },
187 size: 'medium',
188 rowKey: 'id',
189 });
190 }),
191 ]),
192 { title: '编辑歌曲', footer: false, closable: true, width: '860px' }
193 );
194 };
195
196 const handleDelete = (row: TableData) => {
197 createModalVNode(`确认要将歌曲:${row.song_name} 删除吗?`, {
198 title: '删除操作',
199 onBeforeOk: () => promiseToBoolean(useApply.destroy(row.id)),
200 onOk: () => tableRef.value?.onFetch(),
201 });
202 };
203 </script>
204
205 <style scoped></style>
1 <script setup lang="ts">
2 import { Activity } from '@/types/activity';
3 import { computed } from 'vue';
4 import { User } from '@/types/user';
5 import UserTag from '@/views/audition/activity-show/components/user-tag.vue';
6 import { unionBy } from 'lodash';
7 import useActivityApi from '@/api/activity';
8
9 const props = defineProps<{ data: Activity; loading?: boolean }>();
10
11 const links = computed(() => {
12 return unionBy(props.data.links || [], 'id');
13 });
14
15 const inSideLyricist = computed(
16 (): User[] => links.value?.filter((item: User) => props.data.expand?.lyricist?.ids?.indexOf(item.id) !== -1) || []
17 );
18 const outSideLyricist = computed(() => props.data.expand?.lyricist?.supplement || []);
19
20 const inSideComposer = computed(
21 (): User[] => links.value?.filter((item: User) => props.data.expand?.composer?.ids?.indexOf(item.id) !== -1) || []
22 );
23 const outSideComposer = computed(() => props.data.expand?.composer?.supplement || []);
24
25 const inSideArranger = computed(
26 (): User[] => links.value?.filter((item: User) => props.data.expand?.arranger?.ids?.indexOf(item.id) !== -1) || []
27 );
28 const outSideArranger = computed(() => props.data.expand?.arranger?.supplement || []);
29 </script>
30
31 <template>
32 <a-spin :loading="loading as boolean" style="width: 100%">
33 <a-card :bordered="false">
34 <a-form auto-label-width label-align="left">
35 <a-layout>
36 <a-layout-sider :width="130" style="background: none; box-shadow: none; padding-top: 6px">
37 <a-image show-loader :height="130" :width="130" :src="data.cover" />
38 </a-layout-sider>
39 <a-layout-content style="margin-left: 16px">
40 <a-form-item :hide-label="true">
41 <div class="title">{{ data.song_name }}</div>
42 <a-tag v-for="item in data.tags" :key="item.id" size="small" style="margin-right: 5px">
43 {{ item.name }}
44 </a-tag>
45 <span style="font-size: 10px">
46 {{ useActivityApi.statusOption.find((item) => item.value === data.status)?.label }}
47 </span>
48 </a-form-item>
49 <a-form-item
50 :label-col-style="{ flex: 0 }"
51 :wrapper-col-style="{ flex: 'unset', width: 'inherit' }"
52 :show-colon="true"
53 label="关联厂牌"
54 >
55 <div v-if="data.project">{{ data.project.name }}</div>
56 <div v-else></div>
57 </a-form-item>
58 <a-form-item hide-label>
59 <a-form-item :label-col-style="{ flex: 0 }" :show-colon="true" label="作词">
60 <user-tag v-for="item in inSideLyricist" :key="item.id" :user="{ nick_name: item.nick_name }" style="margin-right: 5px" />
61 <user-tag v-for="item in outSideLyricist" :key="`lyricist-${item}`" :user="{ nick_name: item }" style="margin-right: 5px" />
62 </a-form-item>
63 <a-form-item :label-col-style="{ flex: 0 }" :show-colon="true" label="作曲">
64 <user-tag v-for="item in inSideComposer" :key="item.id" :user="{ nick_name: item.nick_name }" style="margin-right: 5px" />
65 <user-tag v-for="item in outSideComposer" :key="`lyricist-${item}`" :user="{ nick_name: item }" style="margin-right: 5px" />
66 </a-form-item>
67 <a-form-item :label-col-style="{ flex: 0 }" :show-colon="true" label="编曲">
68 <user-tag v-for="item in inSideArranger" :key="item.id" :user="{ nick_name: item.nick_name }" style="margin-right: 5px" />
69 <user-tag v-for="item in outSideArranger" :key="`lyricist-${item}`" :user="{ nick_name: item }" style="margin-right: 5px" />
70 </a-form-item>
71 </a-form-item>
72 <a-form-item hide-label>
73 <a-form-item :label-col-style="{ flex: 0 }" :show-colon="true" label="创建信息">
74 <span v-if="data.user" style="margin-right: 8px">{{ data.user.nick_name }}</span>
75 <span style="margin-right: 8px">{{ data.created_at }} </span>
76 </a-form-item>
77 <a-form-item :label-col-style="{ flex: 0 }" :show-colon="true" label="预计发行时间">
78 {{ data.estimate_release_at }}
79 </a-form-item>
80 <a-form-item :label-col-style="{ flex: 0 }"></a-form-item>
81 </a-form-item>
82 <a-form-item label="推荐语" :show-colon="true" :label-col-style="{ flex: 0 }">
83 <a-typography-paragraph
84 style="margin-bottom: 0; width: 100%; color: var(--color-text-2)"
85 :ellipsis="{ rows: 1, showTooltip: true }"
86 >
87 {{ data.sub_title }}
88 </a-typography-paragraph>
89 </a-form-item>
90 </a-layout-content>
91 </a-layout>
92 </a-form>
93 </a-card>
94 </a-spin>
95 </template>
96
97 <style lang="less" scoped>
98 .arco-form-item {
99 margin-bottom: 0;
100 }
101
102 .arco-form-item-label-col {
103 flex: 0;
104 }
105
106 .title {
107 font-size: 16px;
108 font-weight: bold;
109 margin-right: 8px;
110 }
111
112 .right {
113 margin: 0 20px;
114 min-width: 600px;
115 }
116 </style>
1 <script setup lang="ts">
2 import { onMounted, ref } from 'vue';
3 import useLoading from '@/hooks/loading';
4 import NumberTableColumn from '@/components/filter/number-table-column.vue';
5 import EnumTableColumn from '@/components/filter/enum-table-column.vue';
6 import useUserApi from '@/api/user';
7 import useActivityApi from '@/api/activity';
8 import UserTableColumn from '@/components/filter/user-table-column.vue';
9
10 const props = defineProps<{ activityId: number }>();
11 const emits = defineEmits<{ (e: 'initTotal', value: number): void }>();
12
13 const { loading, setLoading } = useLoading(false);
14 const filter = ref({ nick_name: '', real_name: '', sex: '', identity: '' });
15 const tableRef = ref();
16
17 const { sexOption } = useUserApi;
18
19 const roleOptions = [
20 { value: '0', label: '未认证' },
21 { value: '1,3', label: '音乐人' },
22 { value: '2,3', label: '经纪人' },
23 ];
24
25 const onQuery = async (params: object) => {
26 setLoading(true);
27
28 useActivityApi.getLikeUser(props.activityId, { page: 1, pageSize: 1 }).then(({ meta }) => emits('initTotal', meta.total));
29
30 return useActivityApi
31 .getLikeUser(props.activityId, {
32 ...filter.value,
33 identity: filter.value.identity.split(',').filter(Number),
34 setSort: '-last_listen_at',
35 ...params,
36 })
37 .finally(() => setLoading(false));
38 };
39
40 const onSearch = () => tableRef.value?.onPageChange(1);
41
42 const onReset = () => {
43 filter.value = { nick_name: '', real_name: '', sex: '', identity: '' };
44 onSearch();
45 };
46
47 onMounted(() => onReset());
48 </script>
49
50 <template>
51 <filter-search :loading="loading" :model="filter" :split="3" inline @search="onSearch" @reset="onReset">
52 <filter-search-item label="用户艺名">
53 <a-input v-model="filter.nick_name" placeholder="请输入" />
54 </filter-search-item>
55 <filter-search-item label="性别">
56 <a-select v-model="filter.sex" :options="sexOption" placeholder="请选择" allow-clear />
57 </filter-search-item>
58 <filter-search-item label="身份">
59 <a-select v-model="filter.identity" :options="roleOptions" placeholder="请选择" allow-clear />
60 </filter-search-item>
61 </filter-search>
62
63 <filter-table ref="tableRef" :loading="loading" :on-query="onQuery">
64 <user-table-column title="用户艺名" data-index="user_id" :width="200" show-avatar />
65 <enum-table-column title="性别" data-index="sex" :width="100" :option="sexOption" :dark-value="0" />
66 <a-table-column title="身份" data-index="role" :width="100">
67 <template #cell="{ record }">
68 <span v-if="record.identity === 1">音乐人</span>
69 <span v-else-if="[2, 3].includes(record.identity)">经纪人</span>
70 <span v-else>未认证</span>
71 </template>
72 </a-table-column>
73
74 <number-table-column title="试听歌曲数量" data-index="listen_count" :width="120" :dark-value="0" />
75 <number-table-column title="收藏歌曲数量" data-index="collection_count" :width="120" :dark-value="0" />
76 <number-table-column title="试唱歌曲数量" data-index="submit_work" :width="120" :dark-value="0" />
77 <a-table-column title="最后试听时间" data-index="last_listen_at" :width="180" />
78 </filter-table>
79 </template>
80
81 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import useLoading from '@/hooks/loading';
3 import { createVNode, h, onMounted, ref } from 'vue';
4 import { AnyObject } from '@/types/global';
5 import EnumTableColumn from '@/components/filter/enum-table-column.vue';
6 import { Button, Form, FormItem, Grid, GridItem, Message, Modal, Select } from '@arco-design/web-vue';
7 import ManagerForm from '@/views/audition/activity-show/components/manager-form.vue';
8 import PhoneTableColumn from '@/components/filter/phone-table-column.vue';
9 import useActivityApi from '@/api/activity';
10 import useUserApi from '@/api/user';
11 import UserTableColumn from '@/components/filter/user-table-column.vue';
12
13 const props = defineProps<{ activityId: number; canManage?: number }>();
14 const emits = defineEmits<{ (e: 'initTotal', value: number): void }>();
15
16 // eslint-disable-next-line import/no-named-as-default-member
17 const { getManageUser, createManage, updateManage, deleteManage } = useActivityApi;
18 const { statusOption, officialStatusOption, sexOption } = useUserApi;
19 const { loading, setLoading } = useLoading(false);
20 const filter = ref({ nick_name: '', real_name: '', email: '', phone: '', permission: '', status: '' });
21 const tableRef = ref();
22
23 const permissionOption = [
24 { label: '查看试唱', value: 'view' },
25 { label: '查看报价', value: 'price' },
26 { label: '回复结果', value: 'audit' },
27 ];
28
29 const onQuery = async (params?: AnyObject) => {
30 setLoading(true);
31 getManageUser(props.activityId, { pageSize: 1 }).then(({ meta }) => emits('initTotal', meta.total));
32 return getManageUser(props.activityId, { ...filter.value, ...params, setSort: '-user_id' }).finally(() => setLoading(false));
33 };
34
35 const onSearch = () => tableRef.value?.onPageChange(1);
36 const onReset = () => {
37 filter.value = { nick_name: '', real_name: '', email: '', phone: '', permission: '', status: '' };
38 onSearch();
39 };
40
41 const formatPermission = (permission: any[]) =>
42 permission
43 .map((item) => `[${permissionOption.find((option) => option.value === item)?.label}]` || '')
44 .filter((item) => item.length !== 0)
45 .join('');
46
47 onMounted(() => onReset());
48
49 const onCreate = () => {
50 const formValue = ref<string[]>(['view']);
51 const creating = ref<boolean>(false);
52 const formRef = ref();
53
54 const modal = Modal.open({
55 title: '新增外部管理用户',
56 titleAlign: 'center',
57 content: () => h(ManagerForm, { activityId: props.activityId, ref: formRef }),
58 width: '1000px',
59 footer: () =>
60 createVNode(Grid, { cols: 6 }, () => [
61 createVNode(GridItem, { span: 2, style: { textAlign: 'left' } }, () =>
62 createVNode(Select, {
63 'multiple': true,
64 'options': permissionOption,
65 'modelValue': formValue.value,
66 'onUpdate:modelValue': (val?: string[]) => {
67 if (!val?.includes('view')) {
68 val?.unshift('view');
69 }
70 formValue.value = val || [];
71 },
72 })
73 ),
74 createVNode(GridItem, { offset: 3 }, () =>
75 createVNode(
76 Button,
77 {
78 type: 'primary',
79 loading: creating.value,
80 onClick: () => {
81 if (formRef.value.selectedKeys.length === 0) {
82 return Message.warning('请先选择添加用户');
83 }
84 creating.value = true;
85 return createManage({ activity_id: props.activityId, user_ids: formRef.value.selectedKeys, permission: formValue.value })
86 .then(() => {
87 Message.success('添加成功');
88 tableRef.value?.onFetch();
89 modal.close();
90 })
91 .finally(() => {
92 creating.value = false;
93 });
94 },
95 },
96 () => '确认'
97 )
98 ),
99 ]),
100 });
101 };
102
103 const onUpdate = (record: any) => {
104 const formValue = ref<string[]>(record?.permission || ['view']);
105
106 Modal.open({
107 title: '修改',
108 titleAlign: 'center',
109 content: () =>
110 createVNode(Form, { layout: 'vertical', model: {} }, () =>
111 createVNode(FormItem, { label: '设置用户权限', rowClass: 'mb-0' }, () =>
112 createVNode(Select, {
113 'multiple': true,
114 'options': permissionOption,
115 'modelValue': formValue.value,
116 'onUpdate:modelValue': (val?: string[]) => {
117 if (!val?.includes('view')) {
118 val?.unshift('view');
119 }
120 formValue.value = val || [];
121 },
122 })
123 )
124 ),
125 onBeforeOk: (done) =>
126 updateManage(record.id, { permission: formValue.value })
127 .then(() => {
128 Message.success('更新成功');
129 tableRef.value?.onFetch();
130 done(true);
131 })
132 .catch(() => done(false)),
133 });
134 };
135
136 const onDelete = (record: any) => {
137 Modal.open({
138 title: '删除操作',
139 content: `确认取消用户:${record.user?.nick_name} 的外部管理员身份`,
140 closable: false,
141 onBeforeOk: (done) =>
142 deleteManage(record.id)
143 .then(() => {
144 Message.success('删除成功');
145 tableRef.value?.onFetch();
146 done(true);
147 })
148 .catch(() => done(false)),
149 });
150 };
151 </script>
152
153 <template>
154 <filter-search :model="filter" :loading="loading" @search="onSearch" @reset="onReset">
155 <filter-search-item field="nick_name" label="用户艺名">
156 <a-input v-model="filter.nick_name" allow-clear placeholder="请输入" />
157 </filter-search-item>
158 <filter-search-item field="email" label="用户邮箱">
159 <a-input v-model="filter.email" allow-clear placeholder="请输入" />
160 </filter-search-item>
161 <filter-search-item field="phone" label="用户手机">
162 <a-input v-model="filter.phone" allow-clear placeholder="请输入" />
163 </filter-search-item>
164 <filter-search-item field="" label="外部权限">
165 <a-select v-model="filter.permission" :options="permissionOption" allow-clear placeholder="请选择" />
166 </filter-search-item>
167 <filter-search-item field="status" label="状态">
168 <a-select v-model="filter.status" :options="statusOption" allow-clear placeholder="请选择" />
169 </filter-search-item>
170 </filter-search>
171 <filter-table ref="tableRef" :loading="loading" :on-query="onQuery">
172 <template #tool>
173 <icon-button v-if="canManage" icon="plus" label="新增" type="primary" @click="onCreate" />
174 </template>
175 <user-table-column title="用户艺名" data-index="user_id" user="user" :width="200" show-avatar />
176 <enum-table-column title="性别" data-index="user.sex" :width="100" :option="sexOption" :dark-value="0" />
177 <a-table-column title="用户邮箱" data-index="user.email" :width="170" :tooltip="true" :ellipsis="true" />
178 <phone-table-column title="手机号码" data-index="user.phone" area-index="user.area_code" :width="170" />
179 <enum-table-column title="关注服务号" data-index="user.official_status" :option="officialStatusOption" :dark-value="0" :width="120" />
180 <a-table-column title="外部权限" data-index="permission" :width="200" :ellipsis="true" :tooltip="true">
181 <template #cell="{ record }"> {{ formatPermission(record.permission) }}</template>
182 </a-table-column>
183 <enum-table-column title="状态" data-index="user.status" :width="80" :option="statusOption" :dark-value="0" />
184 <a-table-column v-if="canManage" :width="120" data-index="operations" title="操作" :tooltip="false" :ellipsis="false">
185 <template #cell="{ record }">
186 <a-space>
187 <a-link :hoverable="false" @click="onUpdate(record)">修改</a-link>
188 <a-link :hoverable="false" @click="onDelete(record)">取消管理</a-link>
189 </a-space>
190 </template>
191 </a-table-column>
192 </filter-table>
193 </template>
194
195 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { ref, onMounted } from 'vue';
3 import { Input, Divider, TableColumn } from '@arco-design/web-vue';
4 import useActivityApi from '@/api/activity';
5 import { AnyObject } from '@/types/global';
6 import useLoading from '@/hooks/loading';
7 import FilterSearch from '@/components/filter/search.vue';
8 import FilterSearchItem from '@/components/filter/search-item.vue';
9 import FilterTable from '@/components/filter/table.vue';
10 import PhoneTableColumn from '@/components/filter/phone-table-column.vue';
11
12 const props = defineProps<{ activityId: number }>();
13 const filter = ref({});
14 const tableRef = ref<InstanceType<typeof FilterTable>>({});
15
16 const { loading, setLoading } = useLoading(false);
17 const selectedKeys = ref([]);
18
19 const onQuery = async (params?: AnyObject) => {
20 setLoading(true);
21 // eslint-disable-next-line import/no-named-as-default-member
22 return useActivityApi
23 .getManageUser(props.activityId, { ...filter.value, unSet: 1, ...params, setSort: '-id' })
24 .finally(() => setLoading(false));
25 };
26
27 const onSearch = () => tableRef.value?.onPageChange(1);
28 const onReset = () => {
29 filter.value = { nick_name: '', real_name: '', emailLike: '', phoneLike: '', status: 1 };
30 onSearch();
31 };
32
33 defineExpose({ selectedKeys });
34
35 onMounted(() => onReset());
36 </script>
37
38 <template>
39 <FilterSearch :loading="loading" :model="filter" size="small" @search="onSearch" @reset="onReset">
40 <FilterSearchItem field="nick_name" label="用户艺名">
41 <Input v-model="filter.nick_name" allow-clear placeholder="请输入" />
42 </FilterSearchItem>
43 <FilterSearchItem field="real_name" label="用户真名">
44 <Input v-model="filter.real_name" allow-clear placeholder="请输入" />
45 </FilterSearchItem>
46 <FilterSearchItem field="email" label="用户邮箱">
47 <Input v-model="filter.emailLike" allow-clear placeholder="请输入" />
48 </FilterSearchItem>
49 <FilterSearchItem field="phone" label="手机号码">
50 <Input v-model="filter.phoneLike" allow-clear placeholder="请输入" />
51 </FilterSearchItem>
52 </FilterSearch>
53 <Divider style="margin-top: 0" />
54 <FilterTable
55 ref="tableRef"
56 v-model:selectedKeys="selectedKeys"
57 size="small"
58 :loading="loading"
59 style="height: 360px"
60 :scroll="{ y: 360 }"
61 :row-selection="{ type: 'checkbox' }"
62 :on-query="onQuery"
63 >
64 <TableColumn title="用户艺名" data-index="nick_name" :width="160" :tooltip="true" :ellipsis="true" />
65 <TableColumn title="用户真名" data-index="real_name" :width="140" :tooltip="true" :ellipsis="true" />
66 <TableColumn title="用户邮箱" data-index="email" :width="140" :tooltip="true" :ellipsis="true" />
67 <PhoneTableColumn title="用户手机" data-index="phone" area-index="area_code" :width="140" :tooltip="true" :ellipsis="true" />
68 </FilterTable>
69 </template>
70
71 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { AnyObject, QueryForParams } from '@/types/global';
3 import useLoading from '@/hooks/loading';
4 import { useWorkApi } from '@/api/activity';
5 import { computed, onMounted, ref } from 'vue';
6
7 import { TableColumn } from '@arco-design/web-vue';
8 import FilterTable from '@/components/filter/table.vue';
9 import UserTableColumn from '@/components/filter/user-table-column.vue';
10 import UserInfo from '@/views/audition/activity-show/components/user-info.vue';
11
12 const props = defineProps<{ activityId: number; queryHook?: () => void }>();
13
14 const { loading, setLoading } = useLoading(false);
15 const tableRef = ref();
16
17 const queryParams = computed((): QueryForParams => {
18 return {
19 activity: props.activityId,
20 status: 1,
21 setColumn: ['id', 'submit_at', 'mode', 'demo_url', 'user_id', 'broker_id'],
22 setWith: ['user:id,avatar,nick_name,real_name,identity,email,province,city', 'broker:id,nick_name,identity'],
23 setSort: '-id',
24 };
25 });
26
27 const onQuery = async (params: AnyObject) => {
28 setLoading(true);
29 props.queryHook?.();
30 return useWorkApi.get({ ...queryParams.value, ...params }).finally(() => setLoading(false));
31 };
32
33 onMounted(() => tableRef.value?.onPageChange(1));
34 </script>
35
36 <template>
37 <FilterTable ref="tableRef" :loading="loading" :on-query="onQuery">
38 <TableColumn :width="160" data-index="user_id " title="用户艺名">
39 <template #cell="{ record }">
40 <UserInfo v-if="record.user" :row="record.user" />
41 </template>
42 </TableColumn>
43 <UserTableColumn :width="140" data-index="broker_id" user="broker" title="队长" dark-value="" />
44 <TableColumn :width="100" data-index="mode" title="试唱方式">
45 <template #cell="{ record }">{{ record.mode === 1 ? '自主上传' : '在线演唱' }}</template>
46 </TableColumn>
47 <TableColumn :width="180" data-index="submit_at" title="提交时间" />
48 <TableColumn :width="480" data-index="demo_url" title="Demo">
49 <template #cell="{ record }">
50 <audio-player :name="record.song_name" :url="record.demo_url" />
51 </template>
52 </TableColumn>
53 </FilterTable>
54 </template>
55
56 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { Modal, Textarea } from '@arco-design/web-vue';
3 import { computed, h } from 'vue';
4 import { ActivityExpand } from '@/types/activity-apply';
5 import { useAppStore } from '@/store';
6 import useAuthApi from '@/api/auth';
7
8 type MaterialData = {
9 title: string;
10 type: string;
11 content: string;
12 name?: string;
13 size?: string;
14 };
15
16 const props = defineProps<{ data: { expand: ActivityExpand; lyric: string; song_name: string }; hideTrack?: boolean }>();
17
18 const appStore = useAppStore();
19 const theme = computed(() => appStore.theme);
20
21 const lyricStyle = computed(() =>
22 theme.value === 'light' ? { border: 'none', backgroundColor: 'white' } : { border: 'none', backgroundColor: '#2a2a2b' }
23 );
24
25 const bytesForHuman = (bytes: number, decimals = 2) => {
26 const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
27
28 let i = 0;
29
30 // eslint-disable-next-line no-plusplus
31 for (i; bytes > 1024; i++) {
32 bytes /= 1024;
33 }
34
35 return `${parseFloat(bytes.toFixed(decimals))} ${units[i]}`;
36 };
37
38 const onDownload = (record: MaterialData) => useAuthApi.downloadFile(record.content, `${props.data.song_name}(${record.title})`);
39
40 const onViewLyric = (lyric: string) => {
41 Modal.open({
42 content: () => h(Textarea, { defaultValue: lyric, autoSize: { maxRows: 20 }, style: lyricStyle.value }),
43 footer: false,
44 closable: false,
45 });
46 };
47
48 const materials = computed((): MaterialData[] => {
49 const data = [
50 {
51 title: '导唱',
52 type: 'guide',
53 name: props.data.expand?.guide_source?.name || '',
54 content: props.data.expand?.guide_source?.url || '',
55 size: bytesForHuman(props.data.expand?.guide_source?.size || 0),
56 },
57 {
58 title: '伴唱',
59 type: 'karaoke',
60 name: props.data.expand?.karaoke_source?.name || '',
61 content: props.data.expand?.karaoke_source?.url || '',
62 size: bytesForHuman(props.data.expand?.karaoke_source?.size || 0),
63 },
64 ];
65 if (!props.hideTrack) {
66 data.push({
67 title: '分轨文件',
68 type: 'track',
69 content: props.data.expand?.track_source?.url || '',
70 name: props.data.expand?.track_source?.name || '',
71 size: bytesForHuman(props.data.expand?.track_source?.size || 0),
72 });
73 }
74
75 return [...data, { title: '歌词', type: 'Lyric', content: props.data.lyric }];
76 });
77 </script>
78
79 <template>
80 <a-table row-key="type" :data="materials" :bordered="false" :table-layout-fixed="true" :pagination="false">
81 <template #columns>
82 <a-table-column title="物料类型" align="center" data-index="title" :width="140" />
83 <a-table-column title="音频播放" data-index="content">
84 <template #cell="{ record }">
85 <template v-if="record.content">
86 <audio-player v-if="['guide', 'karaoke'].indexOf(record.type) !== -1" :name="record.type" :url="record.content" />
87 <div v-if="record.type === 'track'">{{ [record.name, record.size].join(',') }}</div>
88 </template>
89 </template>
90 </a-table-column>
91 <a-table-column title="操作" align="center" :width="200">
92 <template #cell="{ record }">
93 <template v-if="record.content">
94 <a-button v-if="record.type === 'Lyric'" type="primary" size="small" @click="onViewLyric(record.content)">查看</a-button>
95 <a-button v-else type="primary" size="small" @click="onDownload(record)">下载</a-button>
96 </template>
97 </template>
98 </a-table-column>
99 </template>
100 </a-table>
101 </template>
102
103 <style scoped lang="less"></style>
1 <template>
2 <FilterSearch :loading="loading" :model="filter" :inline="true" @search="onSearch" @reset="onReset">
3 <FilterSearchItem label="用户艺名">
4 <Input v-model="filter.searchUser" allow-clear placeholder="请搜索" />
5 </FilterSearchItem>
6 <FilterSearchItem label="经纪人">
7 <Input v-model="filter.searchBusiness" allow-clear placeholder="请搜索" />
8 </FilterSearchItem>
9 <FilterSearchItem label="试唱方式">
10 <Select v-model="filter.mode" :options="modeOption" allow-clear placeholder="请筛选" />
11 </FilterSearchItem>
12 <template #button>
13 <ExportButton :on-download="onExport" />
14 </template>
15 </FilterSearch>
16 <FilterTable ref="tableRef" :loading="loading" :on-query="onQuery" :hide-expand-button-on-empty="true">
17 <TableColumn :width="160" data-index="user_id " title="用户艺名">
18 <template #cell="{ record }">
19 <UserInfo v-if="record.children && record.user" :row="record.user" />
20 </template>
21 </TableColumn>
22 <UserTableColumn :width="140" data-index="business_id" user="business" title="队长" dark-value="" />
23 <TableColumn :width="100" data-index="mode" title="试唱方式">
24 <template #cell="{ record }">{{ record.mode === 1 ? '自主上传' : '在线演唱' }}</template>
25 </TableColumn>
26 <TableColumn title="合作模式" :width="100">
27 <template #cell="{ record }">
28 <template v-if="record.price && record.children">
29 <Link class="link-hover" :hoverable="false" @click="onViewPrice(record.price)">查看报价</Link>
30 </template>
31 </template>
32 </TableColumn>
33 <TableColumn :width="100" data-index="version" title="提交版本">
34 <template #cell="{ record }">
35 <Link class="link-hover" :hoverable="false" icon @click="onDownload(record)">{{ `版本V${record.version}` }}</Link>
36 </template>
37 </TableColumn>
38 <TableColumn :width="460" data-index="demo_url" title="Demo">
39 <template #cell="{ record }">
40 <AudioPlayer :name="record.song_name" :url="record.demo_url" />
41 </template>
42 </TableColumn>
43 <SpaceTableColumn :width="130" fixed="right" title="操作">
44 <template #default="{ record }: { record: ActivityWork }">
45 <template v-if="record.children">
46 <a-button v-if="record.status === 1" size="mini" type="primary" status="success">已确认合作</a-button>
47 <a-button v-else-if="record.status === 2" size="mini">试唱不合适</a-button>
48 <a-button v-else-if="record.status === 0 && [3, 5].indexOf(record.activity_status) !== -1" size="mini">未采纳</a-button>
49 <a-space v-else-if="record.status === 0 && record.activity_status === 1">
50 <a-button type="primary" size="mini" @click="onPass(record)">合作</a-button>
51 <a-button type="primary" status="danger" size="mini" @click="onUnPass(record)">不合适</a-button>
52 </a-space>
53 <a-button v-else size="mini">其他</a-button>
54 </template>
55 </template>
56 </SpaceTableColumn>
57 </FilterTable>
58 </template>
59
60 <script setup lang="ts">
61 import useLoading from '@/hooks/loading';
62 import { onMounted, ref, createVNode, computed } from 'vue';
63 import { useWorkApi } from '@/api/activity';
64 import { AnyObject, QueryForParams } from '@/types/global';
65 import { Input, Select, TableColumn, TableData, Link } from '@arco-design/web-vue';
66
67 import FilterSearch from '@/components/filter/search.vue';
68 import FilterSearchItem from '@/components/filter/search-item.vue';
69 import FilterTable from '@/components/filter/table.vue';
70 import UserTableColumn from '@/components/filter/user-table-column.vue';
71 import SpaceTableColumn from '@/components/filter/space-table-column.vue';
72 import UserInfo from '@/views/audition/activity-show/components/user-info.vue';
73 import AudioPlayer from '@/components/audio-player/index.vue';
74
75 import { createFormItemVNode, createFormVNode, createModalVNode } from '@/utils/createVNode';
76 import { promiseToBoolean } from '@/utils';
77 import { ActivityWork } from '@/types/activity-work';
78 import useAuthApi from '@/api/auth';
79
80 const props = defineProps<{ activityId: number; exportName?: string; queryHook?: () => void }>();
81 const emits = defineEmits<{ (e: 'matchWork', value: ActivityWork): void }>();
82
83 const { loading, setLoading } = useLoading(false);
84 const filter = ref<QueryForParams>({ searchUser: '', searchBusiness: '', mode: '' });
85 const tableRef = ref();
86
87 const modeOption = [
88 { value: 1, label: '自主上传' },
89 { value: 0, label: '在线演唱' },
90 ];
91
92 const queryParams = computed((): QueryForParams => {
93 return {
94 ...filter.value,
95 activity: props.activityId,
96 type: 'submit',
97 setColumn: [
98 'id',
99 'user_id',
100 'business_id',
101 'price_id',
102 'activity_id',
103 'activity_name',
104 'activity_status',
105 'submit_at',
106 'mode',
107 'type',
108 'status',
109 'created_at',
110 'demo_url',
111 'version',
112 ],
113 setWith: [
114 'user:id,avatar,nick_name,real_name,identity,email,province,city,company,rate',
115 'business:id,nick_name,identity',
116 'price:id,value,is_deduct,is_talk,is_accept_address,address_id',
117 'children:id,user_id,activity_id,mode,version,demo_url',
118 ],
119 setSort: '-id',
120 };
121 });
122
123 const onQuery = async (params: AnyObject) => {
124 setLoading(true);
125 props.queryHook?.();
126 return useWorkApi.get({ ...queryParams.value, ...params }).finally(() => setLoading(false));
127 };
128
129 const onSearch = () => tableRef.value?.onPageChange(1);
130
131 const onReset = () => {
132 filter.value = { searchUser: '', searchBusiness: '', mode: '' };
133 onSearch();
134 };
135
136 onMounted(() => onReset());
137
138 const onExport = async () => useWorkApi.getExport(props.exportName as string, queryParams.value);
139
140 const onDownload = (row: TableData) => {
141 return useAuthApi.downloadFile(row.demo_url, `${row.activity_name}-${row.user.nick_name}-V${row.version}`);
142 };
143
144 const onViewPrice = (price: any) => {
145 createModalVNode(
146 () =>
147 createFormVNode({ size: 'small', autoLabelWidth: true }, [
148 createFormItemVNode(
149 { label: '唱酬', showColon: true, rowClass: 'mb-0' },
150 price.value.is_reward ? `${price.value.amounts} 元` : '无'
151 ),
152 createFormItemVNode(
153 { label: '分成', showColon: true, rowClass: 'mb-0' },
154 price.value.is_dividend ? `${price.value.ratio}% | ${price.value.year} | ${price.is_deduct ? '抵扣' : '不抵扣'}` : '无'
155 ),
156 createFormItemVNode({ label: '价格是否可谈', showColon: true, rowClass: 'mb-0' }, price.is_talk ? '【可谈】' : '【不可谈】'),
157 createFormItemVNode(
158 { label: '录音地点', showColon: true, rowClass: 'mb-0' },
159 `${[price.address?.parent?.name, price.address?.name].join('/')}${
160 price.is_accept_address ? '【接受其他录音地点】' : '【不接受其他录音地点】'
161 }`
162 ),
163 ]),
164 { title: '用户报价', hideCancel: true, bodyStyle: { padding: '8px 20px' }, okText: '我知道了' }
165 );
166 };
167
168 const onPass = (row: TableData) => {
169 const itemStyle = { lineHeight: '28px', margin: '4px 0' };
170
171 return createModalVNode(
172 () =>
173 createVNode('ol', {}, [
174 createVNode('li', { style: itemStyle }, '此条Demo的状态将变更为合作状态'),
175 createVNode('li', { style: itemStyle }, `活动《${row.activity_name}》的状态变更为完成状态!`),
176 ]),
177 {
178 title: '确认合作',
179 bodyStyle: { padding: 0 },
180 onBeforeOk: () => promiseToBoolean(useWorkApi.changeStatus(row.id, { status: 1 })),
181 onOk: () => {
182 tableRef.value?.onFetch();
183 emits('matchWork', row as ActivityWork);
184 },
185 }
186 );
187 };
188
189 const onUnPass = (row: TableData) =>
190 createModalVNode(`请确认是否将用户:${row.user.nick_name} 提交的《版本V${row.version}》标记为不合适,并反馈给用户?`, {
191 title: '不适合标记',
192 onBeforeOk: () => promiseToBoolean(useWorkApi.changeStatus(row.id, { status: 2, remark: '' })),
193 onOk: () => tableRef.value?.onFetch(),
194 });
195 </script>
196
197 <style scoped lang="less">
198 .prop {
199 display: flex;
200 align-items: flex-start;
201 }
202
203 .prop-label {
204 font-weight: bold;
205 width: 80px;
206 text-align: right;
207 }
208
209 :deep(.arco-table-cell) {
210 padding: 5px 8px !important;
211
212 :hover {
213 cursor: pointer;
214 }
215
216 & > .arco-table-td-content .arco-btn-size-small {
217 padding: 5px !important;
218 }
219 }
220 </style>
1 <template>
2 <a-popover position="right">
3 <a-typography-paragraph class="name" :ellipsis="{ rows: 1, showTooltip: false }">
4 {{ fullName }}
5 </a-typography-paragraph>
6 <template #content>
7 <p class="prop">
8 <span class="label">邮箱:</span>
9 <span>{{ row.email }}</span>
10 </p>
11 <p class="prop">
12 <span class="label">身份:</span>
13 <span>{{ roleLabel }}</span></p
14 >
15 <p class="prop">
16 <span class="label">常居地:</span>
17 <span>
18 {{ [row.province, row.city].join('-') }}
19 </span>
20 </p>
21 </template>
22 </a-popover>
23 </template>
24
25 <script lang="ts" setup>
26 import { computed, toRefs } from 'vue';
27 import { User } from '@/types/user';
28
29 const props = defineProps<{
30 row: Pick<User, 'id' | 'nick_name' | 'real_name' | 'email' | 'province' | 'city' | 'company' | 'rate' | 'identity'>;
31 }>();
32 const { row } = toRefs(props);
33
34 const roleLabel = computed(() => {
35 if (row.value.identity === 1) {
36 return '音乐人';
37 }
38 if ([2, 3].includes(row.value.identity)) {
39 return '经纪人';
40 }
41
42 return '未认证';
43 });
44
45 const fullName = computed(() => `${row?.value?.nick_name}`);
46 </script>
47
48 <style lang="less" scoped>
49 .prop {
50 display: flex;
51 align-items: flex-start;
52
53 .label {
54 font-weight: bold;
55 width: 80px;
56 }
57 }
58 .name {
59 margin-bottom: 0 !important;
60 width: 100%;
61 }
62 </style>
1 <script setup lang="ts">
2 import { computed } from 'vue';
3
4 const props = defineProps<{ user: { id?: number; nick_name: string } }>();
5
6 const cursor = computed(() => (props.user.id ? 'pointer' : 'default'));
7 </script>
8
9 <template>
10 <a-tag class="link" size="small" @click="() => user.id && $router.push({ name: 'user-show', params: { id: user.id } })">
11 <span>{{ user.nick_name }}</span>
12 <icon-right v-if="user.id" style="margin-left: 8px" />
13 </a-tag>
14 </template>
15
16 <style scoped lang="less">
17 .link:hover {
18 cursor: v-bind(cursor);
19 }
20 </style>
1 <script setup lang="ts">
2 import { onMounted, ref } from 'vue';
3 import useLoading from '@/hooks/loading';
4 import NumberTableColumn from '@/components/filter/number-table-column.vue';
5 import EnumTableColumn from '@/components/filter/enum-table-column.vue';
6 import useUserApi from '@/api/user';
7 import useActivityApi from '@/api/activity';
8 import UserTableColumn from '@/components/filter/user-table-column.vue';
9
10 const props = defineProps<{ activityId: number }>();
11 const emits = defineEmits<{ (e: 'initTotal', value: number): void }>();
12
13 const { loading, setLoading } = useLoading(false);
14 const filter = ref({ nick_name: '', real_name: '', sex: '', identity: '' });
15 const tableRef = ref();
16
17 const { sexOption } = useUserApi;
18
19 const roleOptions = [
20 { value: '0', label: '未认证' },
21 { value: '1,3', label: '音乐人' },
22 { value: '2,3', label: '经纪人' },
23 ];
24
25 const onQuery = async (params: object) => {
26 setLoading(true);
27
28 useActivityApi.getViewUser(props.activityId, { page: 1, pageSize: 1 }).then(({ meta }) => emits('initTotal', meta.total));
29
30 return useActivityApi
31 .getViewUser(props.activityId, {
32 ...filter.value,
33 identity: filter.value.identity.split(',').filter(Number),
34 setSort: '-last_listen_at',
35 ...params,
36 })
37 .finally(() => setLoading(false));
38 };
39
40 const onSearch = () => tableRef.value?.onPageChange(1);
41
42 const onReset = () => {
43 filter.value = { nick_name: '', real_name: '', sex: '', identity: '' };
44 onSearch();
45 };
46
47 onMounted(() => onReset());
48 </script>
49
50 <template>
51 <filter-search :loading="loading" :model="filter" :split="3" inline @search="onSearch" @reset="onReset">
52 <filter-search-item label="用户艺名">
53 <a-input v-model="filter.nick_name" placeholder="请输入" />
54 </filter-search-item>
55 <filter-search-item label="性别">
56 <a-select v-model="filter.sex" :options="sexOption" placeholder="请选择" allow-clear />
57 </filter-search-item>
58 <filter-search-item label="身份">
59 <a-select v-model="filter.identity" :options="roleOptions" placeholder="请选择" allow-clear />
60 </filter-search-item>
61 </filter-search>
62
63 <filter-table ref="tableRef" :loading="loading" :on-query="onQuery">
64 <user-table-column title="用户艺名" data-index="user_id" :width="200" show-avatar />
65 <enum-table-column title="性别" data-index="sex" :width="100" :option="sexOption" :dark-value="0" />
66 <a-table-column title="身份" data-index="role" :width="100">
67 <template #cell="{ record }">
68 <span v-if="record.identity === 1">音乐人</span>
69 <span v-else-if="[2, 3].includes(record.identity)">经纪人</span>
70 <span v-else>未认证</span>
71 </template>
72 </a-table-column>
73
74 <number-table-column title="试听歌曲数量" data-index="listen_count" :width="120" :dark-value="0" />
75 <number-table-column title="收藏歌曲数量" data-index="collection_count" :width="120" :dark-value="0" />
76 <number-table-column title="试唱歌曲数量" data-index="submit_work" :width="120" :dark-value="0" />
77 <a-table-column title="最后试听时间" data-index="last_listen_at" :width="180" />
78 </filter-table>
79 </template>
80
81 <style scoped lang="less"></style>
1 <script setup lang="ts" name="audition-activity-show">
2 import { useRoute, useRouter } from 'vue-router';
3 import { onMounted, ref } from 'vue';
4 import useActivityApi, { useWorkApi } from '@/api/activity';
5 import BasicCard from '@/views/audition/activity-show/components/basic-card.vue';
6 import MaterialTable from '@/views/audition/activity-show/components/material-table.vue';
7 import ViewUserTable from '@/views/audition/activity-show/components/view-user-table.vue';
8 import LikeUserTable from '@/views/audition/activity-show/components/like-user-table.vue';
9 import SubmitTable from '@/views/audition/activity-show/components/submit-table.vue';
10 import ManageTable from '@/views/audition/activity-show/components/manage-table.vue';
11 import MatchTable from '@/views/audition/activity-show/components/match-table.vue';
12
13 import { useRouteQuery } from '@vueuse/router';
14
15 const activityKey = Number(useRoute().params.id);
16 const tabKey = useRouteQuery('tabKey', 'submit-work');
17
18 const activity = ref({});
19
20 const total = ref({ submit_total: 0, match_total: 0, view_total: 0, like_total: 0, manager_total: 0 });
21
22 const syncMatchCount = () =>
23 useWorkApi.get({ activity: activityKey, setColumn: ['id'], type: 'submit', status: 1, pageSize: 1 }).then(({ meta }) => {
24 total.value.match_total = meta.total;
25 });
26
27 const syncSubmitCount = () =>
28 useWorkApi.get({ activity: activityKey, setColumn: ['id'], type: 'submit', pageSize: 1 }).then(({ meta }) => {
29 total.value.submit_total = meta.total;
30 });
31
32 const router = useRouter();
33 onMounted(async () => {
34 await useActivityApi
35 .show(activityKey, {
36 songType: 1,
37 setWith: [
38 'project:id,name,is_promote,is_can_manage',
39 'tags:id,name',
40 'user:id,nick_name,real_name,identity',
41 'links:id,nick_name,identity',
42 ],
43 setColumn: [
44 'id',
45 'song_name',
46 'cover',
47 'sub_title',
48 'lyric',
49 'clip_lyric',
50 'expand',
51 'status',
52 'estimate_release_at',
53 'created_at',
54 'song_type',
55 'project_id',
56 'user_id',
57 ],
58 })
59 .then((data) => {
60 activity.value = data;
61 })
62 .catch(() => router.replace({ name: 'exception-404' }));
63 });
64 </script>
65
66 <template>
67 <page-view has-bread>
68 <basic-card :loading="!activity" :data="activity" />
69
70 <a-card style="margin-top: 16px">
71 <a-tabs v-model:active-key="tabKey" :animation="true" :header-padding="false" :justify="true" type="rounded">
72 <a-tab-pane key="submit-work" :title="`参与试唱(${total.submit_total})`">
73 <SubmitTable :activity-id="activityKey" :export-name="activity.song_name" :query-hook="syncSubmitCount" />
74 </a-tab-pane>
75 <a-tab-pane key="match-work" :title="`合作用户(${total.match_total})`">
76 <match-table :activity-id="activityKey" :query-hook="syncMatchCount" />
77 </a-tab-pane>
78 <a-tab-pane key="material" title="歌曲物料">
79 <material-table :data="activity as any" />
80 </a-tab-pane>
81 <a-tab-pane key="listen-user" :title="`试听用户(${total.view_total})`">
82 <view-user-table :activity-id="activityKey" @init-total="(value) => (total.view_total = value)" />
83 </a-tab-pane>
84 <a-tab-pane key="like-user" :title="`收藏用户(${total.like_total})`">
85 <like-user-table :activity-id="activityKey" @init-total="(value) => (total.like_total = value)" />
86 </a-tab-pane>
87 <a-tab-pane key="out-manager" :title="`外部管理员(${total.manager_total})`">
88 <manage-table
89 ref="managerRef"
90 :activity-id="activityKey"
91 :can-manage="activity.project?.is_can_manage || 0"
92 @init-total="(value) => (total.manager_total = value)"
93 />
94 </a-tab-pane>
95 </a-tabs>
96 </a-card>
97 </page-view>
98 </template>
99
100 <style lang="less" scoped>
101 textarea.arco-textarea::-webkit-scrollbar {
102 display: none;
103 }
104 </style>
1 <template>
2 <page-view has-bread has-card>
3 <filter-search :model="filter" :loading="loading" @search="onSearch" @reset="onReset">
4 <filter-search-item label="歌曲名称" field="song_name">
5 <a-input v-model="filter.songName" allow-clear placeholder="请输入搜索歌曲名称" />
6 </filter-search-item>
7 <filter-search-item v-if="!hideSearchItem?.includes('projectName')" label="厂牌名称" field="projectName">
8 <a-input v-model="filter.projectName" allow-clear placeholder="请输入搜索厂牌名称" />
9 </filter-search-item>
10 <filter-search-item label="标签名称" field="tagName">
11 <a-input v-model="filter.tagName" allow-clear placeholder="请输入搜索标签名称" />
12 </filter-search-item>
13 <filter-search-item label="状态" field="status">
14 <a-select v-model="filter.status" :options="statusOption" allow-clear multiple placeholder="请选择搜索状态" />
15 </filter-search-item>
16 <filter-search-item label="创建时间" field="createBetween">
17 <a-range-picker v-model="filter.createBetween" show-time :time-picker-props="{ defaultValue: ['00:00:00', '23:59:59'] }" />
18 </filter-search-item>
19 </filter-search>
20 <filter-table ref="tableRef" :loading="loading" :on-query="onQuery" @row-sort="onSort">
21 <template v-if="!hideTool" #tool-right>
22 <export-button :on-download="onExport" />
23 </template>
24 <activity-table-column data-index="id" title="试唱歌曲" :width="500" />
25 <number-table-column data-index="view_count" title="试听人数" :dark-value="0" :width="110" has-sort />
26 <number-table-column data-index="like_count" title="收藏人数" :dark-value="0" :width="110" has-sort />
27 <number-table-column data-index="submit_work_count" title="提交人数" :dark-value="0" :width="110" has-sort />
28 <date-table-column data-index="created_at" title="创建时间" :width="110" split has-sort />
29 <date-table-column data-index="audit_at" title="通过时间" :width="110" split has-sort />
30 <date-table-column data-index="match_at" title="确认时间" :width="110" split has-sort />
31 <number-table-column data-index="match_day" title="匹配时间" :width="110" :dark-value="0" suffix="天" has-sort />
32 <enum-table-column data-index="status" title="状态" :option="statusOption" :width="110" has-sort />
33 <a-table-column :width="90" align="center" data-index="operations" fixed="right" title="操作">
34 <template #cell="{ record }">
35 <a-space direction="vertical" fill>
36 <a-link
37 :hoverable="false"
38 class="link-hover"
39 @click="$router.push({ name: 'audition-activity-show', params: { id: record.id } })"
40 >
41 查看
42 </a-link>
43 <a-link v-if="record.status === 1" :hoverable="false" class="link-hover" @click="onDown(record)">下架</a-link>
44 <a-link v-if="record.status === 2" :hoverable="false" class="link-hover" @click="onUp(record, 'up')">上架</a-link>
45 <a-link v-if="record.status !== 0 && record.status !== 5" :hoverable="false" class="link-hover" @click="onUpdate(record)">
46 编辑
47 </a-link>
48 <a-link v-if="record.status === 3" :hoverable="false" class="link-hover" @click="onUp(record, 'reUp')">重新上架</a-link>
49 <a-link v-if="record.status === 3" :hoverable="false" class="link-hover" @click="onSend(record)">发行</a-link>
50 <a-link v-if="record.status === 5" :hoverable="false" class="link-hover" @click="onSend(record)">编辑发行</a-link>
51 </a-space>
52 </template>
53 </a-table-column>
54 </filter-table>
55 </page-view>
56 </template>
57
58 <script setup lang="ts" name="audition-activity">
59 import { createVNode, onMounted, ref } from 'vue';
60 import { AnyObject, AttributeData } from '@/types/global';
61 import useLoading from '@/hooks/loading';
62 import useActivityApi from '@/api/activity';
63 import { Message, TableData } from '@arco-design/web-vue';
64 import EnumTableColumn from '@/components/filter/enum-table-column.vue';
65 import DateTableColumn from '@/components/filter/date-table-column.vue';
66 import NumberTableColumn from '@/components/filter/number-table-column.vue';
67 import ActivityForm from '@/views/audition/activity-apply/components/form-content.vue';
68
69 import { createFormVNode, createInputFormItemVNode, createInputVNode, createModalVNode } from '@/utils/createVNode';
70 import ActivityTableColumn from '@/components/filter/activity-table-column.vue';
71
72 const props = defineProps<{
73 initFilter?: AttributeData;
74 hideSearchItem?: string[];
75 hideTool?: boolean;
76 exportName?: string;
77 queryHook?: () => void;
78 }>();
79
80 const { statusOption, get, getExport, update, changeStatus } = useActivityApi;
81 const { loading, setLoading } = useLoading(false);
82
83 const filter = ref<AttributeData>({});
84 const tableRef = ref();
85
86 const onQuery = async (params: object) => {
87 setLoading(true);
88 props.queryHook?.();
89 return get({
90 ...filter.value,
91 ...params,
92 setColumn: [
93 'id',
94 'song_name',
95 'cover',
96 'lang',
97 'speed',
98 'sub_title',
99 'sex',
100 'user_id',
101 'project_id',
102 'send_url',
103 'lyric',
104 'clip_lyric',
105 'expand',
106 'status',
107 'created_at',
108 'match_at',
109 'match_day',
110 'audit_at',
111 'estimate_release_at',
112 ],
113 setWith: ['project:id,name', 'user:id,real_name,nick_name,identity', 'tags:id,name'],
114 setWithCount: ['viewUsers as view_count', 'collectionUsers as like_count', 'submitUsers as submit_work_count'],
115 }).finally(() => setLoading(false));
116 };
117
118 const onSort = (column: string, type: string) => {
119 filter.value.setSort = type ? [(type === 'desc' ? '-' : '') + column, '-id'] : ['-id'];
120 tableRef.value?.onFetch();
121 };
122
123 const onSearch = () => tableRef.value?.onPageChange(1);
124
125 const onReset = () => {
126 filter.value = {
127 songName: '',
128 projectName: '',
129 tagName: '',
130 songType: 1,
131 status: [],
132 createBetween: [],
133 ...props.initFilter,
134 setSort: ['-id'],
135 };
136 tableRef.value?.resetSort();
137 onSearch();
138 };
139
140 const onExport = () => getExport(props.exportName || '歌曲', filter.value);
141
142 const onUpdate = (record: TableData) => {
143 const dialog = createModalVNode(
144 () =>
145 createVNode(ActivityForm, {
146 initValue: record,
147 onSubmit: (attribute: AnyObject) =>
148 update(record.id, Object.assign(attribute, { song_type: 1 })).then(() => {
149 Message.success(`编辑歌曲:${record.song_name}`);
150 tableRef.value?.onFetch();
151 dialog.close();
152 }),
153 }),
154 { title: '编辑歌曲', footer: false, width: 'auto', closable: true }
155 );
156 };
157
158 const onDown = (record: TableData) => {
159 const msg = ref<string>('');
160
161 createModalVNode(
162 () =>
163 createInputVNode(msg, {
164 'placeholder': '请输入下架原因',
165 'maxLength': 20,
166 'show-word-limit': true,
167 }),
168 {
169 title: '变更状态',
170 titleAlign: 'start',
171 okText: '下架',
172 onBeforeOk: (done) =>
173 changeStatus(record.id, { status: 'down', msg: msg.value })
174 .then(() => {
175 tableRef.value?.onFetch();
176 done(true);
177 })
178 .catch(() => done(false)),
179 }
180 );
181 };
182
183 const onUp = (record: TableData, status: 'up' | 'reUp') => {
184 createModalVNode(`请确认是否上架歌曲:${record.song_name}`, {
185 title: '变更状态',
186 titleAlign: 'start',
187 okText: '上架',
188 onBeforeOk: (done) =>
189 changeStatus(record.id, { status })
190 .then(() => {
191 tableRef.value?.onFetch();
192 done(true);
193 })
194 .catch(() => done(false)),
195 });
196 };
197
198 const onSend = (record: TableData) => {
199 const link = ref(record.send_url?.[0]?.url || '');
200
201 createModalVNode(
202 () =>
203 createFormVNode({ model: link }, [
204 createVNode(
205 'div',
206 { style: { fontFamily: 'PingFangSC-Regular, serif', paddingBottom: '16px', color: '#696969' } },
207 '将歌曲发行平台的url链接回填,会展示在App或小程序应用端。将会给您带来更多播放量 ~'
208 ),
209 createInputFormItemVNode(link, { label: 'QQ音乐或酷狗平台链接', rowClass: 'mb-0', required: true }, { size: 'small' }),
210 ]),
211 {
212 title: link.value.length === 0 ? '发行' : '编辑发行',
213 titleAlign: 'start',
214 onBeforeOk: (done) => {
215 changeStatus(record.id, { status: 'send', link: link.value })
216 .then(() => {
217 tableRef.value?.onFetch();
218 done(true);
219 })
220 .catch(() => done(false));
221 },
222 }
223 );
224 };
225
226 onMounted(() => onReset());
227 </script>
228
229 <style scoped lang="less">
230 :deep(.arco-table-cell) {
231 padding: 5px 16px !important;
232 }
233 </style>
1 <script setup lang="ts">
2 import { useSelectionStore } from '@/store';
3
4 import { Layout, LayoutSider, LayoutContent, LayoutFooter, Step, Steps, Space, Divider, Link } from '@arco-design/web-vue';
5 import Step1FormContent from '@/views/audition/demo-apply/components/step1-form-content.vue';
6 import Step2FormContent from '@/views/audition/demo-apply/components/step2-form-content.vue';
7 import Step3FormContent from '@/views/audition/demo-apply/components/step3-form-content.vue';
8 import IconButton from '@/components/icon-button/index.vue';
9
10 import { AnyObject } from '@/types/global';
11 import useLoading from '@/hooks/loading';
12 import { computed, markRaw, ref } from 'vue';
13 import { cloneDeep } from 'lodash';
14
15 const props = defineProps<{
16 initValue: AnyObject;
17 filterProject?: (value: unknown) => boolean;
18 onSubmit: (data: AnyObject) => Promise<any>;
19 }>();
20
21 const { loading, setLoading } = useLoading(false);
22 const formRef = ref();
23 const formValue = ref({ ...cloneDeep(props.initValue) });
24
25 const stepItems = [
26 { value: 1, label: '基本信息', template: markRaw(Step1FormContent) },
27 { value: 2, label: '补充信息', template: markRaw(Step2FormContent) },
28 { value: 3, label: '上传文件', template: markRaw(Step3FormContent) },
29 ];
30
31 const currentStep = ref(1);
32 const currentContent = computed(() => stepItems.find((item) => item.value === currentStep.value)?.template);
33 const nextBtnLabel = computed(() => (currentStep.value === 3 ? '提交' : '下一步'));
34
35 const onPrev = (): void => {
36 currentStep.value = Math.max(1, currentStep.value - 1);
37 };
38
39 const onNext = () => {
40 formRef.value.onValid(async () => {
41 if (currentStep.value === stepItems.length) {
42 setLoading(true);
43 props.onSubmit(formValue.value).finally(() => setLoading(false));
44 }
45 currentStep.value = Math.min(stepItems.length, currentStep.value + 1);
46 });
47 };
48 </script>
49
50 <template>
51 <Layout>
52 <Layout has-sider>
53 <LayoutSider :width="140" class="aside">
54 <Steps :current="currentStep" direction="vertical">
55 <Step v-for="item in stepItems" :key="item.value">{{ item.label }}</Step>
56 </Steps>
57 </LayoutSider>
58 <LayoutContent class="main">
59 <component :is="currentContent" ref="formRef" v-model="formValue" v-model:loading="loading" :filter-project="filterProject" />
60 </LayoutContent>
61 </Layout>
62 <LayoutFooter>
63 <Divider style="margin-top: 8px; margin-bottom: 20px" />
64 <Link :href="useSelectionStore().lyricTool" :hoverable="false" class="link-hover" icon>歌词制作工具</Link>
65 <Space style="float: right">
66 <IconButton v-show="currentStep !== 1" icon="left" label="上一步" @click="onPrev" />
67 <IconButton icon="right" icon-align="right" :loading="loading" :label="nextBtnLabel" @click="onNext" />
68 </Space>
69 </LayoutFooter>
70 </Layout>
71 </template>
72
73 <style scoped lang="less">
74 .aside {
75 box-shadow: unset !important;
76 border-right: 1px solid var(--color-border);
77 margin-right: 20px;
78 background-color: transparent;
79 }
80
81 .main {
82 min-width: 640px;
83 }
84 </style>
1 <script setup lang="ts">
2 import { Form, FormItem, Input, Message, Select } from '@arco-design/web-vue';
3 import { computed, ref } from 'vue';
4 import AvatarUpload from '@/components/avatar-upload/index.vue';
5 import { FieldRule, FormInstance } from '@arco-design/web-vue/es/form';
6 import { useSelectionStore } from '@/store';
7 import { get, set } from 'lodash';
8 import { useVModels } from '@vueuse/core';
9
10 const props = withDefaults(defineProps<{ loading?: boolean; modelValue?: any; filterProject?: (value: any) => boolean }>(), {
11 filterProject: () => true,
12 });
13
14 const emits = defineEmits(['update:modelValue', 'update:loading']);
15
16 const formRef = ref<FormInstance>();
17 const { modelValue: formValue } = useVModels(props, emits);
18
19 const formRule = {
20 'cover': [{ type: 'string', required: true, message: '请上传活动封面' }],
21 'song_name': [{ type: 'string', required: true, message: '请输入歌曲名称' }],
22 'expand.tag_ids': [
23 { type: 'array', required: false, message: '请选择关联标签' },
24 { type: 'array', maxLength: 3, message: '关联标签最多选中3个' },
25 ],
26 'project_id': [{ type: 'number', min: 1, required: true, message: '请选择关联厂牌' }],
27 } as Record<string, FieldRule[]>;
28
29 const tagIds = computed({
30 get: () => get(formValue.value, 'expand.tag_ids', []),
31 set: (val) => set(formValue.value, 'expand.tag_ids', val),
32 });
33
34 const { activityTagOptions, projectOptions } = useSelectionStore();
35
36 const onTagExceedLimitError = (content: string, duration = 1000) => Message.warning({ content, duration });
37
38 defineExpose({
39 onValid: (callback?: () => void) => formRef.value?.validate((errors) => !errors && callback?.()),
40 });
41 </script>
42
43 <template>
44 <Form ref="formRef" :model="formValue" :rules="formRule" :auto-label-width="true">
45 <FormItem label="封面图片" field="cover" :show-colon="true">
46 <AvatarUpload v-model="formValue.cover" :size="100" shape="square" />
47 </FormItem>
48 <FormItem label="歌曲名称" field="song_name" :show-colon="true">
49 <Input v-model="formValue.song_name" :max-length="100" :show-word-limit="true" placeholder="请输入" />
50 </FormItem>
51 <FormItem label="曲风标签" field="expand.tag_ids" :show-colon="true">
52 <Select
53 v-model="tagIds"
54 :options="activityTagOptions"
55 :limit="3"
56 :field-names="{ value: 'id', label: 'name' }"
57 :fallback-option="false"
58 :virtual-list-props="{ height: 200 }"
59 :multiple="true"
60 placeholder="请选择"
61 @exceed-limit="onTagExceedLimitError('关联标签最多选中3个')"
62 />
63 </FormItem>
64 <FormItem label="关联厂牌" field="project_id" :show-colon="true">
65 <Select
66 v-model="formValue.project_id"
67 :options="projectOptions.filter((item) => filterProject(item))"
68 :field-names="{ value: 'id', label: 'name' }"
69 :fallback-option="false"
70 :allow-search="true"
71 placeholder="请选择"
72 />
73 </FormItem>
74 </Form>
75 </template>
76
77 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { Form, FormItem, InputTag } from '@arco-design/web-vue';
3 import { ref, computed } from 'vue';
4 import { FieldRule, FormInstance } from '@arco-design/web-vue/es/form';
5 import { useVModels } from '@vueuse/core';
6 import { get, set } from 'lodash';
7 import UserSelect from '@/components/user-select/index.vue';
8
9 const props = defineProps<{ loading?: boolean; modelValue?: any }>();
10 const emits = defineEmits(['update:modelValue', 'update:loading']);
11
12 const formRef = ref<FormInstance>();
13 const { modelValue: formValue } = useVModels(props, emits);
14
15 const lyricistIds = computed({
16 get: () => get(formValue.value, 'expand.lyricist.ids', []),
17 set: (val) => set(formValue.value, 'expand.lyricist.ids', val),
18 });
19
20 const lyricistSupplement = computed({
21 get: () => get(formValue.value, 'expand.lyricist.supplement', []),
22 set: (val) => set(formValue.value, 'expand.lyricist.supplement', val),
23 });
24
25 const composerIds = computed({
26 get: () => get(formValue.value, 'expand.composer.ids', []),
27 set: (val) => set(formValue.value, 'expand.composer.ids', val),
28 });
29
30 const composerSupplement = computed({
31 get: () => get(formValue.value, 'expand.composer.supplement', []),
32 set: (val) => set(formValue.value, 'expand.composer.supplement', val),
33 });
34
35 const checkMaxUser = (value: any, cb: (error?: string) => void) => (value && value.length > 2 ? cb('最大选择2人') : false);
36
37 const formRule = {
38 'expand.lyricist.supplement': [{ type: 'array', validator: (value, cb) => checkMaxUser(value, cb) }],
39 'expand.composer.supplement': [{ type: 'array', validator: (value, cb) => checkMaxUser(value, cb) }],
40 'estimate_release_at': [{ required: true, message: '请选择预计发布时间' }],
41 } as Record<string, FieldRule[]>;
42
43 defineExpose({
44 onValid: (callback?: () => void) => formRef.value?.validate((errors) => !errors && callback?.()),
45 });
46 </script>
47
48 <template>
49 <Form ref="formRef" :model="formValue" :rules="formRule" :auto-label-width="true">
50 <FormItem label="词作者(用户)" field="expand.lyricist.ids" :show-colon="true">
51 <UserSelect v-model="lyricistIds" placeholder="请选择用户" :limit="2" />
52 </FormItem>
53 <FormItem label="词作者(未注册)" field="expand.lyricist.supplement" :show-colon="true">
54 <InputTag v-model="lyricistSupplement" placeholder="输入完成回车键分隔" :max-tag-count="2" :unique-value="true" />
55 <template #extra>
56 <div>注意:上方填1项即可,歌曲信息中【用户】可点击跳转用户主页,【未注册】仅显示;</div>
57 </template>
58 </FormItem>
59 <FormItem label="曲作者(用户)" field="expand.composer.ids" :show-colon="true">
60 <UserSelect v-model="composerIds" placeholder="请选择用户" :limit="2" />
61 </FormItem>
62 <FormItem label="曲作者(未注册)" field="expand.composer.supplement" :show-colon="true">
63 <InputTag v-model="composerSupplement" placeholder="输入完成回车键分隔" :max-tag-count="2" :unique-value="true" />
64 <template #extra>
65 <div>注意:上方填1项即可,歌曲信息中【用户】可点击跳转用户主页,【未注册】仅显示;</div>
66 </template>
67 </FormItem>
68 </Form>
69 </template>
70
71 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { ref, computed, createVNode } from 'vue';
3 import { FieldRule, FormInstance } from '@arco-design/web-vue/es/form';
4 import { useVModels } from '@vueuse/core';
5 import { Form, FormItem, Link, Textarea, TypographyText } from '@arco-design/web-vue';
6 import InputUpload from '@/components/input-upload/index.vue';
7 import { useSelectionStore } from '@/store';
8 import { get, set } from 'lodash';
9 import useAuthApi from '@/api/auth';
10 import axios from 'axios';
11 import AudioPreview from '@/views/audition/activity-apply/components/audio-preview.vue';
12 import AudioPlayer from '@/components/audio-player/index.vue';
13 import { createModalVNode } from '@/utils/createVNode';
14
15 const props = defineProps<{ loading?: boolean; modelValue?: any }>();
16 const emits = defineEmits(['update:modelValue', 'update:loading']);
17
18 const formRef = ref<FormInstance>();
19 const { loading, modelValue: formValue } = useVModels(props, emits);
20
21 const guideSourceUrl = computed({
22 get: () => get(formValue.value, 'expand.guide_source.url', ''),
23 set: (val) => set(formValue.value, 'expand.guide_source.url', val),
24 });
25
26 const karaokeSourceUrl = computed({
27 get: () => get(formValue.value, 'expand.karaoke_source.url', ''),
28 set: (val) => set(formValue.value, 'expand.karaoke_source.url', val),
29 });
30
31 const onUpdateGuide = (file: { name: string; url: string; size: number }) => {
32 set(formValue.value, 'expand.guide_source', { name: file.name, url: file.url, size: file.size });
33 };
34
35 const onUpdateKaraoke = (file: { name: string; url: string; size: number }) => {
36 set(formValue.value, 'expand.karaoke_source', { name: file.name, url: file.url, size: file.size });
37 };
38
39 const { activityAudioAccept } = useSelectionStore();
40
41 const formRule = {
42 'expand.guide_source.url': [{ required: true, message: '请上传导唱文件' }],
43 'expand.karaoke_source.url': [{ required: false, message: '请上传伴奏文件' }],
44 'lyric': [{ type: 'string', required: true, message: '请填写歌词内容' }],
45 } as Record<string, FieldRule[]>;
46
47 const onDownload = (url: string, fileName: string) => useAuthApi.downloadFile(url, `${formValue.value.song_name}(${fileName})`);
48
49 const guideFile = ref<File | undefined>();
50
51 const getGuideFile = async () => {
52 if (!guideFile.value) {
53 guideFile.value = await axios
54 .get(`${guideSourceUrl.value}`, { responseType: 'blob', timeout: 60000 })
55 .then(({ data }) => Promise.resolve(data));
56 }
57
58 return guideFile.value;
59 };
60
61 const onAudioPreview = async () => {
62 const src = await getGuideFile();
63 createModalVNode(() => createVNode(AudioPreview, { src, lyric: formValue.value.lyric }), {
64 title: '预览歌词-整首',
65 footer: false,
66 closable: true,
67 });
68 };
69
70 defineExpose({
71 onValid: (callback?: () => void) => formRef.value?.validate((errors) => !errors && callback?.()),
72 });
73 </script>
74
75 <template>
76 <Form ref="formRef" :model="formValue" :rules="formRule" :auto-label-width="true">
77 <FormItem label="音频文件" field="expand.guide_source.url" :show-colon="true">
78 <InputUpload
79 v-model="guideSourceUrl"
80 :accept="activityAudioAccept"
81 :limit="100"
82 @success="onUpdateGuide"
83 @choose-file="(file) => (guideFile = file)"
84 @update:loading="(value) => (loading = value)"
85 />
86 </FormItem>
87 <FormItem v-if="guideSourceUrl">
88 <template #label>
89 <Link icon @click="onDownload(guideSourceUrl, '音频文件')">下载音频</Link>
90 </template>
91 <AudioPlayer name="音频文件" :url="guideSourceUrl" />
92 </FormItem>
93 <FormItem label="伴奏文件" field="expand.karaoke_source.url" :show-colon="true">
94 <InputUpload
95 v-model="karaokeSourceUrl"
96 :accept="activityAudioAccept"
97 :limit="100"
98 @success="onUpdateKaraoke"
99 @update:loading="(value) => (loading = value)"
100 />
101 </FormItem>
102 <FormItem v-if="karaokeSourceUrl">
103 <template #label>
104 <Link icon @click="onDownload(karaokeSourceUrl, '伴奏文件')">下载伴奏</Link>
105 </template>
106 <AudioPlayer name="伴奏文件" :url="karaokeSourceUrl" />
107 </FormItem>
108 <FormItem class="lyric" label="歌词文本" field="lyric" :show-colon="true">
109 <Textarea
110 v-model="formValue.lyric"
111 :auto-size="{ minRows: 7, maxRows: 7 }"
112 placeholder="请粘贴带时间的lrc歌词文本至输入框"
113 @blur="() => (formValue.lyric = formValue.lyric?.replace(/(\n[\s\t]*\r*\n)/g, '\n').replace(/^[\n\r\t]*|[\n\r\t]*$/g, ''))"
114 />
115 <!-- <template #extra>
116 <Link :hoverable="false" :disabled="!guideSourceUrl || !formValue.lyric" @click="onAudioPreview"> 预览歌词</Link>
117 </template> -->
118 </FormItem>
119 <TypographyText type="danger" style="font-size: 13px">
120 注意:demo上架后,存在于您的厂牌私库。需要在App应用内,您的厂牌主页下,进行1对1分享给对方试听
121 </TypographyText>
122 </Form>
123 </template>
124
125 <style scoped lang="less">
126 .lyric {
127 :deep(.arco-form-item-extra) {
128 width: 100%;
129 text-align: right;
130 }
131 }
132 </style>
1 <template>
2 <page-view has-card has-bread>
3 <filter-search :loading="loading" @search="onSearch" @reset="onReset">
4 <filter-search-item label="歌曲名称">
5 <a-input v-model="filter.songName" :allow-clear="true" placeholder="请输入搜索歌曲名称" />
6 </filter-search-item>
7 <filter-search-item label="厂牌名称">
8 <a-input v-model="filter.projectName" :allow-clear="true" placeholder="请输入搜索厂牌名称" />
9 </filter-search-item>
10 <filter-search-item label="标签名称">
11 <a-input v-model="filter.tagName" :allow-clear="true" placeholder="请输入搜索标签名称" />
12 </filter-search-item>
13 <filter-search-item label="创建人">
14 <a-input v-model="filter.userName" :allow-clear="true" placeholder="请输入搜索创建人名称" />
15 </filter-search-item>
16 <filter-search-item label="创建时间">
17 <a-range-picker v-model="filter.createBetween" :allow-clear="true" />
18 </filter-search-item>
19 <filter-search-item label="状态">
20 <a-select v-model.number="filter.auditStatus" :options="useApply.auditStatusOption" placeholder="请选择搜索审核状态" />
21 </filter-search-item>
22 </filter-search>
23 <filter-table ref="tableRef" :loading="loading" :on-query="onQuery">
24 <template #tool="{ size }">
25 <a-button v-if="canCreate" :size="size" type="primary" @click="handleCreate">Demo上架</a-button>
26 </template>
27 <activity-table-column :width="540" data-index="id" title="歌曲" hide-sub-title />
28 <user-table-column :width="180" data-index="user_id" title="创建人" user="user" />
29 <date-table-column :width="120" data-index="created_at" title="创建时间" split />
30 <enum-table-column :width="120" data-index="audit_status" title="状态" :option="useApply.auditStatusOption" />
31 <space-table-column :width="80" data-index="operations" title="操作" direction="vertical">
32 <template #default="{ record }">
33 <span v-if="record.audit_status === 0" style="color: rgba(44, 44, 44, 0.5)"></span>
34 <template v-else>
35 <a-link class="link-hover" :hoverable="false" @click="handleUpdate(record)">编辑</a-link>
36 <a-link class="link-hover" :hoverable="false" @click="handleDelete(record)">删除</a-link>
37 </template>
38 </template>
39 </space-table-column>
40 </filter-table>
41 </page-view>
42 </template>
43
44 <script lang="ts" name="audition-apply" setup>
45 import useLoading from '@/hooks/loading';
46 import { computed, createVNode, onActivated, ref } from 'vue';
47 import { AnyObject, QueryForParams } from '@/types/global';
48 import { useApply } from '@/api/activity';
49
50 import FilterTable from '@/components/filter/table.vue';
51 import UserTableColumn from '@/components/filter/user-table-column.vue';
52 import EnumTableColumn from '@/components/filter/enum-table-column.vue';
53 import SpaceTableColumn from '@/components/filter/space-table-column.vue';
54 import DateTableColumn from '@/components/filter/date-table-column.vue';
55 import ActivityTableColumn from '@/components/filter/activity-table-column.vue';
56
57 import { Message, Table, TableData, TabPane, Tabs } from '@arco-design/web-vue';
58 import FormContent from '@/views/audition/demo-apply/components/form-content.vue';
59
60 import { useRouteQuery } from '@vueuse/router';
61 import { useRoute } from 'vue-router';
62 import { createModalVNode } from '@/utils/createVNode';
63 import { promiseToBoolean } from '@/utils';
64
65 import { Project } from '@/types/project';
66 import { useSelectionStore } from '@/store';
67 import { findIndex } from 'lodash';
68
69 const { loading, setLoading } = useLoading(false);
70 const tableRef = ref();
71 const filter = ref<AnyObject>({});
72
73 const canCreate = computed(() => {
74 return findIndex(useSelectionStore().projectOptions, (item) => item.is_can_demo_apply === 1) !== -1;
75 });
76
77 const queryParams = computed(() => {
78 return {
79 ...filter.value,
80 createdForm: 0,
81 songType: 2,
82 setColumn: ['id', 'song_name', 'cover', 'user_id', 'project_id', 'lyric', 'expand', 'audit_status', 'created_at'],
83 setWith: [
84 'project:id,name',
85 'user:id,real_name,nick_name,role',
86 'tags:id,name',
87 'applyRecords:id,activity_id,current,audit_msg,created_at',
88 ],
89 setSort: ['-audit_status', '-updated_at'],
90 } as QueryForParams;
91 });
92
93 const onQuery = async (params: AnyObject): Promise<any> => {
94 setLoading(true);
95 return useApply.get({ ...queryParams.value, ...params }).finally(() => setLoading(false));
96 };
97
98 const onSearch = () => tableRef.value?.onPageChange();
99
100 const initAuditStatus = useRouteQuery('auditStatus', '');
101
102 const onReset = (initParams: AnyObject) => {
103 filter.value = { songName: '', projectName: '', tagName: '', userName: '', auditStatus: '', createBetween: [], ...initParams };
104 onSearch();
105 };
106
107 onActivated(() => {
108 if (useRoute()?.meta?.reload) {
109 onReset({ auditStatus: initAuditStatus.value ? Number(initAuditStatus.value) : '' });
110 } else {
111 tableRef.value?.onFetch();
112 }
113 });
114
115 const handleCreate = () => {
116 const dialog = createModalVNode(
117 () =>
118 createVNode(FormContent, {
119 initValue: { song_type: 2, cover: useSelectionStore().appleDemoCover, created_form: 0, is_push: 0, expand: { push_type: [] } },
120 filterProject: (value: Project) => value.is_can_demo_apply === 1,
121 onSubmit: (data: AnyObject) =>
122 useApply.create(data).then(() => {
123 Message.success(`申请上架活动:${data.song_name}`);
124 tableRef.value?.onFetch();
125 dialog.close();
126 }),
127 }),
128 { title: '创建Demo', footer: false, closable: true, width: 'auto' }
129 );
130 };
131
132 const handleUpdate = (row: TableData) => {
133 const column = [
134 { title: '操作人', dataIndex: 'id', width: 100, ellipsis: true, tooltip: true, render: () => '平台运营' },
135 { title: '操作时间', dataIndex: 'created_at', width: 170, ellipsis: true, tooltip: true },
136 { title: '驳回说明', dataIndex: 'audit_msg', width: 500, ellipsis: true, tooltip: true },
137 ];
138
139 const dialog = createModalVNode(
140 () =>
141 createVNode(Tabs, { type: 'rounded', size: 'mini' }, [
142 createVNode(TabPane, { title: '试唱Demo上架', key: 'applyRef' }, () =>
143 createVNode(FormContent, {
144 initValue: row,
145 filterProject: (value: Project) => value.is_can_demo_apply === 1 || value.id === row.project_id,
146 onSubmit: (data: AnyObject) =>
147 useApply.update(row.id, Object.assign(data, { song_type: 2 })).then(() => {
148 Message.success(`重新申请上架活动:${row.song_name}`);
149 tableRef.value?.onFetch();
150 dialog.close();
151 }),
152 })
153 ),
154 createVNode(TabPane, { title: `审核驳回记录(${row.apply_records?.length || 0}`, key: 'record' }, () => {
155 return createVNode(Table, {
156 columns: column,
157 data: row.apply_records || [],
158 bordered: false,
159 tableLayoutFixed: true,
160 pagination: false,
161 scroll: { y: 370 },
162 size: 'medium',
163 rowKey: 'id',
164 });
165 }),
166 ]),
167 { title: '编辑Demo', footer: false, closable: true, width: '860px' }
168 );
169 };
170
171 const handleDelete = (row: TableData) => {
172 createModalVNode(`确认要将Demo${row.song_name} 删除吗?`, {
173 title: '删除操作',
174 onBeforeOk: () => promiseToBoolean(useApply.destroy(row.id)),
175 onOk: () => tableRef.value?.onFetch(),
176 });
177 };
178 </script>
179
180 <style scoped></style>
1 <script setup lang="ts">
2 import { Activity } from '@/types/activity';
3 import { computed } from 'vue';
4 import { User } from '@/types/user';
5 import UserTag from '@/views/audition/activity-show/components/user-tag.vue';
6 import { unionBy } from 'lodash';
7 import useActivityApi from '@/api/activity';
8
9 const props = defineProps<{ data: Activity; loading?: boolean }>();
10
11 const links = computed(() => {
12 return unionBy(props.data.links || [], 'id');
13 });
14
15 const inSideLyricist = computed(
16 (): User[] => links.value?.filter((item: User) => props.data.expand?.lyricist?.ids?.indexOf(item.id) !== -1) || []
17 );
18 const outSideLyricist = computed(() => props.data.expand?.lyricist?.supplement || []);
19
20 const inSideComposer = computed(
21 (): User[] => links.value?.filter((item: User) => props.data.expand?.composer?.ids?.indexOf(item.id) !== -1) || []
22 );
23 const outSideComposer = computed(() => props.data.expand?.composer?.supplement || []);
24 </script>
25
26 <template>
27 <a-spin :loading="loading as boolean" style="width: 100%">
28 <a-card :bordered="false">
29 <a-form auto-label-width label-align="left">
30 <a-layout>
31 <a-layout-sider :width="130" style="background: none; box-shadow: none; padding-top: 6px">
32 <a-image show-loader :height="130" :width="130" :src="data.cover" />
33 </a-layout-sider>
34 <a-layout-content style="margin-left: 16px">
35 <a-form-item :hide-label="true">
36 <div class="title">{{ data.song_name }}</div>
37 <a-tag v-for="item in data.tags" :key="item.id" size="small" style="margin-right: 5px">
38 {{ item.name }}
39 </a-tag>
40 <span style="font-size: 10px">
41 {{ useActivityApi.statusOption.find((item) => item.value === data.status)?.label }}
42 </span>
43 </a-form-item>
44 <a-form-item
45 :label-col-style="{ flex: 0 }"
46 :wrapper-col-style="{ flex: 'unset', width: 'inherit' }"
47 :show-colon="true"
48 label="关联厂牌"
49 >
50 <div v-if="data.project">{{ data.project.name }}</div>
51 <div v-else></div>
52 </a-form-item>
53 <a-form-item style="margin-bottom: 0 !important" hide-label>
54 <a-form-item :label-col-style="{ flex: 0 }" :show-colon="true" label="作词">
55 <user-tag v-for="item in inSideLyricist" :key="item.id" :user="{ nick_name: item.nick_name }" style="margin-right: 5px" />
56 <user-tag v-for="item in outSideLyricist" :key="`lyricist-${item}`" :user="{ nick_name: item }" style="margin-right: 5px" />
57 </a-form-item>
58 <a-form-item :label-col-style="{ flex: 0 }" :show-colon="true" label="作曲">
59 <user-tag v-for="item in inSideComposer" :key="item.id" :user="{ nick_name: item.nick_name }" style="margin-right: 5px" />
60 <user-tag v-for="item in outSideComposer" :key="`lyricist-${item}`" :user="{ nick_name: item }" style="margin-right: 5px" />
61 </a-form-item>
62 </a-form-item>
63 <a-form-item style="margin-bottom: 0 !important" :label-col-style="{ flex: 0 }" :show-colon="true" label="创建信息">
64 <span v-if="data.user" style="margin-right: 8px">{{ data.user.nick_name }}</span>
65 <span style="margin-right: 8px">{{ data.created_at }} </span>
66 </a-form-item>
67 </a-layout-content>
68 </a-layout>
69 </a-form>
70 </a-card>
71 </a-spin>
72 </template>
73
74 <style lang="less" scoped>
75 .arco-form-item {
76 margin-bottom: 10px;
77 }
78
79 .arco-form-item-label-col {
80 flex: 0;
81 }
82
83 .title {
84 font-size: 16px;
85 font-weight: bold;
86 margin-right: 8px;
87 }
88
89 .right {
90 margin: 0 20px;
91 min-width: 600px;
92 }
93 </style>
1 <script setup lang="ts">
2 import { Modal, Textarea } from '@arco-design/web-vue';
3 import { computed, h } from 'vue';
4 import { ActivityExpand } from '@/types/activity-apply';
5 import { useAppStore } from '@/store';
6 import useAuthApi from '@/api/auth';
7
8 type MaterialData = {
9 title: string;
10 type: string;
11 content: string;
12 name?: string;
13 size?: string;
14 };
15
16 const props = defineProps<{ data: { expand: ActivityExpand; lyric: string; song_name: string }; hideTrack?: boolean }>();
17
18 const appStore = useAppStore();
19 const theme = computed(() => appStore.theme);
20
21 const lyricStyle = computed(() =>
22 theme.value === 'light' ? { border: 'none', backgroundColor: 'white' } : { border: 'none', backgroundColor: '#2a2a2b' }
23 );
24
25 const bytesForHuman = (bytes: number, decimals = 2) => {
26 const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
27
28 let i = 0;
29
30 // eslint-disable-next-line no-plusplus
31 for (i; bytes > 1024; i++) {
32 bytes /= 1024;
33 }
34
35 return `${parseFloat(bytes.toFixed(decimals))} ${units[i]}`;
36 };
37
38 const onDownload = (record: MaterialData) => useAuthApi.downloadFile(record.content, `${props.data.song_name}(${record.title})`);
39
40 const onViewLyric = (lyric: string) => {
41 Modal.open({
42 content: () => h(Textarea, { defaultValue: lyric, autoSize: { maxRows: 20 }, style: lyricStyle.value }),
43 footer: false,
44 closable: false,
45 });
46 };
47
48 const materials = computed((): MaterialData[] => {
49 return [
50 {
51 title: '音频',
52 type: 'guide',
53 name: props.data.expand?.guide_source?.name || '',
54 content: props.data.expand?.guide_source?.url || '',
55 size: bytesForHuman(props.data.expand?.guide_source?.size || 0),
56 },
57 {
58 title: '伴奏',
59 type: 'karaoke',
60 name: props.data.expand?.karaoke_source?.name || '',
61 content: props.data.expand?.karaoke_source?.url || '',
62 size: bytesForHuman(props.data.expand?.karaoke_source?.size || 0),
63 },
64 { title: '歌词', type: 'Lyric', content: props.data.lyric },
65 ];
66 });
67 </script>
68
69 <template>
70 <a-table row-key="type" :data="materials" :bordered="false" :table-layout-fixed="true" :pagination="false">
71 <template #columns>
72 <a-table-column title="物料类型" align="center" data-index="title" :width="140" />
73 <a-table-column title="音频播放" data-index="content">
74 <template #cell="{ record }">
75 <template v-if="record.content">
76 <audio-player v-if="['guide', 'karaoke'].indexOf(record.type) !== -1" :name="record.type" :url="record.content" />
77 <div v-if="record.type === 'track'">{{ [record.name, record.size].join(',') }}</div>
78 </template>
79 </template>
80 </a-table-column>
81 <a-table-column title="操作" align="center" :width="200">
82 <template #cell="{ record }">
83 <template v-if="record.content">
84 <a-button v-if="record.type === 'Lyric'" type="primary" size="small" @click="onViewLyric(record.content)">查看</a-button>
85 <a-button v-else type="primary" size="small" @click="onDownload(record)">下载</a-button>
86 </template>
87 </template>
88 </a-table-column>
89 </template>
90 </a-table>
91 </template>
92
93 <style scoped lang="less"></style>
1 <script setup lang="ts" name="audition-demo-show">
2 import { useRoute, useRouter } from 'vue-router';
3 import { onMounted, ref } from 'vue';
4 import useActivityApi from '@/api/activity';
5 import BasicCard from '@/views/audition/demo-show/components/basic-card.vue';
6 import MaterialTable from '@/views/audition/demo-show/components/material-table.vue';
7 import ViewUserTable from '@/views/audition/activity-show/components/view-user-table.vue';
8 import LikeUserTable from '@/views/audition/activity-show/components/like-user-table.vue';
9
10 import { useRouteQuery } from '@vueuse/router';
11
12 const activityKey = Number(useRoute().params.id);
13 const tabKey = useRouteQuery('tabKey', 'material');
14
15 const activity = ref({});
16 const total = ref({ submit_total: 0, match_total: 0, view_total: 0, like_total: 0, manager_total: 0 });
17
18 const router = useRouter();
19 onMounted(async () => {
20 await useActivityApi
21 .show(activityKey, {
22 songType: 2,
23 setWith: [
24 'project:id,name,is_promote,is_can_manage',
25 'tags:id,name',
26 'user:id,nick_name,real_name,identity',
27 'links:id,nick_name,identity',
28 ],
29 setColumn: ['id', 'song_name', 'cover', 'lyric', 'expand', 'status', 'created_at', 'song_type', 'project_id', 'user_id'],
30 })
31 .then((data) => {
32 activity.value = data;
33 })
34 .catch(() => router.replace({ name: 'exception-404' }));
35 });
36 </script>
37
38 <template>
39 <page-view has-bread>
40 <basic-card :loading="!activity" :data="activity" />
41
42 <a-card style="margin-top: 16px">
43 <a-tabs v-model:active-key="tabKey" :animation="true" :header-padding="false" :justify="true" type="rounded">
44 <a-tab-pane key="material" title="歌曲物料">
45 <material-table :data="activity" />
46 </a-tab-pane>
47 <a-tab-pane key="listen-user" :title="`试听用户(${total.view_total})`">
48 <view-user-table :activity-id="activityKey" @init-total="(value) => (total.view_total = value)" />
49 </a-tab-pane>
50 <a-tab-pane key="like-user" :title="`收藏用户(${total.like_total})`">
51 <like-user-table :activity-id="activityKey" @init-total="(value) => (total.like_total = value)" />
52 </a-tab-pane>
53 </a-tabs>
54 </a-card>
55 </page-view>
56 </template>
57
58 <style lang="less" scoped>
59 textarea.arco-textarea::-webkit-scrollbar {
60 display: none;
61 }
62 </style>
1 <script setup lang="ts" name="audition-activity">
2 import { computed, createVNode, onMounted, ref } from 'vue';
3 import { AnyObject, AttributeData } from '@/types/global';
4 import useLoading from '@/hooks/loading';
5 import useActivityApi, { useApply } from '@/api/activity';
6 import NumberTableColumn from '@/components/filter/number-table-column.vue';
7 import { Message, TableData } from '@arco-design/web-vue';
8 import EnumTableColumn from '@/components/filter/enum-table-column.vue';
9 import { createInputVNode, createModalVNode } from '@/utils/createVNode';
10
11 import DemoForm from '@/views/audition/demo-apply/components/form-content.vue';
12 import ActivityForm from '@/views/audition/activity-apply/components/form-content.vue';
13 import DateTableColumn from '@/components/filter/date-table-column.vue';
14 import ActivityTableColumn from '@/components/filter/activity-table-column.vue';
15 import { useSelectionStore } from '@/store';
16 import { Project } from '@/types/project';
17 import { findIndex } from 'lodash';
18 import { promiseToBoolean } from '@/utils';
19
20 const props = defineProps<{
21 initFilter?: AttributeData;
22 hideSearchItem?: string[];
23 hideTool?: boolean;
24 hideCreate?: boolean;
25 exportName?: string;
26 queryHook?: () => void;
27 }>();
28
29 const { statusOption, get, getExport, update, destroy, changeStatus } = useActivityApi;
30 const { loading, setLoading } = useLoading(false);
31
32 const filter = ref<AttributeData>({});
33 const tableRef = ref();
34
35 const canCreate = computed(() => {
36 return findIndex(useSelectionStore().projectOptions, (item) => item.is_can_demo_apply === 1) !== -1;
37 });
38
39 const onQuery = async (params: object) => {
40 setLoading(true);
41 props.queryHook?.();
42 return get({
43 ...filter.value,
44 ...params,
45 setColumn: [
46 'id',
47 'song_name',
48 'cover',
49 'user_id',
50 'project_id',
51 'send_url',
52 'lyric',
53 'expand',
54 'status',
55 'created_at',
56 'audit_at',
57 'clip_lyric',
58 ],
59 setWith: ['project:id,name', 'user:id,real_name,nick_name,identity', 'tags:id,name'],
60 setWithCount: ['viewUsers as view_count', 'collectionUsers as like_count'],
61 }).finally(() => setLoading(false));
62 };
63
64 const onSort = (column: string, type: string) => {
65 filter.value.setSort = type ? [(type === 'desc' ? '-' : '') + column, '-id'] : ['-id'];
66 tableRef.value?.onFetch();
67 };
68
69 const onSearch = () => tableRef.value?.onPageChange(1);
70
71 const onReset = () => {
72 filter.value = {
73 songName: '',
74 projectName: '',
75 tagName: '',
76 status: [],
77 createBetween: [],
78 ...props.initFilter,
79 songType: 2,
80 setSort: ['-id'],
81 };
82 tableRef.value?.resetSort();
83 onSearch();
84 };
85
86 const handleCreate = () => {
87 const dialog = createModalVNode(
88 () =>
89 createVNode(DemoForm, {
90 initValue: { song_type: 2, cover: useSelectionStore().appleDemoCover, created_form: 0, is_push: 0, expand: { push_type: [] } },
91 filterProject: (value: Project) => value.is_can_demo_apply === 1,
92 onSubmit: (data: AnyObject) =>
93 useApply.create(data).then(() => {
94 Message.success(`申请上架Demo:${data.song_name}`);
95 tableRef.value?.onFetch();
96 dialog.close();
97 }),
98 }),
99 { title: '创建Demo', footer: false, closable: true, width: 'auto' }
100 );
101 };
102
103 const onExport = () => getExport(props.exportName || 'Demo', filter.value);
104
105 const onUpdate = (record: TableData) => {
106 const dialog = createModalVNode(
107 () =>
108 createVNode(DemoForm, {
109 initValue: record,
110 filterProject: (value: Project) => value.is_can_demo_apply === 1 || value.id === record.project_id,
111 onSubmit: (attribute: AnyObject) =>
112 update(record.id, Object.assign(attribute, { song_type: 2 })).then(() => {
113 Message.success(`编辑Demo:${record.song_name}`);
114 tableRef.value?.onFetch();
115 dialog.close();
116 }),
117 }),
118 { title: '编辑Demo', footer: false, width: 'auto', closable: true }
119 );
120 };
121
122 const onDown = (record: TableData) => {
123 const msg = ref<string>('');
124
125 createModalVNode(
126 () =>
127 createInputVNode(msg, {
128 'placeholder': '请输入下架原因',
129 'maxLength': 20,
130 'show-word-limit': true,
131 }),
132 {
133 title: '变更状态',
134 titleAlign: 'start',
135 okText: '下架',
136 onBeforeOk: (done) =>
137 changeStatus(record.id, { status: 'down', msg: msg.value })
138 .then(() => {
139 tableRef.value?.onFetch();
140 done(true);
141 })
142 .catch(() => done(false)),
143 }
144 );
145 };
146
147 const handleCreateActivity = (row: TableData) => {
148 console.log(row);
149 let editData = JSON.parse(JSON.stringify(row));
150 let { cover } = editData;
151 if (cover && cover == useSelectionStore().appleDemoCover) {
152 editData.cover = '';
153 }
154 editData.song_type = 1;
155 const dialog = createModalVNode(
156 () =>
157 createVNode(ActivityForm, {
158 initValue: editData,
159 filterProject: (value: Project) => value.is_can_apply === 1 || value.id === editData.project_id,
160 onSubmit: (data: AnyObject) =>
161 useApply.create(data).then(() => {
162 Message.success(`申请上架活动:${data.song_name}`);
163 tableRef.value?.onFetch();
164 dialog.close();
165 }),
166 }),
167 { title: '创建歌曲', footer: false, closable: true, width: 'auto' }
168 );
169 };
170
171 const onUp = (record: TableData, status: 'up' | 'reUp') => {
172 createModalVNode(`请确认是否上架歌曲:${record.song_name}`, {
173 title: '变更状态',
174 titleAlign: 'start',
175 okText: '上架',
176 onBeforeOk: (done) =>
177 changeStatus(record.id, { status })
178 .then(() => {
179 tableRef.value?.onFetch();
180 done(true);
181 })
182 .catch(() => done(false)),
183 });
184 };
185
186 const onDelete = (row: TableData) =>
187 createModalVNode(`确认要将Demo:${row.song_name} 删除吗?`, {
188 title: '删除操作',
189 onBeforeOk: () => promiseToBoolean(destroy(row.id)),
190 onOk: () => tableRef.value?.onFetch(),
191 });
192
193 onMounted(() => onReset());
194 </script>
195
196 <template>
197 <page-view has-bread has-card>
198 <filter-search :model="filter" :loading="loading" @search="onSearch" @reset="onReset">
199 <filter-search-item label="歌曲名称" field="song_name">
200 <a-input v-model="filter.songName" allow-clear placeholder="请输入搜索歌曲名称" />
201 </filter-search-item>
202 <filter-search-item v-if="!hideSearchItem?.includes('projectName')" label="厂牌名称" field="projectName">
203 <a-input v-model="filter.projectName" allow-clear placeholder="请输入搜索厂牌名称" />
204 </filter-search-item>
205 <filter-search-item label="标签名称" field="tagName">
206 <a-input v-model="filter.tagName" allow-clear placeholder="请输入搜索标签名称" />
207 </filter-search-item>
208 <filter-search-item label="状态" field="status">
209 <a-select v-model="filter.status" :options="statusOption" allow-clear multiple placeholder="请选择搜索状态" />
210 </filter-search-item>
211 <filter-search-item label="创建时间" field="createBetween">
212 <a-range-picker v-model="filter.createBetween" show-time :time-picker-props="{ defaultValue: ['00:00:00', '23:59:59'] }" />
213 </filter-search-item>
214 </filter-search>
215 <filter-table ref="tableRef" :loading="loading" :on-query="onQuery" @row-sort="onSort">
216 <template #tool="{ size }">
217 <a-button v-if="!hideCreate && canCreate" :size="size" type="primary" @click="handleCreate">Demo上架</a-button>
218 </template>
219 <template v-if="!hideTool" #tool-right>
220 <export-button :on-download="onExport" />
221 </template>
222 <activity-table-column data-index="id" title="试唱歌曲" :width="560" hide-sub-title />
223 <number-table-column data-index="view_count" title="试听人数" :dark-value="0" :width="110" has-sort />
224 <number-table-column data-index="like_count" title="收藏人数" :dark-value="0" :width="110" has-sort />
225 <date-table-column data-index="created_at" title="创建时间" :width="110" split has-sort />
226 <!-- <date-table-column data-index="audit_at" title="通过时间" :width="110" split has-sort /> -->
227 <enum-table-column data-index="status" title="状态" :option="statusOption" :width="110" has-sort />
228 <a-table-column :width="90" align="center" data-index="operations" fixed="right" title="操作">
229 <template #cell="{ record }">
230 <a-space direction="vertical" fill>
231 <a-link :hoverable="false" class="link-hover" @click="$router.push({ name: 'audition-demo-show', params: { id: record.id } })">
232 查看
233 </a-link>
234 <a-link v-if="record.status === 1" :hoverable="false" class="link-hover" @click="onDown(record)">下架</a-link>
235 <a-link v-if="record.status === 1" :hoverable="false" class="link-hover" @click="handleCreateActivity(record)">上架试唱</a-link>
236 <a-link v-if="record.status === 2" :hoverable="false" class="link-hover" @click="onUp(record, 'up')">上架</a-link>
237 <a-link v-if="record.status !== 0 && record.status !== 5" :hoverable="false" class="link-hover" @click="onUpdate(record)">
238 编辑
239 </a-link>
240 <a-link v-if="record.status === 2" :hoverable="false" class="link-hover" @click="onDelete(record)">删除</a-link>
241 </a-space>
242 </template>
243 </a-table-column>
244 </filter-table>
245 </page-view>
246 </template>
247
248 <style scoped lang="less">
249 :deep(.arco-table-cell) {
250 padding: 5px 16px !important;
251 }
252 </style>
1 <template>
2 <router-view v-slot="{ Component, route }">
3 <keep-alive :exclude="['audition-activity-show', 'audition-demo-show']">
4 <component :is="Component" :key="route.path" />
5 </keep-alive>
6 </router-view>
7 </template>
8
9 <script lang="ts" setup>
10 import { onMounted } from 'vue';
11 import { useSelectionStore } from '@/store';
12
13 const { queryAll } = useSelectionStore();
14 onMounted(() => queryAll());
15 </script>
16
17 <style lang="less" scoped>
18 .container {
19 padding: 0 30px 20px 20px;
20 }
21
22 .operations {
23 display: flex;
24 }
25 </style>
1 <template>
2 <a-card :hoverable="false" :bordered="false">
3 <div class="content-wrap">
4 <div class="content">
5 <div data-v-55251d00="" class="arco-statistic">
6 <div class="arco-statistic-title">上架歌曲曲风</div>
7 <div class="arco-statistic-content">
8 <div class="arco-statistic-value">
9 <span class="arco-statistic-value-integer"></span>
10 </div>
11 </div>
12 </div>
13 </div>
14 <div class="chart">
15 <Chart :option="chartOption" style="width: 100%" />
16 </div>
17 </div>
18 </a-card>
19 </template>
20
21 <script lang="ts" setup>
22 import useChartOption from '@/hooks/chart-option';
23 import { onMounted, ref } from 'vue';
24 import Chart from '@/components/chart/index.vue';
25
26 import useDashboardApi from '@/api/dashboard';
27 import { ToolTipFormatterParams } from '@/types/echarts';
28
29 type recordType = { value: number; name: string };
30
31 const record = ref<recordType[]>([]);
32
33 const { chartOption } = useChartOption(() => {
34 return {
35 grid: { left: 0, right: 0, top: 0, bottom: 0 },
36 label: { show: false },
37 tooltip: {
38 show: true,
39 trigger: 'item',
40 formatter(params) {
41 const item = params as ToolTipFormatterParams;
42
43 return `
44 <div class="content-panel">
45 <p>
46 <span style="background-color: ${item.color}" class="tooltip-item-icon"></span>
47 <span>${item.name}</span>
48 </p>
49 <span class="tooltip-value">${item.value?.toLocaleString()}</span>
50 <span class="tooltip-value">${item.percent?.toLocaleString()}%</span>
51 </div>`;
52 },
53 className: 'echarts-tooltip-diy',
54 },
55 series: [{ type: 'pie', radius: ['65%', '90%'], label: { show: false }, data: record.value }],
56 };
57 });
58
59 onMounted(async () => {
60 record.value = await useDashboardApi.activityStyle();
61 });
62 </script>
63
64 <style lang="less" scoped>
65 .echarts-tooltip-diy {
66 background: none !important;
67 border: none !important;
68 }
69
70 :deep(.arco-card) {
71 border-radius: 4px;
72 }
73
74 :deep(.arco-card-body) {
75 width: 100%;
76 height: 193px;
77 }
78
79 .content-wrap {
80 width: 100%;
81 height: 100%;
82 display: flex;
83 white-space: nowrap;
84
85 .content {
86 width: 108px;
87
88 :deep(.arco-statistic) {
89 .arco-statistic-title {
90 font-size: 14px;
91 font-weight: bold;
92 white-space: nowrap;
93 }
94
95 .arco-statistic-content {
96 margin-top: 10px;
97 }
98 }
99 }
100
101 .chart {
102 width: calc(100% - 108px);
103 vertical-align: bottom;
104 }
105 }
106 </style>
1 <template>
2 <a-card class="general-card" title="用户试唱" :header-style="{ paddingBottom: 0 }" :body-style="{ padding: '15px 20px 13px 20px' }">
3 <template #extra>
4 <a-space>
5 <a-range-picker
6 v-model="createBetween"
7 value-format="YYYY-MM-DD"
8 style="width: 260px"
9 :allow-clear="false"
10 :disabled-date="disabledDate"
11 @change="$refs.tableRef?.onPageChange()"
12 />
13 <export-button :on-download="onExport" />
14 </a-space>
15 </template>
16
17 <filter-table ref="tableRef" :loading="loading" :on-query="onQuery">
18 <link-table-column
19 title="名称"
20 data-index="activity_name"
21 :width="160"
22 :formatter="(item:SubmitWork) => item.activity_name + ([3,5].includes(item.activity_status)? '( 结束 )' : '')"
23 :to="(item:SubmitWork) => $router.push({ name:item.activity_type === 1 ?'audition-activity-show':'audition-demo-show', params: { id: item.activity_id} })"
24 />
25 <enum-table-column title="类型" data-index="activity_type" :width="60" :option="useActivityApi.songTypeOption" />
26 <link-table-column
27 title="厂牌"
28 data-index="project_id"
29 :width="120"
30 :formatter="(item) => item.project?.name"
31 :to="(item: SubmitWork) => $router.push({ name: 'project-show', params: { id: item.project_id } })"
32 />
33 <filter-table-column title="试唱用户" data-index="user_nick_name" nick-index="user_nick_name" :width="120" />
34 <filter-table-column title="身份" data-index="user_identify" :width="80">
35 <template #default="{ record }: { record: SubmitWork }">
36 <span v-if="record.user_identity === 1">音乐人</span>
37 <span v-else-if="[2, 3].includes(record.user_identity)">经纪人</span>
38 <span v-else>未认证</span>
39 </template>
40 </filter-table-column>
41 <filter-table-column title="经纪人" data-index="business.nick_name" :width="120">
42 <template #default="{ record }: { record: SubmitWork }">
43 <span v-if="record.business">{{ record.business.nick_name }}</span>
44 <span v-else style="color: rgba(44, 44, 44, 0.5)"></span>
45 </template>
46 </filter-table-column>
47 <filter-table-column title="试唱方式" data-index="mode" :width="80">
48 <template #default="{ record }: { record: SubmitWork }">
49 <template v-if="record.mode === 1">自主上传</template>
50 <template v-else>{{ record.sing_type === 'Full' ? '唱整首' : '唱片段' }}</template>
51 </template>
52 </filter-table-column>
53 <link-table-column
54 title="合作模式"
55 data-index="price_id"
56 :width="80"
57 :formatter="(item:SubmitWork) => (item.price ? '查看报价' : '')"
58 :to="(item:SubmitWork) =>onViewPrice(item.price as UserSubmitPrice) "
59 />
60 <filter-table-column title="提交时间" data-index="submit_at" :width="100">
61 <template #default="{ record }: { record: SubmitWork }">
62 {{ dayjs(record.submit_at).format('MM/DD HH:mm') || '' }}
63 </template>
64 </filter-table-column>
65 <filter-table-column title="试唱音频" data-index="demo_url" :width="230" :tooltip="false">
66 <template #default="{ record }: { record: SubmitWork }">
67 <audio-player :url="record.demo_url" :name="record.user_nick_name" />
68 </template>
69 </filter-table-column>
70 <a-table-column title="操作" data-index="demo" :width="120">
71 <template #cell="{ record }">
72 <a-button v-if="record.status === 1" size="mini" type="primary" status="success">已确认合作</a-button>
73 <a-button v-else-if="record.status === 2" size="mini">试唱不合适</a-button>
74 <a-button v-else-if="record.status === 0 && [3, 5].indexOf(record.activity_status) !== -1" size="mini">未采纳</a-button>
75 <a-space v-else-if="record.status === 0 && record.activity_status === 1" size="mini">
76 <a-button type="primary" size="mini" @click="onPass(record)">合作</a-button>
77 <a-button type="primary" status="danger" size="mini" @click="onNotPass(record)">不合适</a-button>
78 </a-space>
79 <a-button v-else size="mini">其他</a-button>
80 </template>
81 </a-table-column>
82 </filter-table>
83 </a-card>
84 </template>
85
86 <script lang="ts" setup>
87 import { computed, createVNode, h, onMounted, ref } from 'vue';
88 import dayjs from 'dayjs';
89 import useLoading from '@/hooks/loading';
90 import { Form, FormItem, Modal, ModalConfig } from '@arco-design/web-vue';
91 import { AnyObject } from '@/types/global';
92 import { ActivityWork } from '@/types/activity-work';
93 import LinkTableColumn from '@/components/filter/link-table-column.vue';
94 import { promiseToBoolean } from '@/utils';
95 import useActivityApi, { useWorkApi } from '@/api/activity';
96 import useDashboardApi from '@/api/dashboard';
97 import { createModalVNode } from '@/utils/createVNode';
98 import EnumTableColumn from '@/components/filter/enum-table-column.vue';
99
100 type UserSubmitPrice = {
101 id: number;
102 value: { year: string; ratio: number; amounts: number; is_reward: 1 | 0; is_dividend: 1 | 0 };
103 is_deduct: 1 | 0;
104 is_talk: 1 | 0;
105 is_accept_address: 1 | 0;
106 address: { name: string; parent: { name: string } };
107 };
108
109 // eslint-disable-next-line @typescript-eslint/no-unused-vars
110 type SubmitWork = {
111 id: number;
112 activity_id: number;
113 activity_name: string;
114 activity_type: number;
115 activity_status: number;
116 project_id: number;
117 project?: { id: number; name: string };
118 user_id: number;
119 user_nick_name: string;
120 user_identity: number;
121
122 business_id: number;
123 business?: { id: number; nick_name: string; identify: number };
124
125 share_id: number;
126 share?: { id: number; nick_name: string; identify: number };
127
128 price_id: number;
129 price?: UserSubmitPrice;
130
131 mode: 0 | 1;
132 sing_type: 'Part' | 'Full';
133
134 demo_url: string;
135 status: 0 | 1 | 2;
136 submit_at: string;
137 };
138
139 const { loading, setLoading } = useLoading(false);
140 const { submitWork, getSubmitWorkExport } = useDashboardApi;
141
142 const createBetween = ref([dayjs().subtract(7, 'day').format('YYYY-MM-DD'), dayjs().format('YYYY-MM-DD')]);
143
144 const timeFilter = computed(() => {
145 return [`${createBetween.value[0]} 00:00:00`, `${createBetween.value[1]} 23:59:59`];
146 });
147
148 const tableRef = ref();
149
150 const onQuery = async (params: AnyObject) => {
151 setLoading(true);
152 return submitWork({ submitBetween: timeFilter.value, ...params, setSort: '-submit_at' }).finally(() => setLoading(false));
153 };
154
155 const onExport = () => getSubmitWorkExport('用户试唱', { submitBetween: timeFilter.value, setSort: '-submit_at' });
156
157 const onPass = (row: ActivityWork) => {
158 const itemStyle = { lineHeight: '28px', margin: '4px 0' };
159
160 return createModalVNode(
161 () =>
162 createVNode('ol', {}, [
163 createVNode('li', { style: itemStyle }, '此条Demo的状态将变更为合作状态'),
164 createVNode('li', { style: itemStyle }, `活动《${row.activity_name}》的状态变更为完成状态!`),
165 ]),
166 {
167 title: '确认合作',
168 bodyStyle: { padding: 0 },
169 onBeforeOk: () => promiseToBoolean(useWorkApi.changeStatus(row.id, { status: 1 })),
170 onOk: () => tableRef.value?.onFetch(),
171 }
172 );
173 };
174
175 const onNotPass = (row: ActivityWork) => {
176 Modal.open({
177 title: '不合适标记',
178 content: `请确认是否将用户:${row.user_nick_name} 提交的试唱标记为不合适,并反馈给用户?`,
179 closable: false,
180 onBeforeOk: () => promiseToBoolean(useWorkApi.changeStatus(row.id, { status: 2, remark: '' })),
181 onOk: () => tableRef.value?.onFetch(),
182 });
183 };
184
185 const onViewPrice = (price: UserSubmitPrice) => {
186 Modal.open({
187 title: '用户报价',
188 content: () =>
189 createVNode(
190 Form,
191 { size: 'small', autoLabelWidth: true },
192 {
193 default: () => [
194 h(FormItem, { label: '唱酬', showColon: true, rowClass: 'mb-0' }, price.value.is_reward ? `${price.value.amounts} 元` : '无'),
195 h(
196 FormItem,
197 { label: '分成', showColon: true, rowClass: 'mb-0' },
198 price.value.is_dividend ? `${price.value.ratio}% | ${price.value.year} | ${price.is_deduct ? '抵扣' : '不抵扣'}` : '无'
199 ),
200 h(FormItem, { label: '价格是否可谈', showColon: true, rowClass: 'mb-0' }, price.is_talk ? '【可谈】' : '【不可谈】'),
201 h(
202 FormItem,
203 { label: '录音地点', showColon: true, rowClass: 'mb-0' },
204 `${[price.address?.parent?.name, price.address?.name].join('/')}${
205 price.is_accept_address ? '【接受其他录音地点】' : '【不接受其他录音地点】'
206 }`
207 ),
208 ],
209 }
210 ),
211 hideCancel: true,
212 bodyStyle: { padding: '8px 20px' },
213 closable: false,
214 maskClosable: false,
215 escToClose: false,
216 okText: '我知道了',
217 } as ModalConfig);
218 };
219
220 const disabledDate = (current?: Date) => dayjs(current).isAfter(dayjs()) || dayjs(current).isBefore(dayjs().subtract(30, 'day'));
221
222 onMounted(async () => tableRef.value?.onPageChange());
223 </script>
224
225 <style lang="less" scoped>
226 .title {
227 line-height: normal;
228 font-weight: 500;
229 font-size: 16px;
230 }
231
232 :deep(.arco-table-cell) {
233 padding: 5px 8px !important;
234
235 .arco-table-td-content .arco-btn-size-small {
236 padding: 5px !important;
237 }
238 }
239 </style>
1 <template>
2 <a-card class="general-card" title="待处理事项" :body-style="{ paddingBottom: '10px', height: '160px' }">
3 <a-table
4 row-key="id"
5 :loading="loading"
6 :data="source"
7 :pagination="pagination"
8 :show-header="false"
9 :bordered="false"
10 size="small"
11 :table-layout-fixed="true"
12 @page-change="onPageChange"
13 >
14 <template #columns>
15 <a-table-column data-index="id" :width="36">
16 <template #cell="{ rowIndex }">{{ getIndex(rowIndex) }}</template>
17 </a-table-column>
18 <a-table-column data-index="activity_id" :width="260" :ellipsis="true" :tooltip="true">
19 <template #cell="{ record }">{{ getTitle(record) }}</template>
20 </a-table-column>
21 <a-table-column data-index="msg" :width="200" :ellipsis="true" :tooltip="true" />
22 <a-table-column data-index="option" :width="50">
23 <template #cell>
24 <a-button type="text" @click="onClick()">处理</a-button>
25 </template>
26 </a-table-column>
27 </template>
28 </a-table>
29 </a-card>
30 </template>
31
32 <script lang="ts" setup>
33 import useLoading from '@/hooks/loading';
34 import { onMounted, ref } from 'vue';
35 import usePagination from '@/hooks/pagination';
36 import { add, multiply } from 'lodash';
37 import { useRouter } from 'vue-router';
38 import useDashboardApi from '@/api/dashboard';
39
40 type ToDoType = {
41 id: number;
42 project?: { id: number; name: string };
43 song_name: string;
44 msg: string;
45 };
46
47 const { loading, setLoading } = useLoading(false);
48 const { pagination, setPage, setPageSize, setTotal } = usePagination({ pageSize: 3, showPageSize: false, size: 'mini' });
49
50 const source = ref<ToDoType[]>([]);
51 const router = useRouter();
52
53 const getIndex = (index: number) => {
54 return add(index + 1, multiply(pagination.value.current - 1, pagination.value.pageSize));
55 };
56 const getTitle = (record: ToDoType) => {
57 return `厂牌【${record.project?.name || '无'}】提交的歌曲【${record.song_name}】未通过审核`;
58 };
59
60 const onClick = () => {
61 router.push({ name: 'audition-apply', query: { auditStatus: 2 } });
62 };
63
64 const onQuery = () => {
65 setLoading(true);
66 useDashboardApi
67 .todo({ page: pagination.value.current, pageSize: pagination.value.pageSize })
68 .then(({ data, meta }) => {
69 source.value = data as ToDoType[];
70 setPage(meta.current);
71 setPageSize(meta.limit);
72 setTotal(meta.total);
73 })
74 .finally(() => {
75 setLoading(false);
76 });
77 };
78
79 const onPageChange = (page: number) => {
80 pagination.value.current = page;
81 onQuery();
82 };
83
84 onMounted(() => onPageChange(1));
85 </script>
86
87 <style lang="less" scoped>
88 :deep(.arco-table-cell) {
89 padding: 5px 8px !important;
90
91 & > .arco-table-td-content .arco-btn-size-small {
92 padding: 5px !important;
93 }
94 }
95 </style>
1 <template>
2 <a-card :hoverable="false" :bordered="false">
3 <div class="content-wrap">
4 <div class="content">
5 <div data-v-55251d00="" class="arco-statistic">
6 <div class="arco-statistic-title">歌手擅长曲风</div>
7 <div class="arco-statistic-content">
8 <div class="arco-statistic-value">
9 <span class="arco-statistic-value-integer"></span>
10 </div>
11 </div>
12 </div>
13 </div>
14 <div class="chart">
15 <Chart :option="chartOption" style="width: 100%" />
16 </div>
17 </div>
18 </a-card>
19 </template>
20
21 <script lang="ts" setup>
22 import useChartOption from '@/hooks/chart-option';
23 import { onMounted, ref } from 'vue';
24 import Chart from '@/components/chart/index.vue';
25
26 import { ToolTipFormatterParams } from '@/types/echarts';
27 import useDashboardApi from '@/api/dashboard';
28
29 type recordType = { value: number; name: string };
30
31 const record = ref<recordType[]>([]);
32
33 const { chartOption } = useChartOption(() => {
34 return {
35 grid: { left: 0, right: 0, top: 0, bottom: 0 },
36 label: { show: false },
37 tooltip: {
38 show: true,
39 trigger: 'item',
40 formatter(params) {
41 const item = params as ToolTipFormatterParams;
42 return `
43 <div class="content-panel">
44 <p>
45 <span style="background-color: ${item.color}" class="tooltip-item-icon"></span>
46 <span style="margin-right: 10px">${item.name}</span>
47 </p>
48 <span class="tooltip-value">${item.value?.toLocaleString()}</span>
49 <span class="tooltip-value">${item.percent?.toLocaleString()}%</span>
50 </div>`;
51 },
52 className: 'echarts-tooltip-diy',
53 },
54 series: [{ type: 'pie', radius: ['65%', '90%'], label: { show: false }, data: record.value }],
55 };
56 });
57
58 onMounted(async () => {
59 record.value = await useDashboardApi.userStyle();
60 });
61 </script>
62
63 <style lang="less" scoped>
64 .echarts-tooltip-diy {
65 background: none !important;
66 border: none !important;
67 }
68
69 :deep(.arco-card) {
70 border-radius: 4px;
71 }
72
73 :deep(.arco-card-body) {
74 width: 100%;
75 height: 193px;
76 }
77
78 .content-wrap {
79 width: 100%;
80 height: 100%;
81 display: flex;
82 white-space: nowrap;
83
84 .content {
85 width: 108px;
86
87 :deep(.arco-statistic) {
88 .arco-statistic-title {
89 font-size: 14px;
90 font-weight: bold;
91 white-space: nowrap;
92 }
93
94 .arco-statistic-content {
95 margin-top: 10px;
96 }
97 }
98 }
99
100 .chart {
101 width: calc(100% - 108px);
102 vertical-align: bottom;
103 }
104 }
105 </style>
1 <template>
2 <div class="container">
3 <Breadcrumb :items="['dashboard']" />
4
5 <a-space class="bot" direction="vertical" :size="16" fill>
6 <a-grid :cols="24" :col-gap="12" :row-gap="12">
7 <a-grid-item :span="{ xs: 12, sm: 12, md: 12, lg: 12, xl: 6, xxl: 6 }">
8 <activity-style-card />
9 </a-grid-item>
10 <a-grid-item :span="{ xs: 12, sm: 12, md: 12, lg: 12, xl: 6, xxl: 6 }">
11 <user-style-card />
12 </a-grid-item>
13 <a-grid-item :span="{ xs: 24, sm: 24, md: 24, lg: 12, xl: 12, xxl: 12 }">
14 <to-do-card />
15 </a-grid-item>
16 </a-grid>
17 </a-space>
18
19 <SubmitWorkPanel class="bot" />
20 </div>
21 </template>
22
23 <script lang="ts" setup>
24 import SubmitWorkPanel from '@/views/dashboard/components/submit-work-panel.vue';
25 import ToDoCard from '@/views/dashboard/components/to-do-card.vue';
26 import { onMounted } from 'vue';
27
28 import UserStyleCard from '@/views/dashboard/components/user-style-card.vue';
29 import ActivityStyleCard from '@/views/dashboard/components/activity-style-card.vue';
30
31 onMounted(() => {});
32 </script>
33
34 <style lang="less" scoped>
35 .container {
36 padding: 0 30px 20px 20px;
37 }
38
39 .bot {
40 margin-bottom: 16px;
41 }
42 </style>
1 <template>
2 <div class="container">
3 <Breadcrumb :items="['exception', 'exception-403']" />
4 <div class="content">
5 <a-result class="result" status="403" subtitle="对不起,您没有访问该资源的权限" />
6 <!-- <a-button key="back" type="primary" @click="goBack"> 返回</a-button>-->
7 </div>
8 </div>
9 </template>
10
11 <script lang="ts" setup>
12 import { useRouter } from 'vue-router';
13
14 const router = useRouter();
15
16 const goBack = () => router.back();
17 </script>
1 <template>
2 <div class="container">
3 <Breadcrumb :items="['exception', 'exception-404']" />
4 <div class="content">
5 <a-result class="result" status="404" subtitle="抱歉,页面不见了~">
6 </a-result>
7 <div class="operation-row">
8 <!-- <a-button key="again" style="margin-right: 16px"> 重试</a-button>-->
9 <a-button key="back" type="primary" @click="goBack"> 返回</a-button>
10 </div>
11 </div>
12 </div>
13 </template>
14
15 <script lang="ts" setup>
16 import { useRouter } from 'vue-router';
17
18 const router = useRouter();
19
20 const goBack = () => router.back();
21 </script>
1 <template>
2 <div class="container">
3 <Breadcrumb :items="['exception', 'exception-500']" />
4 <div class="content">
5 <a-result
6 class="result"
7 status="500"
8 subtitle="抱歉,服务器出了点问题~"
9 />
10 <a-button key="back" type="primary" @click="goBack">返回</a-button>
11 </div>
12 </div>
13 </template>
14
15 <script lang="ts" setup>
16 import { useRouter } from 'vue-router';
17
18 const router = useRouter();
19
20 const goBack = () => router.back();
21 </script>
22
23 <style lang="less" scoped></style>
1 <template>
2 <router-view />
3 </template>
4
5 <script lang="ts">
6 import { defineComponent } from 'vue';
7
8 export default defineComponent({});
9 </script>
10
11 <style lang="less" scoped>
12 .container {
13 // position: relative;
14 // display: flex;
15 // flex-direction: column;
16 // align-items: center;
17 // justify-content: center;
18 // height: 100%;
19 // text-align: center;
20 // background-color: var(--color-bg-1);
21 padding: 0 30px 20px 20px;
22 height: calc(100% - 20px);
23
24 :deep(.content) {
25 position: relative;
26 display: flex;
27 flex-direction: column;
28 align-items: center;
29 justify-content: center;
30 height: 100%;
31 text-align: center;
32 background-color: var(--color-bg-1);
33 border-radius: 4px;
34 }
35 }
36 </style>
1 <template>
2 <a-form ref="fromRef" :model="userInfo" :rules="rules" class="login-form" layout="vertical">
3 <a-form-item field="email" hide-label>
4 <a-input v-model="userInfo.email" placeholder="请输入登陆邮箱">
5 <template #prefix>
6 <icon-user />
7 </template>
8 </a-input>
9 </a-form-item>
10 <a-form-item field="password" hide-label>
11 <a-input-password v-model="userInfo.password" allow-clear placeholder="请输入登陆密码">
12 <template #prefix>
13 <icon-lock />
14 </template>
15 </a-input-password>
16 </a-form-item>
17 </a-form>
18 </template>
19
20 <script lang="ts" setup>
21 import { IconLock, IconUser } from '@arco-design/web-vue/es/icon';
22 import { ref } from 'vue';
23 import { FormInstance } from '@arco-design/web-vue/es/form';
24 import { AttributeData } from '@/types/global';
25 import { showLoginAccount } from '@/utils/env';
26
27 const userInfo = ref({
28 email: showLoginAccount ? '234233@qq.com' : '',
29 password: showLoginAccount ? '123123' : '',
30 });
31
32 const rules = {
33 email: [{ required: true, message: '登陆邮箱不能为空' }],
34 password: [{ required: true, message: '密码不能为空' }],
35 };
36
37 const fromRef = ref<FormInstance>();
38 const onSubmit = (callback: (value: AttributeData) => void) => {
39 fromRef.value?.validate((errors) => !errors && callback(userInfo.value));
40 };
41
42 defineExpose({ onSubmit });
43 </script>
44
45 <style lang="less" scoped>
46 .login-form {
47 &-error-msg {
48 height: 32px;
49 color: rgb(var(--red-6));
50 line-height: 32px;
51 }
52
53 &-register-btn {
54 color: var(--color-text-3) !important;
55 }
56 }
57 </style>
1 <template>
2 <a-form v-model="formValue" class="login-form" size="small" auto-label-width>
3 <a-form-item show-colon label="找回方式" style="margin-bottom: 10px">
4 <a-radio-group v-model="apply" :options="applyType" />
5 </a-form-item>
6 <a-form-item v-if="apply === 1" show-colon label="手机号">
7 <a-input v-model="formValue.phone" max-length="14" placeholder="请输入" />
8 </a-form-item>
9 <a-form-item v-else show-colon label="邮箱">
10 <a-input v-model="email" placeholder="请输入" />
11 </a-form-item>
12 <a-form-item show-colon label="验证码">
13 <a-row :gutter="8">
14 <a-col :span="18">
15 <a-input v-model="formValue.code" max-length="6" placeholder="请输入" />
16 </a-col>
17 <a-col :span="6">
18 <a-button html-type="submit" type="primary">获取</a-button>
19 </a-col>
20 </a-row>
21 </a-form-item>
22 <div class="form-actions">
23 <a-link @click="type = 'login'">返回</a-link>
24 <a-button html-type="submit" type="primary">提交</a-button>
25 </div>
26 </a-form>
27 </template>
28
29 <script lang="ts" setup>
30 import { inject, ref } from 'vue';
31
32 const type = inject('type');
33
34 const apply = ref(1);
35 const applyType = ref([
36 { value: 1, label: '手机号' },
37 { value: 2, label: '邮箱' },
38 ]);
39
40 const email = ref('');
41
42 const formValue = ref({
43 phone: '',
44 code: '',
45 });
46 </script>
47
48 <style scoped>
49 .form-actions {
50 display: flex;
51 justify-content: space-between;
52 }
53 </style>
1 <template>
2 <a-form ref="fromRef" :model="userInfo" :rules="rules" class="login-form" layout="vertical">
3 <a-form-item field="phone" hide-label>
4 <a-select
5 v-model="userInfo.area"
6 style="flex: 100px; margin-right: 15px"
7 :options="areaOption"
8 :virtual-list-props="{ height: 200 }"
9 />
10 <a-input v-model="userInfo.phone" placeholder="请输入登陆手机号" :max-length="11">
11 <template #prefix>
12 <icon-phone />
13 </template>
14 </a-input>
15 </a-form-item>
16 <a-form-item show-colon hide-label>
17 <a-input v-model="userInfo.code" hide-button :max-length="6" placeholder="验证码" @input="formatCode">
18 <template #prefix>
19 <icon-message />
20 </template>
21 <template #suffix>
22 <a-link type="text" class="login-form-sms-btn" :disabled="countTime !== 0" @click="onSend()">
23 {{ countTime <= 0 ? '获取验证码' : countTime + 's' }}
24 </a-link>
25 </template>
26 </a-input>
27 </a-form-item>
28 </a-form>
29 </template>
30
31 <script lang="ts" setup>
32 import { IconMessage, IconPhone } from '@arco-design/web-vue/es/icon';
33 import { onMounted, ref } from 'vue';
34 import { useIntervalFn } from '@vueuse/core';
35 import { FormInstance } from '@arco-design/web-vue/es/form';
36 import { AttributeData } from '@/types/global';
37 import { Message } from '@arco-design/web-vue';
38 import useProviderApi from '@/api/provider';
39 import { map, union } from 'lodash';
40
41 const fromRef = ref<FormInstance>();
42 const countTime = ref(0);
43
44 const userInfo = ref({ area: '+86', phone: '', code: '' });
45 const areaOption = ref<string[]>([]);
46
47 onMounted(() => {
48 // eslint-disable-next-line no-return-assign
49 useProviderApi.area().then(({ data }) => (areaOption.value = union(map(data, (item) => item.identifier))));
50 });
51
52 const rules = {
53 phone: [{ required: true, message: '请输入手机号' }],
54 code: [{ required: true, message: '请输入验证码' }],
55 };
56
57 // eslint-disable-next-line no-return-assign
58 const { pause, resume } = useIntervalFn(() => (countTime.value <= 0 ? pause() : (countTime.value -= 1)), 1000);
59
60 const onSend = async () => {
61 if (countTime.value !== 0 || (await fromRef.value?.validateField('phone'))) {
62 return;
63 }
64 useProviderApi.sms('login', userInfo.value.phone, userInfo.value.area).then(() => {
65 Message.success('短信发送成功,请注意查收!');
66 countTime.value = 60;
67 resume();
68 });
69 };
70
71 const onSubmit = (callback: (value: AttributeData) => void) => {
72 fromRef.value?.validate((errors) => !errors && callback(userInfo.value));
73 };
74
75 const formatCode = (value: string) => {
76 userInfo.value.code = value.replace(/\D/g, '').slice(0, 6);
77 };
78
79 defineExpose({ onSubmit });
80 </script>
81
82 <style lang="less" scoped>
83 .login-form {
84 &-error-msg {
85 height: 32px;
86 color: rgb(var(--red-6));
87 line-height: 32px;
88 }
89
90 &-sms-btn {
91 padding-left: -12px;
92 font-size: 12px;
93 }
94 }
95 </style>
1 <template>
2 <div class="container">
3 <div class="banner">
4 <div class="banner-inner"></div>
5 </div>
6 <div class="content">
7 <div class="content-inner">
8 <div class="login-form-wrapper">
9 <div class="login-form-title">海星试唱</div>
10 <div class="login-form-sub-title">厂牌管理后台</div>
11 <a-card :bordered="true" :hoverable="true" :style="{ width: '360px' }">
12 <a-tabs v-model:active-key="loginType" :justify-="true" :animation="true">
13 <a-tab-pane key="email" title="邮箱登录">
14 <EmailForm ref="emailRef" />
15 </a-tab-pane>
16 <a-tab-pane key="phone" title="手机登录">
17 <PhoneForm ref="phoneRef" />
18 </a-tab-pane>
19 </a-tabs>
20 <div class="login-form-actions">
21 <!-- <a-link>忘记密码</a-link>-->
22 <a-button type="primary" :long="true" :loading="loading" @click="onLogin()"> 登录</a-button>
23 </div>
24 </a-card>
25 </div>
26 </div>
27 </div>
28 </div>
29 </template>
30
31 <script lang="ts" setup>
32 import EmailForm from '@/views/login/components/email-form.vue';
33 import PhoneForm from '@/views/login/components/phone-form.vue';
34
35 import { ref } from 'vue';
36 import { useRouter } from 'vue-router';
37 import { AttributeData } from '@/types/global';
38 import { setToken } from '@/utils/auth';
39 import { Message } from '@arco-design/web-vue';
40 import useLoading from '@/hooks/loading';
41 import useProviderApi from '@/api/provider';
42
43 const { loading, setLoading } = useLoading(false);
44
45 const loginType = ref<'email' | 'phone'>('email');
46 const emailRef = ref<InstanceType<typeof EmailForm>>();
47 const phoneRef = ref<InstanceType<typeof PhoneForm>>();
48 const router = useRouter();
49
50 const onSubmit = (value: AttributeData) => {
51 setLoading(true);
52 useProviderApi
53 .login(loginType.value, value)
54 .then(({ data }) => {
55 const { access_token, refresh_token, nick_name } = data;
56 setToken(access_token, refresh_token);
57 Message.success(`欢迎回来,管理员:${nick_name}`);
58 const { path } = router.currentRoute.value.query;
59 router.replace((path as string) || '/dashboard');
60 })
61 .finally(() => setLoading(false));
62 };
63
64 const onLogin = () => {
65 // eslint-disable-next-line default-case
66 switch (loginType.value) {
67 case 'email':
68 emailRef.value?.onSubmit(onSubmit);
69 break;
70 case 'phone':
71 phoneRef.value?.onSubmit(onSubmit);
72 break;
73 }
74 };
75 </script>
76
77 <style lang="less" scoped>
78 .container {
79 display: flex;
80 height: 100vh;
81
82 .banner {
83 width: 550px;
84 }
85
86 .content {
87 position: relative;
88 display: flex;
89 flex: 1;
90 align-items: center;
91 justify-content: center;
92 padding-bottom: 40px;
93
94 .login-form-wrapper {
95 width: 320px;
96 }
97
98 .login-form-title {
99 color: var(--color-text-1);
100 font-weight: 500;
101 font-size: 30px;
102 line-height: 46px;
103 text-align: center;
104 }
105
106 .login-form-sub-title {
107 color: var(--color-text-3);
108 font-size: 16px;
109 line-height: 24px;
110 text-align: center;
111 margin-bottom: 16px;
112 }
113
114 .login-form-actions {
115 display: flex;
116 justify-content: right;
117 }
118 }
119
120 .footer {
121 position: absolute;
122 right: 0;
123 bottom: 0;
124 width: 100%;
125 }
126 }
127
128 .logo {
129 position: fixed;
130 top: 24px;
131 left: 22px;
132 z-index: 1;
133 display: inline-flex;
134 align-items: center;
135
136 &-text {
137 margin-right: 4px;
138 margin-left: 4px;
139 color: var(--color-fill-1);
140 font-size: 20px;
141 }
142 }
143
144 .banner {
145 display: flex;
146 align-items: center;
147 justify-content: center;
148
149 &-inner {
150 flex: 1;
151 height: 100%;
152 //background-image: url('/src/assets/images/bg.jpg');
153 background-image: url('https://spreadcdn.hikoon.com/default/bg.jpg');
154 background-size: cover;
155 }
156 }
157 </style>
1 <script setup lang="ts">
2 import { Image, List, ListItem, ListItemMeta, TypographyParagraph } from '@arco-design/web-vue';
3 import AudioPlayer from '@/components/audio-player/index.vue';
4 import { onMounted, ref } from 'vue';
5 import usePagination from '@/hooks/pagination';
6 import useLoading from '@/hooks/loading';
7 import useProjectApi from '@/api/project';
8
9 const props = defineProps<{ projectKey: number }>();
10
11 const { loading, setLoading } = useLoading(false);
12 const { pagination, setPage, setTotal } = usePagination({ pageSize: 5, size: 'mini', showPageSize: false });
13 const source = ref([]);
14
15 const onQuery = () => {
16 setLoading(true);
17 useProjectApi
18 .dynamics(props.projectKey, {
19 type: 'audio',
20 page: pagination.value.current,
21 pageSize: pagination.value.pageSize,
22 setSort: '-is_top',
23 })
24 .then(({ data, meta }) => {
25 source.value = data as [];
26 setPage(meta.current);
27 setTotal(meta.total);
28 })
29 .finally(() => setLoading(false));
30 };
31
32 onMounted(() => onQuery());
33 </script>
34
35 <template>
36 <List
37 :loading="loading"
38 :data="source"
39 :bordered="false"
40 :scrollbar="true"
41 :max-height="460"
42 :pagination-props="pagination"
43 @page-change="(page) => setPage(page) && onQuery()"
44 >
45 <template #item="{ item }">
46 <ListItem :key="item.id" style="padding: 4px 16px; height: 60px">
47 <ListItemMeta style="height: 60px; text-align: center; overflow: hidden">
48 <template #avatar>
49 <Image :src="`${item.properties.cover.url}?x-oss-process=image/format,webp`" :width="60" :height="60" fit="fill" />
50 </template>
51 <template #title>
52 <TypographyParagraph :ellipsis="{ rows: 1, showTooltip: true }" style="margin-bottom: 10px; margin-top: 10px; text-align: left">
53 {{ item.intro }}
54 </TypographyParagraph>
55 </template>
56 <template #description>
57 <AudioPlayer :url="item.properties.url" style="width: 410px; height: 20px" name="" />
58 </template>
59 </ListItemMeta>
60 </ListItem>
61 </template>
62 </List>
63 </template>
64
65 <style scoped lang="less">
66 :deep(.arco-list-pagination) {
67 margin-top: 10px !important;
68 margin-bottom: 10px !important;
69 }
70 </style>
1 <script setup lang="ts">
2 import { createVNode, ref, toRef } from 'vue';
3 import { createModalVNode } from '@/utils/createVNode';
4 import { Project } from '@/types/project';
5 import ImageDynamics from '@/views/project-show/components/image-dynamics.vue';
6 import AudioDynamics from '@/views/project-show/components/audio-dynamics.vue';
7 import VideoDynamics from '@/views/project-show/components/video-dynamics.vue';
8
9 const props = defineProps<{
10 loading?: boolean;
11 project: Project & {
12 activity_count: number;
13 activity_up_count: number;
14 activity_match_count: number;
15 activity_send_count: number;
16 demo_count: number;
17 demo_up_count: number;
18 image_dynamic_count: number;
19 audio_dynamic_count: number;
20 video_dynamic_count: number;
21 };
22 }>();
23 const project = toRef(props, 'project');
24 const coverVisible = ref(false);
25
26 const onViewImage = () =>
27 createModalVNode(() => createVNode(ImageDynamics, { projectKey: project.value.id }), {
28 title: '厂牌相册',
29 titleAlign: 'center',
30 closable: true,
31 footer: false,
32 escToClose: true,
33 width: '780px',
34 });
35
36 const onViewAudio = () =>
37 createModalVNode(() => createVNode(AudioDynamics, { projectKey: project.value.id }), {
38 title: '厂牌音频',
39 titleAlign: 'center',
40 closable: true,
41 footer: false,
42 escToClose: true,
43 bodyStyle: { padding: '0 !important' },
44 width: '540px',
45 });
46
47 const onViewVideo = () =>
48 createModalVNode(() => createVNode(VideoDynamics, { projectKey: project.value.id }), {
49 title: '厂牌视频',
50 titleAlign: 'center',
51 closable: true,
52 footer: false,
53 escToClose: true,
54 width: '780px',
55 });
56 </script>
57
58 <template>
59 <a-spin :loading="loading as boolean" style="width: 100%">
60 <a-card :bordered="false">
61 <a-form :model="project" size="small" auto-label-width>
62 <a-row justify="start" align="start">
63 <a-col flex="130px" style="border-radius: 60px; overflow: hidden">
64 <a-image show-loader :height="130" :width="130" :src="project.cover" />
65 </a-col>
66 <a-col style="margin-left: 16px" flex="1">
67 <a-row :gutter="16" justify="space-between">
68 <a-col :span="20">
69 <a-form-item label="厂牌名称" :show-colon="true">{{ project.name }}</a-form-item>
70 </a-col>
71 </a-row>
72 <a-grid :cols="4" :col-gap="12" :row-gap="0">
73 <a-grid-item :span="1">
74 <a-form-item label="创建用户" :show-colon="true">
75 <span v-if="project.user">{{ project.user.nick_name }}</span>
76 <span v-else style="color: rgba(44, 44, 44, 0.5)"></span>
77 </a-form-item>
78 </a-grid-item>
79 <a-grid-item :span="3">
80 <a-form-item label="主理人" :show-colon="true">
81 <span v-if="project.master">{{ project.master.nick_name }}</span>
82 <span v-else style="color: rgba(44, 44, 44, 0.5)"></span>
83 </a-form-item>
84 </a-grid-item>
85 <a-grid-item :span="1">
86 <a-form-item label="试唱歌曲总数" :show-colon="true">
87 {{ project.activity_count || 0 }}
88 </a-form-item>
89 </a-grid-item>
90 <a-grid-item :span="1">
91 <a-form-item label="试唱进行中" :show-colon="true">
92 {{ project.activity_up_count || 0 }}
93 </a-form-item>
94 </a-grid-item>
95 <a-grid-item :span="1">
96 <a-form-item label="试唱已匹配" :show-colon="true">
97 {{ project.activity_match_count || 0 }}
98 </a-form-item>
99 </a-grid-item>
100 <a-grid-item :span="1">
101 <a-form-item label="歌曲已发行" :show-colon="true">
102 {{ project.activity_send_count || 0 }}
103 </a-form-item>
104 </a-grid-item>
105 <a-grid-item :span="1">
106 <a-form-item label="demo歌曲总数" :show-colon="true">
107 {{ project.demo_count || 0 }}
108 </a-form-item>
109 </a-grid-item>
110 <a-grid-item :span="3">
111 <a-form-item label="demo进行中" :show-colon="true">
112 {{ project.demo_up_count || 0 }}
113 </a-form-item>
114 </a-grid-item>
115 <a-grid-item>
116 <a-form-item label="头部封面" :show-colon="true">
117 <a-link v-if="project.head_cover" :hoverable="false" @click="coverVisible = !coverVisible">查看</a-link>
118 <span v-else style="color: rgba(44, 44, 44, 0.5)"></span>
119 </a-form-item>
120 </a-grid-item>
121 <a-grid-item>
122 <a-form-item label="厂牌相册" :show-colon="true">
123 <a-link v-if="project.image_dynamic_count" :hoverable="false" @click="onViewImage">查看</a-link>
124 <span v-else style="color: rgba(44, 44, 44, 0.5)"></span>
125 </a-form-item>
126 </a-grid-item>
127 <a-grid-item>
128 <a-form-item label="厂牌音频" :show-colon="true">
129 <a-link v-if="project.audio_dynamic_count" :hoverable="false" @click="onViewAudio">查看</a-link>
130 <span v-else style="color: rgba(44, 44, 44, 0.5)"></span>
131 </a-form-item>
132 </a-grid-item>
133 <a-grid-item>
134 <a-form-item label="厂牌视频" :show-colon="true">
135 <a-link v-if="project.video_dynamic_count" :hoverable="false" @click="onViewVideo">查看</a-link>
136 <span v-else style="color: rgba(44, 44, 44, 0.5)"></span>
137 </a-form-item>
138 </a-grid-item>
139 </a-grid>
140 <a-form-item label="厂牌简介" :show-colon="true">
141 <a-typography-paragraph
142 v-if="project.intro"
143 style="margin-bottom: 0"
144 type="secondary"
145 :ellipsis="{ rows: 2, showTooltip: true }"
146 >
147 {{ project.intro }}
148 </a-typography-paragraph>
149 <span v-else style="color: #cccccc"></span>
150 </a-form-item>
151 </a-col>
152 </a-row>
153 </a-form>
154 </a-card>
155 </a-spin>
156
157 <a-image-preview
158 v-model:visible="coverVisible"
159 :closable="false"
160 :src="`${project.head_cover}?x-oss-process=image/format,webp`"
161 :actions-layout="[]"
162 />
163 </template>
164
165 <style scoped lang="less">
166 :deep(.arco-typography) {
167 text-align: left;
168 margin-bottom: 0;
169 width: 100%;
170 }
171
172 :deep(.arco-form-item) {
173 margin-bottom: 4px;
174 }
175
176 :deep(.arco-space-item) {
177 margin-bottom: 0 !important;
178 }
179 </style>
1 <script setup lang="ts">
2 import { ImagePreviewGroup, Grid, GridItem, Image, Pagination } from '@arco-design/web-vue';
3 import { onMounted, ref } from 'vue';
4 import usePagination from '@/hooks/pagination';
5 import useProjectApi from '@/api/project';
6
7 const props = defineProps<{ projectKey: number }>();
8
9 const { pagination, setPage, setTotal } = usePagination({ pageSize: 12 });
10 const source = ref([]);
11
12 const onQuery = () =>
13 useProjectApi
14 .dynamics(props.projectKey, {
15 type: 'image',
16 page: pagination.value.current,
17 pageSize: pagination.value.pageSize,
18 setSort: '-is_top',
19 })
20 .then(({ data, meta }) => {
21 source.value = data as [];
22 setPage(meta.current);
23 setTotal(meta.total);
24 });
25
26 onMounted(() => onQuery());
27 </script>
28
29 <template>
30 <ImagePreviewGroup>
31 <Grid :cols="6" :col-gap="12" :row-gap="16">
32 <GridItem v-for="item in source" :key="item.id">
33 <Image
34 :src="`${item.properties.url}?x-oss-process=image/format,webp`"
35 :width="120"
36 :height="120"
37 fit="scale-down"
38 :show-loader="true"
39 />
40 </GridItem>
41 </Grid>
42 </ImagePreviewGroup>
43 <Pagination
44 size="mini"
45 style="justify-content: flex-end; margin-top: 10px"
46 :current="pagination.current"
47 :total="pagination.total"
48 :page-size="pagination.pageSize"
49 :hide-on-single-page="true"
50 @change="(current:number) => setPage(current) && onQuery()"
51 />
52 </template>
53
54 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { onMounted, ref } from 'vue';
3 import useLoading from '@/hooks/loading';
4 import { AnyObject } from '@/types/global';
5 import useProjectApi from '@/api/project';
6 import { Card, Input, Select } from '@arco-design/web-vue';
7 import EnumTableColumn from '@/components/filter/enum-table-column.vue';
8 import PhoneTableColumn from '@/components/filter/phone-table-column.vue';
9 import FilterSearch from '@/components/filter/search.vue';
10 import FilterSearchItem from '@/components/filter/search-item.vue';
11 import FilterTable from '@/components/filter/table.vue';
12 import FilterTableColumn from '@/components/filter/table-column.vue';
13 import useUserApi from '@/api/user';
14 import UserTableColumn from '@/components/filter/user-table-column.vue';
15
16 const props = defineProps<{ projectKey: number; queryHook?: () => void }>();
17
18 const { loading, setLoading } = useLoading(false);
19 const { manageUsers } = useProjectApi;
20
21 const { sexOption, statusOption, officialStatusOption } = useUserApi;
22
23 const filter = ref({});
24 const tableRef = ref();
25
26 const onSearch = () => tableRef.value?.onPageChange(1);
27
28 const onReset = () => {
29 filter.value = { nick_name: '', sex: '', phone_like: '', email_like: '', status: '' };
30 onSearch();
31 };
32
33 const onQuery = async (params: AnyObject) => {
34 setLoading(true);
35 props.queryHook?.();
36 return manageUsers(props.projectKey, { ...filter.value, ...params, setSort: 'id' }).finally(() => setLoading(false));
37 };
38
39 onMounted(() => onReset());
40 </script>
41
42 <template>
43 <Card :bordered="false">
44 <FilterSearch :model="filter" :loading="loading" @search="onSearch" @reset="onReset">
45 <FilterSearchItem label="用户艺名">
46 <Input v-model="filter.nick_name" allow-clear placeholder="请输入" />
47 </FilterSearchItem>
48 <FilterSearchItem label="性别">
49 <Select v-model="filter.sex" allow-clear placeholder="请选择" :options="sexOption" />
50 </FilterSearchItem>
51 <FilterSearchItem label="用户邮箱">
52 <Input v-model="filter.email_like" allow-clear placeholder="请输入" />
53 </FilterSearchItem>
54 <FilterSearchItem label="手机号码">
55 <Input v-model="filter.phone_like" allow-clear placeholder="请输入" />
56 </FilterSearchItem>
57 <FilterSearchItem label="状态">
58 <Select v-model="filter.status" allow-clear placeholder="请选择" :options="statusOption" />
59 </FilterSearchItem>
60 </FilterSearch>
61 <FilterTable ref="tableRef" :loading="loading" :on-query="onQuery">
62 <user-table-column title="用户艺名" data-index="id" :width="200" show-avatar />
63 <EnumTableColumn title="性别" data-index="sex" :option="sexOption" :dark-value="0" :width="70" />
64 <FilterTableColumn title="用户邮箱" data-index="email" :width="180" />
65 <PhoneTableColumn title="手机号码" data-index="phone" area-index="area_code" :width="160" />
66 <EnumTableColumn title="关注服务号" data-index="official_status" :option="officialStatusOption" :dark-value="0" :width="120" />
67 <EnumTableColumn title="状态" data-index="status" :option="statusOption" :dark-value="0" :width="80" />
68 </FilterTable>
69 </Card>
70 </template>
71
72 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { Card, Input, Select } from '@arco-design/web-vue';
3 import { onMounted, ref } from 'vue';
4 import useLoading from '@/hooks/loading';
5 import useUserApi from '@/api/user';
6 import { AnyObject } from '@/types/global';
7 import useProjectApi from '@/api/project';
8 import FilterTable from '@/components/filter/table.vue';
9 import FilterSearch from '@/components/filter/search.vue';
10 import FilterSearchItem from '@/components/filter/search-item.vue';
11 import UserTableColumn from '@/components/filter/user-table-column.vue';
12 import EnumTableColumn from '@/components/filter/enum-table-column.vue';
13 import PhoneTableColumn from '@/components/filter/phone-table-column.vue';
14 import FilterTableColumn from '@/components/filter/table-column.vue';
15
16 const props = defineProps<{ projectKey: number; queryHook?: () => void }>();
17
18 const { loading, setLoading } = useLoading(false);
19 const { sexOption, officialStatusOption } = useUserApi;
20 const { memberUsers } = useProjectApi;
21
22 const filter = ref({ nick_name: '', sex: '', phone_like: '', email_like: '' });
23 const tableRef = ref();
24
25 const onSearch = () => tableRef.value?.onPageChange(1);
26
27 const onReset = () => {
28 filter.value = { nick_name: '', sex: '', phone_like: '', email_like: '' };
29 onSearch();
30 };
31
32 const onQuery = async (params: AnyObject) => {
33 setLoading(true);
34 props.queryHook?.();
35 return memberUsers(props.projectKey, { ...filter.value, ...params, setSort: '-is_top' }).finally(() => setLoading(false));
36 };
37
38 onMounted(() => onReset());
39 </script>
40
41 <template>
42 <Card :bordered="false">
43 <FilterSearch :model="filter" :loading="loading" @search="onSearch" @reset="onReset">
44 <FilterSearchItem label="用户艺名">
45 <Input v-model="filter.nick_name" allow-clear placeholder="请输入" />
46 </FilterSearchItem>
47 <FilterSearchItem label="性别">
48 <Select v-model="filter.sex" allow-clear placeholder="请选择" :options="sexOption" />
49 </FilterSearchItem>
50 <FilterSearchItem label="用户邮箱">
51 <Input v-model="filter.email_like" allow-clear placeholder="请输入" />
52 </FilterSearchItem>
53 <FilterSearchItem label="手机号码">
54 <Input v-model="filter.phone_like" allow-clear placeholder="请输入" />
55 </FilterSearchItem>
56 </FilterSearch>
57 <FilterTable ref="tableRef" :loading="loading" :on-query="onQuery">
58 <user-table-column title="用户艺名" data-index="id" :width="200" show-avatar />
59 <EnumTableColumn title="性别" data-index="sex" :option="sexOption" :dark-value="0" :width="70" />
60 <FilterTableColumn title="用户邮箱" data-index="email" :width="180" />
61 <PhoneTableColumn title="手机号码" data-index="phone" area-index="area_code" :width="160" />
62 <EnumTableColumn title="关注服务号" data-index="official_status" :option="officialStatusOption" :dark-value="0" :width="120" />
63 </FilterTable>
64 </Card>
65 </template>
66
67 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import FilterTable from '@/components/filter/table.vue';
3 import FilterTableColumn from '@/components/filter/table-column.vue';
4 import EnumTableColumn from '@/components/filter/enum-table-column.vue';
5 import { Space, Link, Message, Modal, Form, FormItem, Select } from '@arco-design/web-vue';
6 import useLoading from '@/hooks/loading';
7 import { AnyObject } from '@/types/global';
8 import { computed, createVNode, onMounted, ref } from 'vue';
9 import useUserApi from '@/api/user';
10 import { User } from '@/types/user';
11 import useActivityApi from '@/api/activity';
12
13 const props = defineProps<{ projectKey: number; hasPermission: boolean; user: User }>();
14
15 const { loading, setLoading } = useLoading(false);
16 // eslint-disable-next-line import/no-named-as-default-member
17 const { statusOption, updateManage, deleteManage } = useActivityApi;
18
19 const permissionOption = [
20 { label: '查看试唱', value: 'view' },
21 { label: '查看报价', value: 'price' },
22 { label: '回复结果', value: 'audit' },
23 ];
24
25 const tableRef = ref();
26
27 const onQuery = async (params: AnyObject) => {
28 setLoading(true);
29 return useUserApi.manageSongs(props.user.id, { project_id: props.projectKey, ...params }).finally(() => setLoading(false));
30 };
31
32 onMounted(() => tableRef.value?.onPageChange(1));
33
34 const label = computed(() => `${props.user.nick_name} 管理歌曲共:${tableRef.value?.getCount()} `);
35
36 const formatPermission = (permission: any[]) =>
37 permission
38 .map((item) => `[${permissionOption.find((option) => option.value === item)?.label}]` || '')
39 .filter((item) => item.length !== 0)
40 .join('');
41
42 const onUpdate = (record: any) => {
43 const formValue = ref<string[]>(record?.permission || ['view']);
44
45 Modal.open({
46 title: '修改',
47 titleAlign: 'center',
48 content: () =>
49 createVNode(Form, { layout: 'vertical', model: {} }, () =>
50 createVNode(FormItem, { label: '设置用户权限', rowClass: 'mb-0' }, () =>
51 createVNode(Select, {
52 'multiple': true,
53 'options': permissionOption,
54 'modelValue': formValue.value,
55 'onUpdate:modelValue': (val?: string[]) => {
56 if (!val?.includes('view')) {
57 val?.unshift('view');
58 }
59 formValue.value = val || [];
60 },
61 })
62 )
63 ),
64 onBeforeOk: (done) =>
65 updateManage(record.id, { permission: formValue.value })
66 .then(() => {
67 Message.success('更新成功');
68 tableRef.value?.onFetch();
69 done(true);
70 })
71 .catch(() => done(false)),
72 });
73 };
74
75 const onDelete = (record: any) => {
76 Modal.open({
77 title: '删除操作',
78 content: `确认取消用户:${props.user?.nick_name} 的外部管理员身份`,
79 closable: false,
80 onBeforeOk: (done) =>
81 deleteManage(record.id)
82 .then(() => {
83 Message.success('删除成功');
84 tableRef.value?.onFetch();
85 done(true);
86 })
87 .catch(() => done(false)),
88 });
89 };
90 </script>
91
92 <template>
93 <div style="margin-bottom: 10px">{{ label }}</div>
94 <FilterTable ref="tableRef" :loading="loading" :on-query="onQuery">
95 <FilterTableColumn title="厂牌名称" data-index="project.name" :width="120" />
96 <FilterTableColumn title="歌曲名称" data-index="activity_name" :width="160" />
97 <FilterTableColumn title="权限" data-index="permission" :width="240">
98 <template #default="{ record }">{{ formatPermission(record.permission || []) }}</template>
99 </FilterTableColumn>
100 <EnumTableColumn title="状态" data-index="activity_status" :width="100" :option="statusOption" />
101 <FilterTableColumn v-if="hasPermission" title="操作" :width="120">
102 <template #default="{ record }">
103 <Space>
104 <Link class="link-hover" :hoverable="false" @click="onUpdate(record)">修改</Link>
105 <Link class="link-hover" :hoverable="false" @click="onDelete(record)">取消管理</Link>
106 </Space>
107 </template>
108 </FilterTableColumn>
109 </FilterTable>
110 </template>
111
112 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import useLoading from '@/hooks/loading';
3 import FilterSearchItem from '@/components/filter/search-item.vue';
4 import { Card, Input, Link, Message, Modal, Select } from '@arco-design/web-vue';
5 import FilterSearch from '@/components/filter/search.vue';
6 import { h, onMounted, ref } from 'vue';
7 import FilterTable from '@/components/filter/table.vue';
8 import { AnyObject } from '@/types/global';
9 import useProjectApi from '@/api/project';
10 import EnumTableColumn from '@/components/filter/enum-table-column.vue';
11 import PhoneTableColumn from '@/components/filter/phone-table-column.vue';
12 import FilterTableColumn from '@/components/filter/table-column.vue';
13 import OutFormContent from '@/views/project-show/components/out-form-content.vue';
14
15 import { User } from '@/types/user';
16 import useUserApi from '@/api/user';
17 import UserTableColumn from '@/components/filter/user-table-column.vue';
18
19 const props = defineProps<{ projectKey: number; hasPermission?: boolean; queryHook?: () => void }>();
20
21 const { loading, setLoading } = useLoading(false);
22 const { outManageUsers, destroyOutManageUsers } = useProjectApi;
23
24 const { sexOption, statusOption, officialStatusOption } = useUserApi;
25
26 const filter = ref({});
27 const tableRef = ref();
28
29 const onSearch = () => tableRef.value?.onPageChange(1);
30
31 const onReset = () => {
32 filter.value = { nick_name: '', sex: '', phone_like: '', email_like: '', status: '' };
33 onSearch();
34 };
35
36 const onQuery = async (params: AnyObject) => {
37 setLoading(true);
38 props.queryHook?.();
39 return outManageUsers(props.projectKey, { ...filter.value, ...params, setSort: 'id' }).finally(() => setLoading(false));
40 };
41
42 onMounted(() => onReset());
43
44 const onView = (row: User) => {
45 Modal.open({
46 title: '管理歌曲',
47 titleAlign: 'center',
48 content: () => h(OutFormContent, { user: row, projectKey: props.projectKey, hasPermission: props.hasPermission }),
49 footer: false,
50 closable: true,
51 width: '860px',
52 onClose: () => tableRef.value?.onFetch(),
53 });
54 };
55
56 const onDelete = (record: User) => {
57 Modal.open({
58 title: '删除操作',
59 content: `取消用户:${record?.nick_name} 在此厂牌下的外部管理员身份`,
60 onBeforeOk: (done) =>
61 destroyOutManageUsers(props.projectKey, { user_id: record.id })
62 .then(() => {
63 Message.success('删除成功');
64 tableRef.value?.onFetch();
65 done(true);
66 })
67 .catch(() => done(false)),
68 });
69 };
70 </script>
71
72 <template>
73 <Card :bordered="false">
74 <FilterSearch :model="filter" :loading="loading" @search="onSearch" @reset="onReset">
75 <FilterSearchItem label="用户艺名">
76 <Input v-model="filter.nick_name" allow-clear placeholder="请输入" />
77 </FilterSearchItem>
78 <FilterSearchItem label="性别">
79 <Select v-model="filter.sex" allow-clear placeholder="请选择" :options="sexOption" />
80 </FilterSearchItem>
81 <FilterSearchItem label="用户邮箱">
82 <Input v-model="filter.email_like" allow-clear placeholder="请输入" />
83 </FilterSearchItem>
84 <FilterSearchItem label="手机号码">
85 <Input v-model="filter.phone_like" allow-clear placeholder="请输入" />
86 </FilterSearchItem>
87 <FilterSearchItem label="状态">
88 <Select v-model="filter.status" allow-clear placeholder="请选择" :options="statusOption" />
89 </FilterSearchItem>
90 </FilterSearch>
91 <FilterTable ref="tableRef" :loading="loading" :on-query="onQuery">
92 <user-table-column title="用户艺名" data-index="id" :width="200" show-avatar />
93 <EnumTableColumn title="性别" data-index="sex" :option="sexOption" :dark-value="0" :width="70" />
94 <FilterTableColumn title="用户邮箱" data-index="email" :width="200" />
95 <PhoneTableColumn title="手机号码" data-index="phone" area-index="area_code" :width="160" />
96 <FilterTableColumn title="管理歌曲" :width="100">
97 <template #default="{ record }: { record: User & { activities_count: number[] } }">
98 <Link class="link-hover" :hoverable="false" @click="onView(record)">{{ record.activities_count }}</Link>
99 </template>
100 </FilterTableColumn>
101 <EnumTableColumn title="关注服务号" data-index="official_status" :option="officialStatusOption" :dark-value="0" :width="120" />
102 <EnumTableColumn title="状态" data-index="status" :option="statusOption" :dark-value="0" :width="80" />
103 <FilterTableColumn v-if="hasPermission" title="操作" :width="100">
104 <template #default="{ record }">
105 <Link class="link-hover" :hoverable="false" @click="onDelete(record)">删除</Link>
106 </template>
107 </FilterTableColumn>
108 </FilterTable>
109 </Card>
110 </template>
111
112 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { ImagePreviewGroup, Grid, GridItem, Image, Pagination } from '@arco-design/web-vue';
3 import { h, onMounted, ref } from 'vue';
4 import usePagination from '@/hooks/pagination';
5 import useProjectApi from '@/api/project';
6 import { createModalVNode } from '@/utils/createVNode';
7
8 import vue3videoPlay from 'vue3-video-play';
9 import 'vue3-video-play/dist/style.css';
10
11 const props = defineProps<{ projectKey: number }>();
12
13 const { pagination, setPage, setTotal } = usePagination({ pageSize: 12 });
14 const source = ref<any[]>([]);
15
16 const onQuery = () =>
17 useProjectApi
18 .dynamics(props.projectKey, {
19 type: 'video',
20 page: pagination.value.current,
21 pageSize: pagination.value.pageSize,
22 setSort: '-is_top',
23 })
24 .then(({ data, meta }) => {
25 source.value = data as [];
26 setPage(meta.current);
27 setTotal(meta.total);
28 });
29
30 onMounted(() => onQuery());
31
32 const onClick = (item: any) =>
33 createModalVNode(
34 () => h(vue3videoPlay, { src: item.properties.url, controlBtns: ['volume', 'speedRate', 'pip', 'pageFullScreen', 'fullScreen'] }),
35 {
36 width: 'auto',
37 modalStyle: { padding: '0 !important' },
38 simple: true,
39 footer: false,
40 maskClosable: true,
41 escToClose: true,
42 }
43 );
44 </script>
45
46 <template>
47 <ImagePreviewGroup>
48 <Grid :cols="6" :col-gap="12" :row-gap="16">
49 <GridItem v-for="item in source" :key="item.id">
50 <Image
51 :src="item.properties.cover.url"
52 :width="120"
53 :height="70"
54 fit="scale-down"
55 :show-loader="true"
56 :preview="false"
57 @click="onClick(item)"
58 />
59 </GridItem>
60 </Grid>
61 </ImagePreviewGroup>
62 <Pagination
63 size="mini"
64 style="justify-content: flex-end; margin-top: 10px"
65 :current="pagination.current"
66 :total="pagination.total"
67 :page-size="pagination.pageSize"
68 :hide-on-single-page="true"
69 @change="(current:number) => setPage(current) && onQuery()"
70 />
71 </template>
72
73 <style scoped lang="less"></style>
1 <template>
2 <page-view has-bread>
3 <basic-card :project="project as any" :loading="loading" />
4
5 <a-card :bordered="false" style="margin-top: 16px">
6 <a-tabs v-model:active-key="tabKey" type="rounded" :animation="true" size="small" :justify="true">
7 <a-tab-pane key="activity" :title="`歌曲列表(${activityCount})`">
8 <activity-card
9 style="padding: 0"
10 :has-bread="false"
11 :hide-tool="true"
12 :init-filter="{ status: '', project_id: projectKey }"
13 :hide-search-item="['projectName']"
14 :export-name="project.name"
15 :query-hook="() => syncActivityCount()"
16 />
17 </a-tab-pane>
18 <a-tab-pane key="demo" :title="`Demo列表(${demoCount})`">
19 <demo-card
20 :hide-tool="true"
21 :init-filter="{ project_id: projectKey }"
22 style="padding: 0"
23 :has-bread="false"
24 :hide-create="true"
25 :hide-search-item="['projectName']"
26 :query-hook="() => syncDemoCount()"
27 />
28 </a-tab-pane>
29 <a-tab-pane key="member" :title="`厂牌成员(${memberCount})`">
30 <member-table :project-key="projectKey" :query-hook="() => syncMemberCount()" />
31 </a-tab-pane>
32 <a-tab-pane key="manage" :title="`厂牌管理(${manageCount})`">
33 <manage-table :project-key="projectKey" :query-hook="() => syncManageCount()" />
34 </a-tab-pane>
35 <a-tab-pane key="out-manage" :title="`外部管理(${outManageCount})`">
36 <out-manage-table
37 :project-key="projectKey"
38 :has-permission="project.is_can_manage as boolean"
39 :query-hook="() => syncOutManageCount()"
40 />
41 </a-tab-pane>
42 </a-tabs>
43 </a-card>
44 </page-view>
45 </template>
46
47 <script lang="ts" setup>
48 import { useRoute } from 'vue-router';
49 import { useRouteQuery } from '@vueuse/router';
50 import { onMounted, ref } from 'vue';
51 import useLoading from '@/hooks/loading';
52
53 import useProjectApi from '@/api/project';
54 import useActivityApi from '@/api/activity';
55 import { useSelectionStore } from '@/store';
56 import BasicCard from '@/views/project-show/components/basic-card.vue';
57 import ManageTable from '@/views/project-show/components/manage-table.vue';
58 import OutManageTable from '@/views/project-show/components/out-manage-table.vue';
59 import ActivityCard from '@/views/audition/activity/index.vue';
60 import DemoCard from '@/views/audition/demo/index.vue';
61 import MemberTable from '@/views/project-show/components/member-table.vue';
62
63 const tabKey = useRouteQuery('tabKey', 'activity');
64
65 const projectKey = Number(useRoute().params?.id);
66
67 const project = ref({ name: '', is_can_manage: 0 });
68
69 const { loading, setLoading } = useLoading(true);
70
71 const manageCount = ref(0);
72 const outManageCount = ref(0);
73 const memberCount = ref(0);
74 const demoCount = ref(0);
75 const activityCount = ref(0);
76
77 const syncParams = { pageSize: 1, limit: 1 };
78
79 const syncManageCount = () =>
80 // eslint-disable-next-line no-return-assign
81 useProjectApi.manageUsers(projectKey, syncParams).then(({ meta }) => (manageCount.value = meta.total));
82
83 const syncMemberCount = () =>
84 // eslint-disable-next-line no-return-assign
85 useProjectApi.memberUsers(projectKey, { pageSize: 1, limit: 1 }).then(({ meta }) => (memberCount.value = meta.total));
86
87 const syncOutManageCount = () =>
88 // eslint-disable-next-line no-return-assign
89 useProjectApi.outManageUsers(projectKey, syncParams).then(({ meta }) => (outManageCount.value = meta.total));
90
91 const syncActivityCount = () =>
92 // eslint-disable-next-line import/no-named-as-default-member,no-return-assign
93 useActivityApi
94 .get({ status: '', song_type: 1, audit_status: 1, project_id: projectKey, ...syncParams })
95 // eslint-disable-next-line no-return-assign
96 .then(({ meta }) => (activityCount.value = meta.total));
97
98 const syncDemoCount = () => {
99 // eslint-disable-next-line import/no-named-as-default-member,no-return-assign
100 useActivityApi
101 .get({ status: '', song_type: 2, audit_status: 1, project_id: projectKey, ...syncParams })
102 // eslint-disable-next-line no-return-assign
103 .then(({ meta }) => (demoCount.value = meta.total));
104 };
105
106 const { queryAll } = useSelectionStore();
107
108 onMounted(() => {
109 useProjectApi
110 .show(projectKey)
111 // eslint-disable-next-line no-return-assign
112 .then((data) => (project.value = data as any))
113 .finally(() => setLoading(false));
114
115 queryAll();
116 });
117 </script>
1 <script setup lang="ts">
2 import { Form, FormItem, Input, Textarea } from '@arco-design/web-vue';
3 import AvatarUpload from '@/components/avatar-upload/index.vue';
4 import { computed, ref } from 'vue';
5 import { FieldRule, FormInstance } from '@arco-design/web-vue/es/form';
6 import { Project } from '@/types/project';
7
8 type AttributeType = Omit<Project, 'id'>;
9
10 const props = defineProps<{ modelValue: AttributeType; submit?: (value: AttributeType) => Promise<Project> }>();
11 const emits = defineEmits<{ (e: 'update:modelValue', value: AttributeType): void }>();
12
13 const formRef = ref<FormInstance>();
14
15 const formRule = {
16 cover: [{ required: true, message: '请上传厂牌图片' }],
17 name: [{ required: true, message: '请输入厂牌名称' }],
18 intro: [{ type: 'string', max: 100, message: '厂牌简介不能超过100字' }],
19 } as Record<string, FieldRule[]>;
20
21 const formValue = computed({
22 get: () => props.modelValue,
23 set: (val) => emits('update:modelValue', val),
24 });
25
26 const onSubmit = async () => {
27 const error = await formRef.value?.validate();
28
29 return error ? Promise.reject(error) : props.submit?.(formValue.value);
30 };
31 defineExpose({ onSubmit });
32 </script>
33
34 <template>
35 <Form ref="formRef" layout="vertical" :model="formValue" :rules="formRule">
36 <FormItem hide-label content-class="justify-center" :wrapper-col-props="{ alignItems: 'center' }">
37 <AvatarUpload v-model="formValue.cover" :size="100" />
38 </FormItem>
39 <FormItem label="厂牌名称" field="name" show-colon>
40 <Input v-model="formValue.name" placeholder="请输入" :max-length="30" show-word-limit />
41 </FormItem>
42 <FormItem label="厂牌简介" field="intro" row-class="mb-0" show-colon>
43 <Textarea v-model="formValue.intro" :auto-size="{ maxRows: 4, minRows: 4 }" :max-length="50" show-word-limit />
44 </FormItem>
45 </Form>
46 </template>
47
48 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { Input, Link, TableData, Message } from '@arco-design/web-vue';
3
4 import { onMounted, ref, computed, createVNode } from 'vue';
5 import { useRoute, useRouter } from 'vue-router';
6 import { AnyObject } from '@/types/global';
7 import useLoading from '@/hooks/loading';
8 import useProjectApi from '@/api/project';
9 import NumberTableColumn from '@/components/filter/number-table-column.vue';
10 import UserTableColumn from '@/components/filter/user-table-column.vue';
11 import SpaceTableColumn from '@/components/filter/space-table-column.vue';
12 import { createModalVNode } from '@/utils/createVNode';
13 import { Project } from '@/types/project';
14 import FormContent from '@/views/project/components/form-content.vue';
15
16 const { get, update, getExport } = useProjectApi;
17
18 const router = useRouter();
19
20 const filter = ref<AnyObject>({});
21 const tableRef = ref();
22 const { loading, setLoading } = useLoading(false);
23
24 const queryParams = computed(() => {
25 return {
26 ...filter.value,
27 setWith: ['master:id,nick_name,identity'],
28 setWithCount: ['activity_up', 'activity_match', 'activity_send', 'manage', 'member'],
29 };
30 });
31
32 const onQuery = async (params?: AnyObject) => {
33 setLoading(true);
34 return get({ ...queryParams.value, ...params }).finally(() => setLoading(false));
35 };
36
37 const onSearch = () => tableRef.value?.onPageChange(1);
38
39 const onReset = () => {
40 filter.value = { name: '', masterName: '', status: '', isPromote: '', setSort: ['-id'] };
41 tableRef.value?.resetSort();
42 onSearch();
43 };
44
45 const onSort = (column: string, type: string) => {
46 filter.value.setSort = type ? [(type === 'desc' ? '-' : '') + column, '-id'] : ['-id'];
47 tableRef.value?.onFetch();
48 };
49
50 onMounted(() => onReset());
51
52 const onExport = () => getExport('厂牌', queryParams.value);
53
54 const onShow = (record: TableData) => router.push({ name: 'project-show', params: { id: record.id } });
55
56 const showRoute = computed(() => {
57 return useRoute().name === 'project-show';
58 });
59
60 const onUpdate = (row: TableData) => {
61 const formRef = ref();
62 const formValue = ref({ ...row });
63 createModalVNode(
64 () =>
65 createVNode(FormContent, {
66 'ref': formRef,
67 'model-value': formValue.value,
68 // eslint-disable-next-line no-return-assign
69 'onUpdate:model-value': (val: any) => (formValue.value = val),
70 'submit': (value: Omit<Project, 'id'>) => update(row.id, value),
71 }),
72 {
73 title: '厂牌编辑',
74 titleAlign: 'center',
75 onBeforeOk: (done) =>
76 formRef.value
77 ?.onSubmit()
78 .then(({ name }: Project) => {
79 tableRef.value.onFetch();
80 Message.success(`更新厂牌:${name}`);
81 done(true);
82 })
83 .catch(() => done(false)),
84 }
85 );
86 };
87 </script>
88
89 <template>
90 <router-view v-if="showRoute" :key="$route.path" />
91 <PageView v-show="!showRoute" has-card has-bread>
92 <FilterSearch :model="filter" :loading="loading" :split="3" inline @search="onSearch" @reset="onReset">
93 <FilterSearchItem label="厂牌名称" field="name">
94 <Input v-model="filter.name" allow-clear placeholder="请输入" />
95 </FilterSearchItem>
96 <FilterSearchItem label="主理人" field="masterName">
97 <Input v-model="filter.masterName" allow-clear placeholder="请输入" />
98 </FilterSearchItem>
99 </FilterSearch>
100 <FilterTable ref="tableRef" :loading="loading" :on-query="onQuery" @row-sort="onSort">
101 <template #tool-right>
102 <ExportButton :on-download="onExport" />
103 </template>
104 <UserTableColumn title="名称" data-index="id" avatar-index="cover" nick-index="name" :width="260" show-avatar />
105 <UserTableColumn title="主理人" data-index="master_id" user="master" :width="160" dark-value="" />
106 <NumberTableColumn title="厂牌管理员人数" data-index="manage_count" :dark-value="0" :width="120" has-sort />
107 <NumberTableColumn title="厂牌成员人数" data-index="member_count" :dark-value="0" :width="120" has-sort />
108 <NumberTableColumn title="上架歌曲数" data-index="activity_up_count" :dark-value="0" :width="110" has-sort />
109 <NumberTableColumn title="已匹配歌曲数" data-index="activity_match_count" :dark-value="0" :width="110" has-sort />
110 <NumberTableColumn title="已发行歌曲数" data-index="activity_send_count" :dark-value="0" :width="110" has-sort />
111 <SpaceTableColumn title="操作" data-index="operation" :width="100">
112 <template #default="{ record }">
113 <Link class="link-hover" :hoverable="false" @click="onShow(record)">查看</Link>
114 <Link class="link-hover" :hoverable="false" @click="onUpdate(record)">编辑</Link>
115 </template>
116 </SpaceTableColumn>
117 </FilterTable>
118 </PageView>
119 </template>
120
121 <style scoped lang="less"></style>
1 {
2 "compilerOptions": {
3 "target": "ES2020",
4 "module": "ES2020",
5 "moduleResolution": "node",
6 "strict": true,
7 "jsx": "preserve",
8 "sourceMap": true,
9 "resolveJsonModule": true,
10 "esModuleInterop": true,
11 "skipLibCheck": true,
12 "allowSyntheticDefaultImports": true,
13 "baseUrl": ".",
14 "paths": {
15 "@/*": [
16 "src/*"
17 ]
18 },
19 "lib": [
20 "es2020",
21 "dom"
22 ]
23 },
24 "include": [
25 "src/**/*",
26 "src/**/*.vue"
27 ]
28 }