feat: add public source channel frontend

main
weizong song 1 week ago
parent cbb8eceb5c
commit 7f69ad6508

306
package-lock.json generated

@ -14,6 +14,7 @@
"bootstrap": "^5.3.3",
"element-plus": "^2.14.0",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"vue": "^3.5.32",
"vue-router": "^4.6.4",
"vuedraggable": "^4.1.0"
@ -994,6 +995,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz",
@ -1111,6 +1136,15 @@
"node": ">= 0.4"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
@ -1136,6 +1170,35 @@
"fsevents": "~2.3.2"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
@ -1204,6 +1267,15 @@
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
"license": "MIT"
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -1223,6 +1295,12 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dom7": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/dom7/-/dom7-3.0.0.tgz",
@ -1272,6 +1350,12 @@
"vue": "^3.3.7"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
@ -1440,6 +1524,19 @@
"node": ">=8"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
@ -1500,6 +1597,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@ -1659,6 +1765,15 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
@ -1976,6 +2091,18 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
@ -2155,6 +2282,33 @@
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"license": "BSD-3-Clause"
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-map": {
"version": "7.0.4",
"resolved": "https://registry.npmmirror.com/p-map/-/p-map-7.0.4.tgz",
@ -2168,6 +2322,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
@ -2175,6 +2338,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@ -2230,6 +2402,15 @@
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz",
@ -2286,6 +2467,23 @@
"node": ">=10"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
@ -2312,6 +2510,21 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
@ -2368,6 +2581,12 @@
"compute-scroll-into-view": "^1.0.20"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/slate": {
"version": "0.72.8",
"resolved": "https://registry.npmmirror.com/slate/-/slate-0.72.8.tgz",
@ -2430,6 +2649,32 @@
"integrity": "sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==",
"license": "MIT"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/superjson": {
"version": "2.2.6",
"resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz",
@ -2692,11 +2937,72 @@
"vue": "^3.0.1"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wildcard": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz",
"integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==",
"license": "MIT"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
}
}
}

@ -15,6 +15,7 @@
"bootstrap": "^5.3.3",
"element-plus": "^2.14.0",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"vue": "^3.5.32",
"vue-router": "^4.6.4",
"vuedraggable": "^4.1.0"

@ -0,0 +1,30 @@
import { adminHttp } from './http'
import type { PublicSourceChannelPayload, PublicSourceChannelRow } from './types'
function unwrapRow(data: unknown): PublicSourceChannelRow {
const row = (data as { data?: PublicSourceChannelRow })?.data ?? (data as PublicSourceChannelRow)
if (!row || typeof row !== 'object' || !('id' in row)) throw new Error('公开来源渠道响应格式无效')
return row
}
export async function listPublicSourceChannels(): Promise<PublicSourceChannelRow[]> {
const { data } = await adminHttp.get<unknown>('/public-source-channels')
const rows = (data as { data?: PublicSourceChannelRow[] })?.data ?? (data as PublicSourceChannelRow[])
if (!Array.isArray(rows)) throw new Error('公开来源渠道列表响应格式无效')
return rows
}
export async function createPublicSourceChannel(
payload: PublicSourceChannelPayload,
): Promise<PublicSourceChannelRow> {
const { data } = await adminHttp.post<unknown>('/public-source-channels', payload)
return unwrapRow(data)
}
export async function updatePublicSourceChannel(
channelId: number,
payload: Partial<PublicSourceChannelPayload>,
): Promise<PublicSourceChannelRow> {
const { data } = await adminHttp.put<unknown>(`/public-source-channels/${channelId}`, payload)
return unwrapRow(data)
}

@ -166,6 +166,24 @@ export type SignupChannelStatus = 'enabled' | 'disabled'
export type SignupChannelCallbackType = 'none' | 'web' | 'mini_program'
export type SignupChannelMiniProgramMethod = 'navigateTo' | 'redirectTo' | 'reLaunch' | 'switchTab'
export type PublicSourceChannelStatus = 'enabled' | 'disabled'
export interface PublicSourceChannelRow {
id: number
source_code: string
source_name: string
status: PublicSourceChannelStatus
entry_url: string
created_at?: string
updated_at?: string
}
export interface PublicSourceChannelPayload {
source_code: string
source_name: string
status: PublicSourceChannelStatus
}
/** 赛事报名渠道配置channel_code 由后端创建时自动生成 */
export interface SignupChannelRow {
id: number

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { AdminMenuNode } from '../../api/admin/types'
import AdminMenuNest from './AdminMenuNest.vue'
@ -14,6 +15,11 @@ const router = useRouter()
const activeKey = computed(() => (route.name != null ? String(route.name) : ''))
function handleSelect(key: string) {
if (!router.hasRoute(key)) {
ElMessage.warning('该菜单对应的前端页面尚未发布,请刷新或重新部署前端资源')
return
}
void router.push({ name: key }).catch(() => {
/* 重复导航 */
})

@ -9,7 +9,13 @@ import 'element-plus/dist/index.css'
import './style.css'
import App from './App.vue'
import router from './router'
import { capturePublicSourceFromLocation } from './utils/publicSource'
const pinia = createPinia()
void capturePublicSourceFromLocation()
router.afterEach(() => {
void capturePublicSourceFromLocation()
})
createApp(App).use(pinia).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')

@ -5,6 +5,7 @@ export const ADMIN_COMPONENT_LOADERS: Record<string, () => Promise<unknown>> = {
'admin-competitions-list': () => import('../../views/admin/competition/CompetitionListView.vue'),
'admin-competition-form': () => import('../../views/admin/competition/CompetitionFormView.vue'),
'admin-competition-workspace': () => import('../../views/admin/competition/CompetitionFormView.vue'),
'admin-public-source-channels': () => import('../../views/admin/PublicSourceChannelManageView.vue'),
'admin-reviewers-list': () => import('../../views/admin/review/ReviewerManageView.vue'),
'admin-review-portal': () => import('../../views/admin/review/ReviewPortalView.vue'),
}

@ -0,0 +1,17 @@
declare module 'qrcode' {
export interface QRCodeToCanvasOptions {
width?: number
margin?: number
errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H'
color?: {
dark?: string
light?: string
}
}
export function toCanvas(
canvas: HTMLCanvasElement,
text: string,
options?: QRCodeToCanvasOptions,
): Promise<void>
}

@ -0,0 +1,83 @@
import { getApiBase } from '../config/api'
const SOURCE_CODE_KEY = 'cxxfds_public_source_code'
const SOURCE_CHECKED_AT_KEY = 'cxxfds_public_source_checked_at'
const TTL_DAYS = 30
const TTL_MS = TTL_DAYS * 24 * 60 * 60 * 1000
interface ResolveResponse {
valid?: boolean
source_code?: string
source_name?: string
}
function nowMs(): number {
return Date.now()
}
function storageAvailable(): boolean {
try {
const k = '__cxxfds_storage_probe__'
window.localStorage.setItem(k, '1')
window.localStorage.removeItem(k)
return true
} catch {
return false
}
}
function clearPublicSourceCache(): void {
if (!storageAvailable()) return
window.localStorage.removeItem(SOURCE_CODE_KEY)
window.localStorage.removeItem(SOURCE_CHECKED_AT_KEY)
}
function writePublicSourceCache(sourceCode: string): void {
if (!storageAvailable()) return
window.localStorage.setItem(SOURCE_CODE_KEY, sourceCode)
window.localStorage.setItem(SOURCE_CHECKED_AT_KEY, String(nowMs()))
}
export function readPublicSourceCodeForAttribution(): string | null {
if (!storageAvailable()) return null
const sourceCode = window.localStorage.getItem(SOURCE_CODE_KEY)?.trim() ?? ''
const checkedAtRaw = window.localStorage.getItem(SOURCE_CHECKED_AT_KEY)?.trim() ?? ''
const checkedAt = Number.parseInt(checkedAtRaw, 10)
if (!sourceCode || !Number.isFinite(checkedAt) || checkedAt <= 0) {
clearPublicSourceCache()
return null
}
if (nowMs() - checkedAt > TTL_MS) {
clearPublicSourceCache()
return null
}
return sourceCode
}
async function resolvePublicSource(sourceCode: string): Promise<string | null> {
const trimmed = sourceCode.trim()
if (!/^[A-Za-z0-9_-]{2,64}$/.test(trimmed)) return null
const qs = new URLSearchParams({ source_code: trimmed })
const res = await fetch(`${getApiBase()}/api/v1/public/source-channels/resolve?${qs.toString()}`, {
headers: { Accept: 'application/json' },
})
if (!res.ok) return null
const data = (await res.json().catch(() => ({}))) as ResolveResponse
return data.valid && typeof data.source_code === 'string' ? data.source_code : null
}
export async function capturePublicSourceFromLocation(): Promise<void> {
if (typeof window === 'undefined') return
const sourceCode = new URLSearchParams(window.location.search).get('src')?.trim() ?? ''
if (!sourceCode) return
try {
const resolved = await resolvePublicSource(sourceCode)
if (resolved) {
writePublicSourceCache(resolved)
}
} catch {
// 来源捕获不应影响页面主流程;非法或网络异常均不覆盖旧缓存。
}
}

@ -14,6 +14,7 @@ import {
hasVisibleText,
type BrandingForm,
} from '../utils/competitionBranding'
import { readPublicSourceCodeForAttribution } from '../utils/publicSource'
import '../styles/prototype-styles.css'
import '../styles/login-page-overrides.css'
@ -272,6 +273,7 @@ async function onSubmit() {
}
const url = `${apiBase()}${PARTICIPANT_SMS_LOGIN_PATH}`
try {
const publicSourceCode = readPublicSourceCodeForAttribution()
const r = await fetch(url, {
method: 'POST',
headers: {
@ -283,6 +285,7 @@ async function onSubmit() {
mobile: mobile.value.trim(),
code: codeTrimmed,
competition_slug: competitionSlug.value,
...(publicSourceCode ? { public_source_code: publicSourceCode } : {}),
}),
})
const data = await r.json().catch((): Record<string, unknown> => ({}))

@ -0,0 +1,390 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CopyDocument, Download, Edit, Plus, Refresh, Switch } from '@element-plus/icons-vue'
import { toCanvas } from 'qrcode'
import {
createPublicSourceChannel,
listPublicSourceChannels,
updatePublicSourceChannel,
} from '../../api/admin/publicSourceChannels'
import type {
PublicSourceChannelPayload,
PublicSourceChannelRow,
PublicSourceChannelStatus,
} from '../../api/admin/types'
const channels = ref<PublicSourceChannelRow[]>([])
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const qrDialogVisible = ref(false)
const qrRow = ref<PublicSourceChannelRow | null>(null)
const qrCanvasRef = ref<HTMLCanvasElement | null>(null)
const form = reactive<PublicSourceChannelPayload>({
source_code: '',
source_name: '',
status: 'enabled',
})
const dialogTitle = computed(() => (editingId.value ? '编辑公开来源渠道' : '新增公开来源渠道'))
function resetForm() {
editingId.value = null
form.source_code = ''
form.source_name = ''
form.status = 'enabled'
}
async function loadChannels() {
loading.value = true
try {
channels.value = await listPublicSourceChannels()
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '加载公开来源渠道失败')
} finally {
loading.value = false
}
}
function openCreateDialog() {
resetForm()
dialogVisible.value = true
}
function openEditDialog(row: PublicSourceChannelRow) {
editingId.value = row.id
form.source_code = row.source_code
form.source_name = row.source_name
form.status = row.status
dialogVisible.value = true
}
function validateForm(): boolean {
if (!/^[A-Za-z0-9_-]{2,64}$/.test(form.source_code.trim())) {
ElMessage.warning('渠道编码需为 2-64 位字母、数字、下划线或中划线')
return false
}
if (!form.source_name.trim() || form.source_name.trim().length > 100) {
ElMessage.warning('渠道名称需为 1-100 个字符')
return false
}
return true
}
async function saveChannel() {
if (!validateForm()) return
saving.value = true
const payload: PublicSourceChannelPayload = {
source_code: form.source_code.trim(),
source_name: form.source_name.trim(),
status: form.status,
}
try {
if (editingId.value) {
await updatePublicSourceChannel(editingId.value, payload)
} else {
await createPublicSourceChannel(payload)
}
ElMessage.success('公开来源渠道已保存')
dialogVisible.value = false
await loadChannels()
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '保存公开来源渠道失败')
} finally {
saving.value = false
}
}
async function toggleStatus(row: PublicSourceChannelRow) {
const nextStatus: PublicSourceChannelStatus = row.status === 'enabled' ? 'disabled' : 'enabled'
const label = nextStatus === 'enabled' ? '启用' : '停用'
try {
await ElMessageBox.confirm(`确认${label}${row.source_name}」?`, `${label}渠道`, {
type: nextStatus === 'enabled' ? 'info' : 'warning',
confirmButtonText: label,
cancelButtonText: '取消',
})
await updatePublicSourceChannel(row.id, { status: nextStatus })
ElMessage.success(`渠道已${label}`)
await loadChannels()
} catch (e) {
if (e !== 'cancel' && e !== 'close') {
ElMessage.error(e instanceof Error ? e.message : `渠道${label}失败`)
}
}
}
async function copyEntryUrl(row: PublicSourceChannelRow) {
try {
await navigator.clipboard.writeText(row.entry_url)
ElMessage.success('访问地址已复制')
} catch {
ElMessage.error('复制失败,请手动复制访问地址')
}
}
async function renderQr(row: PublicSourceChannelRow, canvas: HTMLCanvasElement, size = 220) {
await toCanvas(canvas, row.entry_url, {
width: size,
margin: 2,
errorCorrectionLevel: 'M',
color: {
dark: '#111827',
light: '#ffffff',
},
})
}
async function openQrDialog(row: PublicSourceChannelRow) {
qrRow.value = row
qrDialogVisible.value = true
await nextTick()
if (qrCanvasRef.value) {
await renderQr(row, qrCanvasRef.value, 240)
}
}
function drawCenteredText(ctx: CanvasRenderingContext2D, text: string, y: number, width: number) {
const maxWidth = width - 32
let fontSize = 22
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle = '#111827'
do {
ctx.font = `600 ${fontSize}px sans-serif`
if (ctx.measureText(text).width <= maxWidth || fontSize <= 12) break
fontSize -= 1
} while (fontSize > 12)
ctx.fillText(text, width / 2, y, maxWidth)
}
async function downloadQr(row: PublicSourceChannelRow) {
const qrSize = 360
const labelHeight = 72
const qrCanvas = document.createElement('canvas')
await renderQr(row, qrCanvas, qrSize)
const output = document.createElement('canvas')
output.width = qrSize
output.height = qrSize + labelHeight
const ctx = output.getContext('2d')
if (!ctx) {
ElMessage.error('当前浏览器无法生成二维码图片')
return
}
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, output.width, output.height)
ctx.drawImage(qrCanvas, 0, 0)
drawCenteredText(ctx, row.source_name, qrSize + labelHeight / 2 - 2, output.width)
const link = document.createElement('a')
link.download = `${row.source_code}-qrcode.png`
link.href = output.toDataURL('image/png')
link.click()
}
function formatTime(value?: string): string {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
onMounted(() => {
void loadChannels()
})
</script>
<template>
<section class="source-page">
<div class="source-toolbar">
<div>
<h1>公开来源渠道</h1>
<p>用于生成首页入口链接和二维码仅在新用户注册时写入来源归因</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadChannels"></el-button>
<el-button type="primary" :icon="Plus" @click="openCreateDialog"></el-button>
</div>
</div>
<el-table
v-loading="loading"
:data="channels"
border
stripe
empty-text="暂无公开来源渠道"
class="source-table"
>
<el-table-column prop="source_name" label="渠道名称" min-width="150" />
<el-table-column prop="source_code" label="渠道编码" min-width="150" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'enabled' ? 'success' : 'info'">
{{ row.status === 'enabled' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="访问地址" min-width="280">
<template #default="{ row }">
<div class="entry-url">
<span>{{ row.entry_url }}</span>
<el-button link type="primary" :icon="CopyDocument" @click="copyEntryUrl(row)">
复制
</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="二维码" width="130">
<template #default="{ row }">
<el-button size="small" @click="openQrDialog(row)"></el-button>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button size="small" :icon="Edit" @click="openEditDialog(row)"></el-button>
<el-button size="small" :icon="Switch" @click="toggleStatus(row)">
{{ row.status === 'enabled' ? '停用' : '启用' }}
</el-button>
<el-button size="small" :icon="Download" @click="downloadQr(row)"></el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px" destroy-on-close>
<el-form label-width="96px" @submit.prevent="saveChannel">
<el-form-item label="渠道编码" required>
<el-input
v-model.trim="form.source_code"
maxlength="64"
placeholder="例如 wx_menu、poster_001"
/>
</el-form-item>
<el-form-item label="渠道名称" required>
<el-input v-model.trim="form.source_name" maxlength="100" placeholder="例如 公众号菜单" />
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio-button label="enabled">启用</el-radio-button>
<el-radio-button label="disabled">停用</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveChannel"></el-button>
</template>
</el-dialog>
<el-dialog v-model="qrDialogVisible" title="渠道二维码" width="380px" destroy-on-close>
<div v-if="qrRow" class="qr-preview">
<canvas ref="qrCanvasRef" width="240" height="240" />
<div class="qr-label">{{ qrRow.source_name }}</div>
<div class="qr-url">{{ qrRow.entry_url }}</div>
</div>
<template #footer>
<el-button @click="qrDialogVisible = false">关闭</el-button>
<el-button v-if="qrRow" type="primary" :icon="Download" @click="downloadQr(qrRow)">
下载二维码
</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.source-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.source-toolbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.source-toolbar h1 {
margin: 0;
font-size: 22px;
font-weight: 650;
color: var(--el-text-color-primary);
}
.source-toolbar p {
margin: 6px 0 0;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.toolbar-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.source-table {
width: 100%;
}
.entry-url {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.entry-url span,
.qr-url {
overflow-wrap: anywhere;
color: var(--el-text-color-regular);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.qr-preview {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.qr-preview canvas {
width: 240px;
height: 240px;
border: 1px solid var(--el-border-color-lighter);
}
.qr-label {
max-width: 300px;
font-size: 18px;
font-weight: 650;
color: var(--el-text-color-primary);
text-align: center;
overflow-wrap: anywhere;
}
.qr-url {
max-width: 300px;
text-align: center;
}
@media (max-width: 760px) {
.source-toolbar {
flex-direction: column;
}
.toolbar-actions {
justify-content: flex-start;
}
}
</style>
Loading…
Cancel
Save