add第一次提交
89
.auto-import.d.ts
vendored
Normal 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
@ -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']
|
||||
}
|
||||
}
|
||||
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.local
|
||||
.auto-import.json
|
||||
node_modules/.tmp
|
||||
13
index.html
Normal 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
35
package.json
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
|
||||
}
|
||||
BIN
src/assets/header/huodong.png
Normal file
|
After Width: | Height: | Size: 495 B |
BIN
src/assets/header/setup.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/header/xinxiang.png
Normal file
|
After Width: | Height: | Size: 630 B |
BIN
src/assets/tabbar/artifact.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
src/assets/tabbar/bgImg.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/tabbar/discipline.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
src/assets/tabbar/learningSituation.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
src/assets/tabbar/task.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
8
src/assets/tasks/chengjiuN.svg
Normal 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 |
8
src/assets/tasks/chengjiuY.svg
Normal 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 |
5
src/assets/tasks/jiazhangN.svg
Normal 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 |
10
src/assets/tasks/richangN.svg
Normal 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 |
9
src/assets/tasks/richangY.svg
Normal 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 |
8
src/assets/tasks/xinshouY.svg
Normal 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 |
26
src/components/cards/SubjectCard.vue
Normal 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>
|
||||
110
src/components/cards/TaskCard.vue
Normal 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>
|
||||
177
src/components/layout/AppHeader.vue
Normal 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>
|
||||
24
src/components/layout/PageFrame.vue
Normal 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>
|
||||
85
src/components/layout/TabBar.vue
Normal 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>
|
||||
148
src/components/tasks/ActivityProgress.vue
Normal 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
|
||||
/** 0–100,条形末端与各刻度中心对齐(刻度均分整条轨道) */
|
||||
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>
|
||||
103
src/components/tasks/TaskSidebar.vue
Normal 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
@ -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
@ -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
@ -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
@ -0,0 +1 @@
|
||||
export * from './user'
|
||||
9
src/stores/user.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
/** 仅占位:登录、token 逻辑按业务再扩展 */
|
||||
export const userStore = defineStore('user', {
|
||||
state: () => ({
|
||||
token: '',
|
||||
workToken: '',
|
||||
}),
|
||||
})
|
||||
4
src/styles/element-plus.scss
Normal file
@ -0,0 +1,4 @@
|
||||
// Element Plus 可按需追加覆盖样式
|
||||
.el-message {
|
||||
max-width: 90vw;
|
||||
}
|
||||
42
src/styles/global.scss
Normal 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
@ -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
@ -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
@ -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
@ -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)
|
||||
}
|
||||
38
src/views/EnglishTools/index.vue
Normal 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>
|
||||
38
src/views/Insights/index.vue
Normal 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
@ -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>
|
||||
23
src/views/Subjects/index.vue
Normal 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
@ -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
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
30
tsconfig.app.json
Normal 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
@ -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
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.json",
|
||||
"include": ["vite.config.*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
1
tsconfig.node.tsbuildinfo
Normal file
111
vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||