add第一次提交

This commit is contained in:
阿梦 2026-04-28 17:21:13 +08:00
commit 8527a42331
55 changed files with 5929 additions and 0 deletions

89
.auto-import.d.ts vendored Normal file
View File

@ -0,0 +1,89 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const userStore: typeof import('./src/stores/user')['userStore']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

21
.components.d.ts vendored Normal file
View File

@ -0,0 +1,21 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ActivityProgress: typeof import('./src/components/tasks/ActivityProgress.vue')['default']
AppHeader: typeof import('./src/components/layout/AppHeader.vue')['default']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
PageFrame: typeof import('./src/components/layout/PageFrame.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SubjectCard: typeof import('./src/components/cards/SubjectCard.vue')['default']
TabBar: typeof import('./src/components/layout/TabBar.vue')['default']
TaskCard: typeof import('./src/components/cards/TaskCard.vue')['default']
TaskSidebar: typeof import('./src/components/tasks/TaskSidebar.vue')['default']
}
}

2
.env Normal file
View File

@ -0,0 +1,2 @@
VITE_APP_NAME=灵心学
VITE_TENANT_ID=

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules
dist
.DS_Store
*.local
.auto-import.json
node_modules/.tmp

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>灵心学</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3901
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "lingxinxue-web-new",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit --skipLibCheck && vite build",
"preview": "vite preview",
"type-check": "vue-tsc --build"
},
"dependencies": {
"axios": "^1.7.2",
"element-plus": "^2.7.8",
"pinia": "^2.2.0",
"qs": "^6.12.3",
"vant": "^4.9.4",
"vue": "^3.4.31",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/qs": "^6.9.15",
"@vitejs/plugin-vue": "^5.0.5",
"@vitejs/plugin-vue-jsx": "^4.0.1",
"@vue/tsconfig": "^0.5.1",
"postcss": "^8.4.40",
"sass": "^1.77.6",
"typescript": "^5.2.2",
"unplugin-auto-import": "^0.18.2",
"unplugin-vue-components": "^0.27.3",
"vite": "^5.3.4",
"vue-tsc": "^2.0.24"
}
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none"><circle cx="16" cy="16" r="14" fill="#646cff"/><path fill="#fff" d="M10 16 16 10l6 6-6 6z"/></svg>

After

Width:  |  Height:  |  Size: 171 B

51
src/App.vue Normal file
View File

@ -0,0 +1,51 @@
<template>
<el-config-provider :locale="zhCn">
<div class="app-shell">
<main class="app-stage">
<AppHeader />
<section class="app-content">
<RouterView />
</section>
<TabBar />
</main>
</div>
</el-config-provider>
</template>
<script setup lang="ts">
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import AppHeader from '@/components/layout/AppHeader.vue'
import TabBar from '@/components/layout/TabBar.vue'
</script>
<style scoped lang="scss">
.app-shell {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
max-width: 100vw;
}
.app-stage {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
border: 1px solid #dce5f5;
background: #cdd7ec;
box-shadow: 0 4px 12px rgba(51, 83, 145, 0.08);
}
.app-content {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: auto;
padding-bottom: 140px;
}
</style>

112
src/api/http.ts Normal file
View File

@ -0,0 +1,112 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import axios, { type AxiosInstance } from 'axios'
import qs from 'qs'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { getCache, delCache } from '@/utils/store'
import { storeToRefs } from 'pinia'
import { userStore } from '@/stores'
import { platform } from '@/config/platform'
import onlyLast from './only-last'
const timeout = 90000
const instance: AxiosInstance = axios.create({
baseURL: '/api/main',
method: 'get',
headers: {
'Content-Type': 'application/json',
},
timeout,
})
instance.interceptors.request.use(
(config: any) => {
config.baseURL = config.baseURL || '/api/main'
config.timeout = config.timeout || timeout
if (config.method === 'get') {
config.paramsSerializer = {
serialize: (params: any) =>
qs.stringify(params, {
arrayFormat: 'repeat',
allowDots: true,
}),
}
}
onlyLast.processRequest(config)
config.headers['X-Tenant-Id'] = platform.tenantId
if ('Authorization' in config.headers && config.headers['Authorization'] === '') {
return config
}
if (config.baseURL === '/api/campus') {
config.headers['Authorization'] = getCache('workToken')
} else {
config.headers['Authorization'] = getCache('token')
}
return config
},
error => Promise.reject(error),
)
const buildError = (response: any, show = true) => {
const data = response?.data ?? {}
const message = data.message ?? data.msg
let errorMsg = message
let showed = false
if (errorMsg) {
if (String(errorMsg).includes('服务器')) {
errorMsg = '网络不稳定,请稍后再试~'
}
if (show) {
ElMessage.error(String(errorMsg))
showed = true
}
}
if (errorMsg && String(errorMsg).includes('非本平台用户')) {
delCache('token')
delCache('workToken')
router.replace('/login')
}
return {
message: errorMsg,
showed,
}
}
instance.interceptors.response.use(
response => {
const { code, message } = response.data ?? {}
if (!onlyLast.processResponse(response.config)) {
return Promise.reject(response)
}
if (response.headers['content-type']?.includes('octet-stream') || code == 200) return response
return Promise.reject(buildError(response))
},
async error => {
const { response } = error
if (!onlyLast.processResponse(error.config)) {
return Promise.reject(error)
}
if (response?.status === 401 || response?.status === 403) {
const user = userStore()
const { token } = storeToRefs(user)
if (token.value) {
ElMessage.error('登录已过期,请重新登录')
user.$reset()
}
delCache('token')
delCache('workToken')
router.replace('/login')
}
return Promise.reject(buildError(response, false))
},
)
export const http = instance

52
src/api/only-last.ts Normal file
View File

@ -0,0 +1,52 @@
import type { AxiosRequestConfig } from 'axios'
import obj from '@/utils/obj'
/** 同时有多个请求时,仅最后一个生效(与 xuexiaole-web 一致) */
export const only_last_id = 'on_last_id'
const only_last_req_id = 'only_last_req_id'
const controllers = {} as Record<string, AbortController>
const reqIds = {} as Record<string, string>
export type RequestConfig = AxiosRequestConfig & {
[only_last_id]?: string
[only_last_req_id]?: string
}
function processRequest(config: RequestConfig) {
if (!config) return
const onlyLastId = config[only_last_id]
if (!onlyLastId) return
let ctl = controllers[onlyLastId]
if (ctl) {
ctl.abort()
}
ctl = new AbortController()
config.signal = ctl.signal
controllers[onlyLastId] = ctl
const reqId = obj.snowflakeId()
reqIds[onlyLastId] = reqId
config[only_last_req_id] = reqId
}
function processResponse(config: RequestConfig) {
if (!config) return true
const onlyLastId = config[only_last_id]
if (!onlyLastId) return true
const reqId = config[only_last_req_id]
if (reqId === reqIds[onlyLastId]) {
delete reqIds[onlyLastId]
delete controllers[onlyLastId]
return true
}
return false
}
export default {
processRequest,
processResponse,
}

82
src/api/request.ts Normal file
View File

@ -0,0 +1,82 @@
import { http } from './http'
import type { RequestConfig } from './only-last'
export type PageResponse<T> = {
current: number
size: number
totalPage: number
totalRows: number
rows: T[]
}
export async function get<T>(
uri: string,
params?: Record<string, any>,
config?: RequestConfig,
): Promise<T> {
if (params) {
config = config ? { ...config, params } : { params }
}
return (await http.get<any>(uri, config)).data['data'] as T
}
export async function remove<T>(
uri: string,
params?: Record<string, any>,
config?: RequestConfig,
): Promise<T> {
if (params) {
config = config ? { ...config, params } : { params }
}
return (await http.delete<any>(uri, config)).data['data'] as T
}
const processFormData = (data: any): any => {
if (!data) return data
const vs = Object.values(data)
let hasFile = false
for (const v of vs) {
if (v instanceof File) {
hasFile = true
break
}
}
if (!hasFile) return data
const form = new FormData()
const ks = Object.keys(data)
for (const k_ of ks) {
const k = k_ as string
const v = data[k]
if (v) {
form.append(k, v)
}
}
return form
}
export async function post<T>(url: string, data?: any, config?: RequestConfig): Promise<T> {
return (await http.post(url, data, config)).data['data'] as T
}
export async function put<T>(url: string, data?: any, config?: RequestConfig): Promise<T> {
return (await http.put(url, data, config)).data['data'] as T
}
export async function postForm<T>(url: string, data?: any, config?: RequestConfig): Promise<T> {
data = processFormData(data)
if (data) {
let type = 'application/x-www-form-urlencoded'
if (data instanceof FormData) {
type = 'multipart/form-data'
}
const headers = { 'Content-Type': type }
config = config
? {
...config,
headers,
}
: { headers }
}
return (await http.post(url, data, config)).data['data'] as T
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 B

BIN
src/assets/header/setup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
src/assets/tabbar/bgImg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
src/assets/tabbar/task.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -0,0 +1,8 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.334 1.33301C26.0703 1.33301 26.6668 1.92979 26.667 2.66602V14.0391C26.6669 20.2764 21.8918 25.3328 16.001 25.333C10.11 25.333 5.33407 20.2766 5.33398 14.0391V2.66602C5.33416 1.92989 5.93087 1.33318 6.66699 1.33301H25.334ZM8.00098 14.0508C8.00098 18.7008 11.4219 22.4903 15.7012 22.6602L16.001 22.666C20.3187 22.6658 23.8374 18.9824 23.9951 14.374L24.001 14.0508V4H8.00098V14.0508Z" fill="#999999"/>
<rect x="14.668" y="24" width="2.66667" height="4" fill="#999999"/>
<rect x="8" y="28" width="16" height="2.66667" fill="#999999"/>
<path d="M24 6.6237L24.0154 4L29.1783 4.03508C29.9725 4.04047 30.5859 4.73468 30.4935 5.52353L29.5664 13.4366C29.4886 14.1006 28.9311 14.6044 28.2626 14.6146L24.8646 14.6667L24.8298 12.0432L27.2733 12.0041L27.8995 6.65028L24 6.6237Z" fill="#999999"/>
<path d="M6.81641 6.61352L6.8027 4L2.93882 4.06278C2.15637 4.07549 1.55304 4.75616 1.63436 5.53448L2.45849 13.4227C2.52852 14.093 3.0878 14.6057 3.7617 14.6173L6.63715 14.6667L6.66807 12.0534L4.49741 12.0144L3.94114 6.6814L6.81641 6.61352Z" fill="#999999"/>
<path d="M12.8338 14.7925C12.8323 14.7097 12.764 14.6438 12.6812 14.6452L10.3385 14.6868C10.2427 14.6885 10.1663 14.7676 10.1682 14.8635C10.2663 17.6264 12.4084 19.8562 15.1152 19.9927L15.3789 19.9993C15.4587 19.9978 15.5222 19.9318 15.5207 19.852L15.4748 17.4495C15.4736 17.3837 15.4192 17.3315 15.3535 17.3329C14.0746 17.3329 12.9971 16.3172 12.8493 15.0025L12.8338 14.7925Z" fill="#999999"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,8 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.334 1.33398C26.0703 1.33399 26.6668 1.93076 26.667 2.66699V14.04C26.6669 20.2774 21.8918 25.3338 16.001 25.334C10.11 25.334 5.33407 20.2775 5.33398 14.04V2.66699C5.33416 1.93087 5.93087 1.33416 6.66699 1.33398H25.334ZM8.00098 14.0518C8.00098 18.7018 11.4219 22.4913 15.7012 22.6611L16.001 22.667C20.3187 22.6668 23.8374 18.9833 23.9951 14.375L24.001 14.0518V4.00098H8.00098V14.0518Z" fill="black"/>
<rect x="14.668" y="24" width="2.66667" height="4" fill="black"/>
<rect x="8" y="28" width="16" height="2.66667" fill="black"/>
<path d="M24 6.6237L24.0154 4L29.1783 4.03508C29.9725 4.04047 30.5859 4.73468 30.4935 5.52353L29.5664 13.4366C29.4886 14.1006 28.9311 14.6044 28.2626 14.6146L24.8646 14.6667L24.8298 12.0432L27.2733 12.0041L27.8995 6.65028L24 6.6237Z" fill="black"/>
<path d="M6.81641 6.61352L6.8027 4L2.93882 4.06278C2.15637 4.07549 1.55304 4.75616 1.63436 5.53448L2.45849 13.4227C2.52852 14.093 3.0878 14.6057 3.7617 14.6173L6.63715 14.6667L6.66807 12.0534L4.49741 12.0144L3.94114 6.6814L6.81641 6.61352Z" fill="black"/>
<path d="M12.8338 14.7945C12.8323 14.7117 12.764 14.6457 12.6812 14.6472L10.3385 14.6887C10.2427 14.6904 10.1663 14.7696 10.1682 14.8655C10.2663 17.6283 12.4084 19.8582 15.1152 19.9946L15.3789 20.0013C15.4587 19.9997 15.5222 19.9338 15.5207 19.854L15.4748 17.4514C15.4736 17.3857 15.4192 17.3335 15.3535 17.3348C14.0746 17.3348 12.9971 16.3192 12.8493 15.0045L12.8338 14.7945Z" fill="#2D6FE0"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,5 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="9.00297" y="2.70902" width="13.9967" height="13.9967" rx="6.99833" stroke="#999999" stroke-width="2.67"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.3681 28.0105H27.6105C27.8309 28.0105 28.01 27.8167 28.01 27.5764V22.6733C28.01 22.5193 27.9344 22.3746 27.8096 22.2976C23.7934 19.7724 19.821 18.4434 15.9985 18.3411C12.1747 18.4434 8.20365 19.7724 4.18609 22.2976C4.06133 22.3746 3.98568 22.5193 3.98568 22.6733V27.5764C3.98568 27.8167 4.16618 28.0105 4.3865 28.0105H15.3681ZM2.66537 30.6663C1.92899 30.6663 1.33203 30.0694 1.33203 29.333V22.6738C1.33203 21.5984 1.87089 20.6173 2.77474 20.049C7.06173 17.3565 11.3447 15.8961 15.5202 15.7023V15.6797L16.4772 15.7023C20.6527 15.8961 24.9357 17.3565 29.2227 20.049C30.1265 20.6173 30.6654 21.5984 30.6654 22.6738V29.333C30.6654 30.0694 30.0684 30.6663 29.332 30.6663H2.66537Z" fill="#999999"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.5364 20.078C15.7049 19.6611 16.2951 19.6611 16.4636 20.078L18.3179 24.6672C18.3931 24.8532 18.3498 25.0662 18.2079 25.2081L16.3536 27.0624C16.1583 27.2577 15.8417 27.2577 15.6464 27.0624L13.7921 25.2081C13.6502 25.0662 13.6069 24.8532 13.6821 24.6672L15.5364 20.078Z" fill="#999999"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,10 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="path-1-inside-1_163_100582" fill="white">
<rect x="2.66602" y="2.66797" width="27.6582" height="28" rx="1.33333"/>
</mask>
<rect x="2.66602" y="2.66797" width="27.6582" height="28" rx="1.33333" stroke="#999999" stroke-width="5.33333" mask="url(#path-1-inside-1_163_100582)"/>
<rect x="8.40234" y="1.33203" width="2.66667" height="5.33333" fill="#999999"/>
<rect x="21.334" y="1.33398" width="2.66667" height="5.33333" fill="#999999"/>
<rect x="12.4961" y="11.666" width="8" height="2.66667" fill="#999999"/>
<rect x="12.4961" y="17.666" width="8" height="2.66667" fill="#999999"/>
</svg>

After

Width:  |  Height:  |  Size: 694 B

View File

@ -0,0 +1,9 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="path-1-inside-1_163_96718" fill="white">
<rect x="2.66602" y="2.66797" width="27.6582" height="28" rx="1.33333"/>
</mask>
<rect x="2.66602" y="2.66797" width="27.6582" height="28" rx="1.33333" stroke="black" stroke-width="5.33333" mask="url(#path-1-inside-1_163_96718)"/>
<rect x="8.40234" y="1.33203" width="2.66667" height="5.33333" fill="black"/>
<rect x="21.334" y="1.33398" width="2.66667" height="5.33333" fill="black"/>
<path d="M20.4961 20.335H12.4961V17.668H20.4961V20.335ZM20.4961 14.335H12.4961V11.668H20.4961V14.335Z" fill="#2D6FE0"/>
</svg>

After

Width:  |  Height:  |  Size: 660 B

View File

@ -0,0 +1,8 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.66699 2.66602H29.334V23.1035H3.02051L2.66699 23.6992V6.66602C2.66717 4.45713 4.45811 2.66619 6.66699 2.66602Z" stroke="black" stroke-width="2.66667"/>
<mask id="path-2-inside-1_158_93615" fill="white">
<path d="M1.33398 26.2913C1.33398 23.8625 3.30291 21.8936 5.7317 21.8936H30.1458C30.446 21.8936 30.6894 22.1369 30.6894 22.4372V30.689H5.7317C3.30291 30.689 1.33398 28.7201 1.33398 26.2913Z"/>
</mask>
<path d="M1.33398 26.2913C1.33398 23.8625 3.30291 21.8936 5.7317 21.8936H30.1458C30.446 21.8936 30.6894 22.1369 30.6894 22.4372V30.689H5.7317C3.30291 30.689 1.33398 28.7201 1.33398 26.2913Z" stroke="black" stroke-width="5.33333" mask="url(#path-2-inside-1_158_93615)"/>
<path d="M16.1406 6.76367L17.7612 11.1431L22.1406 12.7637L17.7612 14.3842L16.1406 18.7637L14.5201 14.3842L10.1406 12.7637L14.5201 11.1431L16.1406 6.76367Z" fill="#2D6FE0"/>
</svg>

After

Width:  |  Height:  |  Size: 961 B

View File

@ -0,0 +1,26 @@
<template>
<div class="subject-card">
<h3 class="subject-title">{{ title }}</h3>
</div>
</template>
<script setup lang="ts">
defineProps<{ title: string }>()
</script>
<style scoped lang="scss">
.subject-card {
width: 100%;
height: 100%;
padding: 33px;
box-sizing: border-box;
border-radius: 24px;
background: #fff;
}
.subject-title {
font-size: 38px;
font-weight: 700;
color: #121212;
}
</style>

View File

@ -0,0 +1,110 @@
<template>
<article class="task-card" :class="{ 'is-disabled': disabled }">
<div class="task-info">
<h3 class="task-title">
{{ title }}
<span class="task-progress">({{ progress }})</span>
</h3>
<div class="task-rewards">
<span class="reward-item"><i class="reward-icon"></i>x20</span>
<span class="reward-item"><i class="reward-icon"></i>x20</span>
</div>
</div>
<button class="task-btn" :class="{ 'is-disabled': disabled }">前往</button>
</article>
</template>
<script setup lang="ts">
defineProps<{
title: string
progress: string
disabled?: boolean
}>()
</script>
<style scoped lang="scss">
.task-card {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
border-radius: 12px;
border: 1px solid #d3e1f0;
background: #fff;
padding: 24px 48px;
&.is-disabled {
opacity: 0.55;
}
}
.task-info {
display: flex;
flex-direction: column;
}
.task-title {
font-size: 32px;
color: #333;
font-weight: 400;
}
.task-progress {
margin-left: 8px;
color: #3d78f2;
}
.task-rewards {
display: flex;
align-items: center;
gap: 16px;
margin-top: 8px;
font-size: 28px;
color: #333;
}
.reward-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.reward-icon {
display: inline-block;
width: 20px;
height: 20px;
border-radius: 4px;
background: #eff3f8;
}
.task-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-height: 4.125rem;
min-width: 10.5rem;
padding: 0 24px;
border: none;
border-radius: 9999px;
font-size: 1.75rem;
font-weight: 500;
line-height: 1;
color: #fff;
background: #3d78f2;
cursor: pointer;
&:hover {
background: #356de0;
}
&.is-disabled {
background: #aec4f2;
cursor: not-allowed;
&:hover {
background: #aec4f2;
}
}
}
</style>

View File

@ -0,0 +1,177 @@
<template>
<header class="app-header">
<div class="header-user">
<img
src="https://placehold.co/52x52/e9eef9/8f9db7?text=%E5%A4%B4"
alt="学生头像"
class="user-avatar"
/>
<div class="user-info">
<p class="user-name">HI, 张小小</p>
<span class="user-level">Lv1</span>
</div>
</div>
<div class="header-actions">
<button class="grade-btn">
七年级
<span class="grade-arrow"></span>
</button>
<div class="divider" />
<div class="action-list">
<div v-for="item in actions" :key="item.label" class="action-item">
<span class="action-icon">
<img
v-if="item.iconSrc"
:src="item.iconSrc"
:alt="item.label"
class="action-icon-img"
/>
<template v-else>{{ item.icon }}</template>
</span>
<span class="action-label">{{ item.label }}</span>
<span v-if="item.badge > 0" class="action-badge">
{{ item.badge }}
</span>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import xinxiangIcon from '@/assets/header/xinxiang.png'
import huodongIcon from '@/assets/header/huodong.png'
import setupIcon from '@/assets/header/setup.png'
const actions = [
{ label: '作业', icon: '□', iconSrc: xinxiangIcon, badge: 1 },
{ label: '信箱', icon: '✉', iconSrc: xinxiangIcon, badge: 1 },
{ label: '活动', icon: '▣', iconSrc: huodongIcon, badge: 1 },
{ label: '设置', icon: '⚙', iconSrc: setupIcon, badge: 0 },
]
</script>
<style scoped lang="scss">
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-radius: 12px;
padding: 32px 64px;
}
.header-user {
display: flex;
align-items: center;
gap: 12px;
height: 88px;
}
.user-avatar {
height: 88px;
width: 88px;
border-radius: 50%;
border: 1px solid #d0daec;
object-fit: cover;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
}
.user-name {
font-size: 34px;
font-weight: 600;
color: #121212;
}
.user-level {
border-radius: 6px;
background: #3d78f2;
padding: 2px 6px;
font-size: 12px;
color: #fff;
}
.header-actions {
display: flex;
align-items: center;
gap: 32px;
}
.grade-btn {
font-size: 28px;
color: #3f4f6e;
background: transparent;
border: none;
cursor: pointer;
}
.grade-arrow {
margin-left: 4px;
font-size: 12px;
}
.divider {
height: 24px;
width: 2px;
background: #666;
}
.action-list {
display: flex;
align-items: center;
gap: 32px;
}
.action-item {
position: relative;
display: flex;
align-items: center;
color: #1f2937;
// margin-left: 16px;
}
.action-icon {
display: inline-flex;
height: 48px;
width: 48px;
align-items: center;
justify-content: center;
border-radius: 4px;
font-size: 14px;
// margin-left: 16px;
}
.action-icon-img {
height: 48px;
width: 48px;
object-fit: contain;
}
.action-label {
margin-left: 16px;
font-size: 28px;
}
.action-badge {
position: absolute;
right: -4px;
top: -4px;
display: inline-flex;
height: 16px;
min-width: 16px;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: #ef4444;
padding: 0 4px;
font-size: 10px;
font-weight: 500;
color: #fff;
}
</style>

View File

@ -0,0 +1,24 @@
<template>
<div class="page-frame">
<section class="max-box" :class="sectionClass">
<slot />
</section>
</div>
</template>
<script setup lang="ts">
defineProps<{
sectionClass?: string
}>()
</script>
<style scoped lang="scss">
.page-frame {
display: flex;
min-height: 0;
width: 100%;
flex: 1;
flex-direction: column;
padding: 16px 64px;
}
</style>

View File

@ -0,0 +1,85 @@
<template>
<nav class="tabbar">
<RouterLink
v-for="item in tabs"
:key="item.to"
:to="item.to"
class="tabbar-item"
:class="{ 'is-active': route.path === item.to }"
>
<img :src="item.icon" :alt="item.label" class="tabbar-icon" />
<span class="tabbar-label">{{ item.label }}</span>
</RouterLink>
</nav>
</template>
<script setup lang="ts">
import disciplineIcon from '@/assets/tabbar/discipline.png'
import taskIcon from '@/assets/tabbar/task.png'
import artifactIcon from '@/assets/tabbar/artifact.png'
import learningSituationIcon from '@/assets/tabbar/learningSituation.png'
const route = useRoute()
const tabs = [
{ label: '学科', to: '/subjects', icon: disciplineIcon },
{ label: '任务', to: '/tasks', icon: taskIcon },
{ label: '英语神器', to: '/english-tools', icon: artifactIcon },
{ label: '学情中心', to: '/insights', icon: learningSituationIcon },
]
</script>
<style scoped lang="scss">
.tabbar {
position: fixed;
bottom: 0;
left: 50%;
z-index: 50;
display: flex;
height: 104px;
width: calc(100% - 40px);
max-width: 880px;
transform: translateX(-50%);
flex-shrink: 0;
align-items: flex-start;
justify-content: space-around;
padding: 0 24px;
background-image: url('@/assets/tabbar/bgImg.png');
background-repeat: no-repeat;
background-position: center;
background-size: 100% 100%;
}
.tabbar-item {
position: relative;
z-index: 10;
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 6px;
border-top-left-radius: 12px;
border-top-right-radius: 12px;
padding: 4px 0;
font-size: 26px;
line-height: 1;
color: #333;
margin-top: -45px;
text-decoration: none;
&.is-active {
color: #2d6fe0;
}
}
.tabbar-icon {
height: 72px;
width: 72px;
}
.tabbar-label {
margin-top: 8px;
padding-bottom: 4px;
}
</style>

View File

@ -0,0 +1,148 @@
<template>
<section class="activity-progress">
<div class="progress-row">
<div class="progress-meta">
<p class="progress-value">{{ Math.round(props.value) }}</p>
<p class="progress-subtitle">{{ subtitle }}</p>
</div>
<div class="progress-track">
<div class="track-stage">
<div class="track-icons">
<span v-for="n in 5" :key="n" class="progress-cell"></span>
</div>
<div class="progress-bar">
<div class="progress-bar-fill" :style="{ width: `${fillPercent}%` }"></div>
</div>
<div class="track-labels">
<span style="font-size: 24px;" v-for="tick in ticks" :key="tick">{{ tick }}</span>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const ticks = [20, 40, 60, 80, 100] as const
const props = withDefaults(
defineProps<{
subtitle?: string
/** 0100条形末端与各刻度中心对齐刻度均分整条轨道 */
value?: number
}>(),
{
subtitle: '本日活跃',
value: 40,
},
)
/** 均分五列时列中心约在 10%/30%/50%/70%/90%,将数值映射到条形宽度 */
const fillPercent = computed(() => {
const v = Math.min(100, Math.max(0, props.value))
if (v <= 0) return 0
if (v < 20) return (v / 20) * 10
return Math.min(90, v - 10)
})
</script>
<style scoped lang="scss">
.activity-progress {
margin-bottom: 32px;
// border-radius: 12px;
background: #fff;
// padding: 16px 20px;
}
.progress-row {
display: flex;
align-items: flex-end;
gap: 32px;
}
.progress-meta {
display: flex;
flex-direction: column;
align-items: center;
width: 180px;
}
.progress-value {
font-size: 80px;
font-weight: 600;
color: #3d78f2;
}
.progress-subtitle {
margin-top: 4px;
padding: 6px 24px;
font-size: 24px;
color: #3d78f2;
background-color: rgba(51, 126, 255, 0.04);
line-height: 36px;
}
.progress-track {
flex: 1;
margin-left: 68px;
margin-right: 32px;
min-width: 0;
}
.track-stage {
display: flex;
flex-direction: column;
gap: 16px;
}
.track-icons,
.track-labels {
display: grid;
grid-template-columns: repeat(5, 1fr);
justify-items: center;
}
.track-icons {
margin-bottom: 0;
}
.progress-cell {
box-sizing: border-box;
height: 72px;
width: 72px;
max-width: 100%;
border-radius: 4px;
border: 1px solid #ccd5e8;
background: #f2f5fa;
}
/* 轨道通栏,与方块列对齐:蓝段止于刻度处的百分比位置 */
.progress-bar {
height: 6px;
border-radius: 4px;
background: #dde3ed;
position: relative;
overflow: hidden;
}
.progress-bar-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
border-radius: 4px;
background: #3d78f2;
transition: width 0.2s ease;
}
.track-labels {
margin-top: 0;
font-size: 14px;
color: #7c879e;
line-height: 1.2;
}
</style>

View File

@ -0,0 +1,103 @@
<template>
<aside class="task-sidebar">
<button
v-for="item in menus"
:key="item.key"
class="sidebar-item"
:class="{ 'is-active': item.key === modelValue }"
@click="$emit('update:modelValue', item.key)"
>
<span v-if="item.key === modelValue" class="sidebar-indicator"></span>
<img
class="sidebar-icon"
:src="item.key === modelValue ? item.iconActive : item.iconInactive"
alt=""
aria-hidden="true"
/>
<span class="sidebar-label">{{ item.label }}</span>
</button>
</aside>
</template>
<script setup lang="ts">
import chengjiuN from '@/assets/tasks/chengjiuN.svg'
import chengjiuY from '@/assets/tasks/chengjiuY.svg'
import jiazhangN from '@/assets/tasks/jiazhangN.svg'
import richangN from '@/assets/tasks/richangN.svg'
import richangY from '@/assets/tasks/richangY.svg'
import xinshouY from '@/assets/tasks/xinshouY.svg'
export type TaskTab = 'newbie' | 'daily' | 'achievement' | 'parent'
defineProps<{ modelValue: TaskTab }>()
defineEmits<{ (e: 'update:modelValue', value: TaskTab): void }>()
const menus: Array<{ key: TaskTab; label: string; iconActive: string; iconInactive: string }> = [
{ key: 'newbie', label: '新手', iconActive: xinshouY, iconInactive: xinshouY },
{ key: 'daily', label: '日常', iconActive: richangY, iconInactive: richangN },
{ key: 'achievement', label: '成就', iconActive: chengjiuY, iconInactive: chengjiuN },
{ key: 'parent', label: '家长', iconActive: jiazhangN, iconInactive: jiazhangN },
]
</script>
<style scoped lang="scss">
.task-sidebar {
width: 220px;
flex-shrink: 0;
border-radius: 12px;
padding: 64px 0;
background: #f5f7fa;
border: 2px solid #fff;
}
.sidebar-item {
position: relative;
display: flex;
width: 100%;
align-items: center;
justify-content: center;
padding: 32px;
margin-bottom: 8px;
border: none;
background: transparent;
text-align: left;
font-size: 18px;
color: #000;
cursor: pointer;
&:hover {
background: #e5eaf5;
}
&.is-active {
background: #fff;
color: #121212;
&:hover {
background: #fff;
}
}
}
.sidebar-indicator {
position: absolute;
left: 0;
height: 64px;
width: 6px;
border-top-right-radius: 12px;
border-bottom-right-radius: 12px;
background: #3179f5;
}
.sidebar-icon {
height: 28px;
width: 28px;
flex-shrink: 0;
object-fit: contain;
}
.sidebar-label {
font-size: 28px;
margin-left: 16px;
}
</style>

5
src/config/platform.ts Normal file
View File

@ -0,0 +1,5 @@
/** 平台配置(原项目 sass.platform 精简版,可按域名扩展) */
export const platform = {
tenantId: import.meta.env.VITE_TENANT_ID || '',
appName: import.meta.env.VITE_APP_NAME || '灵心学',
}

16
src/main.ts Normal file
View File

@ -0,0 +1,16 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import './styles/element-plus.scss'
import './styles/global.scss'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')

35
src/router/index.ts Normal file
View File

@ -0,0 +1,35 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', redirect: '/subjects' },
{
path: '/subjects',
name: 'subjects',
component: () => import('@/views/Subjects/index.vue'),
},
{
path: '/tasks',
name: 'tasks',
component: () => import('@/views/Tasks/index.vue'),
},
{
path: '/english-tools',
name: 'english-tools',
component: () => import('@/views/EnglishTools/index.vue'),
},
{
path: '/insights',
name: 'insights',
component: () => import('@/views/Insights/index.vue'),
},
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
},
],
})
export default router

1
src/stores/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './user'

9
src/stores/user.ts Normal file
View File

@ -0,0 +1,9 @@
import { defineStore } from 'pinia'
/** 仅占位登录、token 逻辑按业务再扩展 */
export const userStore = defineStore('user', {
state: () => ({
token: '',
workToken: '',
}),
})

View File

@ -0,0 +1,4 @@
// Element Plus 可按需追加覆盖样式
.el-message {
max-width: 90vw;
}

42
src/styles/global.scss Normal file
View File

@ -0,0 +1,42 @@
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html,
body,
#app {
height: 100%;
}
body {
color: $font-color;
font-size: $font-size-3;
font-family:
'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica,
Arial, sans-serif;
background: #3f3f42;
-webkit-tap-highlight-color: transparent;
}
#app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.max-box {
box-sizing: border-box;
margin-inline: auto;
height: 842px;
width: 100%;
max-width: 1792px;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
background: rgba(255, 255, 255, 0.3);
border: 2px solid rgba(255, 255, 255, 0.6);
border-radius: 12px;
}

303
src/styles/variate.scss Normal file
View File

@ -0,0 +1,303 @@
// 主题
$theme-color: #ffe60f; // 品牌色/主操作按钮/按钮移入/文字链
$theme-minor-color: #fffbd2; // 品牌色/主操作按钮/按钮移入/文字链
// 辅助颜色
$success-color: #07cc89;
$success-color-1: #bdf6e3;
$success-color-2: #e7fef8;
$warning-color: #ffa200;
$warning-color-1: #ffd282;
$warning-color-2: #fff2dc;
$error-color: #f00a0a;
$error-color-1: #ffaa9c;
$error-color-2: #ffece9;
$aux-color: #5170fe;
$info-color: #999;
$label-color: #00c1f6;
$other-color: #b03afe;
$warning-fill: #fff3d4;
$aux-fill: #e7ebff;
// 文字
$font-color: #4b4b68; // 主要颜色
$font-tag-color: #4b4b68; // 标签
$font-main-color: #333; // 正文
$font-aux-color: #666; // 辅助色
$font-minor-color: #999; // 次要颜色
$font-hover-color: #ccc; // 提示文字
$font-size-1: 1.67vh;
$font-size-2: 1.83vh;
$font-size-3: 2vh;
$font-size-4: 2.17vh;
$font-size-5: 2.33vh;
$font-size-6: 2.5vh;
$font-size-7: 2.67vh;
$font-cn: Oppo Sans;
$font-en: droid-serif;
$font-num: Oppo Sans;
$font-text: AlibabaHealth;
$font-special: AlimamaShuHeiTi;
$font-weight: 500;
$book-tag-bg: #56d6f0;
$book-tag-color: #fff;
$book-shelf-color: #6a8cf4;
$split-color: #e6e6e6;
$border-color: $split-color;
$border-input-color: #e8eaec;
$gradient: linear-gradient(180deg, #4de798 0%, #a2fb6e 100%);
$gray_bg: radial-gradient(121.65% 95.08% at 50% 83.83%, #eaf2ff 0%, #93a3c0 100%);
$grey-gradient: radial-gradient(135.92% 96.87% at 50% 83.83%, #eaf2ff 0%, #93a3c0 100%);
$white-gradient: linear-gradient(162deg, #fdfdf9 12.23%, #dfebf7 90.14%);
$page-fill: #fafafa;
$bg-fill: #fff;
$bg-aux-fill: #f5f5f5;
$bg-theme-aux-fill: #fffdee;
$input-fill: #fafafa;
$menuItem-hover-fill: #f7f7f7;
$hover-fill: #f9f9f9;
$error-fill: $error-color;
$hover-theme-fill: #eed715;
$disabled-theme-fill: #fff387;
$bg-minor-theme-fill: #fffde9;
$btn-fill: #eeeeee;
$btn-hover-fill: #e6e6e6;
$btn-disabled-fill: #dbdbdb;
$btn-error-fill: #ffefec;
$btn-primary-fill: $theme-color;
$btn-primary-hover-fill: #f8e00e;
$btn-primary-disabled-fill: #fff387;
$scroll-color: #f5f5f5;
$icon-color: #4d4d4d;
$icon-minor-color: #999;
$icon-aux-color: #ccc;
$icon-hover-fill: $hover-fill;
$space-1: 8px;
$space-1_h: 0.67vh;
$space-2: 16px;
$space-2_h: 1.33vh;
$space-3: 24px;
$space-3_h: 2vh;
$space-4: 32px;
$space-4_h: 2.67vh;
$space-5: 40px;
$space-5_h: 3.33vh;
$space-6: 48px;
$space-6_h: 3vh;
$space-7: 56px;
$space-7_h: 4.67vh;
$space-8: 64px;
$space-8_h: 5.33vh;
$space-70: 70px;
$space-70_h: 5.83vh;
$radius-1: 0.33vh;
$radius-2: 0.67vh;
$radius-3: 1vh;
$radius-4: 1.33vh;
$radius-5: 1.67vh;
$radius-6: 2vh;
$radius-7: 2.33vh;
@mixin single_ellipsis {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
@mixin multiple_ellipsis($line: 3) {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: $line;
}
@mixin scroll_bar {
&::-webkit-scrollbar {
width: 4px;
height: 0.33vh;
}
&::-webkit-scrollbar-thumb {
border-radius: 0.67vh;
background-color: $scroll-color;
}
&::-webkit-scrollbar-track {
border-radius: 0.67vh;
background-color: transparent;
}
}
@mixin hidden_scroll {
overflow-y: auto;
scrollbar-width: none;
scrollbar-color: transparent transparent;
::-webkit-scrollbar {
width: 0;
}
}
@mixin font-stroke($color: #424155, $stroke: 3px) {
position: relative;
z-index: 0;
&::after {
width: 100%;
content: attr(data-content);
-webkit-text-stroke: $stroke * 2 $color;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: -1;
}
}
@mixin gradient($color1, $color2) {
background: $color1, $color2;
background-repeat: no-repeat;
background-origin: border-box;
background-clip: content-box, border-box;
}
@mixin checkbox {
:deep(.el-checkbox__input) {
.el-checkbox__inner {
width: 3.33vh;
height: 3.33vh;
border-radius: 50% !important;
background: rgba(0, 0, 0, 0.65);
border: 0.25vh solid #a2fb6e;
&::after {
border-width: 0.25vh;
height: 1.25vh;
left: 0.92vh;
top: 0.42vh;
width: 0.67vh;
border-color: #a2fb6e;
}
&::before {
height: 0.67vh;
top: 1.08vh;
background-color: #a2fb6e;
}
}
}
}
.animate__breath {
animation-name: breath;
animation-timing-function: ease-in-out;
}
.animate__slideInLeft {
animation-name: slideInLeft;
}
.animate__slideInRight {
animation-name: slideInRight;
}
.animate__animated {
animation-duration: 1s;
animation-fill-mode: both;
}
.animate__animated.animate__infinite {
animation-iteration-count: infinite;
}
.animate__shakeX {
animation: shake 0.6s linear forwards;
}
@keyframes shake {
0% {
transform: translateX(0px);
}
12% {
transform: translateX(7px);
}
25% {
transform: translateX(-7px);
}
50% {
transform: translateX(7px);
}
62% {
transform: translateX(-7px);
}
75% {
transform: translateX(7px);
}
100% {
transform: translateX(0px);
}
}
@keyframes breath {
0% {
transform: scale3d(1, 1, 1);
}
50% {
transform: scale3d(1.2, 1.2, 1.2);
}
100% {
transform: scale3d(1, 1, 1);
}
}
@keyframes slideInLeft {
0% {
transform: translate3d(-100%, 0, 0);
visibility: visible;
}
100% {
transform: translate3d(0, 0, 0);
}
}
@keyframes slideInRight {
0% {
transform: translate3d(100%, 0, 0);
visibility: visible;
}
100% {
transform: translate3d(0, 0, 0);
}
}
@mixin animation($name, $color, $rotate: 120deg) {
animation-name: #{$name};
animation-duration: 1.6s;
animation-timing-function: ease;
animation-iteration-count: infinite;
@keyframes #{$name} {
0% {
background-size: 300% 100%;
background-image: linear-gradient($rotate, transparent 40%, $color 50%, transparent 60%);
background-position: 100% 50%;
}
100% {
background-size: 300% 100%;
background-image: linear-gradient($rotate, transparent 40%, $color 50%, transparent 60%);
background-position: 0 50%;
}
}
}
@mixin main_bg {
background: url(https://xxl-1313840333.cos.ap-guangzhou.myqcloud.com/urm/main_bg.svg) center
center / cover no-repeat;
}
@mixin gray_bg {
background: $gray_bg;
}

10
src/utils/obj.ts Normal file
View File

@ -0,0 +1,10 @@
const snowflakeId = (workerId = 1): string => {
const timestamp = BigInt(Date.now())
const workerBits = BigInt(workerId) << 12n
const sequenceBits = BigInt(Math.floor(Math.random() * 4096))
return String((timestamp << 22n) | workerBits | sequenceBits)
}
export default {
snowflakeId,
}

19
src/utils/store.ts Normal file
View File

@ -0,0 +1,19 @@
/**
* xuexiaole-web key API
*/
const prefixKey = (key: string) => `X_${key.toUpperCase()}`
export const setCache = (key: string, value: string) => {
if (!key) return
localStorage.setItem(prefixKey(key), value)
}
export const getCache = (key: string): string => {
if (!key) return ''
return localStorage.getItem(prefixKey(key)) || ''
}
export const delCache = (key: string) => {
if (!key) return
localStorage.removeItem(prefixKey(key))
}

21
src/utils/viewport.ts Normal file
View File

@ -0,0 +1,21 @@
/** 1920 / 1200 设计稿与 xuexiaole-web 一致 */
export const px2vw = (px: number | string) => {
const v = Number(px)
return Number.isNaN(v) ? String(px) : `${(v / 1920) * 100}vw`
}
export const px2vh = (px: number | string) => {
const v = Number(px)
return Number.isNaN(v) ? String(px) : `${(v / 1200) * 100}vh`
}
export const px2px = (px: number | string) => {
const v = Number(px)
return Number.isNaN(v) ? Math.round(Number(px)) : Math.round((v / 1920) * window.innerWidth)
}
export const px2px_h = (px: number | string) => {
const v = Number(px)
return Number.isNaN(v) ? Math.round(Number(px)) : Math.round((v / 1200) * window.innerHeight)
}

View File

@ -0,0 +1,38 @@
<template>
<section class="tools-grid">
<article v-for="tool in tools" :key="tool" class="tool-card">
<h3 class="tool-title">{{ tool }}</h3>
</article>
</section>
</template>
<script setup lang="ts">
const tools = ['单词书', '单词闯关', '单词测评', '词典', '生词本', '乐读', '乐训语感', '句子箱', '零基础']
</script>
<style scoped lang="scss">
.tools-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
border-radius: 12px;
background: #d8e1f0;
padding: 16px;
}
.tool-card {
display: flex;
height: 128px;
align-items: center;
border-radius: 12px;
background: #fff;
padding: 0 28px;
box-shadow: 0 4px 12px rgba(51, 83, 145, 0.08);
}
.tool-title {
font-size: 30px;
font-weight: 700;
color: #121212;
}
</style>

View File

@ -0,0 +1,38 @@
<template>
<section class="insights-grid">
<article v-for="item in items" :key="item" class="insight-card">
<h3 class="insight-title">{{ item }}</h3>
</article>
</section>
</template>
<script setup lang="ts">
const items = ['学员管理', '排行榜', '学情分析', '错题本']
</script>
<style scoped lang="scss">
.insights-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
border-radius: 12px;
background: #d8e1f0;
padding: 16px;
}
.insight-card {
display: flex;
height: 176px;
align-items: center;
border-radius: 12px;
background: #fff;
padding: 0 36px;
box-shadow: 0 4px 12px rgba(51, 83, 145, 0.08);
}
.insight-title {
font-size: 38px;
font-weight: 700;
color: #121212;
}
</style>

23
src/views/LoginView.vue Normal file
View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
function goHome() {
router.replace('/')
}
</script>
<template>
<div class="login px-w">
<p>登录页占位</p>
<el-button type="primary" @click="goHome">返回首页</el-button>
</div>
</template>
<style scoped lang="scss">
.login {
padding: 40px;
font-size: 18px;
}
</style>

View File

@ -0,0 +1,23 @@
<template>
<PageFrame section-class="subjects-grid">
<SubjectCard v-for="item in subjects" :key="item" :title="item" />
</PageFrame>
</template>
<script setup lang="ts">
import PageFrame from '@/components/layout/PageFrame.vue'
import SubjectCard from '@/components/cards/SubjectCard.vue'
const subjects = ['英语', '语文', '数学', '科学']
</script>
<style scoped lang="scss">
:deep(.subjects-grid) {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 33px;
border-radius: 12px;
padding: 33px;
}
</style>

96
src/views/Tasks/index.vue Normal file
View File

@ -0,0 +1,96 @@
<template>
<PageFrame section-class="tasks-section">
<div class="tasks-panel">
<TaskSidebar v-model="activeTab" />
<div class="tasks-content">
<ActivityProgress v-if="activeTab === 'daily'" subtitle="本日活跃" />
<ActivityProgress v-else-if="activeTab === 'achievement'" subtitle="当前成就点" />
<TaskCard
v-for="task in currentTasks"
:key="task.title"
:title="task.title"
:progress="task.progress"
:disabled="task.disabled"
/>
</div>
</div>
</PageFrame>
</template>
<script setup lang="ts">
import PageFrame from '@/components/layout/PageFrame.vue'
import TaskCard from '@/components/cards/TaskCard.vue'
import TaskSidebar, { type TaskTab } from '@/components/tasks/TaskSidebar.vue'
import ActivityProgress from '@/components/tasks/ActivityProgress.vue'
type TaskItem = {
title: string
progress: string
disabled?: boolean
}
const activeTab = ref<TaskTab>('newbie')
const newbieTasks: TaskItem[] = [
{ title: '任务名称任务名称任务名称', progress: '2/5' },
{ title: '任务名称任务名称任务名称', progress: '0/5' },
{ title: '任务名称任务名称任务名称', progress: '5/5', disabled: true },
{ title: '任务名称任务名称任务名称', progress: '5/5', disabled: true },
{ title: '任务名称任务名称任务名称', progress: '2/5' },
{ title: '任务名称任务名称任务名称', progress: '0/5' },
{ title: '任务名称任务名称任务名称', progress: '5/5', disabled: true },
{ title: '任务名称任务名称任务名称', progress: '5/5', disabled: true },
{ title: '任务名称任务名称任务名称', progress: '2/5' },
{ title: '任务名称任务名称任务名称', progress: '0/5' },
{ title: '任务名称任务名称任务名称', progress: '5/5', disabled: true },
{ title: '任务名称任务名称任务名称', progress: '5/5', disabled: true },
]
const dailyTasks: TaskItem[] = [
{ title: '任务名称任务名称任务名称', progress: '2/5' },
{ title: '任务名称任务名称任务名称', progress: '0/5' },
{ title: '任务名称任务名称任务名称', progress: '5/5', disabled: true },
{ title: '任务名称任务名称任务名称', progress: '5/5', disabled: true },
]
const placeholderTasks: TaskItem[] = [
{ title: '任务名称任务名称任务名称', progress: '2/5' },
{ title: '任务名称任务名称任务名称', progress: '0/5' },
{ title: '任务名称任务名称任务名称', progress: '5/5', disabled: true },
{ title: '任务名称任务名称任务名称', progress: '5/5', disabled: true },
]
const currentTasks = computed(() => {
if (activeTab.value === 'newbie') return newbieTasks
if (activeTab.value === 'daily') return dailyTasks
return placeholderTasks
})
</script>
<style scoped lang="scss">
:deep(.tasks-section) {
display: flex;
min-height: 0;
flex-direction: column;
border-radius: 12px;
padding: 16px;
}
.tasks-panel {
display: flex;
flex: 1;
min-height: 0;
border: 1px solid #d3e1f0;
border-radius: 12px;
background: #fff;
}
.tasks-content {
min-height: 0;
flex: 1;
overflow-y: auto;
border-radius: 12px;
padding: 32px;
padding-top: 32px;
}
</style>

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

30
tsconfig.app.json Normal file
View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["element-plus/global"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@c/*": ["./src/components/*"],
"@p/*": ["./src/plugins/*"]
}
},
"include": [".*.d.ts", "src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

9
tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "@vue/tsconfig/tsconfig.json",
"files": [],
"compilerOptions": {
"esModuleInterop": true,
"preserveValueImports": false
},
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

8
tsconfig.node.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "@vue/tsconfig/tsconfig.json",
"include": ["vite.config.*"],
"compilerOptions": {
"composite": true,
"types": ["node"]
}
}

File diff suppressed because one or more lines are too long

111
vite.config.ts Normal file
View File

@ -0,0 +1,111 @@
import { defineConfig } from 'vite'
import path from 'path'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver, VantResolver } from 'unplugin-vue-components/resolvers'
/** 与 xuexiaole-web 一致:构建/开发时将绝大部分 px 转为 vw设计稿宽 1920 */
const pxToViewport = (options = {}) => {
const defaultOptions = {
unitToConvert: 'px',
viewportWidth: 1920,
viewportHeight: 1200,
unitPrecision: 2,
propList: ['*'],
viewportUnit: 'vw',
fontViewportUnit: 'vw',
selectorBlackList: ['.px-w'],
minPixelValue: 1,
mediaQuery: true,
replace: true,
landscape: false,
landscapeUnit: 'vw',
landscapeWidth: 1920,
}
const opts = { ...defaultOptions, ...options }
return {
postcssPlugin: 'postcss-px-to-viewport',
Declaration(decl: { value: string }) {
if (!decl.value.includes('px')) return
const pxRegex = /(\d*\.?\d+)px/g
decl.value = decl.value.replace(pxRegex, (match, group) => {
const pixels = parseFloat(group)
if (pixels <= opts.minPixelValue) return match
const vw = ((pixels / opts.viewportWidth) * 100).toFixed(opts.unitPrecision)
return `${vw}${opts.viewportUnit}`
})
},
}
}
pxToViewport.postcss = true
const dir = (url: string): string => path.resolve(__dirname, url)
export default defineConfig({
resolve: {
alias: {
'@': dir('src'),
'@c': dir('src/components'),
'@p': dir('src/plugins'),
},
},
plugins: [
vue(),
vueJsx(),
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [ElementPlusResolver(), VantResolver()],
eslintrc: {
enabled: true,
filepath: '.auto-import.json',
globalsPropValue: true,
},
dirs: ['src/stores'],
dts: dir('.auto-import.d.ts'),
}),
Components({
dirs: ['src/components'],
extensions: ['vue', 'tsx'],
resolvers: [ElementPlusResolver(), VantResolver()],
dts: dir('.components.d.ts'),
}),
],
css: {
preprocessorOptions: {
scss: {
additionalData: `
@use "@/styles/variate.scss" as *;
`,
api: 'modern',
silenceDeprecations: ['legacy-js-api'],
},
},
postcss: {
plugins: [
pxToViewport({
viewportWidth: 1920,
viewportHeight: 1200,
unitPrecision: 2,
selectorBlackList: ['.px-w'],
}),
],
},
},
server: {
port: 5173,
host: '0.0.0.0',
open: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
})