├── .node-version ├── .prettierignore ├── src ├── api │ ├── activity │ │ ├── status.ts │ │ ├── put-impression.ts │ │ ├── index.ts │ │ ├── delete.ts │ │ ├── create.ts │ │ ├── duration.ts │ │ ├── stats.ts │ │ ├── modify.ts │ │ ├── merge.ts │ │ ├── member.ts │ │ └── read.ts │ ├── index.ts │ ├── user │ │ ├── password.ts │ │ ├── index.ts │ │ ├── utils.ts │ │ ├── activity.ts │ │ ├── time.ts │ │ ├── crypto.ts │ │ └── auth.ts │ ├── group │ │ ├── index.ts │ │ ├── reads.ts │ │ └── crud.ts │ ├── logs.ts │ ├── export.ts │ └── exports.ts ├── components │ ├── activity │ │ ├── law.json │ │ ├── ZTimeStatistics.vue │ │ ├── validation.ts │ │ ├── getActivity.ts │ │ ├── index.ts │ │ ├── activityCategory.ts │ │ ├── ZActivityCard.vue │ │ ├── ZTimeJudge.vue │ │ └── ZUploadFile.vue │ ├── utils │ │ ├── ZAdvancedTable.vue │ │ ├── index.ts │ │ ├── ZButtonTag.vue │ │ └── ZButtonOrCard.vue │ ├── index.ts │ ├── tags │ │ ├── ZSpecialActivityClassify.vue │ │ ├── index.ts │ │ ├── ZActivityTag.vue │ │ ├── ZTime.vue │ │ ├── ZUserGroup.vue │ │ ├── ZUserPosition.vue │ │ ├── ZActivityType.vue │ │ ├── classifications.ts │ │ ├── ZActivityDuration.vue │ │ ├── ZActivityStatus.vue │ │ └── ZActivityMode.vue │ ├── form │ │ ├── index.ts │ │ ├── ZInputDuration.vue │ │ ├── ZSelectActivityMode.vue │ │ ├── ZSelectClass.vue │ │ └── ZSelectLanguage.vue │ ├── log │ │ ├── ZLogList.vue │ │ └── ZLogCard.vue │ └── group │ │ ├── ZGroupList.vue │ │ └── ZGroupTimeStatistics.vue ├── utils │ ├── index.ts │ ├── extraChars.ts │ ├── groupPosition.ts │ └── generate.ts ├── assets │ ├── Ubuntu-R.ttf │ ├── styles │ │ └── element │ │ │ └── index.scss │ ├── main.css │ └── base.css ├── plugins │ ├── index.ts │ ├── dayjs.ts │ ├── jwt.ts │ ├── ua.ts │ ├── short-token.ts │ └── axios.ts ├── views │ ├── activity │ │ ├── index.ts │ │ ├── ActivityPage.vue │ │ ├── CreatePage.vue │ │ ├── CreateHome.vue │ │ ├── CreateActivity.vue │ │ └── ActivityMerge.vue │ ├── SomethingWrong.vue │ ├── index.ts │ ├── HomeView.vue │ ├── NotFound.vue │ ├── AboutView.vue │ ├── user │ │ ├── UserActivity.vue │ │ ├── index.ts │ │ ├── UserPage.vue │ │ ├── UserHome.vue │ │ └── UserNav.vue │ ├── manage │ │ └── ManageHome.vue │ └── group │ │ └── GroupPage.vue ├── i18n │ ├── index.ts │ └── locales │ │ ├── index.ts │ │ ├── validation.ts │ │ ├── home.ts │ │ ├── about.ts │ │ └── nav.ts ├── global.d.ts ├── icons │ ├── positions │ │ ├── IcBaselineClass.vue │ │ ├── PhStudent.vue │ │ ├── MaterialSymbolsAdminPanelSettings.vue │ │ ├── index.ts │ │ └── MingcuteDepartmentFill.vue │ ├── TablerSum.vue │ ├── MaterialSymbolsDescriptionOutline.vue │ ├── MdiEye.vue │ ├── MdiTranslate.vue │ ├── LucideWorkflow.vue │ ├── UilStatistics.vue │ ├── status │ │ └── index.ts │ ├── StreamlineInterfaceUserEditActionsCloseEditGeometricHumanPencilPersonSingleUpUserWrite.vue │ ├── CarbonCloudOffline.vue │ ├── MaterialSymbolsSettings.vue │ ├── MaterialSymbolsAccountCircleOutline.vue │ ├── index.ts │ ├── MaterialSymbolsLightHistoryRounded.vue │ └── MaterialSymbolsPasswordRounded.vue ├── stores │ ├── header.ts │ ├── groups.ts │ └── user.ts ├── main.ts └── router │ └── index.ts ├── public ├── robots.txt ├── brand.jpeg ├── favicon.ico ├── favicon.png ├── pwa-64x64.png ├── favicon-16x16.png ├── favicon-32x32.png ├── pwa-192x192.png ├── pwa-512x512.png ├── mstile-150x150.png ├── vercel.json ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-256x256.png ├── maskable-icon-512x512.png ├── apple-touch-icon-180x180.png ├── browserconfig.xml ├── site.webmanifest └── safari-pinned-tab.svg ├── .husky ├── pre-commit-msg └── pre-commit ├── vercel.json ├── types ├── v2.ts ├── login.d.ts ├── log.d.ts ├── group.d.ts ├── token.d.ts ├── index.d.ts ├── user.v2.d.ts ├── group.v2.d.ts ├── user.d.ts ├── activity.v2.d.ts ├── response.d.ts └── activity.d.ts ├── .npmrc ├── .vscode └── extensions.json ├── .hintrc ├── .prettierrc.json ├── tsconfig.vitest.json ├── cypress.config.ts ├── tsconfig.json ├── pwa-assets.config.ts ├── .editorconfig ├── auto-imports.d.ts ├── tsconfig.node.json ├── .gitignore ├── vitest.config.ts ├── unocss.config.ts ├── tsconfig.app.json ├── .eslintrc.cjs ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── env.d.ts ├── index.html ├── README.md ├── LICENSE ├── SECURITY.md └── vite.config.ts /.node-version: -------------------------------------------------------------------------------- 1 | 22.14.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | index.html -------------------------------------------------------------------------------- /src/api/activity/status.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/activity/law.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/api/activity/put-impression.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/activity/ZTimeStatistics.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/utils/ZAdvancedTable.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * as default from './export' 2 | -------------------------------------------------------------------------------- /src/api/user/password.ts: -------------------------------------------------------------------------------- 1 | export { resetPassword as put } from './auth' 2 | -------------------------------------------------------------------------------- /public/brand.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvms/zvms4-frontend/HEAD/public/brand.jpeg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvms/zvms4-frontend/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvms/zvms4-frontend/HEAD/public/favicon.png -------------------------------------------------------------------------------- /.husky/pre-commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | exec < /dev/tty && node_modules/.bin/cz --hook || true 3 | -------------------------------------------------------------------------------- /public/pwa-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvms/zvms4-frontend/HEAD/public/pwa-64x64.png -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { getUserPositions as getPositionByGroup } from './groupPosition' 2 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [{ "source": "/:path*", "destination": "/index.html" }] 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvms/zvms4-frontend/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvms/zvms4-frontend/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvms/zvms4-frontend/HEAD/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvms/zvms4-frontend/HEAD/public/pwa-512x512.png -------------------------------------------------------------------------------- /src/assets/Ubuntu-R.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvms/zvms4-frontend/HEAD/src/assets/Ubuntu-R.ttf -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvms/zvms4-frontend/HEAD/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [{ "source": "/:path*", "destination": "/index.html" }] 3 | } 4 | -------------------------------------------------------------------------------- /types/v2.ts: -------------------------------------------------------------------------------- 1 | export * from './activity.v2' 2 | export * from './group.v2' 3 | export * from './user.v2' 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | public-hoist-pattern[]=workbox-window 4 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvms/zvms4-frontend/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvms/zvms4-frontend/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvms/zvms4-frontend/HEAD/public/android-chrome-256x256.png -------------------------------------------------------------------------------- /public/maskable-icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvms/zvms4-frontend/HEAD/public/maskable-icon-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvms/zvms4-frontend/HEAD/public/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /types/login.d.ts: -------------------------------------------------------------------------------- 1 | export interface LoginResult { 2 | token: string // JSON Web Token 3 | _id: string // MongoDB ObjectID 4 | } 5 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './activity' 2 | export * from './tags' 3 | export * from './utils' 4 | export * from './form' 5 | -------------------------------------------------------------------------------- /src/components/tags/ZSpecialActivityClassify.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /src/components/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ZButtonOrCard } from './ZButtonOrCard.vue' 2 | export { default as ZButtonTag } from './ZButtonTag.vue' 3 | -------------------------------------------------------------------------------- /src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | export { default as axios } from './axios' 2 | export { default as dayjs } from './dayjs' 3 | export { pad as isPad } from './ua' 4 | -------------------------------------------------------------------------------- /src/views/activity/index.ts: -------------------------------------------------------------------------------- 1 | export { default as CreateActivity } from './CreateActivity.vue' 2 | export { default as CreatePage } from './CreatePage.vue' 3 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | git-format-staged -f 'prettier --ignore-unknown --stdin --stdin-filepath "{}"' . 5 | -------------------------------------------------------------------------------- /src/views/SomethingWrong.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/views/index.ts: -------------------------------------------------------------------------------- 1 | export * from './activity' 2 | export * from './user' 3 | export { default as HomeView } from './HomeView.vue' 4 | export { default as AboutView } from './AboutView.vue' 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "Vue.vscode-typescript-vue-plugin", 5 | "dbaeumer.vscode-eslint", 6 | "esbenp.prettier-vscode" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.hintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "development" 4 | ], 5 | "browserslist": [ 6 | "defaults", 7 | "not ie 11", 8 | "not ios_saf <= 17", 9 | "not safari <= 17" 10 | ] 11 | } -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": false, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "printWidth": 100, 7 | "trailingComma": "none" 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.vitest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.app.json", 3 | "exclude": [], 4 | "compilerOptions": { 5 | "composite": true, 6 | "lib": [], 7 | "types": ["node", "jsdom"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}', 6 | baseUrl: 'http://localhost:4173' 7 | } 8 | }) 9 | -------------------------------------------------------------------------------- /types/log.d.ts: -------------------------------------------------------------------------------- 1 | export interface Log { 2 | _id: string 3 | url: string 4 | user: string 5 | clarity: string 6 | xuehai?: string 7 | xuehai_id?: string 8 | data: string 9 | timestamp: number 10 | ip: string 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/extraChars.ts: -------------------------------------------------------------------------------- 1 | export function extractNonHanCharacters(str: string) { 2 | return str.replace(/[\u4E00-\u9FA5]/g, '') 3 | } 4 | 5 | export function extractHanCharacters(str: string) { 6 | return str.replace(/[^\u4E00-\u9FA5]/g, '') 7 | } 8 | -------------------------------------------------------------------------------- /types/group.d.ts: -------------------------------------------------------------------------------- 1 | import { UserPosition } from './user' 2 | 3 | export interface Group { 4 | _id: string // ObjectId 5 | name: string 6 | type: 'class' | 'permission' | 'group' 7 | description?: string 8 | permissions: UserPosition[] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.node.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | }, 10 | { 11 | "path": "./tsconfig.vitest.json" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /pwa-assets.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, minimal2023Preset as preset } from '@vite-pwa/assets-generator/config' 2 | 3 | export default defineConfig({ 4 | headLinkOptions: { 5 | preset: '2023' 6 | }, 7 | preset, 8 | images: ['public/favicon.png'] 9 | }) 10 | -------------------------------------------------------------------------------- /types/token.d.ts: -------------------------------------------------------------------------------- 1 | export interface LongTermToken { 2 | _id: string 3 | token: string 4 | user: string 5 | expire: string // ISO-8601 6 | } 7 | 8 | export interface ShortTermToken { 9 | // Only 30 minutes 10 | _id: string 11 | token: string 12 | user: string 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | -------------------------------------------------------------------------------- /src/api/user/index.ts: -------------------------------------------------------------------------------- 1 | export * as auth from './auth' 2 | export { read, readOne, update, removeOne, past } from './crud' 3 | export * as time from './time' 4 | export * as password from './password' 5 | export * as activity from './activity' 6 | export type { UserActivityTimePercentile } from './time' 7 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/assets/styles/element/index.scss: -------------------------------------------------------------------------------- 1 | $--colors: ( 2 | 'secondary': ( 3 | 'base': #626aef 4 | ) 5 | ); 6 | 7 | @forward 'element-plus/theme-chalk/src/common/var.scss' with ( 8 | $colors: $--colors 9 | ); 10 | 11 | // @use "element-plus/theme-chalk/src/index.scss" as *; 12 | 13 | // @debug $--colors; 14 | -------------------------------------------------------------------------------- /auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // noinspection JSUnusedGlobalSymbols 5 | // Generated by unplugin-auto-import 6 | // biome-ignore lint: disable 7 | export {} 8 | declare global { 9 | const ElNotification: (typeof import('element-plus/es'))['ElNotification'] 10 | } 11 | -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import { createI18n } from 'vue-i18n' 2 | import { useLocalStorage } from '@vueuse/core' 3 | import { zhCN, enUS } from './locales' 4 | 5 | export default createI18n({ 6 | locale: 'zh-CN', 7 | fallbackLocale: 'zh-CN', 8 | messages: { 9 | 'zh-CN': zhCN, 10 | 'en-US': enUS 11 | }, 12 | legacy: false 13 | }) 14 | -------------------------------------------------------------------------------- /src/api/activity/index.ts: -------------------------------------------------------------------------------- 1 | export { default as insert } from './create' 2 | export { deleteActivity as deleteOne } from './delete' 3 | export * as update from './modify' 4 | export * from './read' 5 | export * as member from './member' 6 | export * as duration from './duration' 7 | export { default as merge } from './merge' 8 | export * as stats from './stats' 9 | -------------------------------------------------------------------------------- /src/components/form/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ZSelectLanguage } from './ZSelectLanguage.vue' 2 | export { default as ZSelectPerson } from './ZSelectPerson.vue' 3 | export { default as ZVerticalNav } from './ZVerticalNav.vue' 4 | export { default as ZInputDuration } from './ZInputDuration.vue' 5 | export { default as ZSelectActivityMode } from './ZSelectActivityMode.vue' 6 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './activity' 2 | export * from './class' 3 | export * from './feedback' 4 | export * from './login' 5 | export * from './notification' 6 | export * from './practice' 7 | export * from './response' 8 | export * from './token' 9 | export * from './trophy' 10 | export * from './user' 11 | export * from './group' 12 | export * as v2 from './v2' 13 | -------------------------------------------------------------------------------- /src/utils/groupPosition.ts: -------------------------------------------------------------------------------- 1 | import type { User, UserPosition } from '@/../types' 2 | import api from '@/api' 3 | 4 | export async function getUserPositions(user: User) { 5 | const result = await Promise.all(user.group.map((gid) => api.group.readOne(gid))) 6 | const positions = result.map((group) => group?.permissions).flat(Infinity) 7 | return positions as UserPosition[] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node18/tsconfig.json", 3 | "include": [ 4 | "vite.config.*", 5 | "vitest.config.*", 6 | "cypress.config.*", 7 | "nightwatch.conf.*", 8 | "playwright.config.*" 9 | ], 10 | "compilerOptions": { 11 | "composite": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Bundler", 14 | "types": ["node"] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/views/activity/ActivityPage.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 14 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | // Declare a module to augment the global 'Window' interface 2 | declare global { 3 | // Define the shape of the clarity function and any associated properties 4 | interface Window { 5 | clarity: { 6 | (config: { [key: string]: unknown }): void 7 | q?: IArguments[] 8 | } 9 | } 10 | } 11 | 12 | // This line is necessary if your file doesn't have any imports or exports 13 | export {} 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | -------------------------------------------------------------------------------- /src/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/icons/positions/IcBaselineClass.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { mergeConfig, defineConfig, configDefaults } from 'vitest/config' 3 | import viteConfig from './vite.config' 4 | 5 | export default mergeConfig( 6 | viteConfig, 7 | defineConfig({ 8 | test: { 9 | environment: 'jsdom', 10 | exclude: [...configDefaults.exclude, 'e2e/*'], 11 | root: fileURLToPath(new URL('./', import.meta.url)) 12 | } 13 | }) 14 | ) 15 | -------------------------------------------------------------------------------- /unocss.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig, 3 | presetUno, 4 | presetIcons, 5 | presetAttributify, 6 | presetTypography, 7 | presetWebFonts 8 | } from 'unocss' 9 | 10 | export default defineConfig({ 11 | presets: [ 12 | presetUno({ 13 | dark: 'class' 14 | }), 15 | presetIcons({ 16 | cdn: 'https://esm.sh', 17 | scale: 1.2 18 | }), 19 | presetAttributify(), 20 | presetTypography(), 21 | presetWebFonts() 22 | ] 23 | }) 24 | -------------------------------------------------------------------------------- /src/assets/main.css: -------------------------------------------------------------------------------- 1 | @import './base.css'; 2 | 3 | body { 4 | user-select: none; 5 | overflow: hidden; 6 | position: fixed; 7 | width: 100%; 8 | font-family: 9 | Ubuntu, 'PingFang SC', 'Source Han Sans SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, 10 | Arial, sans-serif !important; 11 | } 12 | 13 | ::-webkit-scrollbar { 14 | display: none; /* Chrome Safari */ 15 | } 16 | 17 | @font-face { 18 | font-family: 'Ubuntu'; 19 | src: url('./Ubuntu-R.ttf'); 20 | } 21 | -------------------------------------------------------------------------------- /src/icons/TablerSum.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | -------------------------------------------------------------------------------- /src/components/tags/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ZUserPosition } from './ZUserPosition.vue' 2 | export { default as ZActivityStatus } from './ZActivityStatus.vue' 3 | export { default as ZActivityType } from './ZActivityType.vue' 4 | export { default as ZActivityMode } from './ZActivityMode.vue' 5 | export { default as ZSpecialActivityClassify } from './ZSpecialActivityClassify.vue' 6 | export { default as ZActivityDuration } from './ZActivityDuration.vue' 7 | export { default as ZUserGroup } from './ZUserGroup.vue' 8 | -------------------------------------------------------------------------------- /src/api/activity/delete.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/plugins/axios' 2 | import { temporaryToken } from '@/plugins/short-token' 3 | 4 | export async function deleteActivity(id: string, uid: string, token: string = '') { 5 | if (!token) { 6 | token = await temporaryToken(uid) 7 | } 8 | const result = ( 9 | await axios(`/v2/activities/${id}`, { 10 | method: 'delete', 11 | headers: { 12 | Authorization: `Bearer ${token}` 13 | } 14 | }) 15 | ).data 16 | return result 17 | } 18 | -------------------------------------------------------------------------------- /src/icons/MaterialSymbolsDescriptionOutline.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/api/user/utils.ts: -------------------------------------------------------------------------------- 1 | function base64ToByteArray(base64String: string) { 2 | const raw = atob(base64String) 3 | const byteArray = new Uint8Array(raw.length) 4 | for (let i = 0; i < raw.length; i++) { 5 | byteArray[i] = raw.charCodeAt(i) 6 | } 7 | return byteArray 8 | } 9 | 10 | function byteArrayToHex(byteArray: Uint8Array) { 11 | return Array.from(byteArray, function (byte) { 12 | return ('0' + (byte & 0xff).toString(16)).slice(-2) 13 | }).join('') 14 | } 15 | 16 | export { base64ToByteArray, byteArrayToHex } 17 | -------------------------------------------------------------------------------- /src/icons/MdiEye.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue", "types/*"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "composite": true, 7 | "baseUrl": ".", 8 | "paths": { 9 | "@/*": ["./src/*"] 10 | }, 11 | "target": "ES2021", 12 | "moduleResolution": "Bundler", 13 | "types": ["vite-plugin-pwa/vue", "element-plus/global"], 14 | "allowImportingTsExtensions": true, 15 | "noEmit": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /types/user.v2.d.ts: -------------------------------------------------------------------------------- 1 | export interface ActivityMemberUser { 2 | _id: string // ObjectId 3 | id: number 4 | name: string 5 | groups: string[] 6 | past: string[] 7 | } 8 | 9 | export type UserPosition = 'admin' | 'volunteer' | 'club' | 'monitor' | 'student' 10 | 11 | export type WithPassword = { 12 | password: string 13 | } & T 14 | 15 | export interface UserLogin { 16 | id: number 17 | password: string 18 | } 19 | 20 | export interface UserActivityTimeSums { 21 | onCampus: number 22 | offCampus: number 23 | socialPractice: number 24 | } 25 | -------------------------------------------------------------------------------- /src/api/activity/create.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/plugins/axios' 2 | import type { Activity } from '@/../types/v2' 3 | import { dayjs } from 'element-plus' 4 | 5 | export async function createActivity(activity: Activity) { 6 | /// @ts-ignore 7 | delete activity._id 8 | activity.date = dayjs(activity.date).toISOString() 9 | const result = ( 10 | await axios('/v2/activities', { 11 | method: 'post', 12 | data: activity 13 | }) 14 | ).data as { 15 | id: string 16 | } 17 | return result.id 18 | } 19 | 20 | export default createActivity 21 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution') 3 | 4 | module.exports = { 5 | root: true, 6 | extends: [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/eslint-config-typescript', 10 | '@vue/eslint-config-prettier/skip-formatting' 11 | ], 12 | overrides: [ 13 | { 14 | files: ['cypress/e2e/**/*.{cy,spec}.{js,ts,jsx,tsx}'], 15 | extends: ['plugin:cypress/recommended'] 16 | } 17 | ], 18 | parserOptions: { 19 | ecmaVersion: 'latest' 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/plugins/dayjs.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import duration from 'dayjs/plugin/duration' 3 | import calendar from 'dayjs/plugin/calendar' 4 | import isToday from 'dayjs/plugin/isToday' 5 | import isBetween from 'dayjs/plugin/isBetween' 6 | import utc from 'dayjs/plugin/utc' 7 | import timezone from 'dayjs/plugin/timezone' 8 | 9 | dayjs.extend(duration) 10 | dayjs.extend(calendar) 11 | dayjs.extend(isToday) 12 | dayjs.extend(isBetween) 13 | 14 | dayjs.extend(utc) 15 | dayjs.extend(timezone) 16 | 17 | dayjs.tz.setDefault('Asia/Shanghai') 18 | 19 | export default dayjs 20 | -------------------------------------------------------------------------------- /src/api/activity/duration.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/plugins/axios' 2 | import { ElMessage } from 'element-plus' 3 | import type { ActivityMember } from 'types/activity.v2' 4 | 5 | export async function modify( 6 | user: string, 7 | aid: string, 8 | duration: number, 9 | mode: ActivityMember['mode'] 10 | ) { 11 | await axios({ 12 | url: `/v2/activities/${aid}/members/${user.toString()}/record`, 13 | method: 'put', 14 | data: { duration, mode } 15 | }) 16 | ElMessage({ 17 | message: 'Successfully modified duration', 18 | type: 'success', 19 | plain: true 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/icons/MdiTranslate.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/icons/LucideWorkflow.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | -------------------------------------------------------------------------------- /src/stores/header.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { useTitle } from '@vueuse/core' 3 | 4 | export const useHeaderStore = defineStore('header', { 5 | state: () => ({ 6 | header: 'ZVMS 4', 7 | base: 'ZVMS 4' 8 | }), 9 | actions: { 10 | setHeader(header: string) { 11 | const newHeader = header + ' - ' + this.base 12 | this.header = newHeader 13 | const title = useTitle() 14 | title.value = newHeader 15 | }, 16 | resetHeader() { 17 | this.header = this.base 18 | const title = useTitle() 19 | title.value = this.base 20 | } 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /src/components/activity/validation.ts: -------------------------------------------------------------------------------- 1 | import type { Activity } from '@/../types/v2' 2 | import dayjs from '@/plugins/dayjs' 3 | 4 | export function validateActivity( 5 | activity: Activity, 6 | index: 'info' | 'member' | 'review' = 'review' 7 | ): boolean { 8 | if (index !== 'member') { 9 | if (!activity) return false 10 | if (!activity.name) return false 11 | if (!activity.date) return false 12 | if (!activity.approver) return false 13 | try { 14 | dayjs(activity.date) 15 | } catch { 16 | return false 17 | } 18 | if (!activity.type) return false 19 | } 20 | return true 21 | } 22 | -------------------------------------------------------------------------------- /src/stores/groups.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import type { Group } from '@/../types' 3 | import api from '@/api' 4 | 5 | export const useGroupsStore = defineStore('group', { 6 | state: () => ({ 7 | groups: {} as Record 8 | }), 9 | actions: { 10 | async fetchGroup(id: string) { 11 | if (id in this.groups) { 12 | return this.groups[id] 13 | } else { 14 | const group = await api.group.readOne(id) 15 | if (group) { 16 | this.groups[id] = group 17 | return group 18 | } 19 | } 20 | } 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /src/icons/UilStatistics.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /src/i18n/locales/index.ts: -------------------------------------------------------------------------------- 1 | import * as about from './about' 2 | import * as activity from './activity' 3 | import * as home from './home' 4 | import * as nav from './nav' 5 | import * as validation from './validation' 6 | import * as manage from './manage' 7 | 8 | export const enUS = { 9 | about: about.enUS, 10 | activity: activity.enUS, 11 | home: home.enUS, 12 | nav: nav.enUS, 13 | validation: validation.enUS, 14 | manage: manage.enUS 15 | } 16 | 17 | export const zhCN = { 18 | about: about.zhCN, 19 | activity: activity.zhCN, 20 | home: home.zhCN, 21 | nav: nav.zhCN, 22 | validation: validation.zhCN, 23 | manage: manage.zhCN 24 | } 25 | -------------------------------------------------------------------------------- /src/api/activity/stats.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/plugins/axios' 2 | 3 | async function getActivityStats(aid: string) { 4 | const response = await axios({ 5 | url: `/v2/statistics/activities/${aid}/description`, 6 | method: 'get' 7 | }) 8 | 9 | return response.data as Record 10 | } 11 | 12 | async function getActivityDistributionLayers(aid: string) { 13 | const response = await axios({ 14 | url: `/v2/statistics/activities/${aid}/layers`, 15 | method: 'get' 16 | }) 17 | 18 | return response.data as Array<{ value: number; count: number }> 19 | } 20 | 21 | export { getActivityStats as description, getActivityDistributionLayers as layers } 22 | -------------------------------------------------------------------------------- /src/utils/generate.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Activity, 3 | ActivityMember, 4 | Registration, 5 | Special, 6 | ActivityInstance, 7 | } from '@/../types' 8 | import { ElMessage } from 'element-plus' 9 | export function generateActivity( 10 | base: Activity, 11 | members: ActivityMember[], 12 | approverStudent?: string, 13 | registration?: Registration, 14 | special?: Special, 15 | submitting: boolean = false 16 | ) { 17 | const activity = { 18 | ...base, 19 | members 20 | } as ActivityInstance 21 | if (!activity.approver || activity.approver === 'member') { 22 | activity.approver = approverStudent ?? 'member' 23 | } 24 | return activity 25 | } 26 | -------------------------------------------------------------------------------- /src/icons/status/index.ts: -------------------------------------------------------------------------------- 1 | import { Check, Close, Edit, CaretLeft } from '@element-plus/icons-vue' 2 | import { Loading } from '@icon-park/vue-next' 3 | import type { MemberActivityStatus } from '@/../types' 4 | import type { Component } from 'vue' 5 | 6 | export const memberActivityStatuses = { 7 | pending: { 8 | icon: Loading, 9 | color: 'primary' 10 | }, 11 | effective: { 12 | icon: Check, 13 | color: 'success' 14 | }, 15 | refused: { 16 | icon: Close, 17 | color: 'danger' 18 | } 19 | } as Record< 20 | MemberActivityStatus, 21 | { 22 | icon: Component 23 | color: 'primary' | 'success' | 'warning' | 'danger' | 'info' 24 | } 25 | > 26 | -------------------------------------------------------------------------------- /src/icons/StreamlineInterfaceUserEditActionsCloseEditGeometricHumanPencilPersonSingleUpUserWrite.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /src/icons/CarbonCloudOffline.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /types/group.v2.d.ts: -------------------------------------------------------------------------------- 1 | import { UserPosition } from './user.v2' 2 | 3 | export interface Group { 4 | _id: string // ObjectId 5 | name: string 6 | type: 'class' | 'permission' | 'group' | 'club' 7 | description: string 8 | /** 9 | * @description The ID of the person in charge of the group 10 | */ 11 | charge: string 12 | /** 13 | * @description The ID of the person who is responsible to contact with the group 14 | */ 15 | contact: string 16 | permissions: UserPosition[] 17 | categories: string[] 18 | } 19 | 20 | /** 21 | * @description Only used for statistical analysis 22 | */ 23 | export interface Category { 24 | _id: string 25 | type: 'grade' | 'class' | 'organization' 26 | name: string 27 | description: string 28 | } 29 | -------------------------------------------------------------------------------- /types/user.d.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | _id: string // ObjectId 3 | id: number 4 | name: string 5 | sex?: 'male' | 'female' | 'unknown' 6 | group: string[] 7 | past: string[] 8 | } 9 | 10 | export type UserPosition = 'system' | 'admin' | 'auditor' | 'department' | 'secretary' | 'student' 11 | 12 | export type WithPassword = { 13 | password: string 14 | } & T 15 | 16 | export interface UserLogin { 17 | id: number 18 | password: string 19 | } 20 | 21 | export interface ClassType { 22 | type: 'Z' | 'J' 23 | grade: number 24 | year: number 25 | class: number 26 | number: number 27 | } 28 | 29 | export interface UserActivityTimeSums { 30 | 'on-campus': number 31 | 'off-campus': number 32 | 'social-practice': number 33 | } 34 | -------------------------------------------------------------------------------- /src/views/NotFound.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/icons/positions/PhStudent.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/assets/base.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | min-height: 100vh; 7 | /* color: var(--color-text); */ 8 | /* background: var(--color-background); */ 9 | /* background-color: #fefefe; */ 10 | transition: 11 | color 0.5s, 12 | background-color 0.5s !important; 13 | line-height: 1.6; 14 | font-family: 15 | Teyvat, 16 | Inter, 17 | -apple-system, 18 | BlinkMacSystemFont, 19 | 'Segoe UI', 20 | Roboto, 21 | Oxygen, 22 | Ubuntu, 23 | Cantarell, 24 | 'Fira Sans', 25 | 'Droid Sans', 26 | 'Helvetica Neue', 27 | sans-serif; 28 | font-size: 15px; 29 | text-rendering: optimizeLegibility; 30 | -webkit-font-smoothing: antialiased; 31 | -moz-osx-font-smoothing: grayscale; 32 | } 33 | -------------------------------------------------------------------------------- /src/api/user/activity.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/plugins/axios' 2 | import type { ActivityInstance, Response } from '@/../types' 3 | import { ElNotification } from 'element-plus' 4 | import type { Activity } from '../../../types/activity.v2' 5 | 6 | async function getUserActivities( 7 | id: string, 8 | page: number = 1, 9 | perpage: number = 10, 10 | search: string = '', 11 | sort: string = '_id', 12 | ascending: boolean = false 13 | ) { 14 | return ( 15 | await axios(`/v2/users/${id}/activities`, { 16 | method: 'get', 17 | params: { 18 | page, 19 | perpage, 20 | search, 21 | sort, 22 | asc: ascending 23 | } 24 | }) 25 | ).data as { 26 | activities: Activity[] 27 | total: number 28 | } 29 | } 30 | 31 | export { getUserActivities as read } 32 | -------------------------------------------------------------------------------- /src/api/activity/modify.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/plugins/axios' 2 | import type { Response } from '@/../types' 3 | 4 | async function modifyActivityInfo(aid: string, name: string, description: string) { 5 | const result = ( 6 | await axios({ 7 | url: `/v2/activities/${aid}/info`, 8 | method: 'put', 9 | data: { 10 | description, 11 | name 12 | } 13 | }) 14 | ).data as Response 15 | if (result.status === 'error') { 16 | return result 17 | } 18 | return 19 | } 20 | 21 | async function modifyActivityStatus(aid: string, status: string) { 22 | await axios({ 23 | url: `/v2/activities/${aid}/status`, 24 | method: 'put', 25 | data: { 26 | status 27 | } 28 | }) 29 | } 30 | 31 | export { modifyActivityInfo as info, modifyActivityStatus as status } 32 | -------------------------------------------------------------------------------- /src/icons/MaterialSymbolsSettings.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module 'virtual:pwa-register/vue' { 4 | // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error 5 | // @ts-expect-error ignore when vue is not installed 6 | import type { Ref } from 'vue' 7 | import type { RegisterSWOptions } from 'vite-plugin-pwa/types' 8 | 9 | export type { RegisterSWOptions } 10 | 11 | export function useRegisterSW(options?: RegisterSWOptions): { 12 | needRefresh: Ref 13 | offlineReady: Ref 14 | updateServiceWorker: (reloadPage?: boolean) => Promise 15 | } 16 | } 17 | 18 | export type ClarityFunction = { 19 | (config: { [key: string]: unknown }): void 20 | q?: IArguments[] 21 | } 22 | 23 | declare global { 24 | interface window { 25 | clarity: ClarityFunction 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/api/group/index.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/plugins/axios.ts' 2 | 3 | export { getGroup as readOne, getGroups as read, updateMethods as update, deleteGroup as delete } from './crud' 4 | export * as reads from './reads' 5 | export async function template(id: string, name: string) { 6 | const result = await axios(`/groups/${id}/template?export_format=excel`, { 7 | method: 'get', 8 | responseType: 'blob' 9 | }) 10 | const mime = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' 11 | // Use `blob` style to download it 12 | const file = new Blob([result.data], { type: mime }) 13 | const url = URL.createObjectURL(file) 14 | const a = document.createElement('a') 15 | a.href = url 16 | const extension = 'xlsx' 17 | a.download = `${name}.${extension}` 18 | a.click() 19 | URL.revokeObjectURL(url) 20 | } 21 | -------------------------------------------------------------------------------- /src/icons/positions/MaterialSymbolsAdminPanelSettings.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/api/user/time.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/plugins/axios' 2 | import type { Response, UserActivityTimeSums } from '@/../types' 3 | import { ElNotification } from 'element-plus' 4 | import type { ActivityMember } from 'types/activity.v2' 5 | 6 | async function getUserTime(user: string) { 7 | return (await axios(`/v2/users/${user}/time`)).data as { 8 | 'on-campus': number 9 | 'off-campus': number 10 | 'social-practice': number 11 | } 12 | } 13 | 14 | export interface UserActivityTimePercentile { 15 | value: number 16 | group: number 17 | grade: number 18 | } 19 | 20 | async function getUserTimePercentile(user: string) { 21 | return (await axios(`/v2/users/${user}/time_statistics`)).data as Record< 22 | ActivityMember['mode'], 23 | UserActivityTimePercentile 24 | > 25 | } 26 | 27 | export { getUserTime as read, getUserTimePercentile as percentile } 28 | -------------------------------------------------------------------------------- /src/plugins/jwt.ts: -------------------------------------------------------------------------------- 1 | import type { UserPosition } from '@/../types' 2 | 3 | export function parseJwt(token: string): { 4 | header: { 5 | alg: string 6 | typ: string 7 | } 8 | payload: { 9 | iat: number 10 | exp: number 11 | sub: string 12 | scope: 'access_token' | 'temporary_token' 13 | per: UserPosition[] 14 | jti: string 15 | } 16 | } { 17 | const header = token.split('.')[0] 18 | const payload = token.split('.')[1] 19 | function base64UrlDecode(input: string) { 20 | return decodeURIComponent( 21 | atob(input.replace(/-/g, '+').replace(/_/g, '/')) 22 | .split('') 23 | .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) 24 | .join('') 25 | ) 26 | } 27 | return { 28 | header: JSON.parse(base64UrlDecode(header)), 29 | payload: JSON.parse(base64UrlDecode(payload)) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/icons/positions/index.ts: -------------------------------------------------------------------------------- 1 | import { default as StudentIcon } from './PhStudent.vue' 2 | import { default as SecretaryIcon } from './IcBaselineClass.vue' 3 | import { default as DepartmentIcon } from './MingcuteDepartmentFill.vue' 4 | import { default as AdminIcon } from './MaterialSymbolsAdminPanelSettings.vue' 5 | import type { UserPosition } from '@/../types' 6 | import type { Component } from 'vue' 7 | 8 | export const positions = { 9 | student: { 10 | icon: StudentIcon, 11 | color: 'primary' 12 | }, 13 | secretary: { 14 | icon: SecretaryIcon, 15 | color: 'info' 16 | }, 17 | department: { 18 | icon: DepartmentIcon, 19 | color: 'warning' 20 | }, 21 | admin: { 22 | icon: AdminIcon, 23 | color: 'danger' 24 | } 25 | } as Record< 26 | UserPosition, 27 | { 28 | icon: Component 29 | color: 'primary' | 'info' | 'warning' | 'success' | 'danger' 30 | } 31 | > 32 | -------------------------------------------------------------------------------- /src/api/activity/merge.ts: -------------------------------------------------------------------------------- 1 | import type { Activity } from '@/../types/v2' 2 | import { temporaryToken } from '@/plugins/short-token.ts' 3 | import axios from '@/plugins/axios' 4 | 5 | export default async function ( 6 | targets: Activity[], 7 | name: string, 8 | description: string, 9 | origin: Activity['origin'], 10 | mergeOptions: { 11 | duplicateUser: 'max' | 'sum' 12 | proceedPending: boolean 13 | }, 14 | uid: string 15 | ) { 16 | // 1. Read the token from the cache 17 | const token = await temporaryToken(uid) 18 | // 2. Read the activities 19 | // 3. Check the result 20 | return await axios('/v2/activities/amalgamation', { 21 | data: { 22 | activities: targets.map((x) => x._id), 23 | name, 24 | description, 25 | origin, 26 | ...mergeOptions 27 | }, 28 | method: 'post', 29 | headers: { 30 | Authorization: `Bearer ${token}` 31 | } 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /src/components/activity/getActivity.ts: -------------------------------------------------------------------------------- 1 | import api from '@/api' 2 | import type { Activity } from '@/../types/v2' 3 | 4 | export async function getActivity( 5 | user: string, 6 | mode: 'mine' | 'class' | 'campus', 7 | page: number = 1, 8 | perpage: number = 10, 9 | query: string = '', 10 | classid: string = '', 11 | type: string, 12 | sortField: string = '_id', 13 | ascending: boolean = false 14 | ): Promise< 15 | | { 16 | activities: Activity[] 17 | total: number 18 | } 19 | | undefined 20 | > { 21 | if (mode === 'mine') { 22 | return await api.activity.read.mine(user, page, perpage, query, sortField, ascending) 23 | } else if (mode === 'class') { 24 | return await api.activity.read.class(page, perpage, query, classid, sortField, ascending) 25 | } else if (mode === 'campus') { 26 | return await api.activity.read.campus(type, page, perpage, query, sortField, ascending) 27 | } 28 | // ... 29 | } 30 | -------------------------------------------------------------------------------- /src/components/tags/ZActivityTag.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 33 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import './assets/main.css' 2 | import './assets/styles/element/index.scss' 3 | 4 | import { createApp, type Plugin } from 'vue' 5 | import { createPinia } from 'pinia' 6 | 7 | import App from './App.vue' 8 | import router from './router' 9 | import i18n from './i18n' 10 | 11 | import ElementPlus from 'element-plus' 12 | // import CKEditor from '@ckeditor/ckeditor5-vue' 13 | 14 | import 'element-plus/theme-chalk/index.css' 15 | import 'element-plus/theme-chalk/dark/css-vars.css' 16 | import 'vant/lib/index.css' 17 | import 'animate.css/animate.min.css' 18 | import 'virtual:uno.css' 19 | 20 | import persistedstate from 'pinia-plugin-persistedstate' 21 | 22 | const app = createApp(App) 23 | 24 | app.use(createPinia().use(persistedstate)) 25 | app.use(router as unknown as Plugin) 26 | app.use(i18n as unknown as Plugin) 27 | // app.use(CKEditor as unknown as Plugin) 28 | 29 | app.use(ElementPlus, { 30 | size: 'default' 31 | }) 32 | 33 | app.mount('#app') 34 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ZVMS 4", 3 | "short_name": "ZVMS", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-256x256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/pwa-64x64.png", 17 | "sizes": "64x64", 18 | "type": "image/png" 19 | }, 20 | { 21 | "src": "/pwa-192x192.png", 22 | "sizes": "192x192", 23 | "type": "image/png" 24 | }, 25 | { 26 | "src": "/pwa-512x512.png", 27 | "sizes": "512x512", 28 | "type": "image/png" 29 | }, 30 | { 31 | "src": "maskable-icon-512x512.png", 32 | "sizes": "512x512", 33 | "type": "image/png", 34 | "purpose": "maskable" 35 | } 36 | ], 37 | "theme_color": "#ffffff", 38 | "background_color": "#ffffff", 39 | "display": "standalone" 40 | } 41 | -------------------------------------------------------------------------------- /src/api/logs.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from '../../types' 2 | import axios from '@/plugins/axios.ts' 3 | import { ElNotification } from 'element-plus' 4 | import type { Log } from '../../types/log' 5 | 6 | export async function read( 7 | page: number = 1, 8 | perpage: number = 10, 9 | query: string = '', 10 | user: string = '' 11 | ) { 12 | const url = user ? `/users/${user}/logs` : '/logs' 13 | const result = ( 14 | await axios(url, { 15 | params: { 16 | mode: 'campus', 17 | page, 18 | perpage, 19 | query 20 | } 21 | }) 22 | ).data as Response & { 23 | metadata: { 24 | size: number 25 | } 26 | } 27 | if (result.status === 'error') { 28 | ElNotification({ 29 | title: `Error fetching log list (${result.code})`, 30 | message: result.message, 31 | type: 'error' 32 | }) 33 | return 34 | } 35 | return { 36 | data: result.data, 37 | size: result.metadata.size 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ZVMS 4 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ZVMS 4 2 | 3 | [![Vercel](https://vercelbadge.vercel.app/api/zvms/zvms4-frontend)](https://vercel.com/quasi-studio/zvms4-frontend) [![Netlify Status](https://api.netlify.com/api/v1/badges/64e280fa-ea7f-4f6b-a89d-7696b483b178/deploy-status)](https://app.netlify.com/sites/zvms/deploys) 4 | 5 | > The frontend of ZHZX Volunteer Management System 6 | 7 | The v4 of ZVMS is brought to you with many new features, which gives you a brand new experience. 8 | 9 | ZVMS 4 搭载了许多先前版本所想要实现却没有实现的功能。它可以给予你全新的体验。 10 | 11 | It checks if you are using XueHai pad with UA (Samsung pad, Android Webkit), in order to extend the platform but make you use the pad properly in the school. For example, the auto route to maps and so on will not displayed in pads. 12 | 13 | 我们可以通过 User Agent 自动判断您是使用学海平板访问抑或者普通访问,以便让你合规使用学海平板。例如,外链将不会在学海平板中生效。 14 | 15 | The most feature of v4 is the data structure. We use `mongodb` instead of `sqlite`, which can make us manage data more convenient and effective. 16 | 17 | V4 最大的更新是对于数据结构。我们使用了 `mongodb`,这种非关系型数据库可以帮助你更好地管理义工数据。 18 | -------------------------------------------------------------------------------- /types/activity.v2.d.ts: -------------------------------------------------------------------------------- 1 | export interface Activity { 2 | _id: string 3 | type: 'on-campus' | 'off-campus' | 'social-practice' | 'hybrid' 4 | name: string 5 | description: string 6 | date: string 7 | createdAt: string 8 | updatedAt: string 9 | /** 10 | * @description The ID of the user who is responsible to the activity 11 | */ 12 | appointee: string 13 | /** 14 | * @description Either the ID of the user who approved the activity or explicitly stated as school "authority." 15 | */ 16 | approver: string | 'authority' | 'member' 17 | creator: string 18 | status: 'pending' | 'effective' | 'refused' 19 | place: string 20 | origin: 'prize' | 'labor' | 'club' | 'organization' | 'activities' | 'import' | 'other' 21 | } 22 | 23 | /** 24 | * @description From V2, the activity member will separate from the activity. 25 | */ 26 | export interface ActivityMember { 27 | _id: string 28 | member: string 29 | activity: string 30 | status: 'effective' | 'refused' | 'pending' 31 | mode: 'on-campus' | 'off-campus' | 'social-practice' 32 | duration: number 33 | } 34 | -------------------------------------------------------------------------------- /src/icons/MaterialSymbolsAccountCircleOutline.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/views/AboutView.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/icons/index.ts: -------------------------------------------------------------------------------- 1 | export { default as MaterialSymbolsAccountCircleOutline } from './MaterialSymbolsAccountCircleOutline.vue' 2 | export { default as MaterialSymbolsDescriptionOutline } from './MaterialSymbolsDescriptionOutline.vue' 3 | export { default as MaterialSymbolsLightHistoryRounded } from './MaterialSymbolsLightHistoryRounded.vue' 4 | export { default as MaterialSymbolsPasswordRounded } from './MaterialSymbolsPasswordRounded.vue' 5 | export { default as MaterialSymbolsSettings } from './MaterialSymbolsSettings.vue' 6 | export { default as MdiEye } from './MdiEye.vue' 7 | export { default as MdiTranslate } from './MdiTranslate.vue' 8 | export { default as StreamlineInterfaceUserEditActionsCloseEditGeometricHumanPencilPersonSingleUpUserWrite } from './StreamlineInterfaceUserEditActionsCloseEditGeometricHumanPencilPersonSingleUpUserWrite.vue' 9 | export { default as TablerSum } from './TablerSum.vue' 10 | export { default as IcBaselineClass } from './positions/IcBaselineClass.vue' 11 | export { default as CarbonCloudOffline } from './CarbonCloudOffline.vue' 12 | export { default as LucideWorkflow } from './LucideWorkflow.vue' 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 zvms 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/icons/positions/MingcuteDepartmentFill.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /src/icons/MaterialSymbolsLightHistoryRounded.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/plugins/ua.ts: -------------------------------------------------------------------------------- 1 | import { UAParser } from 'ua-parser-js' 2 | 3 | const parser = new UAParser(navigator.userAgent) 4 | 5 | const result = parser.getResult() 6 | 7 | export const getBrowser = () => result.browser 8 | 9 | export const getDevice = () => result.device 10 | 11 | export const getEngine = () => result.engine 12 | 13 | export const getOS = () => result.os 14 | 15 | export const getUA = () => result.ua 16 | 17 | export const getCPU = () => result.cpu 18 | 19 | export function getXuehaiId(): number { 20 | return window.xhBrowserJava && window.xhBrowserJava?.getUserId() || 0 21 | } 22 | 23 | export const pad = () => { 24 | if (result.device.vendor === 'Samsung' && result.os.name === 'Android' || getXuehaiId()) { 25 | return true 26 | } else { 27 | return false 28 | } 29 | } 30 | 31 | export function getTabletType(): 'p615' | 'p620' | 'p200' | 'p355' { 32 | const model = result.device.model 33 | if (!pad()) return 'p620' 34 | switch (model) { 35 | case 'SM-P620': 36 | return 'p620' 37 | case 'SM-P615C': 38 | return 'p615' 39 | case 'SM-P200': 40 | return 'p200' 41 | case 'SM-P355C': 42 | return 'p355' 43 | default: 44 | return 'p620' 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/views/activity/CreatePage.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 40 | -------------------------------------------------------------------------------- /src/components/tags/ZTime.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 46 | -------------------------------------------------------------------------------- /src/api/export.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from '@/../types' 2 | import { ElNotification } from 'element-plus' 3 | import type { UserActivityTimeSums } from 'types/user.v2' 4 | import axios from '@/plugins/axios.ts' 5 | 6 | export * as activity from './activity' 7 | export * as user from './user' 8 | export * as group from './group' 9 | export * as exports from './exports' 10 | export * as logs from './logs' 11 | export const time = { 12 | async reads( 13 | search: string = '', 14 | page: number = 1, 15 | perpage: number = 5, 16 | sort: string = 'id', 17 | asc: boolean = true 18 | ) { 19 | const result = ( 20 | await axios(`/v2/times`, { 21 | params: { 22 | search, 23 | page, 24 | perpage, 25 | sort, 26 | asc 27 | } 28 | }) 29 | ).data as Response<{ 30 | _id: string 31 | name: string 32 | id: string 33 | 'on-campus': number 34 | 'off-campus': number 35 | 'social-practice': number 36 | }> & { 37 | metadata: { 38 | size: number 39 | } 40 | } 41 | if (result.status === 'error') { 42 | ElNotification({ 43 | title: 'Error when fetching data (' + result.code + ')', 44 | message: result.message, 45 | type: 'error' 46 | }) 47 | return 48 | } 49 | return { 50 | time: result.data, 51 | size: result.metadata.size 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | This program is used to count student volunteer hours and activities at Zhenhai Middle School. 4 | 5 | This project is officially adapted to the actual situation of Zhenhai Middle School by the development team, if you are interested in the project, you are welcome to develop the project. However, we are not responsible for secondary development. 6 | 7 | This version is ZVMS 4. This means that ZVMS 1-3 are not supported after the release (EOF). 8 | 9 | ## Safety in Campus Use 10 | 11 | Due to school regulations, we do not place some features on the Xuehai Pad accessible to volunteers, but other access is not restricted. Our method of determining this is through UA. When your `device` is `Samsung`, it will automatically be determined to be a Xuehai Pad. We will continue to optimize this method as we go along. 12 | 13 | In the meantime, we have integrated the program into Clarity. This means that your activity on the platform will be recorded, so please do not display private information directly. 14 | 15 | ## Application Development Security 16 | 17 | We strive to maximize security during development. We have Dependabot enabled to validate the security of our dependencies, and we use CodeQL to analyze our code for potential vulnerabilities. At the same time, we use emerging technology stacks, such as using `bcrypt` instead of `sha`, `RSA` for transfers, and so on. 18 | 19 | If you find some security vulnerabilities, please raise an issue. 20 | -------------------------------------------------------------------------------- /src/components/tags/ZUserGroup.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 53 | -------------------------------------------------------------------------------- /src/plugins/short-token.ts: -------------------------------------------------------------------------------- 1 | import { ElMessageBox } from 'element-plus' 2 | import api from '@/api' 3 | import i18n from '@/i18n' 4 | 5 | export function temporaryToken(userid: string): Promise { 6 | const locale = i18n.global.locale.value 7 | const locales = { 8 | 'zh-CN': { 9 | title: '请输入密码', 10 | message: '请确认您的密码。', 11 | confirm: '确认', 12 | cancel: '取消', 13 | validation: '' 14 | }, 15 | 'en-US': { 16 | title: '', 17 | message: '', 18 | confirm: '', 19 | cancel: '', 20 | validation: '' 21 | } 22 | } 23 | return new Promise((resolve, reject) => { 24 | ElMessageBox.prompt(locales[locale].message, locales[locale].title, { 25 | confirmButtonText: locales[locale].confirm, 26 | cancelButtonText: locales[locale].cancel, 27 | inputType: 'password', 28 | type: 'warning', 29 | showClose: false, 30 | closeOnClickModal: false, 31 | closeOnPressEscape: false, 32 | }) 33 | .then(({ value }) => { 34 | api.user.auth 35 | .useLongTermAuth(userid, value, 'short') 36 | .then((result) => { 37 | if (result) { 38 | resolve(result.token) 39 | } else { 40 | reject('Login failed') 41 | } 42 | }) 43 | .catch((err) => { 44 | reject(err) 45 | }) 46 | }) 47 | .catch((err) => { 48 | reject(err) 49 | }) 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /src/components/tags/ZUserPosition.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 52 | -------------------------------------------------------------------------------- /src/components/log/ZLogList.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /src/icons/MaterialSymbolsPasswordRounded.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/i18n/locales/validation.ts: -------------------------------------------------------------------------------- 1 | export const enUS = { 2 | create: { 3 | name: { 4 | required: 'Name is required.' 5 | }, 6 | date: { 7 | required: 'Date is required.', 8 | format: 'Date format is invalid.' 9 | }, 10 | classify: { 11 | required: 'Origin of activity is required.' 12 | }, 13 | upload: { 14 | required: 'Upload is required.' 15 | }, 16 | mode: { 17 | required: 'Mode is required.' 18 | }, 19 | type: { 20 | required: 'Type is required.' 21 | }, 22 | member: { 23 | person: { 24 | required: 'Person is required.' 25 | }, 26 | mode: { 27 | required: 'Mode is required.' 28 | }, 29 | duration: { 30 | required: 'Duration is required.', 31 | invalid: 'Duration is invalid. It should be a number and greater than 0 and less than 18.' 32 | } 33 | } 34 | } 35 | } 36 | 37 | export const zhCN = { 38 | create: { 39 | name: { 40 | required: '名称不能为空。' 41 | }, 42 | date: { 43 | required: '日期不能为空。', 44 | format: '日期格式不正确。' 45 | }, 46 | mode: { 47 | required: '义工模式不能为空。' 48 | }, 49 | type: { 50 | required: '义工类型不能为空。' 51 | }, 52 | classify: { 53 | required: '义工来源不能为空。' 54 | }, 55 | upload: { 56 | required: '上传不能为空。' 57 | }, 58 | member: { 59 | person: { 60 | required: '人员不能为空。' 61 | }, 62 | mode: { 63 | required: '模式不能为空。' 64 | }, 65 | duration: { 66 | required: '时长不能为空。', 67 | invalid: '时长不正确。' 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/components/activity/index.ts: -------------------------------------------------------------------------------- 1 | import type { UserPosition, CreateActivityType } from '@/../types' 2 | import { pad } from '@/plugins/ua.ts' 3 | 4 | export { default as ZActivityCreate } from './ZActivityCreate.vue' 5 | export { default as ZActivityDetails } from './ZActivityDetails.vue' 6 | export { default as ZActivityList } from './ZActivityList.vue' 7 | export { default as ZActivityMember } from './ZActivityMember.vue' 8 | export { default as ZActivityMemberTimeJudge } from './ZTimeJudge.vue' 9 | export { default as ZActivityCard } from './ZActivityCard.vue' 10 | export { default as ZActivityMemberList } from './ZActivityMemberList.vue' 11 | export { default as ZActivityPage } from './ZActivityPage.vue' 12 | export { default as ZUserTimeJudge } from './ZUserTimeJudge.vue' 13 | export { default as ZActivityMerge } from './ZActivityMerge.vue' 14 | export { default as ZUploadFile } from './ZUploadFile.vue' 15 | export function permissions(positions: UserPosition[]) { 16 | function upperStudent(positions: UserPosition[]) { 17 | const result = 18 | positions.includes('admin') || 19 | positions.includes('department') 20 | ? true 21 | : false 22 | return result 23 | } 24 | function specialManagement(positions: UserPosition[]) { 25 | const result = positions.includes('admin') || positions.includes('department') ? true : false 26 | return result 27 | } 28 | const insert = { 29 | normal: upperStudent(positions), 30 | special: upperStudent(positions), 31 | merge: specialManagement(positions), 32 | upload: positions.includes('admin') && !pad() 33 | } as Record 34 | return insert 35 | } 36 | -------------------------------------------------------------------------------- /src/api/group/reads.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/plugins/axios' 2 | import type { Response, User } from '@/../types' 3 | 4 | export async function users( 5 | gid: string, 6 | page: number = 1, 7 | perpage: number = 10, 8 | search: string = '', 9 | pwdm: boolean = false 10 | ) { 11 | const response = ( 12 | await axios(`/v2/groups/${gid}/users`, { 13 | params: { 14 | page, 15 | perpage, 16 | search, 17 | pwdm 18 | } 19 | }) 20 | ).data as { 21 | members: User[] 22 | total: number 23 | } 24 | return { 25 | users: response.members, 26 | size: response.total 27 | } 28 | } 29 | 30 | export async function time( 31 | gid: string, 32 | page: number = 1, 33 | perpage: number = 10, 34 | search: string = '', 35 | exceed: boolean = true, 36 | shortage: boolean = false, 37 | sort: string = 'id', 38 | asc: boolean = true 39 | ) { 40 | const response = ( 41 | await axios(`/v2/groups/${gid}/time`, { 42 | params: { 43 | page, 44 | perpage, 45 | exceed, 46 | shortage, 47 | search, 48 | sort, 49 | asc 50 | } 51 | }) 52 | ).data as { 53 | data: { 54 | _id: string 55 | name: string 56 | id: string 57 | 'on-campus': number 58 | 'off-campus': number 59 | 'social-practice': number 60 | }[] 61 | metadata: { size: number } 62 | } 63 | return { 64 | time: response.data, 65 | size: response.metadata.size 66 | } 67 | } 68 | 69 | export async function statisticsCompliance(groupId: string) { 70 | return (await axios(`/v2/groups/${groupId}/statistics/compliance`)).data 71 | } 72 | -------------------------------------------------------------------------------- /types/response.d.ts: -------------------------------------------------------------------------------- 1 | export interface SuccessResponse { 2 | status: 'ok' 3 | data: T 4 | code: number 5 | } 6 | 7 | export interface ErrorResponse { 8 | status: 'error' 9 | message: string 10 | code: number 11 | } 12 | 13 | export interface BadRequestResponse extends ErrorResponse { 14 | code: 400 15 | } 16 | 17 | export interface UnauthorizedResponse extends ErrorResponse { 18 | code: 401 19 | } 20 | 21 | export interface ForbiddenResponse extends ErrorResponse { 22 | code: 403 23 | permission: number 24 | } 25 | 26 | export interface NotFoundResponse extends ErrorResponse { 27 | code: 404 28 | } 29 | 30 | export interface ConflictResponse extends ErrorResponse { 31 | code: 409 32 | } 33 | 34 | export interface InternalErrorResponse extends ErrorResponse { 35 | code: 500 36 | } 37 | 38 | export interface NotImplementedResponse extends ErrorResponse { 39 | code: 501 40 | } 41 | 42 | export interface BadGatewayResponse extends ErrorResponse { 43 | code: 502 44 | } 45 | 46 | export interface ServiceUnavailableResponse extends ErrorResponse { 47 | code: 503 48 | } 49 | 50 | export interface GatewayTimeoutResponse extends ErrorResponse { 51 | code: 504 52 | } 53 | 54 | export interface UnknownResponse extends ErrorResponse { 55 | code: 0 56 | } 57 | 58 | export type ErrorResponseInstance = 59 | | BadRequestResponse 60 | | UnauthorizedResponse 61 | | ForbiddenResponse 62 | | NotFoundResponse 63 | | ConflictResponse 64 | | InternalErrorResponse 65 | | NotImplementedResponse 66 | | BadGatewayResponse 67 | | ServiceUnavailableResponse 68 | | GatewayTimeoutResponse 69 | | UnknownResponse 70 | 71 | export type Response = SuccessResponse | ErrorResponseInstance 72 | -------------------------------------------------------------------------------- /src/api/activity/member.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/plugins/axios' 2 | import { temporaryToken } from '@/plugins/short-token' 3 | import type { Response } from '@/../types' 4 | import type { ActivityMember } from '@/../types/v2' 5 | 6 | export async function insert(aid: string, member: ActivityMember) { 7 | const result = ( 8 | await axios(`/v2/activities/${aid}/members`, { 9 | method: 'post', 10 | data: member 11 | }) 12 | ).data as Response 13 | if (result.status === 'ok') { 14 | return result.data 15 | } 16 | } 17 | 18 | export async function insertMany(aid: string, members: ActivityMember[]) { 19 | const result = ( 20 | await axios(`/v3/activities/${aid}/members`, { 21 | method: 'post', 22 | data: members 23 | }) 24 | ).data as Response 25 | if (result.status === 'ok') { 26 | return result.data 27 | } 28 | } 29 | 30 | export async function remove(id: string, aid: string, uid: string) { 31 | const token = await temporaryToken(uid) 32 | const result = ( 33 | await axios(`/v2/activities/${aid}/members/${id}`, { 34 | method: 'delete', 35 | headers: { 36 | Authorization: `Bearer ${token}` 37 | } 38 | }) 39 | ).data as Response 40 | if (result.status === 'ok') { 41 | return result.data 42 | } 43 | } 44 | 45 | export async function read(aid: string, sid: string) { 46 | return (await axios(`/v2/activities/${aid}/members/${sid}`)).data as ActivityMember 47 | } 48 | 49 | export async function reads(aid: string, page: number, perpage: number, search: string) { 50 | const result = ( 51 | await axios(`/v2/activities/${aid}/members`, { 52 | params: { 53 | page, 54 | perpage, 55 | search 56 | } 57 | }) 58 | ).data as { 59 | members: ActivityMember[] 60 | total: number 61 | } 62 | return result 63 | } 64 | -------------------------------------------------------------------------------- /src/components/form/ZInputDuration.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 66 | -------------------------------------------------------------------------------- /src/components/utils/ZButtonTag.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 69 | -------------------------------------------------------------------------------- /src/i18n/locales/home.ts: -------------------------------------------------------------------------------- 1 | export const enUS = { 2 | greeting: '{greet}, {name}!', 3 | greetings: { 4 | morning: 'Morning', 5 | afternoon: 'Afternoon', 6 | evening: 'Evening' 7 | }, 8 | positions: { 9 | student: 'Student', 10 | secretary: 'Secretary', 11 | department: 'Department', 12 | admin: 'Admin' 13 | }, 14 | labels: { 15 | name: 'Name', 16 | number: 'Student ID', 17 | class: 'Class', 18 | identify: 'User Group', 19 | past: 'Used Identities', 20 | nonPast: 'None' 21 | }, 22 | panels: { 23 | information: { 24 | title: 'Personal Information' 25 | }, 26 | time: { 27 | title: 'Activity Time', 28 | discount: 'Show Discount', 29 | discount_desc: 30 | 'According to certain rules, on-campus activity credits exceeding the baseline can convert to off-campus credits in a rate of 1/3, whereas off-campus credits exceeding the baseline can convert to on-campus credits in a rate of 1/2. Each type of credit, also called "discount," should be no more than 6 hours every mode.', 31 | unit: 'Hour | Hours', 32 | least: '{type}: {least} Hours at least' 33 | } 34 | } 35 | } 36 | 37 | export const zhCN = { 38 | greeting: '{greet},{name}!', 39 | greetings: { 40 | morning: '早上好', 41 | afternoon: '下午好', 42 | evening: '晚上好' 43 | }, 44 | positions: { 45 | student: '学生', 46 | secretary: '团支书', 47 | department: '学生会', 48 | admin: '管理员' 49 | }, 50 | labels: { 51 | name: '姓名', 52 | number: '学号', 53 | class: '班级', 54 | identify: '身份', 55 | past: '过往身份', 56 | nonPast: '无' 57 | }, 58 | panels: { 59 | information: { 60 | title: '个人信息' 61 | }, 62 | time: { 63 | title: '义工时间', 64 | discount: '显示折算', 65 | discount_desc: 66 | '根据相关规定,校内活动时间达标的,可以按 1/3 的比例将超出部分折算为校外时长;而校外义工时间达标的,可以按 1/2 的比例将超出部分折算为校内时长。每种折算在每个模式下不得超过6小时。', 67 | unit: '小时', 68 | least: '{type}: 至少 {least} 小时' 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 15 | 17 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/views/activity/CreateHome.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 62 | -------------------------------------------------------------------------------- /src/components/form/ZSelectActivityMode.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 59 | -------------------------------------------------------------------------------- /src/api/user/crypto.ts: -------------------------------------------------------------------------------- 1 | // Function to convert PEM formatted public key to a CryptoKey object 2 | async function importPublicKey(pemKey: string) { 3 | const withoutNewlines = pemKey 4 | .replace('-----BEGIN PUBLIC KEY-----', '') 5 | .replace('-----END PUBLIC KEY-----', '') 6 | .split('\n') 7 | .filter((line) => line.trim() !== '') 8 | .join('') 9 | // Base64 decode the string to get the binary data 10 | const binaryDerString = window.atob(withoutNewlines) 11 | // Convert from a binary string to an ArrayBuffer 12 | const binaryDer = str2ab(binaryDerString) 13 | 14 | return window.crypto.subtle.importKey( 15 | 'spki', 16 | binaryDer, 17 | { 18 | name: 'RSA-OAEP', 19 | hash: 'SHA-256' // Specify the hash algorithm 20 | }, 21 | true, 22 | ['encrypt'] 23 | ) 24 | } 25 | 26 | // Utility function to convert a binary string to an ArrayBuffer 27 | function str2ab(str: string) { 28 | const buf = new ArrayBuffer(str.length) 29 | const bufView = new Uint8Array(buf) 30 | for (let i = 0, strLen = str.length; i < strLen; i++) { 31 | bufView[i] = str.charCodeAt(i) 32 | } 33 | return buf 34 | } 35 | 36 | // Function to encrypt data using RSA-OAEP 37 | async function encryptData(publicKey: CryptoKey, data: string) { 38 | const encoder = new TextEncoder() 39 | const encodedData = encoder.encode(data) 40 | 41 | const encryptedData = await window.crypto.subtle.encrypt( 42 | { 43 | name: 'RSA-OAEP' 44 | }, 45 | publicKey, 46 | encodedData 47 | ) 48 | 49 | return encryptedData 50 | } 51 | 52 | export { importPublicKey, encryptData } 53 | 54 | // // Example usage 55 | // (async () => { 56 | // const publicKeyPem = 'YOUR PUBLIC KEY PEM HERE'; 57 | // const dataToEncrypt = "Hello, World!"; 58 | 59 | // try { 60 | // const publicKey = await importPublicKey(publicKeyPem); 61 | // const encryptedData = await encryptData(publicKey, dataToEncrypt); 62 | // console.log("Encrypted Data:", new Uint8Array(encryptedData)); 63 | // } catch (e) { 64 | // console.error("Encryption error", e); 65 | // } 66 | // })(); 67 | -------------------------------------------------------------------------------- /src/api/activity/read.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/plugins/axios' 2 | import type { Activity } from '@/../types/v2' 3 | import { read as mine } from '@/api/user/activity' 4 | 5 | async function getClassActivities( 6 | page: number = 1, 7 | perpage: number = 10, 8 | search: string = '', 9 | classid: string = '', 10 | sort: string = '_id', 11 | asc: boolean = false 12 | ) { 13 | return ( 14 | await axios(`/v2/groups/${classid}/activities`, { 15 | params: { 16 | page, 17 | perpage, 18 | search, 19 | classid, 20 | sort, 21 | asc 22 | } 23 | }) 24 | ).data as { 25 | activities: Activity[] 26 | total: number 27 | } 28 | } 29 | 30 | async function getAllActivities( 31 | filter: { 32 | type: string 33 | }, 34 | page: number = 1, 35 | perpage: number = 10, 36 | search: string = '', 37 | sortField: string = '_id', 38 | ascending: boolean = false 39 | ) { 40 | return ( 41 | await axios('/v2/activities', { 42 | params: { 43 | mode: 'campus', 44 | activity_type: filter.type, 45 | page, 46 | perpage, 47 | search, 48 | sort: sortField, 49 | asc: ascending 50 | } 51 | }) 52 | ).data as { 53 | activities: Activity[] 54 | total: number 55 | } 56 | } 57 | 58 | async function getActivity(id: string) { 59 | // id: ObjectId 60 | return (await axios(`/v2/activities/${id}`)).data as { 61 | activity: Activity 62 | members_count: number 63 | } 64 | } 65 | 66 | const exports = { 67 | campus: ( 68 | filter: string, 69 | page: number = 1, 70 | perpage: number = 10, 71 | query: string = '', 72 | sortField: string = '_id', 73 | ascending: boolean = false 74 | ) => getAllActivities({ type: filter }, page, perpage, query, sortField, ascending), 75 | class: ( 76 | page: number = 1, 77 | perpage: number = 10, 78 | query: string = '', 79 | classid: string = '', 80 | sortField: string = '_id', 81 | ascending: boolean = false 82 | ) => getClassActivities(page, perpage, query, classid, sortField, ascending), 83 | mine, 84 | single: (id: string) => getActivity(id) 85 | } 86 | 87 | export { exports as read } 88 | -------------------------------------------------------------------------------- /src/api/exports.ts: -------------------------------------------------------------------------------- 1 | import type { Dayjs } from 'dayjs' 2 | import { axios } from '@/plugins' 3 | 4 | async function createExportTask( 5 | type: 'time' | 'users' | 'activities', 6 | format: 'csv' | 'json' | 'excel' | 'html' | 'latex', 7 | start?: Dayjs, 8 | end?: Dayjs, 9 | allowCache: boolean = false, 10 | includeDescription: boolean = false // Default to false, can be changed in the UI 11 | ) { 12 | const body = { 13 | format, 14 | start: start?.toISOString(), 15 | end: end?.toISOString(), 16 | allow_cache: allowCache, 17 | include_description: includeDescription 18 | } 19 | const result = await axios(`/exports/${type}`, { 20 | data: body, 21 | method: 'post' 22 | }) 23 | if (result.data) { 24 | return result.data.data 25 | } 26 | } 27 | 28 | async function queryTaskStatus(id: string) { 29 | const result = await axios(`/exports/${id}`, { 30 | method: 'get' 31 | }) 32 | return result.data.data as { 33 | status: 'pending' | 'processing' | 'completed' | 'failed' 34 | percentage: number 35 | } 36 | } 37 | 38 | async function downloadTaskFile( 39 | id: string, 40 | name: string, 41 | format: 'csv' | 'excel' | 'json' | 'html' | 'latex', 42 | language: string = 'en-US' // Default to English, can be changed in the UI 43 | ) { 44 | const result = await axios(`/exports/${id}/file`, { 45 | method: 'get', 46 | params: { language }, 47 | responseType: 'blob' 48 | }) 49 | const mime = ( 50 | { 51 | csv: 'text/csv', 52 | excel: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 53 | json: 'application/json', 54 | html: 'text/html', 55 | latex: 'application/octet-stream' 56 | } as { [key: string]: string } 57 | )[format] 58 | // Use `blob` style to download it 59 | const file = new Blob([result.data], { type: mime }) 60 | const url = URL.createObjectURL(file) 61 | const a = document.createElement('a') 62 | a.href = url 63 | const extension = format === 'excel' ? 'xlsx' : format === 'latex' ? 'tex' : format 64 | a.download = `${name}.${extension}` 65 | a.click() 66 | URL.revokeObjectURL(url) 67 | } 68 | 69 | export { createExportTask as create, queryTaskStatus as query, downloadTaskFile as download } 70 | -------------------------------------------------------------------------------- /src/components/tags/ZActivityType.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 84 | -------------------------------------------------------------------------------- /src/components/form/ZSelectClass.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 85 | -------------------------------------------------------------------------------- /src/components/log/ZLogCard.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /src/components/tags/classifications.ts: -------------------------------------------------------------------------------- 1 | import type { Component } from 'vue' 2 | import type { 3 | ActivityType, 4 | SpecialActivityClassification, 5 | ActivityMode, 6 | ActivityStatus, 7 | MemberActivityStatus, 8 | CreateActivityType 9 | } from '@/../types' 10 | import type { Activity } from '@/../types/v2' 11 | import { Vacation, School, CityGate, Star, Data, Other, Merge } from '@icon-park/vue-next' 12 | import { memberActivityStatuses } from '@/icons/status' 13 | import { Trophy, Upload, User } from '@element-plus/icons-vue' 14 | import { LucideWorkflow } from '@/icons' 15 | 16 | interface IconAndColor { 17 | icon: Component 18 | color: 'primary' | 'success' | 'warning' | 'danger' | 'info' 19 | } 20 | 21 | type set = 'mode' | 'status' | 'type' | 'specials' | 'member' | 'create' 22 | type typing = 23 | | ActivityMode 24 | | ActivityStatus 25 | | ActivityType 26 | | SpecialActivityClassification 27 | | MemberActivityStatus 28 | 29 | export default { 30 | mode: { 31 | 'on-campus': { 32 | icon: School, 33 | color: 'primary' 34 | }, 35 | 'off-campus': { 36 | icon: CityGate, 37 | color: 'success' 38 | }, 39 | 'social-practice': { 40 | icon: Vacation, 41 | color: 'warning' 42 | }, 43 | hybrid: { 44 | icon: Star, 45 | color: 'danger' 46 | } 47 | } as Record, 48 | status: { 49 | effective: memberActivityStatuses.effective 50 | } as Record, 51 | type: { 52 | specified: { 53 | color: 'primary', 54 | icon: School 55 | }, 56 | social: { 57 | color: 'success', 58 | icon: CityGate 59 | }, 60 | scale: { 61 | color: 'warning', 62 | icon: Vacation 63 | }, 64 | special: { 65 | color: 'danger', 66 | icon: Star 67 | } 68 | } as Record, 69 | create: { 70 | normal: { 71 | color: 'primary', 72 | icon: LucideWorkflow 73 | }, 74 | merge: { 75 | color: 'success', 76 | icon: Merge 77 | }, 78 | upload: { 79 | color: 'warning', 80 | icon: Upload 81 | }, 82 | } as Record, 83 | member: memberActivityStatuses as Record 84 | } as Record> 85 | 86 | export { type typing as TypeSet, type set as Set } 87 | -------------------------------------------------------------------------------- /src/api/group/crud.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/plugins/axios' 2 | import { temporaryToken } from '@/plugins/short-token' 3 | import type { Group, Response, UserPosition } from '@/../types' 4 | import { ElNotification } from 'element-plus' 5 | 6 | export async function createGroup(group: Group) { 7 | const response = await axios({ 8 | method: 'post', 9 | url: '/groups', 10 | data: group 11 | }) 12 | const data = response.data as Response<{ _id: string }> 13 | if (data.status === 'ok') { 14 | ElNotification({ 15 | title: 'Success', 16 | message: 'Group created successfully: ' + data.data._id, 17 | type: 'success' 18 | }) 19 | return data.data._id 20 | } 21 | return false 22 | } 23 | 24 | export async function getGroup(gid: string) { 25 | const response = await axios('/groups/' + gid) 26 | const data = response.data as Response 27 | if (data.status === 'ok') { 28 | return data.data 29 | } 30 | } 31 | 32 | export async function getGroups( 33 | type: 'all' | 'permission' | 'class' = 'all', 34 | page = 1, 35 | limit = 10, 36 | search = '' 37 | ) { 38 | const response = await axios('/groups', { 39 | params: { type, page, perpage: limit, search } 40 | }) 41 | const data = response.data as Response & { metadata: { size: number } } 42 | if (data.status === 'ok') { 43 | return { 44 | groups: data.data, 45 | size: data.metadata.size 46 | } 47 | } 48 | } 49 | 50 | export async function editGroupName(gid: string, name: string) { 51 | const response = await axios({ 52 | method: 'put', 53 | url: `/groups/${gid}/name`, 54 | data: { name } 55 | }) 56 | const data = response.data as Response 57 | return data.status === 'ok' 58 | } 59 | 60 | export async function editGroupDescription(gid: string, description: string) { 61 | const response = await axios({ 62 | method: 'put', 63 | url: `/groups/${gid}/description`, 64 | data: { description } 65 | }) 66 | const data = response.data as Response 67 | return data.status === 'ok' 68 | } 69 | 70 | export async function deleteGroup(uid: string, gid: string) { 71 | const token = await temporaryToken(uid) 72 | const response = await axios({ 73 | method: 'delete', 74 | url: `/groups/${gid}`, 75 | headers: { 76 | Authorization: `Bearer ${token}` 77 | } 78 | }) 79 | const data = response.data as Response 80 | return data.status === 'ok' 81 | } 82 | 83 | export const updateMethods = { 84 | name: editGroupName, 85 | description: editGroupDescription, 86 | } 87 | -------------------------------------------------------------------------------- /types/activity.d.ts: -------------------------------------------------------------------------------- 1 | export interface Activity { 2 | _id: string 3 | type: 'specified' | 'special' | 'social' | 'scale' 4 | name: string 5 | description: string 6 | members: ActivityMember[] 7 | date: string // ISO-8601 8 | createdAt: string // ISO-8601 9 | updatedAt: string // ISO-8601 10 | creator: string // ObjectId 11 | status: 'pending' | 'effective' | 'refused' 12 | special?: SpecialInstance 13 | registration?: Registration 14 | approver: 'authority' | 'member' | 'unknown' | string 15 | } 16 | 17 | export interface Registration { 18 | place: string 19 | } 20 | 21 | export interface ActivityMember { 22 | _id: string // ObjectId 23 | status: 'effective' 24 | mode: 'on-campus' | 'off-campus' | 'social-practice' 25 | duration: number 26 | } 27 | 28 | export type ActivityType = Activity['type'] 29 | 30 | export type CreateActivityType = 'normal' | 'special' | 'merge' | 'upload' 31 | 32 | export type MemberActivityStatus = ActivityMember['status'] 33 | 34 | export type ActivityStatus = Activity['status'] 35 | 36 | export type ActivityMode = 'on-campus' | 'off-campus' | 'social-practice' | 'hybrid' 37 | 38 | export interface SpecifiedActivity extends Activity { 39 | type: 'specified' 40 | registration: Registration 41 | } 42 | 43 | export interface SocialActivity extends Activity { 44 | type: 'social' 45 | } 46 | 47 | export interface ScaleActivity extends Activity { 48 | type: 'scale' 49 | //url: string // FTP Social Practice Report Location. 50 | } 51 | 52 | export interface Special { 53 | classify: 'prize' | 'import' | 'club' | 'deduction' | 'other' 54 | //prize?: string // ObjectId 55 | //origin?: string // Path to the file. 56 | //reason?: string 57 | } 58 | 59 | export type SpecialActivityClassification = Special['classify'] 60 | 61 | export interface PrizeSpecial extends Special { 62 | classify: 'prize' 63 | //prize: string // ObjectId 64 | } 65 | 66 | export interface ImportSpecial extends Special { 67 | classify: 'import' 68 | //origin: string // Path to the file. 69 | } 70 | 71 | export interface ClubSpecial extends Special { 72 | classify: 'club' 73 | } 74 | 75 | //export interface DeductionSpecial extends Special { 76 | // classify: 'deduction' 77 | // reason: string 78 | //} 79 | 80 | export type SpecialInstance = 81 | | PrizeSpecial 82 | | ImportSpecial 83 | | ClubSpecial 84 | // | DeductionSpecial 85 | | Special 86 | 87 | export interface SpecialActivity extends Activity { 88 | type: 'special' 89 | special: SpecialInstance 90 | } 91 | 92 | export type ActivityInstance = SpecifiedActivity | SocialActivity | ScaleActivity | SpecialActivity 93 | -------------------------------------------------------------------------------- /src/i18n/locales/about.ts: -------------------------------------------------------------------------------- 1 | export const enUS = { 2 | about: { 3 | project: 'About ZVMS', 4 | developers: 'About Developers' 5 | }, 6 | footer: 7 | 'ZVMS Developer Team (from ZZDev) & Practice Department of the Student Union of Zhenhai High School', 8 | license: 'MIT Licensed', 9 | repository: { 10 | license: 'License', 11 | version: { 12 | '0': 'Version', 13 | '1': 'is developed by', 14 | '2': '. Thanks!' 15 | }, 16 | thank: { 17 | mean: 'Meanwhile, thanks to', 18 | icon: 'for providing the off-campus server, and', 19 | test: 'for the brand new icon, also', 20 | end: 'for his crazy robustness testing.' 21 | }, 22 | v4More: { 23 | '0': 'After the release of V4,', 24 | '1': 'also participated (or participating) maintenance.' 25 | } 26 | }, 27 | privacy: { 28 | title: 'Privacy Policy', 29 | content: 30 | 'This site partners with Microsoft Clarity and Microsoft Advertising to capture how you use and interact with this site through behavioral metrics, heatmaps, and session replays to improve your browsing experience on this site. We use first and third-party cookies and other tracking technologies to obtain website usage data to determine the popularity of this site and online activities. Additionally, we use this information for political and educational management and review, website optimization, and security purposes. For more information on how Microsoft collects and uses your data, please visit the Microsoft Privacy Statement (https://privacy.microsoft.com/privacystatement).' 31 | }, 32 | bio: 'An open-source project with long history.', 33 | alternative: '' 34 | } 35 | 36 | export const zhCN = { 37 | about: { 38 | project: '关于 ZVMS', 39 | developers: '关于开发者' 40 | }, 41 | footer: 'ZVMS 开发组、镇海中学学生会', 42 | license: '基于 MIT 证书开源', 43 | repository: { 44 | license: '许可协议', 45 | version: { 46 | '0': '本项目', 47 | '1': '版本由 ', 48 | '2': '开发。感谢!' 49 | }, 50 | thank: { 51 | mean: '同时,感谢先前', 52 | icon: '提供的校外服务器,', 53 | test: '设计的新版(v2.0 - 最新版)图标,以及', 54 | end: '的疯狂测试。' 55 | }, 56 | v4More: { 57 | '0': '在 V4 版本发布、投入使用后,', 58 | '1': '也参与了平台的维护和修缮。' 59 | } 60 | }, 61 | privacy: { 62 | title: '隐私政策', 63 | content: 64 | '本站与 Microsoft Clarity 和 Microsoft Advertising 合作,通过行为指标、热图和会话重播来捕获您使用本站的方式以及与本站的互动,从而改进本站的浏览体验。我们使用第一和第三方 Cookie 及其他跟踪技术获取网站使用数据,以确定本站的受欢迎程度和在线活动。此外,我们还将此信息用于政教处管理与审查、网站优化和安全目的。有关 Microsoft 如何收集和使用您的数据的详细信息,请访问 Microsoft 隐私声明 (https://privacy.microsoft.com/privacystatement)。' 65 | }, 66 | bio: '一个历史悠久的开源项目。', 67 | alternative: '镇海中学义工管理系统' 68 | } 69 | -------------------------------------------------------------------------------- /src/components/activity/activityCategory.ts: -------------------------------------------------------------------------------- 1 | import type { ActivityMember } from 'types/activity.v2' 2 | 3 | export { default as categories } from './categories.json' 4 | 5 | export function composeDurationRecommendation(config: { 6 | lte?: number 7 | gte?: number 8 | unlimited?: boolean 9 | documented?: boolean 10 | lang?: string 11 | attachment?: number 12 | ticketRequired?: boolean 13 | }): string { 14 | console.log(config) 15 | const { lte, gte, unlimited, documented, lang, attachment, ticketRequired } = config 16 | const attachmentText = { 17 | 1: 18 | lang === 'zh-CN' 19 | ? '附件一《常见义工活动时间核准参照表Ⅰ》' 20 | : 'Attachment I: Common Volunteer Activity Time Approval Reference Table', 21 | 2: 22 | lang === 'zh-CN' 23 | ? '附件二《常见义工活动时间核准参照表Ⅱ》' 24 | : 'Attachment II: Common Volunteer Activity Time Approval Reference Table' 25 | } 26 | const attachmentName = 27 | attachment === 1 ? attachmentText[1] : attachment === 2 ? attachmentText[2] : '' 28 | let base = 29 | lang === 'zh-CN' 30 | ? `根据《镇海中学学生义工管理细则》(2025 年 2 月修订)${attachmentName},` 31 | : `According to the 'Volunteer Management Regulations of Zhenhai High School' (Revised in February 2025) ${attachmentName}, ` 32 | if (ticketRequired) { 33 | base += 34 | lang === 'zh-CN' 35 | ? `该义工活动需要《非常规义工申请单》。` 36 | : `This volunteer activity requires a 'Non-Regular Volunteer Application Form'. ` 37 | } 38 | if (lte && !gte && !unlimited) { 39 | return ( 40 | base + 41 | (lang === 'zh-CN' 42 | ? `该义工时长发放不应超过 ${lte} 小时。` 43 | : `The volunteer hours granted should not exceed ${lte} hours.`) 44 | ) 45 | } 46 | if (gte && !lte && !unlimited) { 47 | return ( 48 | base + 49 | (lang === 'zh-CN' 50 | ? `该义工时长发放不应低于 ${gte} 小时。` 51 | : `The volunteer hours granted should not be less than ${gte} hours.`) 52 | ) 53 | } 54 | if (lte && gte && !unlimited) { 55 | return ( 56 | base + 57 | (lang === 'zh-CN' 58 | ? `该义工时长发放应在 ${gte} 至 ${lte} 小时之间。` 59 | : `The volunteer hours granted should be between ${gte} and ${lte} hours.`) 60 | ) 61 | } 62 | if (unlimited && !documented) { 63 | return ( 64 | base + 65 | (lang === 'zh-CN' 66 | ? `在该计入模式下封顶全满(中途退出减一半以上)。` 67 | : `In this mode, the cap is full (exiting halfway counts as more than half).`) 68 | ) 69 | } 70 | if (unlimited && documented) { 71 | return ( 72 | base + 73 | (lang === 'zh-CN' 74 | ? `在该计入模式下封顶全满(视成果分层发放)。` 75 | : `In this mode, the cap is full (distribution is based on results).`) 76 | ) 77 | } 78 | return '' 79 | } 80 | -------------------------------------------------------------------------------- /src/i18n/locales/nav.ts: -------------------------------------------------------------------------------- 1 | export const enUS = { 2 | home: '', 3 | activity: '', 4 | create: '', 5 | manage: '', 6 | about: '', 7 | logout: '', 8 | reset: '', 9 | language: '', 10 | dark: '', 11 | languages: { 12 | 'zh-CN': '', 13 | 'en-US': '', 14 | }, 15 | pages: { 16 | NotFound: { 17 | title: '', 18 | description: '', 19 | action: { 20 | back: '', 21 | report: '', 22 | }, 23 | }, 24 | SomethingWentWrong: { 25 | title: '', 26 | description: '', 27 | }, 28 | }, 29 | login: { 30 | actions: { 31 | login: '', 32 | reset: '', 33 | logout: '', 34 | }, 35 | motto: '', 36 | form: { 37 | account: '', 38 | password: '', 39 | }, 40 | }, 41 | breadcrumbs: { 42 | view: { 43 | home: '', 44 | specific: '', 45 | }, 46 | actions: { 47 | create: '', 48 | edit: '', 49 | delete: '', 50 | search: '', 51 | filter: '', 52 | sort: '', 53 | refresh: '', 54 | }, 55 | about: { 56 | home: '', 57 | developer: '', 58 | project: '', 59 | version: '', 60 | }, 61 | settings: { 62 | profile: '', 63 | language: '', 64 | logout: '', 65 | }, 66 | manage: '', 67 | }, 68 | } 69 | 70 | export const zhCN = { 71 | home: '首页', 72 | activity: '义工列表', 73 | manage: '管理', 74 | create: '创建义工', 75 | about: '关于', 76 | logout: '退出登录', 77 | reset: '密码修改', 78 | language: '', 79 | dark: '夜间模式', 80 | languages: { 81 | 'zh-CN': '', 82 | 'en-US': '', 83 | }, 84 | pages: { 85 | NotFound: { 86 | title: '404 页面未找到', 87 | description: '您访问的页面不存在。', 88 | action: { 89 | back: '返回首页', 90 | report: '', 91 | }, 92 | }, 93 | SomethingWentWrong: { 94 | title: '', 95 | description: '', 96 | }, 97 | }, 98 | login: { 99 | actions: { 100 | login: '登录', 101 | reset: '重置', 102 | logout: '登出' 103 | }, 104 | motto: '励志 进取 勤奋 健美', 105 | form: { 106 | account: '学号', 107 | password: '密码', 108 | }, 109 | }, 110 | breadcrumbs: { 111 | view: { 112 | home: '查看', 113 | specific: '义工列表', 114 | }, 115 | actions: { 116 | create: '创建', 117 | edit: '编辑', 118 | delete: '删除', 119 | search: '搜索', 120 | filter: '过滤', 121 | sort: '排序', 122 | refresh: '刷新', 123 | }, 124 | settings: { 125 | profile: '个人资料', 126 | language: '', 127 | logout: '登出', 128 | }, 129 | manage: '管理', 130 | }, 131 | } 132 | -------------------------------------------------------------------------------- /src/components/activity/ZActivityCard.vue: -------------------------------------------------------------------------------- 1 | 68 | 69 | 96 | -------------------------------------------------------------------------------- /src/api/user/auth.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/plugins/axios' 2 | import type { Response, LoginResult } from '@/../types' 3 | import { ElNotification } from 'element-plus' 4 | import { byteArrayToHex } from './utils' 5 | import { encryptData, importPublicKey } from './crypto' 6 | 7 | export async function getRSAPublicCert(): Promise { 8 | const result = ( 9 | await axios('/cert', { 10 | method: 'GET', 11 | params: { 12 | type: 'public', 13 | method: 'RSA' 14 | } 15 | }) 16 | ).data as Response 17 | if (result.status === 'error') { 18 | ElNotification({ 19 | title: 'Error when fetching RSA public key', 20 | message: result.message, 21 | type: 'error' 22 | }) 23 | return '' 24 | } 25 | return result.data 26 | } 27 | 28 | export async function resetPassword( 29 | user: string, 30 | password: string, 31 | token: string, 32 | reset: boolean = false 33 | ) { 34 | const payload = JSON.stringify({ 35 | password: password, 36 | time: Date.now() 37 | }) 38 | const publicKey = await importPublicKey(await getRSAPublicCert()) 39 | const credential = await encryptData(publicKey, payload) 40 | const hex = byteArrayToHex(new Uint8Array(credential)) 41 | const result = (await axios(`/users/${user}/password`, { 42 | method: 'PUT', 43 | data: { 44 | credential: hex 45 | }, 46 | headers: { 47 | Authorization: `Bearer ${token}` 48 | } 49 | })) as Response 50 | if (result.status === 'error') { 51 | ElNotification({ 52 | title: 'Error when resetting password', 53 | message: result.message, 54 | type: 'error' 55 | }) 56 | return 57 | } 58 | if (!reset) { 59 | localStorage.setItem('token', result.data.token) 60 | } 61 | return result.data 62 | } 63 | 64 | async function UserLogin(user: string, password: string, term: 'long' | 'short' = 'long') { 65 | const payload = JSON.stringify({ 66 | password: password, 67 | time: Date.now() 68 | }) 69 | const publicKey = await importPublicKey(await getRSAPublicCert()) 70 | const credential = await encryptData(publicKey, payload) 71 | const hex = byteArrayToHex(new Uint8Array(credential)) 72 | const result = (await axios('/users/auth', { 73 | method: 'POST', 74 | data: { 75 | id: user.toString(), 76 | credential: hex, 77 | mode: term 78 | } 79 | })) as Response 80 | if (result.status === 'error') { 81 | ElNotification({ 82 | title: 'Login failed (' + result.code + ')', 83 | message: result.message, 84 | type: 'error' 85 | }) 86 | return 87 | } 88 | if (term === 'long') { 89 | localStorage.setItem('token', result.data.token) 90 | } 91 | return result.data 92 | } 93 | 94 | export { UserLogin as useLongTermAuth } 95 | -------------------------------------------------------------------------------- /src/views/user/UserActivity.vue: -------------------------------------------------------------------------------- 1 | 68 | 69 | 96 | -------------------------------------------------------------------------------- /src/components/form/ZSelectLanguage.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 99 | 100 | 105 | -------------------------------------------------------------------------------- /src/views/activity/CreateActivity.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 100 | -------------------------------------------------------------------------------- /src/components/tags/ZActivityDuration.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 103 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import HomeView from '../views/HomeView.vue' 3 | 4 | const router = createRouter({ 5 | history: createWebHistory(import.meta.env.BASE_URL), 6 | routes: [ 7 | { 8 | path: '/', 9 | name: 'home', 10 | component: HomeView 11 | }, 12 | { 13 | path: '/about', 14 | name: 'about', 15 | component: () => import('../views/AboutView.vue') 16 | }, 17 | { 18 | path: '/user/login', 19 | name: 'user-login', 20 | component: () => import('../views/user/UserLogin.vue') 21 | }, 22 | { 23 | path: '/user', 24 | name: 'user', 25 | component: () => import('../views/user/UserHome.vue') 26 | }, 27 | { 28 | path: '/user/:id', 29 | name: 'user-page', 30 | component: () => import('../views/user/UserPage.vue') 31 | }, 32 | { 33 | path: '/user/:id/:action', 34 | name: 'user-action', 35 | component: () => import('../views/user/UserPage.vue') 36 | }, 37 | { 38 | path: '/activity/create', 39 | name: 'activity-create', 40 | component: () => import('../views/activity/CreateActivity.vue'), 41 | children: [ 42 | { 43 | path: '/activity/create', 44 | name: 'activity-create-home', 45 | component: () => import('../views/activity/CreateHome.vue') 46 | }, 47 | { 48 | path: '/activity/create/:type', 49 | name: 'activity-create-type', 50 | component: () => import('../views/activity/CreatePage.vue') 51 | } 52 | ] 53 | }, 54 | { 55 | path: '/activities', 56 | name: 'activity', 57 | component: () => import('../views/user/UserActivity.vue') 58 | }, 59 | { 60 | path: '/activities/:mode', 61 | name: 'activity-mode', 62 | component: () => import('../views/user/UserActivity.vue') 63 | }, 64 | { 65 | path: '/manage', 66 | name: 'manage', 67 | component: () => import('../views/manage/ManageHome.vue') 68 | }, 69 | { 70 | path: '/manage/:action', 71 | name: 'manage-action', 72 | component: () => import('../views/manage/ManageHome.vue') 73 | }, 74 | { 75 | path: '/group/:id', 76 | name: 'group-user', 77 | component: () => import('../views/group/GroupPage.vue') 78 | }, 79 | { 80 | path: '/group/:id/:action', 81 | name: 'group-action', 82 | component: () => import('../views/group/GroupPage.vue') 83 | }, 84 | { 85 | path: '/activity/details/:id', 86 | name: 'activity-view', 87 | component: () => import('../views/activity/ActivityPage.vue') 88 | }, 89 | { 90 | path: '/sww', 91 | name: 'something-went-wrong', 92 | component: () => import('../views/SomethingWrong.vue') 93 | }, 94 | { 95 | path: '/:pathMatch(.*)*', 96 | name: 'not-found', 97 | component: () => import('../views/NotFound.vue') 98 | }, 99 | ] 100 | }) 101 | 102 | export default router 103 | -------------------------------------------------------------------------------- /src/views/manage/ManageHome.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 100 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | import { defineConfig } from 'vite' 3 | import vue from '@vitejs/plugin-vue' 4 | import vueJsx from '@vitejs/plugin-vue-jsx' 5 | // import basicSsl from '@vitejs/plugin-basic-ssl' 6 | import UnoCSS from 'unocss/vite' 7 | import legacy from '@vitejs/plugin-legacy' 8 | import vueDevtools from 'vite-plugin-vue-devtools' 9 | import VueComponents from 'unplugin-vue-components/vite' 10 | import AutoImport from 'unplugin-auto-import/vite' 11 | import Icons from 'unplugin-icons/vite' 12 | import IconsResolver from 'unplugin-icons/resolver' 13 | import { VitePWA as pwa } from 'vite-plugin-pwa' 14 | import { ElementPlusResolver, VantResolver } from 'unplugin-vue-components/resolvers' 15 | 16 | // https://vitejs.dev/config/ 17 | export default defineConfig({ 18 | plugins: [ 19 | vue(), 20 | vueJsx(), 21 | legacy({ 22 | targets: [ 23 | 'defaults', 24 | 'not IE 11', 25 | 'chrome 64', 26 | 'firefox 78', 27 | 'safari 12', 28 | 'edge 79', 29 | 'Android >= 8' 30 | ], 31 | polyfills: ['es.promise', 'es.symbol', 'es.string.replace-all'] 32 | }), 33 | // basicSsl(), 34 | UnoCSS(), 35 | vueDevtools(), 36 | pwa({ 37 | injectRegister: 'auto', 38 | registerType: 'autoUpdate', 39 | immediate: true, 40 | includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'], 41 | manifest: { 42 | name: 'ZVMS 4', 43 | short_name: 'ZVMS', 44 | description: 'The Zhenhai High School Volunteer Management System 4', 45 | theme_color: '#ffffff', 46 | icons: [ 47 | { 48 | src: 'pwa-192x192.png', 49 | sizes: '192x192', 50 | type: 'image/png' 51 | }, 52 | { 53 | src: 'pwa-512x512.png', 54 | sizes: '512x512', 55 | type: 'image/png' 56 | } 57 | ] 58 | }, 59 | workbox: { 60 | cleanupOutdatedCaches: false, 61 | sourcemap: true, 62 | maximumFileSizeToCacheInBytes: 5 * 1024 * 1024 63 | }, 64 | onRegisteredSW(swUrl, r) { 65 | r && 66 | setInterval(async () => { 67 | if (r.installing || !navigator) { 68 | return 69 | } 70 | 71 | if ('connection' in navigator && !navigator.onLine) { 72 | return 73 | } 74 | 75 | const resp = await fetch(swUrl, { 76 | cache: 'no-store', 77 | headers: { 78 | cache: 'no-store', 79 | 'cache-control': 'no-cache' 80 | } 81 | }) 82 | 83 | if (resp?.status === 200) { 84 | await r.update() 85 | } 86 | }, 36000000) 87 | } 88 | }), 89 | Icons({}), 90 | AutoImport({ 91 | resolvers: [ElementPlusResolver(), VantResolver(), IconsResolver()] 92 | }), 93 | VueComponents({ 94 | resolvers: [ElementPlusResolver(), VantResolver(), IconsResolver()] 95 | }) 96 | ], 97 | resolve: { 98 | alias: { 99 | '@/../types': fileURLToPath(new URL('./types', import.meta.url)), 100 | '@': fileURLToPath(new URL('./src', import.meta.url)), 101 | '@zvms/zvms4-types': fileURLToPath(new URL('./types', import.meta.url)) 102 | } 103 | } 104 | }) 105 | -------------------------------------------------------------------------------- /src/components/activity/ZTimeJudge.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 90 | 91 | 130 | -------------------------------------------------------------------------------- /src/plugins/axios.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from 'axios' 2 | import { ElMessage } from 'element-plus' 3 | import nprogress from 'nprogress' 4 | import 'nprogress/nprogress.css' 5 | import router from '@/router' 6 | import { useUserStore } from '@/stores/user' 7 | import { getXuehaiId } from '@/plugins/ua.ts' 8 | 9 | // Function to get the value of a specific cookie 10 | function getCookieValue(cookieName: string) { 11 | const cookies = document.cookie.split('; ') 12 | for (const cookie of cookies) { 13 | const [name, value] = cookie.split('=') 14 | if (name === cookieName) { 15 | return value 16 | } 17 | } 18 | return null 19 | } 20 | 21 | /* 22 | export const baseURL = import.meta.env.PROD 23 | ? 'https://api.zvms.site/api/' 24 | : 'http://localhost:8000/api/' 25 | */ 26 | 27 | export const baseURL = 'https://api.zvms.site/api/' 28 | 29 | const axiosInstance = axios.create({ 30 | baseURL, 31 | withCredentials: true, 32 | timeout: 12000, 33 | headers: { 34 | 'Content-type': 'application/json', 35 | 'Clarity-ID': getCookieValue('_clck')?.split('%7C')[0] ?? '', 36 | 'Xuehai-ID': getXuehaiId() && ('' + getXuehaiId()) || '', 37 | } 38 | }) 39 | 40 | // Request Interceptor 41 | 42 | axiosInstance.interceptors.request.use( 43 | (config) => { 44 | nprogress.start() 45 | const token = localStorage.getItem('token') 46 | if (token && !config.headers['Authorization']) { 47 | config.headers['Authorization'] = `Bearer ${token}` 48 | } 49 | return config 50 | }, 51 | (error) => { 52 | nprogress.done() 53 | return Promise.reject(error) 54 | } 55 | ) 56 | 57 | // Response Interceptor 58 | 59 | axiosInstance.interceptors.response.use( 60 | (response) => { 61 | nprogress.done() 62 | return response 63 | }, 64 | (error: Error | AxiosError) => { 65 | nprogress.done() 66 | let errorDisplayed = false 67 | if (axios.isAxiosError(error)) { 68 | if (error.response?.status === 401) { 69 | if (!errorDisplayed) { 70 | errorDisplayed = true 71 | const token = localStorage.getItem('token') 72 | ElMessage({ 73 | message: token ? '登录已过期' : '未登录', 74 | type: 'error', 75 | grouping: true, 76 | plain: true 77 | }) 78 | if (token) { 79 | localStorage.removeItem('token') 80 | } 81 | useUserStore() 82 | .removeUser() 83 | .then(() => { 84 | router.replace('/user/login') 85 | }) 86 | } 87 | } else { 88 | if (!errorDisplayed) { 89 | errorDisplayed = true 90 | ElMessage({ 91 | message: error.response?.data?.detail 92 | ? '' + 93 | (typeof error.response?.data?.detail === 'string' 94 | ? error.response?.data?.detail 95 | : JSON.stringify(error.response?.data?.detail)) 96 | : '未知错误', 97 | type: 'error', 98 | grouping: true, 99 | plain: true 100 | }) 101 | } 102 | } 103 | } else { 104 | if (!errorDisplayed) { 105 | errorDisplayed = true 106 | ElMessage({ 107 | message: 108 | '错误: ' + error.message 109 | ? 'data: ' + JSON.stringify(error.message) 110 | : '未知错误', 111 | type: 'error', 112 | grouping: true, 113 | plain: true 114 | }) 115 | } 116 | } 117 | } 118 | ) 119 | 120 | export default axiosInstance 121 | -------------------------------------------------------------------------------- /src/components/tags/ZActivityStatus.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 118 | -------------------------------------------------------------------------------- /src/components/group/ZGroupList.vue: -------------------------------------------------------------------------------- 1 | 68 | 69 | 110 | -------------------------------------------------------------------------------- /src/views/user/index.ts: -------------------------------------------------------------------------------- 1 | import { temporaryToken } from '@/plugins/short-token.ts' 2 | import { ElMessageBox } from 'element-plus' 3 | 4 | export { default as UserActivity } from './UserActivity.vue' 5 | export { default as UserHome } from './UserHome.vue' 6 | export { default as UserLogin } from './UserLogin.vue' 7 | export { default as UserNav } from './UserNav.vue' 8 | 9 | const locales: Record< 10 | string, 11 | { 12 | password: { 13 | title: string 14 | message: string 15 | confirmButtonText: string 16 | cancelButtonText: string 17 | inputErrorMessage: string 18 | } 19 | password_confirm: { 20 | title: string 21 | message: string 22 | inputErrorMessage: string 23 | } 24 | } 25 | > = { 26 | 'zh-CN': { 27 | password: { 28 | title: '修改密码', 29 | message: '请输入新密码', 30 | confirmButtonText: '确定', 31 | cancelButtonText: '取消', 32 | inputErrorMessage: '密码至少8位,至多14位,且至少包含大/小写字母,数字和特殊字符中的3种' 33 | }, 34 | password_confirm: { 35 | title: '修改密码', 36 | message: '请再次输入新密码', 37 | inputErrorMessage: '密码不匹配' 38 | } 39 | }, 40 | 'en-US': { 41 | password: { 42 | title: '', 43 | message: '', 44 | confirmButtonText: '', 45 | cancelButtonText: '', 46 | inputErrorMessage: '' 47 | }, 48 | password_confirm: { 49 | title: '', 50 | message: '', 51 | inputErrorMessage: '' 52 | } 53 | } 54 | } 55 | 56 | export async function modifyPasswordDialogs( 57 | user: string, 58 | locale: string, 59 | caller: (a: string, b: string) => Promise, 60 | token_?: string 61 | ) { 62 | function validatePasswordStrength(pwd: string): boolean { 63 | let strength = 0 64 | if (!/^[ -\x7e]{8,14}$/.test(pwd)) { 65 | return false 66 | } 67 | if (/^(?=.*[a-z])/.test(pwd)) { 68 | strength += 1 69 | } 70 | if (/^(?=.*[A-Z])/.test(pwd)) { 71 | strength += 1 72 | } 73 | if (/^(?=.*[0-9])/.test(pwd)) { 74 | strength += 1 75 | } 76 | if (/^(?=.*[^a-zA-Z0-9])/.test(pwd)) { 77 | strength += 1 78 | } 79 | return strength >= 3 80 | } 81 | const token = token_ || (await temporaryToken(user)) 82 | if (!token) { 83 | throw new Error('Authorization canceled') 84 | } 85 | const input = await ElMessageBox.prompt( 86 | locales['zh-CN'].password.message, 87 | locales['zh-CN'].password.title, 88 | { 89 | confirmButtonText: locales['zh-CN'].password.confirmButtonText, 90 | cancelButtonText: locales['zh-CN'].password.cancelButtonText, 91 | inputValidator: validatePasswordStrength, 92 | inputType: 'password', 93 | inputErrorMessage: locales['zh-CN'].password.inputErrorMessage, 94 | showClose: false, 95 | closeOnClickModal: false, 96 | closeOnPressEscape: false, 97 | } 98 | ).catch(() => { 99 | throw new Error('Password input canceled') 100 | }) 101 | const confirm = await ElMessageBox.prompt( 102 | locales['zh-CN'].password_confirm.message, 103 | locales['zh-CN'].password_confirm.title, 104 | { 105 | confirmButtonText: locales['zh-CN'].password.confirmButtonText, 106 | cancelButtonText: locales['zh-CN'].password.cancelButtonText, 107 | inputValidator: (ipt: string) => input.value === ipt, 108 | inputType: 'password', 109 | inputErrorMessage: locales['zh-CN'].password_confirm.inputErrorMessage, 110 | showClose: false, 111 | closeOnClickModal: false, 112 | closeOnPressEscape: false, 113 | } 114 | ).catch(() => { 115 | throw new Error('Password confirm canceled') 116 | }) 117 | if (input.value === confirm.value) { 118 | await caller(token, input.value) 119 | } else { 120 | throw new Error('Password not match') 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/components/utils/ZButtonOrCard.vue: -------------------------------------------------------------------------------- 1 | 88 | 89 | 153 | -------------------------------------------------------------------------------- /src/views/user/UserPage.vue: -------------------------------------------------------------------------------- 1 | 92 | 93 | 126 | -------------------------------------------------------------------------------- /src/components/group/ZGroupTimeStatistics.vue: -------------------------------------------------------------------------------- 1 | 105 | 106 | 136 | -------------------------------------------------------------------------------- /src/views/user/UserHome.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 119 | 120 | 128 | -------------------------------------------------------------------------------- /src/components/activity/ZUploadFile.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 113 | 114 | 124 | -------------------------------------------------------------------------------- /src/stores/user.ts: -------------------------------------------------------------------------------- 1 | import api from '@/api' 2 | import type { User, UserPosition } from '@/../types' 3 | import { defineStore } from 'pinia' 4 | import { ElNotification } from 'element-plus' 5 | import { useRouter } from 'vue-router' 6 | import { getUserPositions } from '@/utils/groupPosition' 7 | 8 | export const useUserStore = defineStore('user', { 9 | state: () => ({ 10 | id: 0, 11 | _id: '', 12 | name: '', 13 | position: [] as UserPosition[], 14 | groups: [] as string[], 15 | class_id: '', 16 | isLogin: false, 17 | shouldResetPassword: false, 18 | time: { 19 | socialPractice: 0, 20 | onCampus: 0, 21 | offCampus: 0 22 | }, 23 | language: 'zh-CN' 24 | }), 25 | actions: { 26 | async getUserClassId(groups: string[]) { 27 | const result = await Promise.all(groups.map((group) => api.group.readOne(group))) 28 | const group = result.find((group) => group?.type === 'class') 29 | if (group) { 30 | this.class_id = group._id 31 | } 32 | }, 33 | async setUserInformation(user: User) { 34 | this._id = user._id 35 | this.id = user.id 36 | this.name = user.name 37 | this.groups = user.group 38 | await this.getUserClassId(user.group) 39 | this.position = await getUserPositions(user) 40 | }, 41 | async setUser(user: string, password: string) { 42 | const result = await api.user.auth.useLongTermAuth(user, password) 43 | if (result) { 44 | const information = (await api.user.readOne(result._id)) as User 45 | await this.setUserInformation(information) 46 | if (!this.validatePasswordStrength(password)) { 47 | this.shouldResetPassword = true 48 | } 49 | this.isLogin = true 50 | } 51 | }, 52 | async refreshUser() { 53 | const result = (await api.user.readOne(this._id)) as User 54 | await this.setUserInformation(result) 55 | }, 56 | getUser() { 57 | return { 58 | id: this.id, 59 | name: this.name 60 | } 61 | }, 62 | async removeUser() { 63 | this._id = '' 64 | this.id = 0 65 | this.name = '' 66 | this.groups = [] 67 | this.isLogin = false 68 | this.shouldResetPassword = false 69 | }, 70 | async getUserActivityTime() { 71 | const result = (await api.user.time.read(this._id)) as unknown as { 72 | offCampus: number 73 | onCampus: number 74 | socialPractice: number 75 | } 76 | this.time.offCampus = result.offCampus 77 | this.time.onCampus = result.onCampus 78 | this.time.socialPractice = result.socialPractice 79 | }, 80 | setLanguage(language: string) { 81 | this.language = language 82 | }, 83 | async resetPassword(token: string, newPassword: string) { 84 | const result = await api.user.password.put(this._id, newPassword, token) 85 | if (!result) { 86 | return 87 | } 88 | ElNotification({ 89 | title: '密码修改成功', 90 | message: '请重新登录', 91 | type: 'success' 92 | }) 93 | await this.removeUser() 94 | }, 95 | setTime(time: { onCampus: number; offCampus: number; socialPractice: number }) { 96 | this.time.onCampus = time.onCampus 97 | this.time.offCampus = time.offCampus 98 | this.time.socialPractice = time.socialPractice 99 | }, 100 | relatedGroup(group: string): boolean { 101 | if (this.position.includes('admin') || this.position.includes('department')) { 102 | return true 103 | } else if (this.position.includes('secretary')) { 104 | return group === this.class_id 105 | } else return false 106 | }, 107 | validatePasswordStrength(pwd: string): boolean { 108 | let strength = 0 109 | if (!(/^[ -\x7e]{8,14}$/.test(pwd))) { 110 | return false 111 | } 112 | if (/^(?=.*[a-z])/.test(pwd)) { 113 | strength += 1 114 | } 115 | if (/^(?=.*[A-Z])/.test(pwd)) { 116 | strength += 1 117 | } 118 | if (/^(?=.*[0-9])/.test(pwd)) { 119 | strength += 1 120 | } 121 | if (/^(?=.*[^a-zA-Z0-9])/.test(pwd)) { 122 | strength += 1 123 | } 124 | return strength >= 3 125 | }, 126 | }, 127 | persist: { 128 | storage: localStorage 129 | } 130 | }) 131 | -------------------------------------------------------------------------------- /src/views/user/UserNav.vue: -------------------------------------------------------------------------------- 1 | 100 | 101 | 137 | 138 | 160 | -------------------------------------------------------------------------------- /src/views/activity/ActivityMerge.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /src/components/tags/ZActivityMode.vue: -------------------------------------------------------------------------------- 1 | 115 | 116 | 155 | -------------------------------------------------------------------------------- /src/views/group/GroupPage.vue: -------------------------------------------------------------------------------- 1 | 133 | 134 | 160 | --------------------------------------------------------------------------------