Commit ee7a1044 ee7a104476a53278cb6da2977734e8b5235718f1 by 杨俊

Init

0 parents
Showing 127 changed files with 5827 additions and 0 deletions
1 #VITE_API_URL=https://service.hising.orb.local
2 VITE_API_URL=https://hi-sing-service-dev.hikoon.com
3 VITE_OSS_ACCESS_KEY=LTAI4GKtcA6yTV6wnapivq7Y
4 VITE_OSS_ACCESS_SECRET=QPEt0HPEuRe7wIk2wZNtKPF6L0xMmQ
5 VITE_OSS_BUCKET=hisin-dev
6 VITE_OSS_REGION=oss-cn-beijing
7 VITE_OSS_HOST=https://hi-sing-cdn-dev.hikoon.com
8 VITE_OSS_ENDPOINT=https://hisin-dev.oss-cn-beijing.aliyuncs.com
9 VITE_SHOW_LOGIN_ACCOUNT=true
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 defineModel: 'readonly',
41 withDefaults: 'readonly',
42 },
43 rules: {
44 "prettier/prettier": 'off',
45 // 'prettier/prettier': 1,
46 'import/order': 0,
47 // Vue: Recommended rules to be closed or modify
48 'vue/require-default-prop': 0,
49 'vue/singleline-html-element-content-newline': 0,
50 'vue/max-attributes-per-line': 0,
51 // Vue: Add extra rules
52 'vue/custom-event-name-casing': [2, 'camelCase'],
53 'vue/no-v-text': 1,
54 'vue/padding-line-between-blocks': 1,
55 'vue/require-direct-export': 1,
56 'vue/multi-word-component-names': 0,
57 // Allow @ts-ignore comment
58 '@typescript-eslint/ban-ts-comment': 0,
59 '@typescript-eslint/no-unused-vars': 1,
60 '@typescript-eslint/no-empty-function': 1,
61 '@typescript-eslint/no-explicit-any': 0,
62 '@typescript-eslint/no-implicit-any': 0,
63 'camelcase': 'off',
64 'max-classes-per-file': 'off',
65 '@typescript-eslint/camelcase': 0,
66 'import/extensions': [
67 2,
68 'ignorePackages',
69 {
70 js: 'never',
71 jsx: 'never',
72 ts: 'never',
73 tsx: 'never',
74 },
75 ],
76 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
77 'no-param-reassign': 0,
78 'no-return-assign': 0,
79 'no-shadow': 0,
80 'prefer-regex-literals': 0,
81 'import/no-extraneous-dependencies': 0,
82 'class-methods-use-this': 0,
83 "vue/first-attribute-linebreak": ["error", {
84 "singleline": "ignore",
85 "multiline": "ignore"
86 }]
87 },
88 };
1 node_modules
2 .DS_Store
3 .env.production
4 dist
5 .idea/*
6 dist-ssr
7 *.local
8 yarn.lock
1 /dist/*
2 .local
3 .output.js
4 /node_modules/**
5
6 **/*.svg
1 module.exports = {
2 tabWidth: 2,
3 semi: true,
4 printWidth: 140,
5 singleQuote: true,
6 quoteProps: 'consistent',
7 htmlWhitespaceSensitivity: 'strict',
8 vueIndentScriptAndStyle: true,
9 };
1 module.exports = {
2 extends: [
3 'stylelint-config-standard',
4 'stylelint-config-rational-order',
5 'stylelint-config-prettier',
6 'stylelint-config-recommended-vue',
7 ],
8 defaultSeverity: 'warning',
9 plugins: ['stylelint-order'],
10 rules: {
11 'at-rule-no-unknown': [
12 true,
13 {
14 ignoreAtRules: ['plugin'],
15 },
16 ],
17 'rule-empty-line-before': [
18 'always',
19 {
20 except: ['after-single-line-comment', 'first-nested'],
21 },
22 ],
23 'selector-pseudo-class-no-unknown': [
24 true,
25 {
26 ignorePseudoClasses: ['deep'],
27 },
28 ],
29 },
30 };
1 module.exports = {
2 plugins: ['@vue/babel-plugin-jsx'],
3 };
1 module.exports = {
2 extends: ['@commitlint/config-conventional'],
3 };
1 /* eslint-disable */
2 /* prettier-ignore */
3 // @ts-nocheck
4 // Generated by unplugin-vue-components
5 // Read more: https://github.com/vuejs/core/pull/3399
6 import '@vue/runtime-core'
7
8 export {}
9
10 declare module '@vue/runtime-core' {
11 export interface GlobalComponents {
12 RouterLink: typeof import('vue-router')['RouterLink']
13 RouterView: typeof import('vue-router')['RouterView']
14 }
15 }
1 /**
2 * If you use the template method for development, you can use the unplugin-vue-components plugin to enable on-demand loading support.
3 * 按需引入
4 * https://github.com/antfu/unplugin-vue-components
5 * https://arco.design/vue/docs/start
6 * Although the Pro project is full of imported components, this plugin will be used by default.
7 * 虽然Pro项目中是全量引入组件,但此插件会默认使用。
8 */
9 import Components from 'unplugin-vue-components/vite';
10 import { ArcoResolver } from 'unplugin-vue-components/resolvers';
11
12 export default function configArcoResolverPlugin() {
13 const arcoResolverPlugin = Components({
14 dirs: [], // Avoid parsing src/components. 避免解析到src/components
15 deep: false,
16 resolvers: [ArcoResolver()],
17 });
18 return arcoResolverPlugin;
19 }
1 /**
2 * Theme import
3 * 样式按需引入
4 * https://github.com/arco-design/arco-plugins/blob/main/packages/plugin-vite-vue/README.md
5 * https://arco.design/vue/docs/start
6 */
7 import { vitePluginForArco } from '@arco-plugins/vite-vue';
8
9 export default function configArcoStyleImportPlugin() {
10 const arcoResolverPlugin = vitePluginForArco({});
11 return arcoResolverPlugin;
12 }
1 /**
2 * Used to package and output gzip. Note that this does not work properly in Vite, the specific reason is still being investigated
3 * gzip压缩
4 * https://github.com/anncwb/vite-plugin-compression
5 */
6 import type { Plugin } from 'vite';
7 import compressPlugin from 'vite-plugin-compression';
8
9 export default function configCompressPlugin(
10 compress: 'gzip' | 'brotli',
11 deleteOriginFile = false
12 ): Plugin | Plugin[] {
13 const plugins: Plugin[] = [];
14
15 if (compress === 'gzip') {
16 plugins.push(
17 compressPlugin({
18 ext: '.gz',
19 deleteOriginFile,
20 })
21 );
22 }
23
24 if (compress === 'brotli') {
25 plugins.push(
26 compressPlugin({
27 ext: '.br',
28 algorithm: 'brotliCompress',
29 deleteOriginFile,
30 })
31 );
32 }
33 return plugins;
34 }
1 /**
2 * Image resource files used to compress the output of the production environment
3 * 图片压缩
4 * https://github.com/anncwb/vite-plugin-imagemin
5 */
6 import viteImagemin from 'vite-plugin-imagemin';
7
8 export default function configImageminPlugin() {
9 const imageminPlugin = viteImagemin({
10 gifsicle: {
11 optimizationLevel: 7,
12 interlaced: false,
13 },
14 optipng: {
15 optimizationLevel: 7,
16 },
17 mozjpeg: {
18 quality: 20,
19 },
20 pngquant: {
21 quality: [0.8, 0.9],
22 speed: 4,
23 },
24 svgo: {
25 plugins: [
26 {
27 name: 'removeViewBox',
28 },
29 {
30 name: 'removeEmptyAttrs',
31 active: false,
32 },
33 ],
34 },
35 });
36 return imageminPlugin;
37 }
1 /**
2 * Generation packaging analysis
3 * 生成打包分析
4 */
5 import visualizer from 'rollup-plugin-visualizer';
6 import { isReportMode } from '../utils';
7
8 export default function configVisualizerPlugin() {
9 if (isReportMode()) {
10 return visualizer({
11 filename: './node_modules/.cache/visualizer/stats.html',
12 open: true,
13 gzipSize: true,
14 brotliSize: true,
15 });
16 }
17 return [];
18 }
1 /**
2 * Whether to generate package preview
3 * 是否生成打包报告
4 */
5 export default {};
6
7 export function isReportMode(): boolean {
8 return process.env.REPORT === 'true';
9 }
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 configArcoStyleImportPlugin from './plugin/arcoStyleImport';
7
8 export default defineConfig({
9 plugins: [vue(), vueJsx(), svgLoader({ svgoConfig: {} }), configArcoStyleImportPlugin()],
10 resolve: {
11 alias: [
12 {
13 find: '@',
14 replacement: resolve(__dirname, '../src'),
15 },
16 {
17 find: 'assets',
18 replacement: resolve(__dirname, '../src/assets'),
19 },
20 {
21 find: 'vue-i18n',
22 replacement: 'vue-i18n/dist/vue-i18n.cjs.js', // Resolve the i18n warning issue
23 },
24 {
25 find: 'vue',
26 replacement: 'vue/dist/vue.esm-bundler.js', // compile template
27 },
28 ],
29 extensions: ['.ts', '.js'],
30 },
31 define: {
32 'process.env': {},
33 '__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': false,
34 },
35 css: {
36 preprocessorOptions: {
37 less: {
38 modifyVars: {
39 hack: `true; @import (reference) "${resolve('src/assets/style/breakpoint.less')}";`,
40 },
41 javascriptEnabled: true,
42 },
43 },
44 },
45 });
1 import { loadEnv, mergeConfig } from "vite";
2 import baseConfig from "./vite.config.base";
3 import configCompressPlugin from "./plugin/compress";
4 import configVisualizerPlugin from "./plugin/visualizer";
5 import configArcoResolverPlugin from "./plugin/arcoResolver";
6 import configImageminPlugin from "./plugin/imagemin";
7 import VitePluginOss from "vite-plugin-oss";
8
9 const config = loadEnv("production", "");
10
11 const ossPath = `vendor/user/asset${new Date()
12 .toLocaleString("zh")
13 .slice(0, 20)
14 .replace(/[\s/:]/g, "")}`;
15 export default mergeConfig(
16 {
17 mode: "production",
18 base: config.VITE_OSS_HOST,
19 server: {
20 https: true
21 },
22 plugins: [
23 configCompressPlugin("gzip"),
24 configVisualizerPlugin(),
25 configArcoResolverPlugin(),
26 configImageminPlugin(),
27 VitePluginOss({
28 from: `./dist/${ossPath}/**`,
29 accessKeyId: config.VITE_OSS_ACCESS_KEY,
30 accessKeySecret: config.VITE_OSS_ACCESS_SECRET,
31 bucket: config.VITE_OSS_BUCKET,
32 region: config.VITE_OSS_REGION,
33 quitWpOnError: true,
34 deleteOrigin: true,
35 deleteEmptyDir: true
36 })
37 ],
38 build: {
39 assetsDir: ossPath,
40 rollupOptions: {
41 output: {
42 manualChunks: {
43 arco: ["@arco-design/web-vue"],
44 chart: ["echarts", "vue-echarts"],
45 vue: ["vue", "vue-router", "pinia", "@vueuse/core", "vue-i18n"]
46 }
47 }
48 },
49 chunkSizeWarningLimit: 2000
50 }
51 },
52 baseConfig
53 );
1 import { mergeConfig } from 'vite';
2 import eslint from 'vite-plugin-eslint';
3 import baseConfig from './vite.config.base';
4
5 export default mergeConfig(
6 {
7 mode: 'development',
8 server: {
9 open: true,
10 fs: {
11 strict: false,
12 },
13 },
14 plugins: [
15 eslint({
16 cache: false,
17 include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.vue'],
18 exclude: ['node_modules'],
19 }),
20 ],
21 define: {
22 __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: true,
23 },
24 },
25 baseConfig
26 );
1 import { mergeConfig } from 'vite';
2 import baseConfig from './vite.config.base';
3 import configCompressPlugin from './plugin/compress';
4 import configVisualizerPlugin from './plugin/visualizer';
5 import configArcoResolverPlugin from './plugin/arcoResolver';
6 import configImageminPlugin from './plugin/imagemin';
7
8 export default mergeConfig(
9 {
10 mode: 'production',
11 server: {
12 https: true,
13 },
14 plugins: [configCompressPlugin('gzip'), configVisualizerPlugin(), configArcoResolverPlugin(), configImageminPlugin()],
15 build: {
16 rollupOptions: {
17 output: {
18 manualChunks: {
19 arco: ['@arco-design/web-vue'],
20 chart: ['echarts', 'vue-echarts'],
21 vue: ['vue', 'vue-router', 'pinia', '@vueuse/core', 'vue-i18n'],
22 },
23 },
24 },
25 chunkSizeWarningLimit: 2000,
26 },
27 },
28 baseConfig
29 );
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 <meta name="referrer" content="no-referrer"/>
8 <title>海星试唱</title>
9 </head>
10 <body>
11 <div id="app"></div>
12 <script type="module" src="/src/main.ts"></script>
13 </body>
14 </html>
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": "vue-tsc --noEmit && vite --config ./config/vite.config.dev.ts",
10 "build": "vue-tsc --noEmit && vite build --config ./config/vite.config.prod.ts",
11 "build:cdn": "vue-tsc --noEmit && vite build --config ./config/vite.config.cdn.ts",
12 "report": "cross-env REPORT=true npm run build",
13 "preview": "npm run build && vite preview --host",
14 "type:check": "vue-tsc --noEmit --skipLibCheck",
15 "lint-staged": "npx lint-staged"
16 },
17 "lint-staged": {
18 "*.{js,ts,jsx,tsx}": [
19 "prettier --write",
20 "eslint --fix"
21 ],
22 "*.vue": [
23 "stylelint --fix",
24 "prettier --write",
25 "eslint --fix"
26 ],
27 "*.{less,css}": [
28 "stylelint --fix",
29 "prettier --write"
30 ]
31 },
32 "dependencies": {
33 "@arco-design/web-vue": "^2.44.7",
34 "@vueuse/core": "^7.3.0",
35 "arco-design-pro-vue": "^2.7.2",
36 "axios": "^1.6.0",
37 "dayjs": "^1.11.5",
38 "echarts": "^5.4.0",
39 "lodash-es": "^4.17.21",
40 "mitt": "^3.0.0",
41 "nprogress": "^0.2.0",
42 "pinia": "^2.0.23",
43 "query-string": "^8.0.3",
44 "sortablejs": "^1.15.0",
45 "vue": "^3.3.11",
46 "vue-echarts": "^6.2.3",
47 "vue-i18n": "^9.2.2",
48 "vue-router": "^4.0.14",
49 "ali-oss": "^6.17.1",
50 "file-saver": "^2.0.5",
51 "rabbit-lyrics": "^2.1.1"
52 },
53 "devDependencies": {
54 "@types/ali-oss": "^6.16.3",
55 "@types/file-saver": "^2.0.5",
56 "@arco-plugins/vite-vue": "^1.4.5",
57 "@commitlint/cli": "^17.1.2",
58 "@commitlint/config-conventional": "^17.1.0",
59 "@types/lodash": "^4.14.186",
60 "@types/lodash-es": "^4.17.12",
61 "@types/nprogress": "^0.2.0",
62 "@types/sortablejs": "^1.15.0",
63 "@typescript-eslint/eslint-plugin": "^5.40.0",
64 "@typescript-eslint/parser": "^5.40.0",
65 "@vitejs/plugin-vue": "^3.1.2",
66 "@vitejs/plugin-vue-jsx": "^2.0.1",
67 "@vue/babel-plugin-jsx": "^1.1.1",
68 "@vueuse/router": "^10.3.0",
69 "consola": "^2.15.3",
70 "cross-env": "^7.0.3",
71 "eslint": "^8.25.0",
72 "eslint-config-airbnb-base": "^15.0.0",
73 "eslint-config-prettier": "^8.5.0",
74 "eslint-import-resolver-typescript": "^3.5.1",
75 "eslint-plugin-import": "^2.26.0",
76 "eslint-plugin-prettier": "^4.2.1",
77 "eslint-plugin-vue": "^9.6.0",
78 "less": "^4.1.3",
79 "lint-staged": "^13.0.3",
80 "postcss-html": "^1.5.0",
81 "prettier": "^2.7.1",
82 "rollup": "^3.9.1",
83 "rollup-plugin-visualizer": "^5.8.2",
84 "stylelint": "^14.13.0",
85 "stylelint-config-prettier": "^9.0.3",
86 "stylelint-config-rational-order": "^0.1.2",
87 "stylelint-config-standard": "^29.0.0",
88 "stylelint-order": "^5.0.0",
89 "typescript": "^4.8.4",
90 "unplugin-vue-components": "^0.24.1",
91 "vite": "^3.2.5",
92 "vite-plugin-compression": "^0.5.1",
93 "vite-plugin-eslint": "^1.8.1",
94 "vite-plugin-imagemin": "^0.6.1",
95 "vite-plugin-oss": "^1.2.13",
96 "vite-svg-loader": "^3.6.0",
97 "vue-tsc": "^1.0.14"
98 },
99 "engines": {
100 "node": ">=14.0.0"
101 },
102 "resolutions": {
103 "bin-wrapper": "npm:bin-wrapper-china",
104 "rollup": "^2.56.3",
105 "gifsicle": "5.2.0"
106 },
107 "volta": {
108 "node": "18.3.0",
109 "yarn": "1.22.19"
110 }
111 }
1 <template>
2 <a-config-provider size="small">
3 <router-view />
4 </a-config-provider>
5 </template>
1 // eslint-disable-next-line max-classes-per-file
2 import { AnyObject, QueryForParams, ServiceResponse } from '@/types/global';
3 import axios from 'axios';
4 import { ActivityApply } from '@/types/activity-apply';
5 import { Activity, ActivityViewUser } from '@/types/activity';
6
7 export default class useActivityApi {
8 static statusOption = [
9 { label: '处理中', value: 0 },
10 { label: '已上架', value: 1 },
11 { label: '已下架', value: 2 },
12 { label: '已匹配', value: 3 },
13 { label: '已发行', value: 5 },
14 { label: '处理失败', value: 4 },
15 ];
16
17 static weightOption = [
18 { label: '无', value: 0 },
19 { label: '低', value: 30 },
20 { label: '中', value: 60 },
21 { label: '高', value: 90 },
22 ];
23
24 static workSingTypeOption = [
25 { label: '自主上传', value: 1 },
26 { label: '唱整首', value: 2 },
27 { label: '唱片段', value: 3 },
28 ];
29
30 static workSingStatusOption = [
31 { label: '待采纳', value: 0 },
32 { label: '已确认', value: 1 },
33 { label: '不合适', value: 2 },
34 { label: '未采纳', value: 3 },
35 { label: '其他', value: 4 },
36 ];
37
38 static songTypeOption = [
39 { label: '歌曲', value: 1 },
40 { label: 'Demo', value: 2 },
41 ];
42
43 static async get(params: QueryForParams): Promise<ServiceResponse<Activity[]>> {
44 return axios.get('/activities', { params });
45 }
46
47 static async show(id: number, params?: QueryForParams): Promise<Activity> {
48 return axios.get(`/activities/${id}`, { params }).then((res) => Promise.resolve(res.data));
49 }
50
51 static async changeStatus(id: number, data: AnyObject) {
52 return axios.put<Activity>(`/activities/${id}/change-status`, data).then((res) => Promise.resolve(res.data));
53 }
54
55 static async update(id: number, data: AnyObject): Promise<Activity> {
56 return axios.put(`/activities/${id}`, data).then((res) => Promise.resolve(res.data));
57 }
58
59 static async destroy(id: number) {
60 return axios.delete(`/activities/${id}`).then((res) => Promise.resolve(res.data));
61 }
62
63 static async getManageUser(activityId: number, params: QueryForParams): Promise<any> {
64 return axios.get(`/audition/activities/${activityId}/managers`, { params });
65 }
66
67 static async createManage(data: AnyObject): Promise<any> {
68 return axios.post('/audition/activity-managers', data).then((res) => Promise.resolve(res.data));
69 }
70
71 static async updateManage(id: number, data: AnyObject): Promise<any> {
72 return axios.put(`/audition/activity-managers/${id}`, data).then((res) => Promise.resolve(res.data));
73 }
74
75 static async deleteManage(id: number): Promise<any> {
76 return axios.delete(`/audition/activity-managers/${id}`).then((res) => Promise.resolve(res.data));
77 }
78
79 static async getViewUser(activityId: number, params: QueryForParams): Promise<ServiceResponse<ActivityViewUser[]>> {
80 return axios.get(`/activities/${activityId}/views`, { params });
81 }
82
83 static async getLikeUser(activityId: number, params: QueryForParams): Promise<ServiceResponse<ActivityViewUser[]>> {
84 return axios.get(`/activities/${activityId}/collects`, { params });
85 }
86 }
87
88 export class useApply {
89 static auditStatusOption = [
90 { label: '审核不通过', value: 2 },
91 { label: '审核中', value: 0 },
92 ];
93
94 static async get(params: QueryForParams): Promise<ServiceResponse<ActivityApply[]>> {
95 return axios.get('audition/applies', { params });
96 }
97
98 static async create(data: AnyObject) {
99 return axios.post('/activities', data).then((res) => Promise.resolve(res.data));
100 }
101
102 static async update(id: number, data: AnyObject) {
103 return axios.put(`audition/applies/${id}`, data).then((res) => Promise.resolve(res.data));
104 }
105
106 static async destroy(id: number) {
107 return axios.delete(`/audition/applies/${id}`);
108 }
109 }
110
1 import axios from "axios";
2 import { AuthorizedState } from "@/store/modules/authorized/type";
3 import FileSaver from "file-saver";
4
5 export default class useAuthApi {
6 static async info() {
7 return axios
8 .get<{
9 user: AuthorizedState;
10 permissions: string[];
11 menus: string[];
12 can_create_demo: number
13 }>("auth/info")
14 .then((res) => Promise.resolve(res.data));
15 }
16
17 static changePwd(data: { password: string; password_confirmation: string }) {
18 return axios.put("auth/change-pwd", 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 axios from 'axios';
2 import type { TableData } from '@arco-design/web-vue/es/table/interface';
3
4 export interface ContentDataRecord {
5 x: string;
6 y: number;
7 }
8
9 export function queryContentData() {
10 return axios.get<ContentDataRecord[]>('/api/content-data');
11 }
12
13 export interface PopularRecord {
14 key: number;
15 clickNumber: string;
16 title: string;
17 increases: number;
18 }
19
20 export function queryPopularList(params: { type: string }) {
21 return axios.get<TableData[]>('/api/popular/list', { params });
22 }
1 import axios from 'axios';
2 import type { AxiosRequestConfig, AxiosResponse } from 'axios';
3 import { Message } from '@arco-design/web-vue';
4 import { getToken } from '@/utils/auth';
5
6 export interface HttpResponse<T = unknown> {
7 status: 'success' | 'fail' | 'error';
8 msg: string;
9 code: number;
10 data: T;
11 }
12
13 export interface ServiceResponse<T = unknown> extends HttpResponse<T> {
14 meta: { current: number; limit: number; total: number };
15 }
16
17 if (import.meta.env.VITE_API_URL) {
18 axios.defaults.baseURL = import.meta.env.VITE_API_URL;
19 }
20
21 axios.interceptors.request.use(
22 // @ts-ignore
23 (config: AxiosRequestConfig) => {
24 if (!config.url?.startsWith('/provider')) {
25 config.baseURL = `${config.baseURL}/user`;
26 }
27
28 const token = getToken();
29 if (token) {
30 if (!config.headers) {
31 config.headers = {};
32 }
33 config.headers.Authorization = `Bearer ${token}`;
34 }
35 return config;
36 },
37 (error) => {
38 // do something
39 return Promise.reject(error);
40 }
41 );
42 // add response interceptors
43 axios.interceptors.response.use(
44 // @ts-ignore
45 async (response: AxiosResponse<ServiceResponse>): Promise<ServiceResponse | unknown> => {
46 if (response.data instanceof Blob) {
47 return Promise.resolve(response);
48 }
49
50 return Promise.resolve(response.data);
51 },
52 (error) => {
53 if (error.response.status === 401) {
54 // clearToken();
55 window.location.reload();
56 Message.warning({ id: 'token_lose', content: '登陆信息已失效,请重新登陆...' });
57 } else {
58 Message.error({
59 id: 'service_error',
60 content: error.response.data.msg || error.msg || 'Request Error',
61 duration: 5 * 1000,
62 });
63 }
64
65 return Promise.reject(error);
66 }
67 );
1 import axios from 'axios';
2
3 export interface MessageRecord {
4 id: number;
5 type: string;
6 title: string;
7 subTitle: string;
8 avatar?: string;
9 content: string;
10 time: string;
11 status: 0 | 1;
12 messageType?: number;
13 }
14 export type MessageListType = MessageRecord[];
15
16 export function queryMessageList() {
17 return axios.post<MessageListType>('/api/message/list');
18 }
19
20 interface MessageStatus {
21 ids: number[];
22 }
23
24 export function setMessageStatus(data: MessageStatus) {
25 return axios.post<MessageListType>('/api/message/read', data);
26 }
27
28 export interface ChatRecord {
29 id: number;
30 username: string;
31 content: string;
32 time: string;
33 isCollect: boolean;
34 }
35
36 export function queryChatList() {
37 return axios.post<ChatRecord[]>('/api/chat/list');
38 }
1 import { AnyObject } from '@/types/global';
2 import axios from 'axios';
3
4 export default class useProviderApi {
5 static sms(type: string, phone: string, area?: string) {
6 return axios.post('/provider/sms', { type, phone, area, platform: 'user' });
7 }
8
9 static async area() {
10 return axios.get('/provider/area');
11 }
12
13 static async config(params?: AnyObject) {
14 return axios.get('/provider/configs', { params })
15 }
16
17 static async login(type: 'phone' | 'email', data: object) {
18 return axios.post<{ access_token: string; refresh_token: string; nick_name: string }>('/provider/login', {
19 platform: 'user',
20 type,
21 ...data,
22 });
23 }
24 }
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 }
No preview for this file type
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 // ==============breakpoint============
2
3 // Extra small screen / phone
4 @screen-xs: 480px;
5
6 // Small screen / tablet
7 @screen-sm: 576px;
8
9 // Medium screen / desktop
10 @screen-md: 768px;
11
12 // Large screen / wide desktop
13 @screen-lg: 992px;
14
15 // Extra large screen / full hd
16 @screen-xl: 1200px;
17
18 // Extra extra large screen / large desktop
19 @screen-xxl: 1600px;
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 }
...\ No newline at end of file ...\ No newline at end of file
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 <div v-if="!modelValue" class="no-avatar">
6 <icon-plus />
7 </div>
8 <img v-else :src="modelValue" style="width: 100%; height: 100%" alt="" />
9 <template #trigger-icon>
10 <icon-camera />
11 </template>
12 </Avatar>
13 </template>
14 </Upload>
15 </template>
16
17 <script lang="ts" setup>
18 import useOss from '@/hooks/oss';
19 import { IconCamera, IconPlus } from '@arco-design/web-vue/es/icon';
20 import { Avatar, Message, Upload, UploadRequest, useFormItem } from '@arco-design/web-vue';
21 import { computed, toRefs } from 'vue';
22
23 const props = withDefaults(
24 defineProps<{
25 modelValue?: string;
26 size?: number;
27 shape?: 'circle' | 'square';
28 accept?: string;
29 limit?: number;
30 }>(),
31 {
32 modelValue: '',
33 size: 80,
34 shape: 'circle',
35 accept: 'image/*',
36 limit: 5,
37 }
38 );
39
40 const { shape } = toRefs(props);
41
42 const borderRadius = computed(() => (shape.value === 'square' ? 'unset' : 'var(--border-radius-circle)'));
43
44 const { eventHandlers } = useFormItem();
45
46 const emits = defineEmits(['update:modelValue']);
47
48 const { upload } = useOss();
49
50 const onBeforeUpload = (file: File) => {
51 if (file.size > props.limit * 1024 * 1024) {
52 Message.warning(`${file.name} 文件超过${props.limit}MB,无法上传`);
53 return Promise.resolve(false);
54 }
55
56 return Promise.resolve(file);
57 };
58
59 const onUpload = (option: any): UploadRequest => {
60 const { fileItem } = option;
61
62 if (fileItem.file) {
63 upload(fileItem.file, 'image').then((res) => {
64 emits('update:modelValue', res?.url || '');
65 eventHandlers.value?.onChange?.();
66 });
67 }
68
69 return {};
70 };
71 </script>
72
73 <style lang="less" scoped>
74 :deep(.arco-avatar-text) {
75 width: 100%;
76 height: 100%;
77 overflow: hidden;
78 display: inline-block;
79 border-radius: v-bind(borderRadius);
80 }
81
82 .arco-avatar-image {
83 transform: unset !important;
84 }
85
86 .no-avatar {
87 top: 0;
88 left: 0;
89 display: flex;
90 position: absolute;
91 align-content: center;
92 align-items: center;
93 justify-content: center;
94 width: 100%;
95 height: 100%;
96 }
97 </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: 'user',
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)?.nick_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 withDefaults(defineProps<{ title?: string; dataIndex: string; split?: boolean }>(), { split: true });
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 }">
15 <template v-if="dataIndex && !getValue(record, dataIndex)">
16 <span style="color: rgba(0, 0, 0, 0.3)"></span>
17 </template>
18 <template v-else-if="split">
19 <div>{{ dayjs(getValue(record, dataIndex))?.format("YYYY-MM-DD") || "" }}</div>
20 <div>{{ dayjs(getValue(record, dataIndex))?.format("HH:mm:ss") || "" }}</div>
21 </template>
22 <template v-else>{{ getValue(record, dataIndex) }}</template>
23 </template>
24 </TableColumn>
25 </template>
26
27 <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 withDefaults(defineProps<{ title?: string; dataIndex: string; areaIndex?: string }>(), {
6 areaIndex: "area_code"
7 });
8
9 const getValue = (record: object, path: string) => {
10 return get(record, path, "");
11 };
12 </script>
13
14 <template>
15 <TableColumn v-bind="$attrs" :title="title" :data-index="dataIndex">
16 <template #default="{ record }"> {{ `(+${getValue(record, areaIndex)}) ${getValue(record, dataIndex)}` }}</template>
17 </TableColumn>
18 </template>
19
20 <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 defineEmits<{ (e?: "search"): void; (e?: "reset"): void }>();
35
36 const layoutStyle = { marginBottom: "12px" };
37 const layoutRightStyle = props.hideSearch && props.hideReset ? {} : { borderLeft: "1px solid var(--color-neutral-3)" };
38
39 if (!props.hideDivider) {
40 Object.assign(layoutStyle, { paddingBottom: "12px", borderBottom: "1px solid var(--color-neutral-3)" });
41 }
42 </script>
43
44 <template>
45 <Layout :style="layoutStyle">
46 <LayoutContent>
47 <Form auto-label-width :model="model || {}" label-align="right" :size="size">
48 <Grid :cols="split as number" :col-gap="12" :row-gap="12">
49 <slot :size="size" />
50 </Grid>
51 </Form>
52 </LayoutContent>
53 <LayoutSider class="right" :style="layoutRightStyle">
54 <Space :size="12" :direction="inline ? 'horizontal' : 'vertical'">
55 <IconButton
56 v-if="!hideSearch"
57 :size="size"
58 :icon="searchIcon"
59 :label="searchLabel"
60 type="primary"
61 :loading="loading"
62 @click="$emit('search')"
63 />
64 <IconButton v-if="!hideReset" :size="size" :icon="resetIcon" :label="resetLabel" @click="$emit('reset')" />
65 <slot name="button" />
66 </Space>
67 </LayoutSider>
68 </Layout>
69 </template>
70
71 <style scoped lang="less">
72 .right {
73 border-left: 1px solid var(--color-neutral-3);
74 margin-left: 12px;
75 padding-left: 12px;
76 box-shadow: unset;
77 width: auto !important;
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 }: { record: TableData, rowIndex?: number }">
13 <Space :direction="direction" fill>
14 <slot name="default" :record="record" :index="rowIndex" />
15 </Space>
16 </template>
17 </TableColumn>
18 </template>
19
20 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 // eslint-disable-next-line @typescript-eslint/no-unused-vars
3 import { TableColumn, TableData, TableSortable } from '@arco-design/web-vue';
4
5 type PropType = { title?: string; dataIndex?: string; tooltip?: boolean; ellipsis?: boolean; hasSort?: boolean };
6
7 const sortable = { sortDirections: ['ascend', 'descend'], sorter: true, defaultSortOrder: '' } as TableSortable;
8
9 withDefaults(defineProps<PropType>(), { tooltip: true, hasSort: false });
10 </script>
11
12 <template>
13 <TableColumn
14 v-bind="$attrs"
15 :title="title"
16 :data-index="dataIndex"
17 :tooltip="tooltip as boolean"
18 :ellipsis="ellipsis || tooltip as boolean"
19 :sortable="hasSort ? sortable : undefined as any"
20 >
21 <template v-if="$slots.default" #cell="{ record, rowIndex }: { record: TableData, rowIndex: number }">
22 <slot name="default" :record="record" :index="rowIndex" />
23 </template>
24 </TableColumn>
25 </template>
26
27 <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 flex="1" class="table-tool-item" style="text-align: left">
77 <slot name="tool" :size="size" />
78 </Col>
79 <Col flex="auto" class="table-tool-item" style="text-align: right">
80 <slot name="tool-right" :size="size" />
81 </Col>
82 </Row>
83 <Table ref="tableRef" v-bind="$attrs" :row-key="rowKey as string" :loading="loading as boolean" :size="size as sizeType"
84 :data="list" :pagination="pagination" :bordered="false" :table-layout-fixed="true" @page-change="onPageChange"
85 @page-size-change="onSizeChange"
86 @sorter-change="(dataIndex, direction) => $emit('rowSort', dataIndex, formatSortType(direction))">
87 <template #columns>
88 <slot />
89 </template>
90 </Table>
91 </template>
92
93 <style lang="less" scoped>
94 :deep(.arco-table-cell) {
95 padding: 5px 8px !important;
96
97 :hover {
98 cursor: v-bind(hoverType);
99 }
100
101 &>.arco-table-td-content .arco-btn-size-small {
102 padding: 5px !important;
103 }
104 }
105
106 :deep(.table-tool-item) {
107 >* {
108 margin-bottom: 12px !important;
109 }
110 }
111 </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 <Upload :accept="accept" :custom-request="onUpload" :file-list="[]" :show-file-list="false">
3 <template #upload-button>
4 <div class="arco-upload-list-item">
5 <Image
6 v-if="modelValue"
7 :style="{ minHeight }"
8 :width="width"
9 :height="height"
10 :preview="false"
11 :src="modelValue"
12 :fit="fit"
13 show-loader
14 />
15 <div v-else :style="style" class="arco-upload-picture-card">
16 <div class="arco-upload-picture-card-text">
17 <IconPlus />
18 </div>
19 </div>
20 </div>
21 </template>
22 </Upload>
23 </template>
24
25 <script lang="ts" setup>
26 import useOss from '@/hooks/oss';
27 import { computed } from 'vue';
28 import { IconPlus } from '@arco-design/web-vue/es/icon';
29 import { UploadRequest, useFormItem, Upload, Image } from '@arco-design/web-vue';
30
31 type PropType = {
32 modelValue?: string;
33 accept?: string;
34 width?: number | string;
35 height?: number | string;
36 minHeight?: string;
37 fit?: 'none' | 'contain' | 'cover' | 'fill' | 'scale-down' | undefined;
38 };
39
40 const props = withDefaults(defineProps<PropType>(), {
41 modelValue: '',
42 accept: 'image/*',
43 width: 80,
44 height: 'auto',
45 minHeight: '100%',
46 fit: 'cover',
47 });
48
49 const emits = defineEmits(['update:modelValue']);
50 const { eventHandlers } = useFormItem();
51
52 const style = computed(() => {
53 return {
54 width: `${props.width}px`,
55 height: props.height.constructor === String ? 'auto' : `${props.height}px`,
56 maxWidth: '100%',
57 maxHeight: '100%',
58 minHeight: '80px',
59 };
60 });
61
62 const { upload } = useOss();
63
64 const onUpload = (option: any): UploadRequest => {
65 const { fileItem } = option;
66
67 if (fileItem.file) {
68 upload(fileItem.file, 'image').then((res) => {
69 emits('update:modelValue', res?.url || '');
70 eventHandlers.value?.onChange?.();
71 });
72 }
73
74 return {};
75 };
76 </script>
77
78 <style lang="less" scoped>
79 :deep(.arco-avatar-text) {
80 width: 100%;
81 height: 100%;
82 }
83
84 :deep(.arco-upload-list-item) {
85 margin-top: 0 !important;
86 }
87
88 .no-avatar {
89 position: absolute;
90 display: flex;
91 align-content: center;
92 align-items: center;
93 justify-content: center;
94 width: 100%;
95 height: 100%;
96 top: 0;
97 left: 0;
98 }
99 </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 { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, GraphicComponent } 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 TagSelect from './tag-select/index.vue';
16 import UserSelect from './user-select/index.vue';
17 import FilterSearch from './filter/search.vue';
18 import FilterSearchItem from './filter/search-item.vue';
19 import FilterTable from './filter/table.vue';
20 import FilterTableColumn from './filter/table-column.vue';
21 import PageView from './page-view/index.vue';
22
23 // Manually introduce ECharts modules to reduce packing size
24
25 use([
26 CanvasRenderer,
27 BarChart,
28 LineChart,
29 PieChart,
30 RadarChart,
31 GridComponent,
32 TooltipComponent,
33 LegendComponent,
34 DataZoomComponent,
35 GraphicComponent,
36 ]);
37
38 export default {
39 install(Vue: App) {
40 Vue.component('Chart', Chart);
41 Vue.component('Breadcrumb', Breadcrumb);
42 Vue.component('AvatarUpload', AvatarUpload);
43 Vue.component('InputUpload', InputUpload);
44 Vue.component('ImageUpload', ImageUpload);
45 Vue.component('RouterButton', RouterButton);
46 Vue.component('ExportButton', ExportButton);
47 Vue.component('IconButton', IconButton);
48 Vue.component('AudioPlayer', AudioPlayer);
49 Vue.component('TagSelect', TagSelect);
50 Vue.component('UserSelect', UserSelect);
51 Vue.component('FilterSearch', FilterSearch);
52 Vue.component('FilterSearchItem', FilterSearchItem);
53 Vue.component('FilterTable', FilterTable);
54 Vue.component('FilterTableColumn', FilterTableColumn);
55 Vue.component('PageView', PageView);
56 },
57 };
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 } 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 import { startsWith } from 'lodash';
28
29 type FileType = { name: string; url: string; size: number; type: string; width?: number; height?: number; duration?: number };
30
31 const props = defineProps({
32 modelValue: { type: String, default: '' },
33 prefix: { type: String, default: 'file' },
34 limit: { type: Number, default: 0 },
35 placeholder: { type: String, default: '请选择' },
36 });
37
38 const emits = defineEmits<{
39 (e: 'update:modelValue', value: string): 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 & { width?: number; height?: number; duration?: number }) => {
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, 'image/')) {
56 const imgObj = new Image();
57 imgObj.src = URL.createObjectURL(file);
58 imgObj.onload = () => {
59 file.width = imgObj.width;
60 file.height = imgObj.height;
61 };
62 }
63
64 if (startsWith(file.type, 'audio/') || startsWith(file.type, 'video/')) {
65 const audioElement = new Audio(URL.createObjectURL(file));
66 audioElement.addEventListener('loadedmetadata', () => {
67 file.duration = audioElement.duration * 1000;
68 });
69 }
70
71 return Promise.resolve(file);
72 };
73
74 const onProgress = (p: number) => {
75 percent.value = p;
76 };
77
78 const onUpload = (option: any): UploadRequest => {
79 const { fileItem } = option;
80
81 if (fileItem.file) {
82 setLoading(true);
83 // eslint-disable-next-line vue/custom-event-name-casing
84 emits('choose-file', fileItem.file as File);
85 upload(fileItem.file, props.prefix, onProgress)
86 .then((res) => {
87 emits('update:modelValue', res?.url || '');
88 eventHandlers.value?.onChange?.();
89 fileItem.percent = 100;
90 fileItem.url = res?.url || '';
91 fileItem.status = 'done';
92 emits('success', {
93 name: fileItem.name,
94 url: fileItem.url,
95 size: fileItem.file.size,
96 type: fileItem.file.type,
97 width: fileItem.file.width || 0,
98 height: fileItem.file.height || 0,
99 duration: fileItem.file.duration || 0,
100 });
101 })
102 .finally(() => {
103 setLoading(false);
104 });
105 }
106
107 return {};
108 };
109 </script>
110
111 <style lang="less" scoped>
112 ::v-deep(.arco-input-append) {
113 padding: 0;
114 }
115 </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 <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 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 }
9 interface ImportMetaEnv {
10 readonly VITE_API_BASE_URL: string;
11 }
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 OSS from "ali-oss";
2 import { Message } from "@arco-design/web-vue";
3
4 let ossClient: OSS;
5
6 const createOssClient = () => {
7 if (!ossClient) {
8 ossClient = new OSS({
9 accessKeyId: import.meta.env.VITE_OSS_ACCESS_KEY,
10 accessKeySecret: import.meta.env.VITE_OSS_ACCESS_SECRET,
11 bucket: import.meta.env.VITE_OSS_BUCKET,
12 region: import.meta.env.VITE_OSS_REGION,
13 endpoint: import.meta.env.VITE_OSS_ENDPOINT,
14 cname: true,
15 timeout: 90000
16 });
17 }
18
19 return ossClient;
20 };
21
22 export default function useOss() {
23 const getFileName = () => {
24 function rx() {
25 return Math.random().toString(36).substring(2);
26 }
27
28 return `${rx()}${new Date().getTime()}${rx()}`;
29 };
30
31 const getFileDir = () => {
32 return new Date().toISOString().slice(0, 10).replace(/-/g, "");
33 };
34
35 const getFileType = (file: File) => {
36 return file.name?.split(".")?.pop()?.toLowerCase();
37 };
38
39 const getHost = () => {
40 if (import.meta.env.VITE_OSS_HOST && import.meta.env.VITE_OSS_HOST.length !== 0) {
41 return import.meta.env.VITE_OSS_HOST;
42 }
43
44 return import.meta.env.VITE_OSS_ENDPOINT;
45 };
46
47 return {
48 ossClient,
49 async upload(
50 file: File,
51 prefix: string,
52 onProgress?: (percent: number) => void
53 ): Promise<{ url: string; name: string; response: unknown }> {
54 return createOssClient()
55 .multipartUpload(`${prefix}/${getFileDir()}/${getFileName()}.${getFileType(file)}`, file, { progress: onProgress })
56 .then((res) => {
57 return Promise.resolve({
58 response: res.res,
59 url: `${getHost()}/${res.name}`,
60 name: `${getFileName()}.${getFileType(file)}`
61 });
62 })
63 .catch(() => {
64 Message.error({ content: "上传失败" });
65 // eslint-disable-next-line prefer-promise-reject-errors
66 return Promise.reject(false);
67 });
68 }
69 };
70 }
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 userStore = 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 userStore.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 // You can add any rules you want
27 };
28 }
1 import { ref, UnwrapRef } from 'vue';
2 import { AxiosResponse } from 'axios';
3 import { HttpResponse } from '@/api/interceptor';
4 import useLoading from './loading';
5
6 // use to fetch list
7 // Don't use async function. It doesn't work in async function.
8 // Use the bind function to add parameters
9 // example: useRequest(api.bind(null, {}))
10
11 export default function useRequest<T>(
12 api: () => Promise<AxiosResponse<HttpResponse>>,
13 defaultValue = [] as unknown as T,
14 isLoading = true
15 ) {
16 const { loading, setLoading } = useLoading(isLoading);
17 const response = ref<T>(defaultValue);
18 api()
19 .then((res) => {
20 response.value = res.data as unknown as UnwrapRef<T>;
21 })
22 .finally(() => {
23 setLoading(false);
24 });
25 return { loading, response };
26 }
1 import { onMounted, onBeforeMount, onBeforeUnmount } from 'vue';
2 import { useDebounceFn } from '@vueuse/core';
3 import { useAppStore } from '@/store';
4 import { addEventListen, removeEventListen } from '@/utils/event';
5
6 const WIDTH = 992; // https://arco.design/vue/component/grid#responsivevalue
7
8 function queryDevice() {
9 const rect = document.body.getBoundingClientRect();
10 return rect.width - 1 < WIDTH;
11 }
12
13 export default function useResponsive(immediate?: boolean) {
14 const appStore = useAppStore();
15 function resizeHandler() {
16 if (!document.hidden) {
17 const isMobile = queryDevice();
18 appStore.toggleDevice(isMobile ? 'mobile' : 'desktop');
19 appStore.toggleMenu(isMobile);
20 }
21 }
22 const debounceFn = useDebounceFn(resizeHandler, 100);
23 onMounted(() => {
24 if (immediate) debounceFn();
25 });
26 onBeforeMount(() => {
27 addEventListen(window, 'resize', debounceFn);
28 });
29 onBeforeUnmount(() => {
30 removeEventListen(window, 'resize', debounceFn);
31 });
32 }
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 { ref } from 'vue';
2
3 export default function useVisible(initValue = false) {
4 const visible = ref(initValue);
5 const setVisible = (value: boolean) => {
6 visible.value = value;
7 };
8 const toggle = () => {
9 visible.value = !visible.value;
10 };
11 return {
12 visible,
13 setVisible,
14 toggle,
15 };
16 }
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
24 const goto = (item: RouteRecordRaw) => router.push({ name: item.name });
25
26 const syncServicePermission = (item: RouteRecordRaw) => {
27 if (item.meta) {
28 const serverConfig = appMenu.value.find((menu) => item.name === menu.name);
29 item.meta.title = serverConfig?.label || item.meta?.title || "";
30 item.meta.order = serverConfig?.weight || item.meta?.order || 0;
31 item.meta.icon = serverConfig?.icon || item.meta?.icon || "";
32 }
33 item.children?.map((child) => syncServicePermission(child));
34 return item;
35 };
36
37 const appRoute = computed((): RouteRecordRaw[] => {
38 return router
39 .getRoutes()
40 .find((el) => el.name === "root")
41 ?.children.filter((element) => element.meta?.hideInMenu !== true)
42 .map((item: RouteRecordRaw) => syncServicePermission(item)) as RouteRecordRaw[];
43 });
44
45 const menuTree = computed((): RouteRecordRaw[] => {
46 function travel(_routes: RouteRecordRaw[], layer: number) {
47 if (!_routes) return null;
48 const collector: any = orderBy(_routes, "meta.order", "desc").map((element) => {
49 // no access
50 if (!permission.accessRouter(element)) {
51 return null;
52 }
53
54 // leaf node
55 if (!element.children) {
56 return element;
57 }
58
59 // route filter hideInMenu true
60 element.children = element.children.filter((x) => x.meta?.hideInMenu !== true);
61
62 // Associated child node
63 const subItem = travel(element.children, layer);
64 if (subItem.length) {
65 element.children = subItem;
66 return element;
67 }
68 // the else logic
69 if (layer > 1) {
70 element.children = subItem;
71 return element;
72 }
73
74 if (element.meta?.hideInMenu === false) {
75 return element;
76 }
77
78 return null;
79 });
80 return collector.filter(Boolean);
81 }
82
83 return travel(appRoute.value, 0);
84 });
85
86 watch(
87 route,
88 (newVal) => {
89 if (newVal.meta.requiresAuth) {
90 const key = newVal.meta.hideInMenu ? last(newVal.matched)?.meta?.menuSelectKey : last(newVal.matched)?.name;
91 selectedKey.value = [key as string];
92 openKey.value = [...(newVal.meta.breadcrumb || [])];
93 }
94 },
95 { immediate: true }
96 );
97 watch(
98 () => appStore.menuCollapse,
99 (newVal) => {
100 collapsed.value = newVal;
101 },
102 { immediate: true }
103 );
104 const setCollapse = (val: boolean) => {
105 appStore.updateSettings({ menuCollapse: val });
106 };
107
108 const renderSubMenu = () => {
109 function travel(_route: RouteRecordRaw[], nodes = []) {
110 if (_route) {
111 _route.forEach((element) => {
112 // This is demo, modify nodes as needed
113 const icon = element?.meta?.icon ? `<${element?.meta?.icon}/>` : ``;
114 let r;
115
116 if (element && element.children && element.children.length !== 0) {
117 r = (
118 <a-sub-menu key={element?.name} title={element.meta?.title} v-slots={{ icon: () => h(compile(icon)) }}>
119 {element?.children?.map((elem) => {
120 return (
121 <a-menu-item key={elem.name} onClick={() => goto(elem)}>
122 {elem.meta?.title}
123 {travel(elem.children ?? [])}
124 </a-menu-item>
125 );
126 })}
127 </a-sub-menu>
128 );
129 } else {
130 r = (
131 <a-menu-item key={element?.name} v-slots={{ icon: () => h(compile(icon)) }}
132 onClick={() => goto(element)}>
133 {element.meta?.title}
134 </a-menu-item>
135 );
136 }
137 nodes.push(r as never);
138 });
139 }
140 return nodes;
141 }
142
143 return travel(menuTree.value);
144 };
145
146 return () => (
147 <a-menu
148 v-model:collapsed={collapsed.value}
149 show-collapse-button
150 auto-open={false}
151 v-model:selected-keys={selectedKey.value}
152 v-model:open-keys={openKey.value}
153 auto-open-selected={true}
154 auto-scroll-into-view={true}
155 level-indent={34}
156 style={{ height: "100%" }}
157 onCollapse={setCollapse}
158 >
159 {renderSubMenu()}
160 </a-menu>
161 );
162 }
163 });
164 </script>
165
166 <style lang="less" scoped>
167 :deep(.arco-menu-inner) {
168 .arco-menu-inline-header {
169 display: flex;
170 align-items: center;
171 }
172
173 .arco-icon {
174 &:not(.arco-icon-down) {
175 font-size: 18px;
176 }
177 }
178 }
179 </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" :image-url="avatar" />
44 <div :style="{ color: theme === 'dark' ? 'white' : 'black', lineHeight: '32px' }">
45 <span>{{ name }}</span>
46 <icon-down />
47 </div>
48 </a-space>
49 <template #content>
50 <!-- <a-doption>-->
51 <!-- <a-space @click="switchRoles">-->
52 <!-- <icon-tag />-->
53 <!-- <span>-->
54 <!-- {{ $t('messageBox.switchRoles') }}-->
55 <!-- </span>-->
56 <!-- </a-space>-->
57 <!-- </a-doption>-->
58 <!-- <a-doption>-->
59 <!-- <a-space @click="handleChangePwd">-->
60 <!-- <icon-lock />-->
61 <!-- <span>修改密码</span>-->
62 <!-- </a-space>-->
63 <!-- </a-doption>-->
64 <a-doption>
65 <a-space @click="handleLogout">
66 <icon-export />
67 <span> 退出登录 </span>
68 </a-space>
69 </a-doption>
70 </template>
71 </a-dropdown>
72 </li>
73 </ul>
74 </div>
75 </template>
76
77 <script lang="ts" setup>
78 import { computed } from 'vue';
79 import { useAppStore, useAuthorizedStore } from '@/store';
80 // import useUser from '@/hooks/user';
81 // import ToggleThemeButton from '@/layout/components/toggle-theme-button.vue';
82 import { IconDown, IconExport } from '@arco-design/web-vue/es/icon';
83
84 const appStore = useAppStore();
85 const authorized = useAuthorizedStore();
86
87 const avatar = computed(() => {
88 return authorized.avatar;
89 });
90
91 const name = computed(() => {
92 return authorized.nick_name;
93 });
94
95 const { theme } = appStore;
96
97 // const theme = computed(() => {
98 // return appStore.theme;
99 // });
100
101 // const setVisible = () => {
102 // appStore.updateSettings({ globalSettings: true });
103 // };
104 // const refBtn = ref();
105 // const triggerBtn = ref();
106 // const setPopoverVisible = () => {
107 // const event = new MouseEvent('click', {
108 // view: window,
109 // bubbles: true,
110 // cancelable: true,
111 // });
112 // refBtn.value.dispatchEvent(event);
113 // };
114 const handleLogout = () => authorized.logout();
115
116 // const handleChangePwd = () => useUser().resetPwd();
117 // const setDropDownVisible = () => {
118 // const event = new MouseEvent('click', {
119 // view: window,
120 // bubbles: true,
121 // cancelable: true,
122 // });
123 // triggerBtn.value.dispatchEvent(event);
124 // };
125 </script>
126
127 <style lang="less" scoped>
128 .navbar {
129 display: flex;
130 justify-content: space-between;
131 height: 100%;
132 background-color: var(--color-bg-2);
133 border-bottom: 1px solid var(--color-border);
134 }
135
136 .left-side {
137 display: flex;
138 align-items: center;
139 padding-left: 20px;
140 }
141
142 .right-side {
143 display: flex;
144 padding-right: 10px;
145 list-style: none;
146
147 :deep(.locale-select) {
148 border-radius: 20px;
149 }
150
151 li {
152 display: flex;
153 align-items: center;
154 padding: 0 10px;
155 }
156
157 a {
158 color: var(--color-text-1);
159 text-decoration: none;
160 }
161
162 .nav-btn {
163 border-color: rgb(var(--gray-2));
164 color: rgb(var(--gray-8));
165 font-size: 16px;
166 }
167
168 .trigger-btn,
169 .ref-btn {
170 position: absolute;
171 bottom: 14px;
172 }
173
174 .trigger-btn {
175 margin-left: 14px;
176 }
177 }
178 </style>
179
180 <style lang="less">
181 .message-popover {
182 .arco-popover-content {
183 margin-top: 0;
184 }
185 }
186
187 .arco-dropdown-open .arco-icon-down {
188 transform: rotate(180deg);
189 }
190 </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">
34 import { computed, defineComponent, watch } from "vue";
35 import { useRoute, useRouter } from "vue-router";
36 import { useAppStore, useAuthorizedStore } from "@/store";
37 import usePermission from "@/hooks/permission";
38 import Menu from "@/layout/components/menu.vue";
39 import Navbar from "@/layout/components/navbar.vue";
40
41 export default defineComponent({
42 // eslint-disable-next-line vue/no-reserved-component-names
43 components: { Navbar, Menu },
44 setup() {
45 const appStore = useAppStore();
46 const authorizedStore = useAuthorizedStore();
47 const router = useRouter();
48 const route = useRoute();
49 const permission = usePermission();
50 const navbarHeight = `60px`;
51 const menu = computed(() => appStore.menu);
52 const menuWidth = computed(() => {
53 return appStore.menuCollapse ? 48 : appStore.menuWidth;
54 });
55 const collapse = computed(() => {
56 return appStore.menuCollapse;
57 });
58 const paddingStyle = computed(() => {
59 const paddingLeft = menu.value ? { paddingLeft: `${menuWidth.value}px` } : {};
60 const paddingTop = { paddingTop: navbarHeight };
61 return { ...paddingLeft, ...paddingTop };
62 });
63 const setCollapsed = (val: boolean) => {
64 appStore.updateSettings({ menuCollapse: val });
65 };
66 watch(
67 () => authorizedStore.permissions,
68 (roleValue) => {
69 if (roleValue && !permission.accessRouter(route)) router.push({ name: "notFound" });
70 }
71 );
72 return {
73 menu,
74 menuWidth,
75 paddingStyle,
76 collapse,
77 setCollapsed
78 };
79 }
80 });
81 </script>
82
83 <style lang="less" scoped>
84 @nav-size-height: 60px;
85 @layout-max-width: 1100px;
86
87 .layout {
88 width: 100%;
89 height: 100%;
90 }
91
92 .layout-navbar {
93 position: fixed;
94 top: 0;
95 left: 0;
96 z-index: 100;
97 width: 100%;
98 min-width: @layout-max-width;
99 height: @nav-size-height;
100 }
101
102 .layout-sider {
103 position: fixed;
104 top: 0;
105 left: 0;
106 z-index: 99;
107 height: 100%;
108
109 &::after {
110 position: absolute;
111 top: 0;
112 right: -1px;
113 display: block;
114 width: 1px;
115 height: 100%;
116 background-color: var(--color-border);
117 content: '';
118 }
119
120 > :deep(.arco-layout-sider-children) {
121 overflow-y: hidden;
122 }
123 }
124
125 .menu-wrapper {
126 height: 100%;
127 overflow: auto;
128 overflow-x: hidden;
129
130 :deep(.arco-menu) {
131 ::-webkit-scrollbar {
132 width: 12px;
133 height: 4px;
134 }
135
136 ::-webkit-scrollbar-thumb {
137 border: 4px solid transparent;
138 background-clip: padding-box;
139 border-radius: 7px;
140 background-color: var(--color-text-4);
141 }
142
143 ::-webkit-scrollbar-thumb:hover {
144 background-color: var(--color-text-3);
145 }
146 }
147 }
148
149 .layout-content {
150 min-width: @layout-max-width;
151 min-height: 100vh;
152 overflow-y: hidden;
153 background-color: var(--color-fill-2);
154 transition: padding-left 0.2s;
155 }
156 </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
6 import store from './store';
7 import router from './router';
8 import directive from './directive';
9 import App from './App.vue';
10
11 import '@/assets/style/global.less';
12 import '@/api/interceptor';
13
14 const app = createApp(App);
15
16 app.use(ArcoVue, {});
17 app.use(ArcoVueIcon);
18
19 app.use(store);
20 app.use(router);
21 app.use(globalComponents);
22 app.use(directive);
23
24 app.mount('#app');
1 import { createRouter, createWebHistory, LocationQueryRaw } from 'vue-router';
2 import NProgress from 'nprogress'; // progress bar
3 import 'nprogress/nprogress.css';
4
5 import usePermission from '@/hooks/permission';
6 import { clearToken, isLogin } from '@/utils/auth';
7 import PageLayout from '@/layout/index.vue';
8
9 import { useAuthorizedStore } from '@/store';
10 import appRoutes from './modules';
11
12 NProgress.configure({ showSpinner: false }); // NProgress Configuration
13
14 const router = createRouter({
15 history: createWebHistory(),
16 routes: [
17 {
18 path: '/login',
19 name: 'login',
20 component: () => import('@/views/login/index.vue'),
21 meta: {
22 title: '登陆',
23 requiresAuth: false,
24 },
25 },
26 {
27 name: 'root',
28 path: '/',
29 component: PageLayout,
30 redirect: '/demos',
31 children: appRoutes,
32 },
33 {
34 path: '/:pathMatch(.*)*',
35 name: 'notFound',
36 redirect: '/exception/404',
37 meta: {
38 requiresAuth: true,
39 },
40 },
41 ],
42 scrollBehavior() {
43 return { top: 0 };
44 },
45 });
46
47 router.beforeEach(async (to, from, next) => {
48 NProgress.start();
49 const authorizedStore = useAuthorizedStore();
50
51 if (from.name !== undefined) {
52 to.meta.from = from.name;
53 }
54
55 async function crossroads() {
56 const Permission = usePermission();
57
58 if (Permission.accessRouter(to)) {
59 next();
60 } else {
61 next({ name: 'exception-403' });
62 }
63 NProgress.done();
64 }
65
66 if (isLogin()) {
67 if (authorizedStore.permissions.length) {
68 await crossroads();
69 } else {
70 try {
71 await authorizedStore.syncInfo();
72 await crossroads();
73 } catch (error) {
74 clearToken();
75 next({ name: 'login', query: { path: to.path } as LocationQueryRaw });
76 NProgress.done();
77 }
78 }
79 } else {
80 if (to.name === 'login') {
81 next();
82 NProgress.done();
83 return;
84 }
85
86 if (to.name === 'root') {
87 next({ name: 'login' });
88 } else {
89 next({ name: 'login', query: { path: to.path } as LocationQueryRaw });
90 }
91 NProgress.done();
92 }
93 });
94
95 router.afterEach(async (to) => {
96 sessionStorage.setItem(
97 'route',
98 to.meta?.breadcrumb?.toString() ||
99 to.matched
100 ?.slice(1)
101 .map((item) => item.name)
102 .toString() ||
103 'dashboard'
104 );
105 });
106
107 export default router;
1 export default [
2 {
3 path: "demos",
4 name: "audition-demo",
5 component: () => import("@/views/demo/index.vue"),
6 meta: {
7 title: "Demo管理",
8 icon: "icon-dashboard",
9 requiresAuth: true,
10 hideInMenu: false,
11 isRedirect: true,
12 roles: ["*"],
13 breadcrumb: ["audition-demo"],
14 reload: true
15 }
16 },
17 {
18 path: "demos/:id(\\d+)",
19 name: "audition-demo-show",
20 component: () => import("@/views/demo-show/index.vue"),
21 meta: {
22 title: "详情",
23 requiresAuth: false,
24 hideInMenu: true,
25 reload: true,
26 menuSelectKey: "audition-demo",
27 roles: ["*"],
28 breadcrumb: ["audition-demo", "audition-demo-show"]
29 }
30 }
31 ];
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 },
21 },
22 {
23 path: '404',
24 name: 'exception-404',
25 component: () => import('@/views/exception/404/index.vue'),
26 meta: {
27 title: '404',
28 requiresAuth: true,
29 roles: ['*'],
30 hideInMenu: true,
31 },
32 },
33 {
34 path: '500',
35 name: 'exception-500',
36 component: () => import('@/views/exception/500/index.vue'),
37 meta: {
38 title: '500',
39 requiresAuth: true,
40 roles: ['*'],
41 },
42 },
43 ],
44 };
1 import Exception from "./exception";
2 import Demo from "./demo";
3
4 export default [...Demo, Exception];
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 hideInMenu?: boolean;
13 isRedirect?: boolean;
14 title?: string;
15 order?: number;
16 breadcrumb?: string[];
17 }
18 }
1 import { createPinia } from 'pinia';
2 import useAppStore from './modules/app';
3 // eslint-disable-next-line import/no-cycle
4 import useAuthorizedStore from './modules/authorized';
5
6 import useSelectionStore from '@/store/modules/selection';
7
8 const pinia = createPinia();
9
10 export { useAppStore, useSelectionStore, useAuthorizedStore };
11 export default pinia;
12
1 import { defineStore } from 'pinia';
2 import type { RouteRecordNormalized } from 'vue-router';
3 import { AppState } from './types';
4
5 const useAppStore = defineStore('app', {
6 state: (): AppState => ({
7 theme: 'light',
8 colorWeak: false,
9 navbar: true,
10 menu: true,
11 topMenu: false,
12 hideMenu: false,
13 menuCollapse: false,
14 footer: true,
15 themeColor: '#165DFF',
16 menuWidth: 220,
17 globalSettings: false,
18 device: 'desktop',
19 tabBar: false,
20 permissions: [],
21 }),
22
23 getters: {
24 appCurrentSetting(state: AppState): AppState {
25 return { ...state };
26 },
27 appDevice(state: AppState) {
28 return state.device;
29 },
30 appAsyncMenus(state: AppState): RouteRecordNormalized[] {
31 return state.serverMenu as unknown as RouteRecordNormalized[];
32 },
33 appMenu(state: AppState) {
34 return state.permissions?.filter((item) => item.type === 'Menu') || [];
35 },
36 },
37
38 actions: {
39 // Update app settings
40 updateSettings(partial: Partial<AppState>) {
41 // @ts-ignore-next-line
42 this.$patch(partial);
43 },
44
45 // Change theme color
46 toggleTheme(dark: boolean) {
47 if (dark) {
48 this.theme = 'dark';
49 document.body.setAttribute('arco-theme', 'dark');
50 } else {
51 this.theme = 'light';
52 document.body.removeAttribute('arco-theme');
53 }
54 },
55 toggleDevice(device: string) {
56 this.device = device;
57 },
58 toggleMenu(value: boolean) {
59 this.hideMenu = value;
60 },
61 setPermissions(permissions = []) {
62 this.$patch({ permissions });
63 },
64 },
65 });
66
67 export default useAppStore;
1 export interface SystemPermission {
2 id: number;
3 name: string;
4 type: 'Menu' | 'Button';
5 label: string;
6 icon: string;
7 parent_id: number;
8 weight: number;
9 parent?: SystemPermission;
10 children?: SystemPermission[];
11 created_at: string;
12 updated_at: string;
13 }
14
15 export interface AppState {
16 theme: string;
17 colorWeak: boolean;
18 navbar: boolean;
19 menu: boolean;
20 topMenu: boolean;
21 hideMenu: boolean;
22 menuCollapse: boolean;
23 themeColor: string;
24 menuWidth: number;
25 globalSettings: boolean;
26 device: string;
27 permissions: SystemPermission[];
28 [key: string]: unknown;
29 }
1 import { defineStore } from "pinia";
2 import { clearToken } from "@/utils/auth";
3 import useAuthApi from "@/api/auth";
4 import { AuthorizedState } from "./type";
5 import { Message } from "@arco-design/web-vue";
6 import { removeRouteListener } from "@/utils/route-listener";
7 // eslint-disable-next-line import/no-cycle
8 import { useAppStore } from "@/store";
9
10 const useAuthorizedStore = defineStore("authorized", {
11 state: (): AuthorizedState => ({
12 id: undefined,
13 nick_name: undefined,
14 avatar: undefined,
15 permissions: [],
16 can_create_demo: 0
17 }),
18 getters: {
19 authorizedInfo(state: AuthorizedState): AuthorizedState {
20 return { ...state };
21 },
22 getKey(state: AuthorizedState): number {
23 return state.id || 0;
24 },
25 isCreateDemo(state: AuthorizedState) {
26 return state.can_create_demo;
27 }
28 },
29 actions: {
30 setInfo(partial: Partial<AuthorizedState>) {
31 // @ts-ignore
32 this.$patch(partial);
33 },
34 async syncInfo() {
35 const { user, permissions, menus, can_create_demo } = await useAuthApi.info();
36 this.setInfo({ ...user, can_create_demo, permissions });
37 useAppStore().setPermissions(menus as any);
38 },
39 async logout() {
40 this.$reset();
41 clearToken();
42 removeRouteListener();
43 window.location.reload();
44 Message.success("登出成功");
45 },
46
47 async syncToken() {
48 // TODO
49 // const { data } = await refreshToken();
50 // setToken(data.access_token);
51 }
52 }
53 });
54
55 export default useAuthorizedStore;
1 export interface AuthorizedState {
2 id?: number;
3 nick_name?: string;
4 avatar?: string;
5 permissions: string[];
6
7 [key: string]: unknown;
8 }
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('/provider/users', { params }).then(({ data }) => {
62 this.user = data;
63 });
64 },
65 queryTag(params?: AnyObject) {
66 axios.get('/provider/tags',{ params }).then(({ data }) => {
67 this.tag = data;
68 });
69 },
70 queryConfig(params?: AnyObject) {
71 axios.get('/provider/configs', { params }).then(({ data }) => {
72 this.config = data;
73 });
74 },
75 },
76 });
77
78 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 { 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 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 export interface MockParams {
2 url: string;
3 type: string;
4 body: string;
5 }
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 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 // 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
7 export interface User {
8 id: number;
9 avatar?: string;
10 nick_name: string;
11 real_name: string;
12 phone: string;
13 email: string;
14 business_id: 0;
15 business?: User;
16 province?: string;
17 city?: string;
18 role?: 'Singer' | 'Business' | 'ProjectUser' | 'SystemUser' | 'Admin';
19 remarks?: string;
20 like_activities_count?: number;
21 sound?: string;
22 status?: number;
23 last_login?: string;
24 created_at?: string;
25 updated_at?: string;
26 projects?: Project[];
27 tags?: Tag[];
28 styles?: Tag[];
29 voices?: Tag[];
30 skills?: Tag[];
31 identity: number;
32
33 [key: string]: unknown;
34 }
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 = (accessToken: string, refreshToken: string) => {
10 localStorage.setItem('access_token', accessToken);
11 localStorage.setItem('refresh_token', refreshToken);
12 };
13
14 const clearToken = () => {
15 localStorage.removeItem('access_token');
16 localStorage.removeItem('refresh_token');
17 };
18
19 export { isLogin, getToken, setToken, clearToken };
1 import { AnyObject } from '@/types/global';
2 import { createVNode, Ref, VNode } from 'vue';
3 import { Form, FormItem, Input, InputNumber, Textarea, Modal, Select } from '@arco-design/web-vue';
4 import InputUpload from '@/components/input-upload/index.vue';
5
6 import { RenderContent } from '@arco-design/web-vue/es/_utils/types';
7 import { ModalConfig } from '@arco-design/web-vue/es/modal/interface';
8 import { set } from 'lodash';
9
10 export function createModalVNode(content: RenderContent, props?: object & Omit<ModalConfig, 'content'>) {
11 return Modal.open({
12 content,
13 titleAlign: 'start',
14 closable: false,
15 escToClose: false,
16 maskClosable: false,
17 okButtonProps: { size: 'small' },
18 cancelButtonProps: { size: 'small' },
19 ...props,
20 });
21 }
22
23 export function createFormVNode(props?: AnyObject, children?: VNode | VNode[] | undefined): VNode {
24 return createVNode(Form, { autoLabelWidth: true, ...props }, () => children);
25 }
26
27 export function createFormItemVNode(props?: AnyObject, children?: string | VNode | VNode[]): VNode {
28 return createVNode(FormItem, { showColon: true, ...props }, () => children);
29 }
30
31 export function createSelectVNode(value: Ref, options: AnyObject[], props?: AnyObject): VNode {
32 return createVNode(Select, {
33 options,
34 'modelValue': value.value,
35 'placeholder': '请选择',
36 'onUpdate:modelValue': (val?: unknown) => set(value, 'value', val),
37 ...props,
38 });
39 }
40
41 export function createSelectionFormItemVNode(value: Ref, options: AnyObject[], itemProps?: AnyObject, props?: AnyObject): VNode {
42 return createFormItemVNode(itemProps, createSelectVNode(value, options, props));
43 }
44
45 export function createInputVNode(value: Ref, props?: AnyObject): VNode {
46 return createVNode(Input, {
47 'modelValue': value.value,
48 'placeholder': '请输入',
49 'onUpdate:modelValue': (val?: string) => set(value, 'value', val || ''),
50 ...props,
51 });
52 }
53
54 export function createInputFormItemVNode(value: Ref, itemProps?: AnyObject, props?: AnyObject): VNode {
55 return createFormItemVNode(itemProps, createInputVNode(value, props));
56 }
57
58 export function createInputNumberVNode(value: Ref, props?: AnyObject): VNode {
59 return createVNode(InputNumber, {
60 'modelValue': value.value,
61 'placeholder': '请输入',
62 'onUpdate:modelValue': (val?: string) => set(value, 'value', val || 0),
63 'min': 0,
64 'max': 255,
65 ...props,
66 });
67 }
68
69 export function createInputNumberFormItemVNode(value: Ref, itemProps?: AnyObject, props?: AnyObject): VNode {
70 return createFormItemVNode(itemProps, createInputNumberVNode(value, props));
71 }
72
73 export function createTextareaVNode(value: Ref, props?: AnyObject) {
74 return createVNode(Textarea, {
75 'modelValue': value.value,
76 'placeholder': '请输入',
77 'onUpdate:modelValue': (val?: string) => set(value, 'value', val || 0),
78 'max-length': 500,
79 'show-word-limit': true,
80 'auto-size': { minRows: 3, maxRows: 6 },
81 ...props,
82 });
83 }
84
85 export function createTextareaFormItemVNode(value: Ref, itemProps?: AnyObject, props?: AnyObject) {
86 return createFormItemVNode(itemProps, createTextareaVNode(value, props));
87 }
88
89 export function createInputUploadVNode(value: Ref, props?: AnyObject): VNode {
90 return createVNode(InputUpload, {
91 'modelValue': value.value,
92 'placeholder': '请选择',
93 'onUpdate:modelValue': (val?: string) => set(value, 'value', val || 0),
94 ...props,
95 });
96 }
97
98 export function createInputUploadFormItemVNode(value: Ref, itemProps?: AnyObject, props?: AnyObject): VNode {
99 return createFormItemVNode(itemProps, createInputUploadVNode(value, props));
100 }
1 // const debug = import.meta.env.MODE !== 'production';
2
3 // export default debug;
4 export const debug = import.meta.env.MODE !== 'production';
5
6 export const ossConfig = {
7 accessKeyId: import.meta.env.VITE_OSS_ACCESS_KEY as string,
8 accessKeySecret: import.meta.env.VITE_OSS_ACCESS_SECRET as string,
9 bucket: import.meta.env.VITE_OSS_BUCKET,
10 region: import.meta.env.VITE_OSS_REGION,
11 host: import.meta.env.VITE_OSS_HOST,
12 endpoint: import.meta.env.VITE_OSS_ENDPOINT,
13 };
14
15 export const apiHost = import.meta.env.VITE_API_HOST;
16
17 export const showLoginAccount = import.meta.env.VITE_SHOW_LOGIN_ACCOUNT;
18
19 export const isProduction = import.meta.env.VITE_ENV === 'production';
1 export function addEventListen(target: Window | HTMLElement, event: string, handler: EventListenerOrEventListenerObject, capture = false) {
2 if (target.addEventListener && typeof target.addEventListener === 'function') {
3 target.addEventListener(event, handler, capture);
4 }
5 }
6
7 export function removeEventListen(
8 target: Window | HTMLElement,
9 event: string,
10 handler: EventListenerOrEventListenerObject,
11 capture = false
12 ) {
13 if (target.removeEventListener && typeof target.removeEventListener === 'function') {
14 target.removeEventListener(event, handler, capture);
15 }
16 }
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++) {
62 // @ts-ignore
63 channels.push(audioBuffer.getChannelData(i));
64 }
65
66 while (pos < length) {
67 // eslint-disable-next-line no-plusplus
68 for (i = 0; i < numOfChan; i++) {
69 // interleave channels
70 sample = Math.max(-1, Math.min(1, channels[i][offset])); // clamp
71 // eslint-disable-next-line no-bitwise
72 sample = (0.5 + sample < 0 ? sample * 32768 : sample * 32767) | 0; // scale to 16-bit signed int
73 view.setInt16(pos, sample, true); // write 16-bit sample
74 pos += 2;
75 }
76 // eslint-disable-next-line no-plusplus
77 offset++; // next source sample
78 }
79
80 // create Blob
81 return new Blob([buffer], { type: 'audio/wav' });
82
83 function setUint16(data: number) {
84 view.setUint16(pos, data, true);
85 pos += 2;
86 }
87
88 function setUint32(data: number) {
89 view.setUint32(pos, data, true);
90 pos += 4;
91 }
92 };
93
94 export const getLyricTimeArr = (lyric: string): string[] => {
95 const times: string[] = [];
96
97 lyric.split('\n').forEach((item) => {
98 item = item.replace(/(^\s*)|(\s*$)/g, '');
99 times.push(item.substring(item.indexOf('[') + 1, item.indexOf(']')));
100 });
101
102 return compact(times);
103 };
104
105 export function isUndefined(obj: any): obj is undefined {
106 return obj === undefined;
107 }
108
109 export function isString(obj: any): obj is string {
110 return opt.call(obj) === '[object String]';
111 }
112
113 export const promiseToBoolean = (callback: Promise<any>) => callback.then(() => true).catch(() => false);
1 const opt = Object.prototype.toString;
2
3 export function isArray(obj: any): obj is any[] {
4 return opt.call(obj) === '[object Array]';
5 }
6
7 export function isObject(obj: any): obj is { [key: string]: any } {
8 return opt.call(obj) === '[object Object]';
9 }
10
11 export function isString(obj: any): obj is string {
12 return opt.call(obj) === '[object String]';
13 }
14
15 export function isNumber(obj: any): obj is number {
16 return opt.call(obj) === '[object Number]' && obj === obj; // eslint-disable-line
17 }
18
19 export function isRegExp(obj: any) {
20 return opt.call(obj) === '[object RegExp]';
21 }
22
23 export function isFile(obj: any): obj is File {
24 return opt.call(obj) === '[object File]';
25 }
26
27 export function isBlob(obj: any): obj is Blob {
28 return opt.call(obj) === '[object Blob]';
29 }
30
31 export function isUndefined(obj: any): obj is undefined {
32 return obj === undefined;
33 }
34
35 export function isNull(obj: any): obj is null {
36 return obj === null;
37 }
38
39 export function isFunction(obj: any): obj is (...args: any[]) => any {
40 return typeof obj === 'function';
41 }
42
43 export function isEmptyObject(obj: any): boolean {
44 return isObject(obj) && Object.keys(obj).length === 0;
45 }
46
47 export function isExist(obj: any): boolean {
48 return obj || obj === 0;
49 }
50
51 export function isWindow(el: any): el is Window {
52 return el === window;
53 }
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 /**
2 * Listening to routes alone would waste rendering performance. Use the publish-subscribe model for distribution management
3 * 单独监听路由会浪费渲染性能。使用发布订阅模式去进行分发管理。
4 */
5 import mitt, { Handler } from 'mitt';
6 import type { RouteLocationNormalized } from 'vue-router';
7
8 const emitter = mitt();
9
10 const key = Symbol('ROUTE_CHANGE');
11
12 let latestRoute: RouteLocationNormalized;
13
14 export function setRouteEmitter(to: RouteLocationNormalized) {
15 emitter.emit(key, to);
16 latestRoute = to;
17 }
18
19 export function listenerRouteChange(handler: (route: RouteLocationNormalized) => void, immediate = true) {
20 emitter.on(key, handler as Handler);
21 if (immediate && latestRoute) {
22 handler(latestRoute);
23 }
24 }
25
26 export function removeRouteListener() {
27 emitter.off(key);
28 }
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/demo-show/components/user-tag.vue";
6 import { unionBy } from "lodash";
7 import useActivityApi from "@/api/activity";
8
9 const props = defineProps<{ data: Activity }>();
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-card v-show="data" :bordered="false">
28 <a-form :model="data" auto-label-width label-align="left">
29 <a-layout>
30 <a-layout-sider :width="130" style="background: none; box-shadow: none; padding-top: 6px">
31 <a-image show-loader :height="130" :width="130" :src="data.cover" />
32 </a-layout-sider>
33 <a-layout-content style="margin-left: 16px">
34 <a-form-item :hide-label="true">
35 <div class="title">{{ data.song_name }}</div>
36 <a-tag v-for="item in data.tags" :key="item.id" size="small" style="margin-right: 5px">
37 {{ item.name }}
38 </a-tag>
39 <span style="font-size: 10px">
40 {{ useActivityApi.statusOption.find((item) => item.value === data.status)?.label }}
41 </span>
42 </a-form-item>
43 <a-form-item :label-col-style="{ flex: 0 }" :wrapper-col-style="{ flex: 'unset', width: 'inherit' }"
44 :show-colon="true" label="关联用户">
45 <div v-if="data?.user">{{ data.user?.nick_name }}</div>
46 <div v-else></div>
47 </a-form-item>
48 <a-form-item style="margin-bottom: 0 !important" hide-label>
49 <a-form-item :label-col-style="{ flex: 0 }" :show-colon="true" label="作词">
50 <user-tag v-for="item in inSideLyricist" :key="item.id" :user="{ nick_name: item.nick_name }"
51 style="margin-right: 5px" />
52 <user-tag v-for="item in outSideLyricist" :key="`lyricist-${item}`" :user="{ nick_name: item }"
53 style="margin-right: 5px" />
54 </a-form-item>
55 <a-form-item :label-col-style="{ flex: 0 }" :show-colon="true" label="作曲">
56 <user-tag v-for="item in inSideComposer" :key="item.id" :user="{ nick_name: item.nick_name }"
57 style="margin-right: 5px" />
58 <user-tag v-for="item in outSideComposer" :key="`lyricist-${item}`" :user="{ nick_name: item }"
59 style="margin-right: 5px" />
60 </a-form-item>
61 </a-form-item>
62 <a-form-item style="margin-bottom: 0 !important" :label-col-style="{ flex: 0 }" :show-colon="true"
63 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 </template>
72
73 <style lang="less" scoped>
74 .arco-form-item {
75 margin-bottom: 10px;
76 }
77
78 .arco-form-item-label-col {
79 flex: 0;
80 }
81
82 .title {
83 font-size: 16px;
84 font-weight: bold;
85 margin-right: 8px;
86 }
87
88 .right {
89 margin: 0 20px;
90 min-width: 600px;
91 }
92 </style>
1 <script setup lang="ts">
2 import { Modal, Textarea } from "@arco-design/web-vue";
3 import { computed, h } from "vue";
4 import { useAppStore } from "@/store";
5 import useAuthApi from "@/api/auth";
6 import { Activity } from "@/types/activity";
7
8 type MaterialData = { title: string; type: string; content: string; name?: string; size?: string; };
9
10 const props = defineProps<{ data: Pick<Activity, "expand" | "lyric" | "song_name">; hideTrack?: boolean }>();
11
12 const appStore = useAppStore();
13 const theme = computed(() => appStore.theme);
14
15 const lyricStyle = computed(() =>
16 theme.value === "light" ? { border: "none", backgroundColor: "white" } : {
17 border: "none",
18 backgroundColor: "#2a2a2b"
19 }
20 );
21
22 const bytesForHuman = (bytes: number, decimals = 2) => {
23 const units = ["B", "KB", "MB", "GB", "TB", "PB"];
24
25 let i = 0;
26
27 // eslint-disable-next-line no-plusplus
28 for (i; bytes > 1024; i++) {
29 bytes /= 1024;
30 }
31
32 return `${parseFloat(bytes.toFixed(decimals))} ${units[i]}`;
33 };
34
35 const onDownload = (record: MaterialData) => useAuthApi.downloadFile(record.content, `${props.data.song_name}(${record.title})`);
36
37 const onViewLyric = (lyric: string) => {
38 Modal.open({
39 content: () => h(Textarea, { defaultValue: lyric, autoSize: { maxRows: 20 }, style: lyricStyle.value }),
40 footer: false,
41 closable: false
42 });
43 };
44
45 const materials = computed((): MaterialData[] => {
46 return [
47 {
48 title: "音频",
49 type: "guide",
50 name: props.data.expand?.guide_source?.name || "",
51 content: props.data.expand?.guide_source?.url || "",
52 size: bytesForHuman(props.data.expand?.guide_source?.size || 0)
53 },
54 {
55 title: "伴奏",
56 type: "karaoke",
57 name: props.data.expand?.karaoke_source?.name || "",
58 content: props.data.expand?.karaoke_source?.url || "",
59 size: bytesForHuman(props.data.expand?.karaoke_source?.size || 0)
60 },
61 { title: "歌词", type: "Lyric", content: props.data.lyric }
62 ];
63 });
64 </script>
65
66 <template>
67 <a-table row-key="type" :data="materials" :bordered="false" :table-layout-fixed="true" :pagination="false">
68 <template #columns>
69 <a-table-column title="物料类型" align="center" data-index="title" :width="140" />
70 <a-table-column title="音频播放" data-index="content">
71 <template #cell="{ record }">
72 <template v-if="record.content">
73 <audio-player v-if="['guide', 'karaoke'].indexOf(record.type) !== -1" :name="record.type"
74 :url="record.content" />
75 <div v-if="record.type === 'track'">{{ [record.name, record.size].join(",") }}</div>
76 </template>
77 </template>
78 </a-table-column>
79 <a-table-column title="操作" align="center" :width="200">
80 <template #cell="{ record }">
81 <template v-if="record.content">
82 <a-button v-if="record.type === 'Lyric'" type="primary" size="small" @click="onViewLyric(record.content)">
83 查看
84 </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">
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" 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/demo-show/components/basic-card.vue";
6 import MaterialTable from "@/views/demo-show/components/material-table.vue";
7 import { useRouteQuery } from "@vueuse/router";
8 import useLoading from "@/hooks/loading";
9 import { Activity } from "@/types/activity";
10
11 const activityKey = Number(useRoute().params.id);
12 const tabKey = useRouteQuery("tabKey", "material");
13 const activity = ref<Activity>();
14
15 const { loading, setLoading } = useLoading(false);
16
17 const router = useRouter();
18 onMounted(async () => {
19 setLoading(true);
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: Activity) => {
32 activity.value = data;
33 })
34 .catch(() => router.replace({ name: "exception-404" }))
35 .finally(() => setLoading(false));
36 });
37 </script>
38
39 <template>
40 <page-view has-bread>
41 <a-spin :loading="loading" style="width: 100%">
42 <basic-card v-if="activity" :data="activity as Activity" />
43 </a-spin>
44
45 <a-card style="margin-top: 16px">
46 <a-tabs v-model:active-key="tabKey" :animation="true" :header-padding="false" :justify="true" type="rounded">
47 <a-tab-pane key="material" title="歌曲物料">
48 <material-table v-if="activity" :data="activity" />
49 </a-tab-pane>
50 </a-tabs>
51 </a-card>
52 </page-view>
53 </template>
54
55 <style lang="less" scoped>
56 textarea.arco-textarea::-webkit-scrollbar {
57 display: none;
58 }
59 </style>
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 <script setup lang="ts">
2 import { useSelectionStore } from "@/store";
3
4 import {
5 Layout,
6 LayoutSider,
7 LayoutContent,
8 LayoutFooter,
9 Step,
10 Steps,
11 Space,
12 Divider,
13 Link
14 } from "@arco-design/web-vue";
15 import Step1FormContent from "@/views/demo/components/step1-form-content.vue";
16 import Step2FormContent from "@/views/demo/components/step2-form-content.vue";
17 import Step3FormContent from "@/views/demo/components/step3-form-content.vue";
18 import IconButton from "@/components/icon-button/index.vue";
19
20 import { AnyObject } from "@/types/global";
21 import useLoading from "@/hooks/loading";
22 import { computed, markRaw, ref } from "vue";
23 import { cloneDeep } from "lodash";
24
25 const props = defineProps<{
26 initValue: AnyObject;
27 filterProject?: (value: unknown) => boolean;
28 onSubmit: (data: AnyObject) => Promise<any>;
29 }>();
30
31 const { loading, setLoading } = useLoading(false);
32 const formRef = ref();
33 const formValue = ref({ ...cloneDeep(props.initValue) });
34
35 const stepItems = [
36 { value: 1, label: "基本信息", template: markRaw(Step1FormContent) },
37 { value: 2, label: "补充信息", template: markRaw(Step2FormContent) },
38 { value: 3, label: "上传文件", template: markRaw(Step3FormContent) }
39 ];
40
41 const currentStep = ref(1);
42 const currentContent = computed(() => stepItems.find((item) => item.value === currentStep.value)?.template);
43 const nextBtnLabel = computed(() => (currentStep.value === 3 ? "提交" : "下一步"));
44
45 const onPrev = (): void => {
46 currentStep.value = Math.max(1, currentStep.value - 1);
47 };
48
49 const onNext = () => {
50 formRef.value.onValid(async () => {
51 if (currentStep.value === stepItems.length) {
52 setLoading(true);
53 props.onSubmit(formValue.value).finally(() => setLoading(false));
54 }
55 currentStep.value = Math.min(stepItems.length, currentStep.value + 1);
56 });
57 };
58 </script>
59
60 <template>
61 <Layout>
62 <Layout has-sider>
63 <LayoutSider :width="120" class="aside">
64 <Steps :current="currentStep" direction="vertical" :small="true">
65 <Step v-for="item in stepItems" :key="item.value">{{ item.label }}</Step>
66 </Steps>
67 </LayoutSider>
68 <LayoutContent class="main">
69 <component :is="currentContent" ref="formRef" v-model="formValue" v-model:loading="loading"
70 :filter-project="filterProject" />
71 </LayoutContent>
72 </Layout>
73 <LayoutFooter>
74 <Divider style="margin-top: 8px; margin-bottom: 20px" />
75 <Link :href="useSelectionStore().lyricTool" :hoverable="false" class="link-hover" icon>歌词制作工具</Link>
76 <Space style="float: right">
77 <IconButton v-show="currentStep !== 1" icon="left" label="上一步" @click="onPrev" />
78 <IconButton icon="right" icon-align="right" :loading="loading" :label="nextBtnLabel" @click="onNext" />
79 </Space>
80 </LayoutFooter>
81 </Layout>
82 </template>
83
84 <style scoped lang="less">
85 .aside {
86 box-shadow: unset !important;
87 border-right: 1px solid var(--color-border);
88 margin-right: 20px;
89 background-color: transparent;
90 }
91
92 .main {
93 min-width: 640px;
94 }
95 </style>
1 <script setup lang="ts">
2 import { Form, FormItem, Input } from '@arco-design/web-vue';
3 import { computed, ref } from 'vue';
4 import AvatarUpload from '@/components/avatar-upload/index.vue';
5 import TagSelect from '@/components/tag-select/index.vue';
6 import { FieldRule, FormInstance } from '@arco-design/web-vue/es/form';
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 defineExpose({
35 onValid: (callback?: () => void) => formRef.value?.validate((errors) => !errors && callback?.()),
36 });
37 </script>
38
39 <template>
40 <Form ref="formRef" :model="formValue" :rules="formRule" :auto-label-width="true" size="small">
41 <FormItem label="封面图片" field="cover" :show-colon="true">
42 <AvatarUpload v-model="formValue.cover" :size="100" shape="square" />
43 </FormItem>
44 <FormItem label="歌曲名称" field="song_name" :show-colon="true">
45 <Input v-model="formValue.song_name" :max-length="100" :show-word-limit="true" placeholder="请输入" />
46 </FormItem>
47 <FormItem label="曲风标签" field="expand.tag_ids" :show-colon="true">
48 <TagSelect v-model="tagIds" :multiple="true" :limit="3" placeholder="请选择" limit-error-msg="关联标签最多选中3个" />
49 </FormItem>
50 </Form>
51 </template>
52
53 <style scoped lang="less"></style>
1 <script setup lang="ts">
2 import { Form, FormItem, InputTag } from '@arco-design/web-vue';
3 import UserSelect from '@/components/user-select/index.vue';
4
5 import { ref, computed } from 'vue';
6 import { FieldRule, FormInstance } from '@arco-design/web-vue/es/form';
7 import { useVModels } from '@vueuse/core';
8 import { get, set } from 'lodash';
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 lyricistIds = computed({
17 get: () => get(formValue.value, 'expand.lyricist.ids', []),
18 set: (val) => set(formValue.value, 'expand.lyricist.ids', val),
19 });
20
21 const lyricistSupplement = computed({
22 get: () => get(formValue.value, 'expand.lyricist.supplement', []),
23 set: (val) => set(formValue.value, 'expand.lyricist.supplement', val),
24 });
25
26 const composerIds = computed({
27 get: () => get(formValue.value, 'expand.composer.ids', []),
28 set: (val) => set(formValue.value, 'expand.composer.ids', val),
29 });
30
31 const composerSupplement = computed({
32 get: () => get(formValue.value, 'expand.composer.supplement', []),
33 set: (val) => set(formValue.value, 'expand.composer.supplement', val),
34 });
35
36 const checkMaxUser = (value: any, cb: (error?: string) => void) => (value && value.length > 2 ? cb('最大选择2人') : false);
37
38 const formRule = {
39 'expand.lyricist.supplement': [{ type: 'array', validator: (value, cb) => checkMaxUser(value, cb) }],
40 'expand.composer.supplement': [{ type: 'array', validator: (value, cb) => checkMaxUser(value, cb) }],
41 'estimate_release_at': [{ required: true, message: '请选择预计发布时间' }],
42 } as Record<string, FieldRule[]>;
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" size="small">
51 <FormItem label="词作者(用户)" field="expand.lyricist.ids" :show-colon="true">
52 <UserSelect v-model="lyricistIds" placeholder="请选择用户" :multiple="true" :allow-search="true" :limit="2" />
53 </FormItem>
54 <FormItem label="词作者(未注册)" field="expand.lyricist.supplement" :show-colon="true">
55 <InputTag v-model="lyricistSupplement" placeholder="输入完成回车键分隔" :max-tag-count="2" :unique-value="true" />
56 <template #extra>
57 <div>注意:上方填1项即可,歌曲信息中【用户】可点击跳转用户主页,【未注册】仅显示;</div>
58 </template>
59 </FormItem>
60 <FormItem label="曲作者(用户)" field="expand.composer.ids" :show-colon="true">
61 <UserSelect v-model="composerIds" placeholder="请选择用户" :multiple="true" :allow-search="true" :limit="2" />
62 </FormItem>
63 <FormItem label="曲作者(未注册)" field="expand.composer.supplement" :show-colon="true">
64 <InputTag v-model="composerSupplement" placeholder="输入完成回车键分隔" :max-tag-count="2" :unique-value="true" />
65 <template #extra>
66 <div>注意:上方填1项即可,歌曲信息中【用户】可点击跳转用户主页,【未注册】仅显示;</div>
67 </template>
68 </FormItem>
69 </Form>
70 </template>
71
72 <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 axios from 'axios';
10 import AudioPreview from '@/views/demo/components/audio-preview.vue';
11 import AudioPlayer from '@/components/audio-player/index.vue';
12 import { createModalVNode } from '@/utils/createVNode';
13 import useAuthApi from "@/api/auth";
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 'clip_lyric': [{ type: 'string', required: true, message: '请填写推荐歌词内容' }],
46 } as Record<string, FieldRule[]>;
47
48 const onDownload = (url: string, fileName: string) => useAuthApi.downloadFile(url, `${formValue.value.song_name}(${fileName})`);
49
50 const onFormatLyric = (key: string) => {
51 formValue.value[key] = formValue.value[key].replace(/(\n[\s\t]*\r*\n)/g, '\n').replace(/^[\n\r\t]*|[\n\r\t]*$/g, '');
52 };
53
54 const guideFile = ref<File | undefined>();
55
56 const getGuideFile = async () => {
57 if (!guideFile.value) {
58 guideFile.value = await axios
59 .get(`${guideSourceUrl.value}?response-content-type=Blob`, { responseType: 'blob', timeout: 60000 })
60 .then(({ data }) => Promise.resolve(data));
61 }
62
63 return guideFile.value;
64 };
65
66 const onAudioPreview = async () => {
67 const src = await getGuideFile();
68 createModalVNode(() => createVNode(AudioPreview, { src, lyric: formValue.value.lyric }), {
69 title: '预览歌词-整首',
70 footer: false,
71 closable: true,
72 });
73 };
74
75 defineExpose({
76 onValid: (callback?: () => void) => formRef.value?.validate((errors) => !errors && callback?.()),
77 });
78 </script>
79
80 <template>
81 <Form ref="formRef" :model="formValue" :rules="formRule" :auto-label-width="true" size="small">
82 <FormItem label="音频文件" field="expand.guide_source.url" :show-colon="true">
83 <InputUpload v-model="guideSourceUrl" :accept="activityAudioAccept" :limit="100" @success="onUpdateGuide"
84 @choose-file="(file: any) => (guideFile = file)" @update:loading="(value: boolean) => (loading = value)" />
85 </FormItem>
86 <FormItem v-if="guideSourceUrl">
87 <template #label>
88 <Link icon @click="onDownload(guideSourceUrl, '音频文件')">下载音频</Link>
89 </template>
90 <AudioPlayer name="音频文件" :url="guideSourceUrl" />
91 </FormItem>
92 <FormItem label="伴奏文件" field="expand.karaoke_source.url" :show-colon="true">
93 <InputUpload v-model="karaokeSourceUrl" :accept="activityAudioAccept" :limit="100" @success="onUpdateKaraoke"
94 @update:loading="(value: boolean) => (loading = value)" />
95 </FormItem>
96 <FormItem v-if="karaokeSourceUrl">
97 <template #label>
98 <Link icon @click="onDownload(karaokeSourceUrl, '伴奏文件')">下载伴奏</Link>
99 </template>
100 <AudioPlayer name="伴奏文件" :url="karaokeSourceUrl" />
101 </FormItem>
102 <FormItem class="lyric" label="歌词文本" field="lyric" :show-colon="true">
103 <Textarea v-model="formValue.lyric" :auto-size="{ minRows: 6, maxRows: 6 }" placeholder="请粘贴带时间的lrc歌词文本至输入框"
104 @blur="() => onFormatLyric('lyric')" />
105 <!-- <template #extra>
106 <Link :hoverable="false" :disabled="!guideSourceUrl || !formValue.lyric" @click="onAudioPreview"> 预览歌词</Link>
107 </template> -->
108 </FormItem>
109 <TypographyText type="danger" style="font-size: 13px">
110 注意:demo上架后,存在于您的私库。需要在App应用内,您的个人主页下,进行1对1分享给对方试听
111 </TypographyText>
112 </Form>
113 </template>
114
115 <style scoped lang="less">
116 .lyric {
117 :deep(.arco-form-item-extra) {
118 width: 100%;
119 text-align: right;
120 }
121 }
122 </style>
1 <script setup lang="ts" name="audition-demo">
2 import { createVNode, onMounted, ref } from "vue";
3 import useLoading from "@/hooks/loading";
4 import useActivityApi, { useApply } from "@/api/activity";
5 import EnumTableColumn from "@/components/filter/enum-table-column.vue";
6 import DateTableColumn from "@/components/filter/date-table-column.vue";
7 import ActivityTableColumn from "@/components/filter/activity-table-column.vue";
8 import { AnyObject, AttributeData } from "@/types/global";
9 import ActivityForm from "@/views/demo/components/form-content.vue";
10 import { Message, TableData } from "@arco-design/web-vue";
11 import { createInputVNode, createModalVNode } from "@/utils/createVNode";
12 import { useAuthorizedStore, useSelectionStore } from "@/store";
13 import { promiseToBoolean } from "@/utils";
14 import { tryOnMounted } from "@vueuse/core";
15
16 const props = defineProps<{
17 initFilter?: AttributeData;
18 hideSearchItem?: string[];
19 hideTool?: boolean;
20 hideCreate?: boolean;
21 exportName?: string;
22 queryHook?: () => void;
23 }>();
24
25 const { statusOption, get, update, destroy, changeStatus } = useActivityApi;
26 const { loading, setLoading } = useLoading(false);
27
28 const filter = ref({
29 songName: "",
30 projectName: "",
31 tagName: "",
32 status: [],
33 createBetween: [],
34 songType: 2,
35 setSort: ["-id"]
36 });
37 const tableRef = ref();
38
39 const { isCreateDemo } = useAuthorizedStore();
40
41 const onQuery = async (params: object) => {
42 setLoading(true);
43 props.queryHook?.();
44 return get({
45 ...filter.value,
46 ...params,
47 setColumn: ["id", "song_name", "cover", "user_id", "lyric", "expand", "status", "created_at"],
48 setWith: ["user:id,real_name,nick_name,identity", "tags:id,name"]
49 }).finally(() => setLoading(false));
50 };
51
52 const onSort = (column: string, type: string) => {
53 filter.value.setSort = type ? [(type === "desc" ? "-" : "") + column, "-id"] : ["-id"];
54 tableRef.value?.onFetch();
55 };
56
57 const onSearch = () => tableRef.value?.onPageChange(1);
58
59 const handleCreate = () => {
60
61 const dialog = createModalVNode(
62 () =>
63 createVNode(ActivityForm, {
64 initValue: { song_type: 2, cover: useSelectionStore().appleDemoCover, is_push: 0, expand: { push_type: [] } },
65 onSubmit: (data: AnyObject) =>
66 useApply.create(data).then(() => {
67 Message.success(`申请上架Demo:${data.song_name}`);
68 tableRef.value?.onFetch();
69 dialog.close();
70 })
71 }),
72 { title: "创建Demo", footer: false, closable: true, width: "auto" }
73 );
74 };
75
76 const onUpdate = (record: TableData) => {
77 const dialog = createModalVNode(
78 () =>
79 createVNode(ActivityForm, {
80 initValue: record,
81 onSubmit: (attribute: AnyObject) =>
82 update(record.id, Object.assign(attribute, { song_type: 2 })).then(() => {
83 Message.success(`编辑Demo:${record.song_name}`);
84 tableRef.value?.onFetch();
85 dialog.close();
86 })
87 }),
88 { title: "编辑Demo", footer: false, width: "auto", closable: true }
89 );
90 };
91
92 const onDown = (record: TableData) => {
93 const msg = ref<string>("");
94
95 createModalVNode(
96 () =>
97 createInputVNode(msg, {
98 "placeholder": "请输入下架原因",
99 "maxLength": 20,
100 "show-word-limit": true
101 }),
102 {
103 title: "变更状态",
104 titleAlign: "start",
105 okText: "下架",
106 onBeforeOk: (done) =>
107 changeStatus(record.id, { status: "down", msg: msg.value })
108 .then(() => {
109 tableRef.value?.onFetch();
110 done(true);
111 })
112 .catch(() => done(false))
113 }
114 );
115 };
116
117 const onUp = (record: TableData, status: "up" | "reUp") => {
118 createModalVNode(`请确认是否上架歌曲:${record.song_name}`, {
119 title: "变更状态",
120 titleAlign: "start",
121 okText: "上架",
122 onBeforeOk: (done) =>
123 changeStatus(record.id, { status })
124 .then(() => {
125 tableRef.value?.onFetch();
126 done(true);
127 })
128 .catch(() => done(false))
129 });
130 };
131
132 const onDelete = (row: TableData) =>
133 createModalVNode(`确认要将Demo:${row.song_name} 删除吗?`, {
134 title: "删除操作",
135 onBeforeOk: () => promiseToBoolean(destroy(row.id)),
136 onOk: () => tableRef.value?.onFetch()
137 });
138
139
140 const onReset = () => {
141 filter.value = {
142 songName: "",
143 projectName: "",
144 tagName: "",
145 status: [],
146 createBetween: [],
147 ...props.initFilter,
148 songType: 2,
149 setSort: ["-id"]
150 };
151 tableRef.value?.resetSort();
152 onSearch();
153 };
154
155
156 onMounted(() => onReset());
157 tryOnMounted(async () => {
158 useSelectionStore().queryUser({ fetchType: "all", status: 1 });
159 useSelectionStore().queryTag({ fetchType: "all", type: 1 });
160 useSelectionStore().queryConfig({
161 fetchType: "all",
162 identifier: ["activity_demo_cover", "activity_lyric_tool", "activity_audio_accept"]
163 });
164 });
165 </script>
166
167 <template>
168 <page-view has-bread has-card>
169 <filter-search :model="filter" :loading="loading" @search="onSearch" @reset="onReset">
170 <filter-search-item label="歌曲名称" field="song_name">
171 <a-input v-model="filter.songName" allow-clear placeholder="请输入搜索歌曲名称" />
172 </filter-search-item>
173 <filter-search-item label="标签名称" field="tagName">
174 <a-input v-model="filter.tagName" allow-clear placeholder="请输入搜索标签名称" />
175 </filter-search-item>
176 <filter-search-item label="状态" field="status">
177 <a-select v-model="filter.status" :options="statusOption" allow-clear multiple placeholder="请选择搜索状态" />
178 </filter-search-item>
179 <filter-search-item label="创建时间" field="createBetween">
180 <a-range-picker v-model="filter.createBetween" show-time
181 :time-picker-props="{ defaultValue: ['00:00:00', '23:59:59'] }" />
182 </filter-search-item>
183 </filter-search>
184 <filter-table ref="tableRef" :loading="loading" :on-query="onQuery" @row-sort="onSort">
185
186 <template #tool="{ size }">
187 <a-button v-if="isCreateDemo" :size="size" type="primary" @click="handleCreate">上架Demo</a-button>
188 </template>
189 <template #tool-right>
190 <div class="tipsBox">
191 <span>注意:如果上传后状态为“处理中”,需要“</span><i>编辑</i><span>”或“</span><i>下架</i><span>”,请点击“</span><i>搜索</i><span>”或</span><i>刷新浏览器</i>
192 </div>
193 </template>
194
195 <activity-table-column data-index="id" title="试唱歌曲" :width="560" hide-sub-title />
196 <date-table-column data-index="created_at" title="创建时间" :width="110" split has-sort />
197 <enum-table-column data-index="status" title="状态" :option="statusOption" :width="110" has-sort />
198 <a-table-column :width="90" align="center" data-index="operations" fixed="right" title="操作">
199 <template #cell="{ record }">
200 <a-space direction="vertical" fill>
201 <a-link :hoverable="false" class="link-hover"
202 @click="$router.push({ name: 'audition-demo-show', params: { id: record.id } })">
203 查看
204 </a-link>
205 <a-link v-if="record.status === 1" :hoverable="false" class="link-hover" @click="onDown(record)">下架
206 </a-link>
207 <a-link v-if="record.status === 2" :hoverable="false" class="link-hover" @click="onUp(record, 'up')">上架
208 </a-link>
209 <a-link v-if="record.status !== 0 && record.status !== 5" :hoverable="false" class="link-hover"
210 @click="onUpdate(record)">
211 编辑
212 </a-link>
213 <a-link v-if="record.status === 2" :hoverable="false" class="link-hover" @click="onDelete(record)">删除
214 </a-link>
215 </a-space>
216 </template>
217 </a-table-column>
218 </filter-table>
219 </page-view>
220 </template>
221
222 <style scoped lang="less">
223 :deep(.arco-table-cell) {
224 padding: 5px 16px !important;
225 }
226
227 .tipsBox {
228 font-size: 14px;
229
230 span {
231 color: #333;
232 }
233
234 i {
235 color: red;
236 font-style: normal;
237 }
238 }
239 </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="抱歉,页面不见了~"></a-result>
6 <div class="operation-row">
7 <!-- <a-button key="again" style="margin-right: 16px"> 重试</a-button>-->
8 <a-button key="back" type="primary" @click="goBack"> 返回</a-button>
9 </div>
10 </div>
11 </div>
12 </template>
13
14 <script lang="ts" setup>
15 import { useRouter } from 'vue-router';
16
17 const router = useRouter();
18
19 const goBack = () => router.back();
20 </script>
1 <template>
2 <div class="container">
3 <Breadcrumb :items="['exception', 'exception-500']" />
4 <div class="content">
5 <a-result class="result" status="500" 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>
18
19 <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 <script setup lang="ts">
2 import { onMounted, reactive, ref } from 'vue';
3 import { FormInstance, Message } from '@arco-design/web-vue';
4 import { useIntervalFn } from '@vueuse/core';
5 import useProviderApi from '@/api/provider';
6 import { union, map } from 'lodash-es';
7 import useLoading from '@/hooks/loading';
8
9 const emits = defineEmits(['login']);
10
11 const { loading, setLoading } = useLoading();
12
13 const countTime = ref<number>(0);
14
15 const areaOption = ref<string[]>([]);
16
17 onMounted(() => {
18 useProviderApi.area().then(({ data }) => (areaOption.value = union(map(data, (item) => item.identifier))));
19 });
20
21 const formRef = ref<FormInstance>();
22 const formValue = reactive({ area: '+86', phone: '', code: '' });
23
24 const formRule = {
25 phone: [{ required: true, message: '请输入手机号' }],
26 code: [{ required: true, message: '请输入验证码' }],
27 };
28
29 const formatCode = (value: string) => {
30 formValue.code = value.replace(/\D/g, '').slice(0, 6);
31 };
32
33 const { pause, resume } = useIntervalFn(() => {
34 // eslint-disable-next-line no-unused-expressions
35 countTime.value <= 0 ? pause() : (countTime.value -= 1);
36 }, 1000);
37
38 const onSend = async () => {
39 if (countTime.value !== 0 || (await formRef.value?.validateField('phone'))) {
40 return;
41 }
42
43 useProviderApi.sms('login', formValue.phone, formValue.area).then(() => {
44 Message.success('短信发送成功,请注意查收!');
45 countTime.value = 60;
46 resume();
47 });
48 };
49
50 const onLogin = () => {
51 formRef.value?.validate((errors) => {
52 if (!errors) {
53 setLoading(true);
54 useProviderApi
55 .login('phone', formValue)
56 .then(({ data }) => emits('login', data))
57 .finally(() => setLoading(false));
58 }
59 });
60 };
61 </script>
62
63 <template>
64 <a-form ref="formRef" :model="formValue" :rules="formRule" class="login-form" layout="vertical">
65 <a-form-item field="phone" hide-label>
66 <a-select v-model="formValue.area" style="flex: 100px; margin-right: 15px" :options="areaOption"
67 :virtual-list-props="{ height: 200 }" />
68 <a-input v-model="formValue.phone" style="flex: auto" placeholder="请输入登陆手机号" :max-length="11">
69 <template #prefix>
70 <icon-phone />
71 </template>
72 </a-input>
73 </a-form-item>
74 <a-form-item field="code" hide-label>
75 <a-input v-model="formValue.code" hide-button :max-length="6" placeholder="验证码" @input="formatCode">
76 <template #prefix>
77 <icon-message />
78 </template>
79 <template #suffix>
80 <a-link type="text" class="form-sms-btn" :disabled="countTime !== 0" @click="onSend()">
81 {{ countTime <= 0 ? '获取验证码' : countTime + 's' }} </a-link>
82 </template>
83 </a-input>
84 </a-form-item>
85 <a-form-item style="margin-bottom: 0" hide-label>
86 <a-button type="primary" :long="true" :loading="loading" @click="onLogin">登录</a-button>
87 </a-form-item>
88 </a-form>
89 </template>
90
91 <style lang="less" scoped>
92 .login-form {
93 &-error-msg {
94 height: 32px;
95 color: rgb(var(--red-6));
96 line-height: 32px;
97 }
98 }
99
100 .form-sms-btn {
101 padding-left: -12px;
102 font-size: 12px;
103 }
104 </style>
1 <script setup lang="ts">
2 import { setToken } from '@/utils/auth';
3
4 import PhoneContent from '@/views/login/components/phone-content.vue';
5 import { Message } from '@arco-design/web-vue';
6 import { useRouter } from 'vue-router';
7
8 type LoginData = { access_token: string; refresh_token: string; nick_name: string };
9
10 const router = useRouter();
11
12 const onLogin = ({ access_token, refresh_token, nick_name }: LoginData) => {
13 setToken(access_token, refresh_token);
14 Message.success(`欢迎回来,管理员:${nick_name}`);
15 const { path } = router.currentRoute.value.query;
16 // router.replace((path as string) || '/dashboard');
17 router.replace((path as string) || '/demos');
18 };
19 </script>
20
21 <template>
22 <div class="container">
23 <div class="banner">
24 <div class="banner-inner"></div>
25 </div>
26 <div class="content">
27 <div class="content-inner">
28 <div class="login-form-wrapper">
29 <div class="login-form-title">海星试唱</div>
30 <div class="login-form-sub-title">个人管理后台</div>
31 <a-card :bordered="true" :hoverable="true">
32 <a-tabs size="small" :justify-="true" :animation="true" lazy-load>
33 <a-tab-pane key="phone" title="手机号">
34 <phone-content @login="onLogin" />
35 </a-tab-pane>
36 </a-tabs>
37 </a-card>
38 </div>
39 </div>
40 </div>
41 </div>
42 </template>
43
44 <style lang="less" scoped>
45 .container {
46 display: flex;
47 height: 100vh;
48
49 .banner {
50 width: 550px;
51 }
52
53 .content {
54 position: relative;
55 display: flex;
56 flex: 1;
57 align-items: center;
58 justify-content: center;
59 padding-bottom: 40px;
60
61 .login-form-wrapper {
62 width: 380px;
63 }
64
65 .login-form-title {
66 color: var(--color-text-1);
67 font-weight: 500;
68 font-size: 30px;
69 line-height: 46px;
70 text-align: center;
71 }
72
73 .login-form-sub-title {
74 color: var(--color-text-3);
75 font-size: 16px;
76 line-height: 24px;
77 text-align: center;
78 margin-bottom: 16px;
79 }
80 }
81
82 .footer {
83 position: absolute;
84 right: 0;
85 bottom: 0;
86 width: 100%;
87 }
88 }
89
90 .banner {
91 display: flex;
92 align-items: center;
93 justify-content: center;
94
95 &-inner {
96 flex: 1;
97 height: 100%;
98 background-image: url('assets/image/user-login-bg.jpg');
99 background-size: cover;
100 }
101 }
102 </style>
1 {
2 "compilerOptions": {
3 "target": "ES2020",
4 "module": "ES2020",
5 "moduleResolution": "node",
6 "strict": true,
7 "jsx": "preserve",
8 "noImplicitAny": false,
9 "sourceMap": true,
10 "resolveJsonModule": true,
11 "esModuleInterop": true,
12 "baseUrl": ".",
13 "paths": {
14 "@/*": [
15 "src/*"
16 ],
17 "assets/*": [
18 "src/assets/*"
19 ]
20 },
21 "lib": [
22 "es2020",
23 "dom"
24 ],
25 "skipLibCheck": true,
26 "terserOptions": {
27 "compress": {
28 "drop_console": true,
29 "drop_debugger": true
30 }
31 }
32 },
33 "include": [
34 "src/**/*",
35 "src/**/*.vue"
36 ],
37 "exclude": [
38 "node_modules"
39 ]
40 }