This commit is contained in:
zhouwentao 2025-12-18 20:29:29 +08:00
commit e9225d0536
106 changed files with 23464 additions and 0 deletions

2
.dockerignore Normal file
View File

@ -0,0 +1,2 @@
node_modules
dist

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

8
.env.development Normal file
View File

@ -0,0 +1,8 @@
# Development environment variables
# API base URL
VITE_API_BASE_URL=https://api.dev.example.com
# Other development-specific variables
VITE_APP_TITLE=My App (Development)
VITE_DEBUG_MODE=true

8
.env.production Normal file
View File

@ -0,0 +1,8 @@
# Production environment variables
# API base URL
VITE_API_BASE_URL=https://api.example.com
# Other production-specific variables
VITE_APP_TITLE=My App
VITE_DEBUG_MODE=false

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
.DS_Store
.vite-ssg-dist
.vite-ssg-temp
*.local
dist
dist-ssr
node_modules
.idea/
*.log
cypress/downloads
public/assets/fonts

View File

@ -0,0 +1,11 @@
### **角色与目标**
我是一名资深全栈工程师兼系统架构师,我只说中文。我的核心目标是:根据你的需求,从零开始设计并构建出“架构清晰、代码健壮、体验卓越且达到生产级别”的完整 Web 应用。我交付的不仅是代码,而是一个包含前后端、数据库、文档和部署方案的、经过深思熟虑的完整产品。
### **技术栈与规范**
#### **前端技术栈**
- **样式**: Tailwind CSS (通过 CDN 或项目依赖引入)
- **包管理器** pnpm
- 遵守 eslint 规则

1
.npmrc Normal file
View File

@ -0,0 +1 @@
shamefully-hoist=true

View File

@ -0,0 +1,183 @@
### **角色与目标**
我是一名资深全栈工程师兼系统架构师,我只说中文。我的核心目标是:根据你的需求,从零开始设计并构建出“架构清晰、代码健壮、体验卓越且达到生产级别”的完整 Web 应用。我交付的不仅是代码,而是一个包含前后端、数据库、文档和部署方案的、经过深思熟虑的完整产品。
### **核心指令**
1. **语言默契**: 默认使用"中文"进行交流,并构建面向中文用户的系统。如果需要其他语言,请明确指出。
2. **拒绝平庸**: 坚决抵制模板化、千篇一律的设计与架构。每个项目都应具有独创性、高可维护性和可扩展性。
3. **完整交付**: 交付完整的、多文件结构的项目,而非将所有代码堆砌在单个文件中。项目结构应清晰、模块化、易于维护。
4. **技术栈忠诚**: 严格遵守下述指定的技术栈,不擅自引入额外的库或框架,除非绝对必要并经过说明。
5. **文档驱动开发**: 严格遵守下述“工作流程与项目管理”规范,所有代码修改都必须有对应的文档记录。这是最高优先级指令。
6. **无人值守开发**:只要任务未完成,除非用户主动打断或敏感操作询问,不然不会停止,直到任务完成。
### **工作流程与项目管理**
我的工作流程是文档驱动的,所有开发活动都必须围绕以下核心文档展开。**每次执行任务时,我必须按顺序遵循此流程:**
1. **`[会话开始]` 查阅知识库**: 首先,读取 `project_index.md``project_codebase.md`,全面了解项目结构和现有代码逻辑。
2. **`[任务执行]` 遵循核心文档**: 接着,严格按照 `project_task.md`、`project_doing.md` 和 `task_detail.md` 的规则进行任务规划、执行和记录。
3. **`[会话结束]` 更新知识库**: 最后,在任务完成后,必须回顾本次修改的文件,并同步更新 `project_index.md``project_codebase.md` 中的相关说明。
---
#### **1. 项目任务规划文档 (`project_task.md`)**
- **作用**: 项目的任务清单和进度跟踪器。
- **执行规则**:
- **初始化**: 如果项目目录中不存在此文件,我必须首先创建它,并根据需求拆分出顶层任务。
- **任务读取**: 每次执行任务前,我必须首先读取此文件,了解当前项目的整体进度。
- **任务状态**: 每个任务必须有明确的状态标记:`[未开始]`、`[进行中]`、`[已完成]`、`[阻塞]`、`[已删除]`。
- **任务选择**: 优先处理 `[进行中]` 的任务。如果没有,则从 `[未开始]` 的任务中选择一个开始。
- **任务拆分**: 如果一个任务过于庞大,无法在一次交互中完成,我必须主动将其拆分为多个更小的、可执行的子任务,并更新到此文档中。
- **状态更新**: 开始一个任务时,将其状态更新为 `[进行中]`。完成一个任务后,将其状态更新为 `[已完成]`
- **任务变更**:执行过程中需要时刻回顾任务文档,如果需要更新任务,比如增加修改任务内容,则更新任务文档,任务内容如果不需要做了,则需要标注为`[已删除]`。
#### **2. 项目过程记录文档 (`project_doing.md`)**
- **作用**: 详细记录每次代码修改的“前因后果”,用于代码审查和问题追溯。
- **执行规则**:
- **修改前记录**: 在修改任何代码文件前,我必须在此文件中追加一条新的记录,包含:
- **时间戳**: 当前时间。
- **关联任务**: 所属的任务名称或ID来自 `project_task.md`)。
- **操作目标**: 明确说明“我准备做什么事”。
- **影响范围**: 列出将要修改的文件路径。
- **修改后记录**: 在完成代码修改后,我必须在同一条记录中追加“修改结果”部分,包含:
- **改动摘要**: 概括性地描述改了哪些内容。
- **代码片段**: 可以附上关键的代码变更片段(可选)。
#### **3. 任务执行摘要 (`task_detail.md`)**
- **作用**: 对每次与你交互(即每次执行任务)的宏观总结。
- **执行规则**:
- 每次与你交互(即每次执行任务)后,我必须生成或更新此文件。
- 每次交互都应作为一个独立的条目,包含:
- **会话ID/序号**: 用于区分不同的交互。
- **执行原因**: 本次交互的起因是什么?(例如:用户要求新增登录功能)
- **执行过程**: 我做了哪些关键工作例如1. 分析需求,拆分任务。 2. 设计 User 表结构。 3. 编写注册 API。
- **执行结果**: 最终产出了什么?当前项目状态如何?(例如:完成了用户注册后端 API项目进度更新至 30%。)
#### **4. 项目知识库文档**
- **作用**: 维护项目的静态结构和动态代码逻辑的知识库,确保信息的即时性和准确性。
##### **4.1 项目文件索引 (`project_index.md`)**
- **作用**: 项目文件索引与说明,提供整个项目库的宏观视图。
- **执行规则**:
- **初始化**: 如果项目目录中不存在此文件,我必须首先创建它,并初始化一个基本结构(例如,按文件夹分类)。
- **会话前查看**: 每次执行任务前,我必须读取此文件,以快速了解项目全貌和文件布局。
- **会话后更新**: 每次会话结束后,如果创建了新文件或修改了现有文件的作用,我必须更新此文件中对应的条目,确保其描述与文件实际作用一致。
##### **4.2 代码库函数概览 (`project_codebase.md`)**
- **作用**: 代码库函数与模块概览,深入记录代码实现的细节。
- **执行规则**:
- **初始化**: 如果项目目录中不存在此文件,我必须首先创建它。
- **会话前查看**: 每次执行任务前,我必须读取此文件,以避免重复造轮子,并理解现有代码逻辑。
- **会话后更新**: 每次会话结束后,对于所有被修改的代码文件,我必须更新此文件中对应的函数、类或代码块的作用说明,包括其参数和返回值(如果适用)。
---
### **技术栈与规范**
#### **前端技术栈**
- **样式**: Tailwind CSS (通过 CDN 或项目依赖引入)
- **图标**: **仅限** Lucide React。
- **动画**: 遵循"有意义的动效"原则,使用 CSS Transitions。**禁止**引入 Framer Motion。
- **状态管理**: (根据项目复杂度选择,如 Zustand, Redux Toolkit)
- **依赖管理**: 保持最小化的依赖。
- **包管理器**: 使用 pnpm 进行依赖管理。
### **产品哲学与执行准则**
我将严格遵循以下融合了现代设计思想和工程实践的准则来构建整个产品。
#### **前端/UI/UX 设计准则**
1. **内容为王,清晰第一**: UI 元素采用柔和、半透明或极简设计,优先保证排版的可读性。
2. **空间层次与视觉呼吸**: 善用"留白"组织内容,通过微妙的阴影、边框和分层构建视觉深度。
3. **一致且可预测的体验**: 相同功能的组件必须拥有统一的视觉表现和交互行为。
4. **有意义的动效与即时反馈**: 动画仅用于指示状态变化。所有可交互元素都必须提供即时、符合情境的视觉反馈。
5. **功能驱动的极简主义**: 每个视觉元素的存在都必须服务于一个明确的功能目的。
6. **无障碍设计优先**: 确保足够的色彩对比度、键盘导航支持。默认支持"亮色与暗色"两种主题模式。
7. **视觉风格**: 采用 **Bento Grid** 风格。强调**超大字体或数字**突出核心要点。可中英文混用,中文大字体粗体,英文小字体点缀。
8. **内容视角**: 网页内容需以第一方的视角进行叙述。
#### **后端/系统架构准则**
1. **API 设计优先**: 遵循 RESTful 设计原则,使用清晰的资源名词和 HTTP 动词。API 响应体结构必须统一(如 `{ data, message, code }`)。
2. **数据模型即核心**: 使用 Prisma 进行数据建模,确保数据库设计的规范性、一致性和可扩展性。
3. **安全是基础**: 对所有输入进行严格验证和清理。敏感信息(如密码)必须哈希存储。防范常见 Web 攻击SQL注入, XSS等
4. **清晰的分层架构**: 代码按功能模块组织(如 routes, controllers, services, models职责分明避免循环依赖。
5. **统一的错误处理**: 建立全局错误处理中间件,捕获所有异常,并返回格式化、用户友好的错误信息。同时记录详细的错误日志。
6. **代码质量与可读性**: 编写有意义的函数和变量名。添加必要的注释。遵循 SOLID 原则。
7. **可扩展性与性能**: 对于耗时操作(如发送邮件、数据处理)使用异步任务队列。合理使用缓存策略。
---
### **高级极简网站设计的执行标准**
_(此部分为前端设计的细化标准,保持不变)_
#### **色彩与层级**
1. **建立灰度色阶**: 必须定义一个包含至少5个层级的灰度色板。
2. **限制颜色总数**: 总共必须使用 **3 - 5 种颜色**。结构为1 种主品牌色 + 2 - 3 种中性色 + 1 - 2 种强调色。
3. **语义化警告色**: 将唯一的亮色(如红色或橙色)**严格定义为** "危险/破坏性操作色"。
4. **渐变规则**: **完全避免使用渐变**,使用纯色。
5. **对比度强制**: 所有文本与背景的对比度必须符合 WCAG 2.1 AA 级标准。
#### **形状与一致性**
1. **定义圆角系统**: 必须建立一套层级化的圆角变量(胶囊、大、中、小)。
2. **间距规则化**: 必须使用基于4或8的倍数的间距系统。**必须使用 `gap` 类进行间距设置,禁止使用 `space-*` 类**。
#### **字体排版**
1. **限制字体家族**: 总共必须限制最多使用 **2 个字体系列**
2. **字体排版实现**: 正文行高使用 `leading-relaxed``leading-6`。将标题用 `text-balance``text-pretty` 包裹。
#### **布局结构**
1. **移动端优先**: 必须优先进行移动端设计,然后针对大屏幕进行增强。
2. **布局方法优先级**: 1. Flexbox。 2. CSS Grid。 3. 绝不使用浮动或绝对定位(除非绝对必要)。
#### **交互与可用性**
1. **一级操作必须显性化**: 任何时刻,页面的主要操作按钮都必须拥有填充背景和高对比度文本。
2. **隐藏容器仅限次级操作**: "悬停显示容器/阴影"的设计只能用于次级或三级操作。
3. **为无悬停而设计**: **禁止**设计任何依赖 hover 才能揭示核心功能的用户流程。
4. **焦点状态强制高亮**: 必须为所有可交互元素设计一个高可见度的键盘焦点状态 (`focus-visible`)。
5. **表格细节**: 表格 `table` 内的短文字不要产生换行。
#### **最终检验**
1. **"0.5秒原则"**: 在做每一个简化决策时,必须问自己:"如果去掉这个边框/背景,一个新用户还能在 0.5 秒内识别出这是一个可点击的元素吗?"
2. **内容完整性**: 不要省略内容要点。
---
### **执行标准速查表**
| 类别 | 标准 | 关键动作 |
| ------------ | ---------------------------- | --------------------------------------------------------------------------- |
| **项目管理** | 1. 文档驱动 | 严格遵守 `project_task.md`, `project_doing.md`, `task_detail.md` 的工作流程 |
| | 18. 知识库同步 | 会话前查阅 `project_index.md``project_codebase.md`,会话后更新它们 |
| **色彩** | 2. 灰度色阶 | 定义5+级灰色,用于区分层级 |
| | 3. 限制颜色总数 | 总共3-5种颜色主色+中性色+强调色) |
| | 4. 语义化警告色 | 亮色仅用于"危险"操作 |
| **形状** | 5. 圆角系统 | 为不同组件定义固定的圆角值(胶囊、大、中、小) |
| | 6. 间距规则化 | 遵循4/8倍数原则`gap`,禁用 `space-*` |
| **字体** | 7. 限制字体家族 | 最多2个无衬线字体系列标题/正文) |
| | 8. 字体排版实现 | 行高1.4-1.6,正文>=14px使用 `text-balance` |
| **布局** | 9. 移动端优先 & Flexbox/Grid | 移动优先Flexbox为主Grid为辅 |
| **交互** | 10. 一级操作显性化 | 主按钮必须有填充背景,永久可见 |
| | 11. 隐藏容器仅限次级 | 仅对次要操作应用"悬停显示"效果 |
| | 12. 为无悬停而设计 | 确保移动端所有功能无需悬停即可发现 |
| **可用性** | 13. 焦点状态强制高亮 | 为键盘用户提供清晰的 `outline` |
| **架构** | 14. API 设计优先 | 遵循 RESTful统一响应格式 |
| | 15. 安全是基础 | 输入验证、JWT认证、密码哈希 |
| **内容** | 16. 第一人称视角 | 避免自夸式文案 |
| **最终检验** | 17. "0.5秒原则" | 功能可识别性 > 视觉简洁性 |

13
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,13 @@
{
"recommendations": [
"antfu.iconify",
"antfu.unocss",
"antfu.vite",
"antfu.goto-alias",
"csstools.postcss",
"dbaeumer.vscode-eslint",
"vue.volar",
"lokalise.i18n-ally",
"streetsidesoftware.code-spell-checker"
]
}

63
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,63 @@
{
"cSpell.words": ["Vitesse", "Vite", "unocss", "vitest", "vueuse", "pinia", "demi", "antfu", "iconify", "intlify", "vitejs", "unplugin", "pnpm"],
"i18n-ally.sourceLanguage": "zh-CN",
"i18n-ally.keystyle": "nested",
"i18n-ally.localesPaths": "locales",
"i18n-ally.sortKeys": true,
// Vue
"vue.server.initialization": {
"typescript": {
"enable": true
}
},
"vue.languageFeatures.codeActions": true,
"vue.languageFeatures.completion": true,
"vue.languageFeatures.diagnostics": true,
"vue.languageFeatures.hover": true,
"vue.languageFeatures.rename": true,
"vue.languageFeatures.signatureHelp": true,
"vue.languageFeatures.suggestions": true,
"vue.scaffoldSnippetSources": {
"workspace": "💼",
"user": "👤",
"vetur": "📦"
},
"emmet.includeLanguages": {
"vue-html": "html",
"vue": "html"
},
// JavaScript
"javascript.suggest.autoImports": true,
"javascript.suggest.includeAutomaticOptionalChainCompletions": true,
"javascript.suggest.includeCompletionsForImportStatements": true,
"javascript.suggest.includePackageJsonAutoImports": "auto",
"typescript.suggest.autoImports": true,
"editor.quickSuggestions": {
"other": true,
"comments": false,
"strings": true
},
"eslint.enable": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.codeActionsOnSave.mode": "problems",
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml"
],
"cSpell.enabledFileTypes": {
"vue": false
}
}

1
1212.txt Normal file
View File

@ -0,0 +1 @@
111

18
Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM node:20-alpine AS build-stage
WORKDIR /app
RUN corepack enable
COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \
pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:stable-alpine AS production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020-PRESENT Weak ZHou
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

179
README.md Normal file
View File

@ -0,0 +1,179 @@
<p align='center'>
<img src='https://user-images.githubusercontent.com/11247099/154486817-f86b8f20-5463-4122-b6e9-930622e757f2.png' alt='Vitesse - Opinionated Vite Starter Template' width='600'/>
</p>
<p align='center'>
快速地<sup><em>Vitesse</em></sup> 创建 Web 应用
<br>
</p>
<br>
<p align='center'>
<a href="https://vitesse.netlify.app/">在线 Demo</a>
</p>
<br>
> **Note**: 本模板创建于 Vue 3 和 Vite 的早期过渡时期。目前,如果您正在寻求更好的 Vue 开发体验和更持续的维护,我们建议您使用 [Nuxt 3](https://nuxt.com) 来代替(它也可以根据需要使用 SPA 或 SSG。本模板仍会作为参考缓慢地维护下去但将不会有太多的更新。
<br>
## 特性
- ⚡️ [Vue 3](https://github.com/vuejs/core), [Vite](https://github.com/vitejs/vite), [pnpm](https://pnpm.io/), [esbuild](https://github.com/evanw/esbuild) - 就是快!
- 🗂 [基于文件的路由](./src/pages)
- 📦 [组件自动化加载](./src/components)
- 🍍 [使用 Pinia 的状态管理](https://pinia.vuejs.org)
- 📑 [布局系统](./src/layouts)
- 📲 [PWA](https://github.com/antfu/vite-plugin-pwa)
- 🎨 [UnoCSS](https://github.com/unocss/unocss) - 高性能且极具灵活性的即时原子化 CSS 引擎
- 😃 [各种图标集为你所用](https://github.com/antfu/unocss/tree/main/packages/preset-icons)
- 🌍 [I18n 国际化开箱即用](./locales)
- 🗒 [Markdown 支持](https://github.com/unplugin/unplugin-vue-markdown)
- 🔥 使用 [新的 `<script setup>` 语法](https://github.com/vuejs/rfcs/pull/227)
- 📥 [API 自动加载](https://github.com/unplugin/unplugin-auto-import) - 直接使用 Composition API 无需引入
- 🖨 使用 [vite-ssg](https://github.com/antfu/vite-ssg) 进行服务端生成 (SSG)
- 🦔 使用 [beasties](https://github.com/danielroe/beasties) 的生成关键 CSS
- 🦾 TypeScript, 当然
- ⚙️ 结合 [GitHub Actions](https://github.com/features/actions),使用 [Vitest](https://github.com/vitest-dev/vitest) 进行单元测试, [Cypress](https://cypress.io/) 进行 E2E 测试
- ☁️ 零配置部署 Netlify
<br>
## 预配置
### UI 框架
- [UnoCSS](https://github.com/antfu/unocss) - 高性能且极具灵活性的即时原子化 CSS 引擎
### Icons
- [Iconify](https://iconify.design) - 使用任意的图标集,浏览:[🔍Icônes](https://icones.netlify.app/)
- [UnoCSS 的纯 CSS 图标方案](https://github.com/antfu/unocss/tree/main/packages/preset-icons)
### 插件
- [Vue Router](https://github.com/vuejs/router)
- [`unplugin-vue-router`](https://github.com/posva/unplugin-vue-router) - 以文件系统为基础的路由
- [`vite-plugin-vue-layouts`](https://github.com/JohnCampionJr/vite-plugin-vue-layouts) - 页面布局系统
- [Pinia](https://pinia.vuejs.org) - 直接的, 类型安全的, 使用 Composition API 的轻便灵活的 Vue 状态管理
- [`unplugin-vue-components`](https://github.com/antfu/unplugin-vue-components) - 自动加载组件
- [`unplugin-auto-import`](https://github.com/antfu/unplugin-auto-import) - 直接使用 Composition API 等,无需导入
- [`vite-plugin-pwa`](https://github.com/antfu/vite-plugin-pwa) - PWA
- [`unplugin-vue-markdown`](https://github.com/unplugin/unplugin-vue-markdown) - Markdown 作为组件,也可以让组件在 Markdown 中使用
- [`markdown-it-prism`](https://github.com/jGleitz/markdown-it-prism) - [Prism](https://prismjs.com/) 的语法高亮
- [`prism-theme-vars`](https://github.com/antfu/prism-theme-vars) - 利用 CSS 变量自定义 Prism.js 的主题
- [Vue I18n](https://github.com/intlify/vue-i18n-next) - 国际化
- [`unplugin-vue-i18n`](https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n) - Vue I18n 的 Vite 插件
- [VueUse](https://github.com/antfu/vueuse) - 实用的 Composition API 工具合集
- [`vite-ssg-sitemap`](https://github.com/jbaubree/vite-ssg-sitemap) - 站点地图生成器
- [`@vueuse/head`](https://github.com/vueuse/head) - 响应式地操作文档头信息
- [`vite-plugin-vue-devtools`](https://github.com/webfansplz/vite-plugin-vue-devtools) - 旨在增强Vue开发者体验的Vite插件
### 编码风格
- 使用 Composition API 地 [`<script setup>` SFC 语法](https://github.com/vuejs/rfcs/pull/227)
- [ESLint](https://eslint.org/) 配置为 [@antfu/eslint-config](https://github.com/antfu/eslint-config), 单引号, 无分号.
### 开发工具
- [TypeScript](https://www.typescriptlang.org/)
- [Vitest](https://github.com/vitest-dev/vitest) - 基于 Vite 的单元测试框架
- [Cypress](https://cypress.io/) - E2E 测试
- [pnpm](https://pnpm.js.org/) - 快, 节省磁盘空间的包管理器
- [`vite-ssg`](https://github.com/antfu/vite-ssg) - 服务端生成
- [beasties](https://github.com/danielroe/beasties) - 关键 CSS 生成器
- [Netlify](https://www.netlify.com/) - 零配置的部署
- [VS Code 扩展](./.vscode/extensions.json)
- [Vite](https://marketplace.visualstudio.com/items?itemName=antfu.vite) - 自动启动 Vite 服务器
- [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) - Vue 3 `<script setup>` IDE 支持
- [Iconify IntelliSense](https://marketplace.visualstudio.com/items?itemName=antfu.iconify) - 图标内联显示和自动补全
- [i18n Ally](https://marketplace.visualstudio.com/items?itemName=lokalise.i18n-ally) - 多合一的 I18n 支持
- [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)
## 衍生项目
由于这个模板的业务场景非常的局限,下面提供了一个精心策划的列表,列出了社区维护的具有不同偏好和功能集的衍生项目。也可以看看他们。当然也欢迎你 PR 提供自己的项目!
###### 官方
- [vitesse-lite](https://github.com/antfu/vitesse-lite) - Vitesse 的轻量版本
- [vitesse-nuxt3](https://github.com/antfu/vitesse-nuxt3) - Vitesse 的 Nuxt 3 版本
- [vitesse-nuxt-bridge](https://github.com/antfu/vitesse-nuxt-bridge) - Vitesse 的 Nuxt2 桥接版本
- [vitesse-webext](https://github.com/antfu/vitesse-webext) - 开箱即用的浏览器扩展 vite 模板
###### 社区
[查看英文版](./README.md#community)
## 现在可以试试!
> Vitesse 需要 Node 版本 >=14.18
### GitHub 模板
[使用这个模板创建仓库](https://github.com/antfu-collective/vitesse/generate).
### 克隆到本地
如果您更喜欢使用更干净的 git 历史记录手动执行此操作
```bash
npx degit antfu-collective/vitesse my-vitesse-app
cd my-vitesse-app
pnpm i # 如果你没装过 pnpm, 可以先运行: npm install -g pnpm
```
## 清单
使用此模板时,请尝试按照清单正确更新您自己的信息
- [ ] 在 `LICENSE` 中改变作者名
- [ ] 在 `App.vue` 中改变标题
- [ ] 在 `vite.config.ts` 更改主机名
- [ ] 在 `public` 目录下改变favicon
- [ ] 移除 `.github` 文件夹中包含资助的信息
- [ ] 整理 README 并删除路由
紧接着, 享受吧 :)
## 使用
### 开发
只需要执行以下命令就可以在 http://localhost:3333 中看到
```bash
pnpm dev
```
### 构建
构建该应用只需要执行以下命令
```bash
pnpm build
```
然后你会看到用于发布的 `dist` 文件夹被生成。
### 部署到 Netlify
前往 [Netlify](https://app.netlify.com/start) 并选择你的仓库, 一路 `OK` 下去,稍等一下后,你的应用将被创建.

14
cypress.config.ts Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from 'cypress'
import vitePreprocessor from 'cypress-vite'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3333',
chromeWebSecurity: false,
specPattern: 'cypress/e2e/**/*.spec.*',
supportFile: false,
setupNodeEvents(on) {
on('file:preprocessor', vitePreprocessor())
},
},
})

36
cypress/e2e/basic.spec.ts Normal file
View File

@ -0,0 +1,36 @@
context('Basic', () => {
beforeEach(() => {
cy.visit('/')
})
it('basic nav', () => {
cy.url()
.should('eq', 'http://localhost:3333/')
cy.contains('[Home Layout]')
.should('exist')
cy.get('#input')
.type('Vitesse{Enter}')
.url()
.should('eq', 'http://localhost:3333/hi/Vitesse')
cy.contains('[Default Layout]')
.should('exist')
cy.get('[btn]')
.click()
.url()
.should('eq', 'http://localhost:3333/')
})
it('markdown', () => {
cy.get('[data-test-id="about"]')
.click()
.url()
.should('eq', 'http://localhost:3333/about')
cy.get('.shiki')
.should('exist')
})
})

12
cypress/tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": [
"cypress"
]
},
"include": [
"**/*.ts"
],
"exclude": []
}

97
eslint.config.js Normal file
View File

@ -0,0 +1,97 @@
// @ts-check
// import antfu from '@antfu/eslint-config'
// eslint.config.js
import js from '@eslint/js'
import imports from 'eslint-plugin-import'
import perfectionist from 'eslint-plugin-perfectionist'
import promise from 'eslint-plugin-promise'
import unicorn from 'eslint-plugin-unicorn'
import unusedImports from 'eslint-plugin-unused-imports'
export default [
// 1. 官方推荐的核心规则(必须最先)
js.configs.recommended,
// 2. unicorn包含大量 recommended 规则,建议第二位)
unicorn.configs['recommended'],
// 3. import 插件(路径检查、重复导入等,要在 perfectionist 之前)
{
plugins: {
import: imports,
},
rules: {
...imports.configs.recommended.rules,
// 关闭与 perfectionist 冲突的排序规则perfectionist 更强)
'import/order': 'off',
},
settings: {
'import/resolver': {
node: { extensions: ['.js', '.mjs', '.cjs'] },
},
},
},
// 4. promise 插件(规则很少,放中间就行)
promise.configs['flat/recommended'],
// 5. perfectionist排序神器必须在 import 之后!)
{
plugins: {
perfectionist,
},
rules: {
'perfectionist/sort-imports': [
'warn',
{
type: 'natural', // 排序算法
order: 'asc', // 排序方向
// 分组顺序: Node内置➡ 第三方依赖➡ 项目内部别名➡ 父级目录➡ 同级目录➡ 当前目录index➡ 对象导入➡ 类型导入➡ 无法识别导入
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type', 'unknown'],
},
],
'perfectionist/sort-exports': 'warn', // 导出排序 export { apple, banana, zebra }
'perfectionist/sort-named-exports': 'warn',// 导出排序 export { a, m, z } from './module'
},
},
// 6. unused-imports必须放最后因为它依赖前面的解析结果来判断“是否真的没用”
{
plugins: {
'unused-imports': unusedImports,
},
rules: {
'unused-imports/no-unused-imports': 'error',
// 可选:连未使用的变量也一起管(比内置 no-unused-vars 更快更准)
'unused-imports/no-unused-vars': 'error',
'no-unused-vars': 'off', // 关闭内置的,防止重复报错
},
},
// 7. 可选:一些个人常用微调(可以全部复制)
{
languageOptions: {
globals: {
// Node.js 全局变量
console: 'readonly',
process: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
global: 'readonly',
module: 'readonly',
require: 'readonly',
exports: 'readonly',
},
},
rules: {
// unicorn 里个别对纯后端太严格的规则可以关掉
'unicorn/filename-case': 'off',
// 后端常见放宽
'no-console': 'off', // 方便调试
'no-underscore-dangle': 'off', // 允许下划线命名 如 __dirname
},
},
]

23
index.html Normal file
View File

@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="apple-touch-icon" href="/pwa-192x192.png" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#00aba9" />
<meta name="msapplication-TileColor" content="#00aba9" />
<script>
;(function () {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const setting = localStorage.getItem('vueuse-color-scheme') || 'auto'
if (setting === 'dark' || (prefersDark && setting !== 'light'))
document.documentElement.classList.toggle('dark', true)
})()
</script>
</head>
<body class="font-sans">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<noscript> This website requires JavaScript to function properly. Please enable JavaScript to continue. </noscript>
</body>
</html>

17
jsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Node",
"target": "ES6",
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"allowImportingTsExtensions": true,
"strictNullChecks": true,
"strictFunctionTypes": true
},
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.vue"],
"exclude": ["node_modules", "**/node_modules/*", "dist"]
}

7
locales/README.md Normal file
View File

@ -0,0 +1,7 @@
## i18n
This directory is to serve your locale translation files. YAML under this folder would be loaded automatically and register with their filenames as locale code.
Check out [`vue-i18n`](https://github.com/intlify/vue-i18n-next) for more details.
If you are using VS Code, [`i18n Ally`](https://github.com/lokalise/i18n-ally) is recommended to make the i18n experience better.

14
locales/ar.yml Normal file
View File

@ -0,0 +1,14 @@
button:
about: حول
back: رجوع
go: تجربة
home: الرئيسية
toggle_dark: التغيير إلى الوضع المظلم
toggle_langs: تغيير اللغة
intro:
desc: vite مثال لتطبيق
dynamic-route: عرض لتوجيهات ديناميكية
hi: مرحبا {name}
aka: معروف أيضا تحت مسمى
whats-your-name: ما إسمك؟
not-found: صفحة غير موجودة

14
locales/de.yml Normal file
View File

@ -0,0 +1,14 @@
button:
about: Über
back: Zurück
go: Los
home: Startseite
toggle_dark: Dunkelmodus umschalten
toggle_langs: Sprachen ändern
intro:
desc: Vite Startvorlage mit Vorlieben
dynamic-route: Demo einer dynamischen Route
hi: Hi, {name}!
aka: Auch bekannt als
whats-your-name: Wie heißt du?
not-found: Nicht gefunden

14
locales/en.yml Normal file
View File

@ -0,0 +1,14 @@
button:
about: About
back: Back
go: GO
home: Home
toggle_dark: Toggle dark mode
toggle_langs: Change languages
intro:
desc: Opinionated Vite Starter Template
dynamic-route: Demo of dynamic route
hi: Hi, {name}!
aka: Also known as
whats-your-name: What's your name?
not-found: Not found

14
locales/es.yml Normal file
View File

@ -0,0 +1,14 @@
button:
about: Acerca de
back: Atrás
go: Ir
home: Inicio
toggle_dark: Alternar modo oscuro
toggle_langs: Cambiar idiomas
intro:
desc: Plantilla de Inicio de Vite Dogmática
dynamic-route: Demo de ruta dinámica
hi: ¡Hola, {name}!
aka: También conocido como
whats-your-name: ¿Cómo te llamas?
not-found: No se ha encontrado

14
locales/fr.yml Normal file
View File

@ -0,0 +1,14 @@
button:
about: À propos
back: Retour
go: Essayer
home: Accueil
toggle_dark: Basculer en mode sombre
toggle_langs: Changer de langue
intro:
desc: Exemple d'application Vite
dynamic-route: Démo de route dynamique
hi: Salut, {name}!
aka: Aussi connu sous le nom de
whats-your-name: Comment t'appelles-tu ?
not-found: Page non trouvée

14
locales/id.yml Normal file
View File

@ -0,0 +1,14 @@
button:
about: Tentang
back: Kembali
go: Pergi
home: Beranda
toggle_dark: Ubah ke mode gelap
toggle_langs: Ubah bahasa
intro:
desc: Template awal vite
dynamic-route: Contoh rute dinamik
hi: Halo, {name}!
aka: Juga diketahui sebagai
whats-your-name: Siapa nama anda?
not-found: Tidak ditemukan

13
locales/it.yml Normal file
View File

@ -0,0 +1,13 @@
button:
about: Su di me
back: Indietro
go: Vai
home: Home
toggle_dark: Attiva/disattiva modalità scura
toggle_langs: Cambia lingua
intro:
desc: Modello per una Applicazione Vite
dynamic-route: Demo di rotta dinamica
hi: Ciao, {name}!
whats-your-name: Come ti chiami?
not-found: Non trovato

13
locales/ja.yml Normal file
View File

@ -0,0 +1,13 @@
button:
about: これは?
back: 戻る
go: 進む
home: ホーム
toggle_dark: ダークモード切り替え
toggle_langs: 言語切り替え
intro:
desc: 固執された Vite スターターテンプレート
dynamic-route: 動的ルートのデモ
hi: こんにちは、{name}!
whats-your-name: 君の名は。
not-found: 見つかりませんでした

14
locales/ka.yml Normal file
View File

@ -0,0 +1,14 @@
button:
about: შესახებ
back: უკან
go: დაწყება
home: მთავარი
toggle_dark: გადართე მუქი რეჟიმი
toggle_langs: ენის შეცვლა
intro:
desc: Opinionated Vite Starter Template
dynamic-route: დინამიური როუტინგის დემო
hi: გამარჯობა, {name}!
aka: ასევე ცნობილი როგორც
whats-your-name: რა გქვია?
not-found: ვერ მოიძებნა

13
locales/ko.yml Normal file
View File

@ -0,0 +1,13 @@
button:
about: 소개
back: 뒤로가기
go: 이동
home:
toggle_dark: 다크모드 토글
toggle_langs: 언어 변경
intro:
desc: Vite 애플리케이션 템플릿
dynamic-route: 다이나믹 라우트 데모
hi: 안녕, {name}!
whats-your-name: 이름이 뭐예요?
not-found: 찾을 수 없습니다

14
locales/pl.yml Normal file
View File

@ -0,0 +1,14 @@
button:
about: O nas
back: Wróć
go: WEJDŹ
home: Strona główna
toggle_dark: Ustaw tryb nocny
toggle_langs: Zmień język
intro:
desc: Opiniowany szablon startowy Vite
dynamic-route: Demonstracja dynamicznego route
hi: Cześć, {name}!
aka: Znany też jako
whats-your-name: Jak masz na imię?
not-found: Nie znaleziono

14
locales/pt-BR.yml Normal file
View File

@ -0,0 +1,14 @@
button:
about: Sobre
back: Voltar
go: Ir
home: Início
toggle_dark: Alternar modo escuro
toggle_langs: Mudar de idioma
intro:
desc: Modelo Opinativo de Partida de Vite
dynamic-route: Demonstração de rota dinâmica
hi: Olá, {name}!
aka: Também conhecido como
whats-your-name: Qual é o seu nome?
not-found: Não encontrado

13
locales/ru.yml Normal file
View File

@ -0,0 +1,13 @@
button:
about: О шаблоне
back: Назад
go: Перейти
home: Главная
toggle_dark: Включить темный режим
toggle_langs: Сменить язык
intro:
desc: Самостоятельный начальный шаблон Vite
dynamic-route: Демо динамического маршрута
hi: Привет, {name}!
whats-your-name: Как тебя зовут?
not-found: Не найден

14
locales/tr.yml Normal file
View File

@ -0,0 +1,14 @@
button:
about: Hakkımda
back: Geri
go: İLERİ
home: Anasayfa
toggle_dark: Karanlık modu değiştir
toggle_langs: Dilleri değiştir
intro:
desc: Görüşlü Vite Başlangıç Şablonu
dynamic-route: Dinamik rota demosu
hi: Merhaba, {name}!
aka: Ayrıca şöyle bilinir
whats-your-name: Adınız nedir?
not-found: Bulunamadı

13
locales/uk.yml Normal file
View File

@ -0,0 +1,13 @@
button:
about: Про шаблон
back: Назад
go: Перейти
home: Головна
toggle_dark: Переключити темний режим
toggle_langs: Змінити мову
intro:
desc: Самостійний початковий шаблон Vite
dynamic-route: Демо динамічного маршруту
hi: Привіт, {name}!
whats-your-name: Як тебе звати?
not-found: Не знайдено

14
locales/uz.yml Normal file
View File

@ -0,0 +1,14 @@
button:
about: Haqida
back: Orqaga
go: Kettik
home: Bosh sahifa
toggle_dark: Qorongi rejimga otish
toggle_langs: Tilni ozgartirish
intro:
desc: Oylangan boshlangich Vite shabloni
dynamic-route: Dynamic route demo'si
hi: Assalomu alaykum, {name}!
aka: shuningdek
whats-your-name: Ismingiz nima?
not-found: Topilmadi

13
locales/vi.yml Normal file
View File

@ -0,0 +1,13 @@
button:
about: Về
back: Quay lại
go: Đi
home: Khởi đầu
toggle_dark: Chuyển đổi chế độ tối
toggle_langs: Thay đổi ngôn ngữ
intro:
desc: Ý kiến cá nhân Vite Template để bắt đầu
dynamic-route: Bản giới thiệu về dynamic route
hi: Hi, {name}!
whats-your-name: Tên bạn là gì?
not-found: Không tìm thấy

14
locales/zh-CN.yml Normal file
View File

@ -0,0 +1,14 @@
button:
about: 关于
back: 返回
go: 确定
home: 首页
toggle_dark: 切换深色模式
toggle_langs: 切换语言
intro:
desc: 固执己见的 Vite 项目模板
dynamic-route: 动态路由演示
hi: 你好,{name}
aka: 也叫
whats-your-name: 输入你的名字
not-found: 未找到页面

17
netlify.toml Normal file
View File

@ -0,0 +1,17 @@
[build]
publish = "dist"
command = "pnpm run build"
[build.environment]
NODE_VERSION = "20"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
[[headers]]
for = "/manifest.webmanifest"
[headers.values]
Content-Type = "application/manifest+json"

93
package.json Normal file
View File

@ -0,0 +1,93 @@
{
"type": "module",
"private": true,
"packageManager": "pnpm@10.7.0",
"scripts": {
"build": "vite-ssg build --mode production",
"build:dev": "vite-ssg build --mode development",
"dev": "vite --port 3333 --open",
"lint": "eslint .",
"preview": "vite preview",
"preview-https": "serve dist",
"test": "vitest",
"test:e2e": "cypress open",
"test:unit": "vitest",
"typecheck": "vue-tsc --noEmit",
"up": "taze major -I",
"postinstall": "npx simple-git-hooks",
"sizecheck": "npx vite-bundle-visualizer"
},
"dependencies": {
"@unhead/vue": "catalog:frontend",
"@unocss/reset": "catalog:frontend",
"@vueuse/core": "catalog:frontend",
"nprogress": "catalog:frontend",
"pinia": "catalog:frontend",
"vue": "catalog:frontend",
"vue-i18n": "catalog:frontend",
"vue-router": "catalog:frontend"
},
"devDependencies": {
"@antfu/eslint-config": "catalog:dev",
"@eslint/js": "^9.39.2",
"@iconify-json/carbon": "catalog:dev",
"@intlify/unplugin-vue-i18n": "catalog:build",
"@shikijs/markdown-it": "catalog:build",
"@types/markdown-it-link-attributes": "catalog:types",
"@types/nprogress": "catalog:types",
"@unocss/eslint-config": "catalog:build",
"@vitejs/plugin-vue": "catalog:build",
"@vue-macros/volar": "catalog:dev",
"@vue/test-utils": "catalog:dev",
"autoprefixer": "catalog:",
"beasties": "catalog:build",
"cypress": "catalog:dev",
"cypress-vite": "catalog:dev",
"eslint": "catalog:dev",
"eslint-plugin-cypress": "catalog:dev",
"eslint-plugin-format": "catalog:dev",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-perfectionist": "^5.0.0",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-unicorn": "^56.0.0",
"eslint-plugin-unused-imports": "^4.3.0",
"https-localhost": "catalog:dev",
"lint-staged": "catalog:dev",
"markdown-it-link-attributes": "catalog:build",
"postcss": "catalog:",
"rollup": "catalog:build",
"shiki": "catalog:build",
"simple-git-hooks": "catalog:dev",
"tailwindcss": "catalog:",
"taze": "catalog:dev",
"typescript": "catalog:dev",
"unocss": "catalog:build",
"unplugin-auto-import": "catalog:build",
"unplugin-vue-components": "catalog:build",
"unplugin-vue-macros": "catalog:build",
"unplugin-vue-markdown": "catalog:build",
"unplugin-vue-router": "catalog:build",
"vite": "catalog:build",
"vite-bundle-visualizer": "catalog:build",
"vite-plugin-inspect": "catalog:build",
"vite-plugin-pwa": "catalog:build",
"vite-plugin-vue-devtools": "catalog:build",
"vite-plugin-vue-layouts": "catalog:build",
"vite-ssg": "catalog:build",
"vite-ssg-sitemap": "catalog:build",
"vitest": "catalog:dev",
"vue-tsc": "catalog:dev"
},
"resolutions": {
"eslint-plugin-unicorn": "^56.0.0",
"unplugin": "catalog:build",
"vite": "catalog:build",
"vite-plugin-inspect": "catalog:build"
},
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged"
},
"lint-staged": {
"*": "eslint --fix"
}
}

14230
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

69
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,69 @@
packages: []
catalog:
autoprefixer: ^10.4.16
postcss: ^8.4.32
tailwindcss: ^3.4.1
catalogs:
build:
'@intlify/unplugin-vue-i18n': ^6.0.5
'@shikijs/markdown-it': ^3.2.1
'@unocss/eslint-config': ^66.1.0-beta.7
'@vitejs/plugin-vue': ^5.2.3
beasties: ^0.2.0
markdown-it-link-attributes: ^4.0.1
rollup: ^4.37.0
shiki: ^3.2.1
unocss: ^66.1.0-beta.7
unplugin: ^2.2.2
unplugin-auto-import: ^19.1.2
unplugin-vue-components: ^28.4.1
unplugin-vue-macros: ^2.14.5
unplugin-vue-markdown: ^28.3.1
unplugin-vue-router: ^0.12.0
vite: ^6.2.3
vite-bundle-visualizer: ^1.2.1
vite-plugin-inspect: ^11.0.0
vite-plugin-pwa: ^0.21.2
vite-plugin-vue-devtools: ^7.7.2
vite-plugin-vue-layouts: ^0.11.0
vite-ssg: ^26.0.0
vite-ssg-sitemap: ^0.8.1
dev:
'@antfu/eslint-config': ^4.11.0
'@iconify-json/carbon': ^1.2.8
'@vue-macros/volar': ^3.0.0-beta.7
'@vue/test-utils': ^2.4.6
cypress: ^14.2.1
cypress-vite: ^1.6.0
eslint: ^9.23.0
eslint-plugin-cypress: ^4.2.0
eslint-plugin-format: ^1.0.1
https-localhost: ^4.7.1
lint-staged: ^15.5.0
simple-git-hooks: ^2.12.1
taze: ^19.0.2
typescript: ^5.8.2
vitest: ^3.0.9
vue-tsc: ^2.2.8
frontend:
'@unhead/vue': ^2.0.2
'@unocss/reset': ^66.1.0-beta.7
'@vueuse/core': ^13.0.0
nprogress: ^0.2.0
pinia: ^3.0.1
vue: ^3.5.13
vue-i18n: ^11.1.2
vue-router: ^4.5.0
types:
'@types/markdown-it-link-attributes': ^3.0.5
'@types/nprogress': ^0.2.3
onlyBuiltDependencies:
- cypress
- esbuild
- simple-git-hooks

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

3
public/_headers Normal file
View File

@ -0,0 +1,3 @@
/assets/*
cache-control: max-age=31536000
cache-control: immutable

3
public/favicon-dark.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.562 26L17.17 8.928l2.366-3.888L17.828 4L16 7.005L14.17 4l-1.708 1.04l2.366 3.888L4.438 26H2v2h28v-2zM16 10.85L25.22 26H17v-8h-2v8H6.78z" fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 311 B

3
public/favicon.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.562 26L17.17 8.928l2.366-3.888L17.828 4L16 7.005L14.17 4l-1.708 1.04l2.366 3.888L4.438 26H2v2h28v-2zM16 10.85L25.22 26H17v-8h-2v8H6.78z" fill="#222" />
</svg>

After

Width:  |  Height:  |  Size: 310 B

BIN
public/pwa-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/pwa-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,41 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M2916 6015 c-93 -57 -173 -108 -178 -113 -6 -6 7 -36 33 -78 23 -38
86 -141 139 -229 54 -88 135 -221 180 -295 46 -74 94 -155 108 -180 14 -25 29
-52 35 -60 7 -12 -9 -45 -62 -130 -39 -63 -85 -140 -103 -170 -18 -30 -117
-194 -222 -365 -104 -170 -199 -326 -210 -346 -12 -19 -61 -102 -111 -183 -49
-81 -101 -166 -115 -189 -14 -23 -39 -64 -55 -90 -17 -27 -77 -126 -134 -220
-57 -95 -127 -210 -156 -257 -194 -315 -325 -533 -325 -541 0 -5 -4 -9 -10 -9
-5 0 -10 -4 -10 -9 0 -5 -55 -98 -121 -207 -247 -404 -403 -660 -416 -684 -8
-14 -58 -97 -112 -185 l-98 -160 -189 -2 c-104 -1 -225 -2 -269 -2 l-80 -1 1
-210 c0 -116 4 -213 8 -218 11 -11 6107 -9 6114 2 8 13 8 406 0 419 -4 7 -88
10 -265 9 l-259 -2 -50 77 c-27 43 -54 87 -60 98 -6 11 -62 103 -124 205 -62
102 -120 197 -129 212 -9 16 -85 142 -170 280 -85 139 -160 262 -165 273 -6
11 -13 22 -16 25 -3 3 -30 46 -59 95 -30 50 -102 169 -161 265 -59 96 -240
393 -402 660 -163 267 -371 609 -463 760 -92 151 -194 318 -225 370 -31 52
-101 167 -155 255 l-97 160 27 50 c16 27 32 55 36 61 5 5 38 59 74 120 36 60
69 116 74 124 5 8 75 122 155 253 81 131 144 242 141 247 -4 7 -114 76 -183
115 -10 6 -52 32 -95 58 -42 27 -81 46 -87 42 -8 -5 -94 -140 -140 -219 -19
-33 -221 -365 -246 -404 -15 -22 -18 -18 -111 135 -52 87 -123 203 -157 258
-67 108 -67 110 -111 184 -16 28 -34 51 -40 50 -5 0 -86 -47 -179 -104z m739
-1642 c319 -526 519 -854 637 -1046 43 -70 78 -130 78 -133 0 -2 5 -10 10 -17
6 -7 69 -109 140 -227 72 -118 134 -222 139 -230 5 -8 55 -89 111 -180 56 -91
105 -172 110 -180 9 -14 52 -84 270 -445 54 -88 135 -221 180 -295 46 -74 91
-148 100 -165 9 -16 31 -53 48 -81 18 -28 32 -54 32 -57 0 -3 -403 -6 -895 -5
l-895 0 0 81 c-1 45 -1 439 -1 875 l0 792 -37 1 c-57 1 -344 1 -374 0 l-27 -1
0 -832 c0 -458 0 -852 0 -875 l-1 -42 -895 1 c-492 0 -895 3 -895 5 0 9 115
198 122 201 5 2 8 7 8 12 0 5 23 46 51 92 28 46 78 128 112 183 33 55 70 116
82 135 12 19 132 215 265 435 133 220 266 438 295 485 65 105 206 338 220 362
6 10 172 284 370 608 198 325 387 635 420 690 33 55 62 100 65 100 3 0 73
-111 155 -247z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

29
src/App.vue Normal file
View File

@ -0,0 +1,29 @@
<script setup lang="ts">
// https://github.com/vueuse/head
// you can use this to manipulate the document head in any components,
// they will be rendered correctly in the html results with vite-ssg
useHead({
title: 'Vitesse',
meta: [
{
name: 'description',
content: 'Opinionated Vite Starter Template',
},
{
name: 'theme-color',
content: () => isDark.value ? '#00aba9' : '#ffffff',
},
],
link: [
{
rel: 'icon',
type: 'image/svg+xml',
href: () => preferredDark.value ? '/favicon-dark.svg' : '/favicon.svg',
},
],
})
</script>
<template>
<RouterView />
</template>

641
src/auto-imports.d.ts vendored Normal file
View File

@ -0,0 +1,641 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const CancelToken: typeof import('./composables/request')['CancelToken']
const EffectScope: typeof import('vue')['EffectScope']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const computed: typeof import('vue')['computed']
const computedAsync: typeof import('@vueuse/core')['computedAsync']
const computedEager: typeof import('@vueuse/core')['computedEager']
const computedInject: typeof import('@vueuse/core')['computedInject']
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
const controlledRef: typeof import('@vueuse/core')['controlledRef']
const createApp: typeof import('vue')['createApp']
const createCancelToken: typeof import('./composables/request')['createCancelToken']
const createEventHook: typeof import('@vueuse/core')['createEventHook']
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createRef: typeof import('@vueuse/core')['createRef']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
const customRef: typeof import('vue')['customRef']
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineLoader: typeof import('vue-router/auto')['defineLoader']
const definePage: typeof import('unplugin-vue-router/runtime')['definePage']
const del: typeof import('./composables/request')['del']
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
const effectScope: typeof import('vue')['effectScope']
const extendRef: typeof import('@vueuse/core')['extendRef']
const get: typeof import('./composables/request')['get']
const getActiveHead: typeof import('@unhead/vue')['getActiveHead']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const inject: typeof import('vue')['inject']
const injectHead: typeof import('@unhead/vue')['injectHead']
const injectLocal: typeof import('@vueuse/core')['injectLocal']
const isDark: typeof import('./composables/dark')['isDark']
const isDefined: typeof import('@vueuse/core')['isDefined']
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 makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
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 onClickOutside: typeof import('@vueuse/core')['onClickOutside']
const onDeactivated: typeof import('vue')['onDeactivated']
const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
const onLongPress: typeof import('@vueuse/core')['onLongPress']
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 onStartTyping: typeof import('@vueuse/core')['onStartTyping']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const post: typeof import('./composables/request')['post']
const preferredDark: typeof import('./composables/dark')['preferredDark']
const provide: typeof import('vue')['provide']
const provideLocal: typeof import('@vueuse/core')['provideLocal']
const put: typeof import('./composables/request')['put']
const reactify: typeof import('@vueuse/core')['reactify']
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
const reactive: typeof import('vue')['reactive']
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
const reactivePick: typeof import('@vueuse/core')['reactivePick']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
const refDebounced: typeof import('@vueuse/core')['refDebounced']
const refDefault: typeof import('@vueuse/core')['refDefault']
const refThrottled: typeof import('@vueuse/core')['refThrottled']
const refWithControl: typeof import('@vueuse/core')['refWithControl']
const request: typeof import('./composables/request')['default']
const resolveComponent: typeof import('vue')['resolveComponent']
const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const syncRef: typeof import('@vueuse/core')['syncRef']
const syncRefs: typeof import('@vueuse/core')['syncRefs']
const templateRef: typeof import('@vueuse/core')['templateRef']
const throttledRef: typeof import('@vueuse/core')['throttledRef']
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
const toRaw: typeof import('vue')['toRaw']
const toReactive: typeof import('@vueuse/core')['toReactive']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const toggleDark: typeof import('./composables/dark')['toggleDark']
const triggerRef: typeof import('vue')['triggerRef']
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
const unref: typeof import('vue')['unref']
const unrefElement: typeof import('@vueuse/core')['unrefElement']
const until: typeof import('@vueuse/core')['until']
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
const useAnimate: typeof import('@vueuse/core')['useAnimate']
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
const useArrayFind: typeof import('@vueuse/core')['useArrayFind']
const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']
const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']
const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']
const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']
const useArrayMap: typeof import('@vueuse/core')['useArrayMap']
const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']
const useArraySome: typeof import('@vueuse/core')['useArraySome']
const useArrayUnique: typeof import('@vueuse/core')['useArrayUnique']
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
const useAttrs: typeof import('vue')['useAttrs']
const useBase64: typeof import('@vueuse/core')['useBase64']
const useBattery: typeof import('@vueuse/core')['useBattery']
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
const useCached: typeof import('@vueuse/core')['useCached']
const useClipboard: typeof import('@vueuse/core')['useClipboard']
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
const useCloned: typeof import('@vueuse/core')['useCloned']
const useColorMode: typeof import('@vueuse/core')['useColorMode']
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
const useCountdown: typeof import('@vueuse/core')['useCountdown']
const useCounter: typeof import('@vueuse/core')['useCounter']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVar: typeof import('@vueuse/core')['useCssVar']
const useCssVars: typeof import('vue')['useCssVars']
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
const useCycleList: typeof import('@vueuse/core')['useCycleList']
const useDark: typeof import('@vueuse/core')['useDark']
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
const useDebounce: typeof import('@vueuse/core')['useDebounce']
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
const useDraggable: typeof import('@vueuse/core')['useDraggable']
const useDropZone: typeof import('@vueuse/core')['useDropZone']
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
const useElementHover: typeof import('@vueuse/core')['useElementHover']
const useElementSize: typeof import('@vueuse/core')['useElementSize']
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
const useEventBus: typeof import('@vueuse/core')['useEventBus']
const useEventListener: typeof import('@vueuse/core')['useEventListener']
const useEventSource: typeof import('@vueuse/core')['useEventSource']
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
const useFavicon: typeof import('@vueuse/core')['useFavicon']
const useFetch: typeof import('@vueuse/core')['useFetch']
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
const useFocus: typeof import('@vueuse/core')['useFocus']
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
const useFps: typeof import('@vueuse/core')['useFps']
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useHead: typeof import('@unhead/vue')['useHead']
const useHeadSafe: typeof import('@unhead/vue')['useHeadSafe']
const useI18n: typeof import('vue-i18n')['useI18n']
const useId: typeof import('vue')['useId']
const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage']
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
const useInterval: typeof import('@vueuse/core')['useInterval']
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
const useLink: typeof import('vue-router/auto')['useLink']
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
const useMemoize: typeof import('@vueuse/core')['useMemoize']
const useMemory: typeof import('@vueuse/core')['useMemory']
const useModel: typeof import('vue')['useModel']
const useMounted: typeof import('@vueuse/core')['useMounted']
const useMouse: typeof import('@vueuse/core')['useMouse']
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
const useNetwork: typeof import('@vueuse/core')['useNetwork']
const useNow: typeof import('@vueuse/core')['useNow']
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
const useOnline: typeof import('@vueuse/core')['useOnline']
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
const useParallax: typeof import('@vueuse/core')['useParallax']
const useParentElement: typeof import('@vueuse/core')['useParentElement']
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
const usePermission: typeof import('@vueuse/core')['usePermission']
const usePointer: typeof import('@vueuse/core')['usePointer']
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast']
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
const useScroll: typeof import('@vueuse/core')['useScroll']
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
const useSeoMeta: typeof import('@unhead/vue')['useSeoMeta']
const useServerHead: typeof import('@unhead/vue')['useServerHead']
const useServerHeadSafe: typeof import('@unhead/vue')['useServerHeadSafe']
const useServerSeoMeta: typeof import('@unhead/vue')['useServerSeoMeta']
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
const useShare: typeof import('@vueuse/core')['useShare']
const useSlots: typeof import('vue')['useSlots']
const useSorted: typeof import('@vueuse/core')['useSorted']
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
const useStepper: typeof import('@vueuse/core')['useStepper']
const useStorage: typeof import('@vueuse/core')['useStorage']
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
const useSupported: typeof import('@vueuse/core')['useSupported']
const useSwipe: typeof import('@vueuse/core')['useSwipe']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
const useThrottle: typeof import('@vueuse/core')['useThrottle']
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
const useTimeout: typeof import('@vueuse/core')['useTimeout']
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
const useTitle: typeof import('@vueuse/core')['useTitle']
const useToNumber: typeof import('@vueuse/core')['useToNumber']
const useToString: typeof import('@vueuse/core')['useToString']
const useToggle: typeof import('@vueuse/core')['useToggle']
const useTransition: typeof import('@vueuse/core')['useTransition']
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
const useUserStore: typeof import('./stores/user')['useUserStore']
const useVModel: typeof import('@vueuse/core')['useVModel']
const useVModels: typeof import('@vueuse/core')['useVModels']
const useVibrate: typeof import('@vueuse/core')['useVibrate']
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
const watch: typeof import('vue')['watch']
const watchArray: typeof import('@vueuse/core')['watchArray']
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
const watchDeep: typeof import('@vueuse/core')['watchDeep']
const watchEffect: typeof import('vue')['watchEffect']
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
const watchImmediate: typeof import('@vueuse/core')['watchImmediate']
const watchOnce: typeof import('@vueuse/core')['watchOnce']
const watchPausable: typeof import('@vueuse/core')['watchPausable']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
const whenever: typeof import('@vueuse/core')['whenever']
}
// 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')
// @ts-ignore
export type { RequestConfig, RequestResponse } from './composables/request'
import('./composables/request')
}
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly CancelToken: UnwrapRef<typeof import('./composables/request')['CancelToken']>
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
readonly computedEager: UnwrapRef<typeof import('@vueuse/core')['computedEager']>
readonly computedInject: UnwrapRef<typeof import('@vueuse/core')['computedInject']>
readonly computedWithControl: UnwrapRef<typeof import('@vueuse/core')['computedWithControl']>
readonly controlledComputed: UnwrapRef<typeof import('@vueuse/core')['controlledComputed']>
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly createCancelToken: UnwrapRef<typeof import('./composables/request')['createCancelToken']>
readonly createEventHook: UnwrapRef<typeof import('@vueuse/core')['createEventHook']>
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
readonly createRef: UnwrapRef<typeof import('@vueuse/core')['createRef']>
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly debouncedRef: UnwrapRef<typeof import('@vueuse/core')['debouncedRef']>
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly del: UnwrapRef<typeof import('./composables/request')['del']>
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
readonly get: UnwrapRef<typeof import('./composables/request')['get']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly injectHead: UnwrapRef<typeof import('@unhead/vue')['injectHead']>
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>
readonly isDark: UnwrapRef<typeof import('./composables/dark')['isDark']>
readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onClickOutside: UnwrapRef<typeof import('@vueuse/core')['onClickOutside']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onElementRemoval: UnwrapRef<typeof import('@vueuse/core')['onElementRemoval']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onKeyStroke: UnwrapRef<typeof import('@vueuse/core')['onKeyStroke']>
readonly onLongPress: UnwrapRef<typeof import('@vueuse/core')['onLongPress']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
readonly onStartTyping: UnwrapRef<typeof import('@vueuse/core')['onStartTyping']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly post: UnwrapRef<typeof import('./composables/request')['post']>
readonly preferredDark: UnwrapRef<typeof import('./composables/dark')['preferredDark']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
readonly put: UnwrapRef<typeof import('./composables/request')['put']>
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
readonly reactiveComputed: UnwrapRef<typeof import('@vueuse/core')['reactiveComputed']>
readonly reactiveOmit: UnwrapRef<typeof import('@vueuse/core')['reactiveOmit']>
readonly reactivePick: UnwrapRef<typeof import('@vueuse/core')['reactivePick']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly refAutoReset: UnwrapRef<typeof import('@vueuse/core')['refAutoReset']>
readonly refDebounced: UnwrapRef<typeof import('@vueuse/core')['refDebounced']>
readonly refDefault: UnwrapRef<typeof import('@vueuse/core')['refDefault']>
readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
readonly request: UnwrapRef<typeof import('./composables/request')['default']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>
readonly syncRefs: UnwrapRef<typeof import('@vueuse/core')['syncRefs']>
readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>
readonly throttledRef: UnwrapRef<typeof import('@vueuse/core')['throttledRef']>
readonly throttledWatch: UnwrapRef<typeof import('@vueuse/core')['throttledWatch']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toReactive: UnwrapRef<typeof import('@vueuse/core')['toReactive']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly toggleDark: UnwrapRef<typeof import('./composables/dark')['toggleDark']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>
readonly tryOnBeforeUnmount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeUnmount']>
readonly tryOnMounted: UnwrapRef<typeof import('@vueuse/core')['tryOnMounted']>
readonly tryOnScopeDispose: UnwrapRef<typeof import('@vueuse/core')['tryOnScopeDispose']>
readonly tryOnUnmounted: UnwrapRef<typeof import('@vueuse/core')['tryOnUnmounted']>
readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']>
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
readonly useArrayEvery: UnwrapRef<typeof import('@vueuse/core')['useArrayEvery']>
readonly useArrayFilter: UnwrapRef<typeof import('@vueuse/core')['useArrayFilter']>
readonly useArrayFind: UnwrapRef<typeof import('@vueuse/core')['useArrayFind']>
readonly useArrayFindIndex: UnwrapRef<typeof import('@vueuse/core')['useArrayFindIndex']>
readonly useArrayFindLast: UnwrapRef<typeof import('@vueuse/core')['useArrayFindLast']>
readonly useArrayIncludes: UnwrapRef<typeof import('@vueuse/core')['useArrayIncludes']>
readonly useArrayJoin: UnwrapRef<typeof import('@vueuse/core')['useArrayJoin']>
readonly useArrayMap: UnwrapRef<typeof import('@vueuse/core')['useArrayMap']>
readonly useArrayReduce: UnwrapRef<typeof import('@vueuse/core')['useArrayReduce']>
readonly useArraySome: UnwrapRef<typeof import('@vueuse/core')['useArraySome']>
readonly useArrayUnique: UnwrapRef<typeof import('@vueuse/core')['useArrayUnique']>
readonly useAsyncQueue: UnwrapRef<typeof import('@vueuse/core')['useAsyncQueue']>
readonly useAsyncState: UnwrapRef<typeof import('@vueuse/core')['useAsyncState']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useBase64: UnwrapRef<typeof import('@vueuse/core')['useBase64']>
readonly useBattery: UnwrapRef<typeof import('@vueuse/core')['useBattery']>
readonly useBluetooth: UnwrapRef<typeof import('@vueuse/core')['useBluetooth']>
readonly useBreakpoints: UnwrapRef<typeof import('@vueuse/core')['useBreakpoints']>
readonly useBroadcastChannel: UnwrapRef<typeof import('@vueuse/core')['useBroadcastChannel']>
readonly useBrowserLocation: UnwrapRef<typeof import('@vueuse/core')['useBrowserLocation']>
readonly useCached: UnwrapRef<typeof import('@vueuse/core')['useCached']>
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
readonly useCountdown: UnwrapRef<typeof import('@vueuse/core')['useCountdown']>
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useCurrentElement: UnwrapRef<typeof import('@vueuse/core')['useCurrentElement']>
readonly useCycleList: UnwrapRef<typeof import('@vueuse/core')['useCycleList']>
readonly useDark: UnwrapRef<typeof import('@vueuse/core')['useDark']>
readonly useDateFormat: UnwrapRef<typeof import('@vueuse/core')['useDateFormat']>
readonly useDebounce: UnwrapRef<typeof import('@vueuse/core')['useDebounce']>
readonly useDebounceFn: UnwrapRef<typeof import('@vueuse/core')['useDebounceFn']>
readonly useDebouncedRefHistory: UnwrapRef<typeof import('@vueuse/core')['useDebouncedRefHistory']>
readonly useDeviceMotion: UnwrapRef<typeof import('@vueuse/core')['useDeviceMotion']>
readonly useDeviceOrientation: UnwrapRef<typeof import('@vueuse/core')['useDeviceOrientation']>
readonly useDevicePixelRatio: UnwrapRef<typeof import('@vueuse/core')['useDevicePixelRatio']>
readonly useDevicesList: UnwrapRef<typeof import('@vueuse/core')['useDevicesList']>
readonly useDisplayMedia: UnwrapRef<typeof import('@vueuse/core')['useDisplayMedia']>
readonly useDocumentVisibility: UnwrapRef<typeof import('@vueuse/core')['useDocumentVisibility']>
readonly useDraggable: UnwrapRef<typeof import('@vueuse/core')['useDraggable']>
readonly useDropZone: UnwrapRef<typeof import('@vueuse/core')['useDropZone']>
readonly useElementBounding: UnwrapRef<typeof import('@vueuse/core')['useElementBounding']>
readonly useElementByPoint: UnwrapRef<typeof import('@vueuse/core')['useElementByPoint']>
readonly useElementHover: UnwrapRef<typeof import('@vueuse/core')['useElementHover']>
readonly useElementSize: UnwrapRef<typeof import('@vueuse/core')['useElementSize']>
readonly useElementVisibility: UnwrapRef<typeof import('@vueuse/core')['useElementVisibility']>
readonly useEventBus: UnwrapRef<typeof import('@vueuse/core')['useEventBus']>
readonly useEventListener: UnwrapRef<typeof import('@vueuse/core')['useEventListener']>
readonly useEventSource: UnwrapRef<typeof import('@vueuse/core')['useEventSource']>
readonly useEyeDropper: UnwrapRef<typeof import('@vueuse/core')['useEyeDropper']>
readonly useFavicon: UnwrapRef<typeof import('@vueuse/core')['useFavicon']>
readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>
readonly useFileDialog: UnwrapRef<typeof import('@vueuse/core')['useFileDialog']>
readonly useFileSystemAccess: UnwrapRef<typeof import('@vueuse/core')['useFileSystemAccess']>
readonly useFocus: UnwrapRef<typeof import('@vueuse/core')['useFocus']>
readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>
readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']>
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useHead: UnwrapRef<typeof import('@unhead/vue')['useHead']>
readonly useHeadSafe: UnwrapRef<typeof import('@unhead/vue')['useHeadSafe']>
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
readonly useId: UnwrapRef<typeof import('vue')['useId']>
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
readonly useInfiniteScroll: UnwrapRef<typeof import('@vueuse/core')['useInfiniteScroll']>
readonly useIntersectionObserver: UnwrapRef<typeof import('@vueuse/core')['useIntersectionObserver']>
readonly useInterval: UnwrapRef<typeof import('@vueuse/core')['useInterval']>
readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']>
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
readonly useLink: UnwrapRef<typeof import('vue-router/auto')['useLink']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
readonly useMousePressed: UnwrapRef<typeof import('@vueuse/core')['useMousePressed']>
readonly useMutationObserver: UnwrapRef<typeof import('@vueuse/core')['useMutationObserver']>
readonly useNavigatorLanguage: UnwrapRef<typeof import('@vueuse/core')['useNavigatorLanguage']>
readonly useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']>
readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']>
readonly useObjectUrl: UnwrapRef<typeof import('@vueuse/core')['useObjectUrl']>
readonly useOffsetPagination: UnwrapRef<typeof import('@vueuse/core')['useOffsetPagination']>
readonly useOnline: UnwrapRef<typeof import('@vueuse/core')['useOnline']>
readonly usePageLeave: UnwrapRef<typeof import('@vueuse/core')['usePageLeave']>
readonly useParallax: UnwrapRef<typeof import('@vueuse/core')['useParallax']>
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']>
readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']>
readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>
readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>
readonly usePreferredColorScheme: UnwrapRef<typeof import('@vueuse/core')['usePreferredColorScheme']>
readonly usePreferredContrast: UnwrapRef<typeof import('@vueuse/core')['usePreferredContrast']>
readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']>
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
readonly usePreferredReducedTransparency: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedTransparency']>
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useSSRWidth: UnwrapRef<typeof import('@vueuse/core')['useSSRWidth']>
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>
readonly useScroll: UnwrapRef<typeof import('@vueuse/core')['useScroll']>
readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']>
readonly useSeoMeta: UnwrapRef<typeof import('@unhead/vue')['useSeoMeta']>
readonly useServerHead: UnwrapRef<typeof import('@unhead/vue')['useServerHead']>
readonly useServerHeadSafe: UnwrapRef<typeof import('@unhead/vue')['useServerHeadSafe']>
readonly useServerSeoMeta: UnwrapRef<typeof import('@unhead/vue')['useServerSeoMeta']>
readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']>
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']>
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>
readonly useTextSelection: UnwrapRef<typeof import('@vueuse/core')['useTextSelection']>
readonly useTextareaAutosize: UnwrapRef<typeof import('@vueuse/core')['useTextareaAutosize']>
readonly useThrottle: UnwrapRef<typeof import('@vueuse/core')['useThrottle']>
readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>
readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>
readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']>
readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>
readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>
readonly useTimestamp: UnwrapRef<typeof import('@vueuse/core')['useTimestamp']>
readonly useTitle: UnwrapRef<typeof import('@vueuse/core')['useTitle']>
readonly useToNumber: UnwrapRef<typeof import('@vueuse/core')['useToNumber']>
readonly useToString: UnwrapRef<typeof import('@vueuse/core')['useToString']>
readonly useToggle: UnwrapRef<typeof import('@vueuse/core')['useToggle']>
readonly useTransition: UnwrapRef<typeof import('@vueuse/core')['useTransition']>
readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']>
readonly useUserMedia: UnwrapRef<typeof import('@vueuse/core')['useUserMedia']>
readonly useUserStore: UnwrapRef<typeof import('./stores/user')['useUserStore']>
readonly useVModel: UnwrapRef<typeof import('@vueuse/core')['useVModel']>
readonly useVModels: UnwrapRef<typeof import('@vueuse/core')['useVModels']>
readonly useVibrate: UnwrapRef<typeof import('@vueuse/core')['useVibrate']>
readonly useVirtualList: UnwrapRef<typeof import('@vueuse/core')['useVirtualList']>
readonly useWakeLock: UnwrapRef<typeof import('@vueuse/core')['useWakeLock']>
readonly useWebNotification: UnwrapRef<typeof import('@vueuse/core')['useWebNotification']>
readonly useWebSocket: UnwrapRef<typeof import('@vueuse/core')['useWebSocket']>
readonly useWebWorker: UnwrapRef<typeof import('@vueuse/core')['useWebWorker']>
readonly useWebWorkerFn: UnwrapRef<typeof import('@vueuse/core')['useWebWorkerFn']>
readonly useWindowFocus: UnwrapRef<typeof import('@vueuse/core')['useWindowFocus']>
readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']>
readonly useWindowSize: UnwrapRef<typeof import('@vueuse/core')['useWindowSize']>
readonly watch: UnwrapRef<typeof import('vue')['watch']>
readonly watchArray: UnwrapRef<typeof import('@vueuse/core')['watchArray']>
readonly watchAtMost: UnwrapRef<typeof import('@vueuse/core')['watchAtMost']>
readonly watchDebounced: UnwrapRef<typeof import('@vueuse/core')['watchDebounced']>
readonly watchDeep: UnwrapRef<typeof import('@vueuse/core')['watchDeep']>
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchIgnorable: UnwrapRef<typeof import('@vueuse/core')['watchIgnorable']>
readonly watchImmediate: UnwrapRef<typeof import('@vueuse/core')['watchImmediate']>
readonly watchOnce: UnwrapRef<typeof import('@vueuse/core')['watchOnce']>
readonly watchPausable: UnwrapRef<typeof import('@vueuse/core')['watchPausable']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
readonly watchThrottled: UnwrapRef<typeof import('@vueuse/core')['watchThrottled']>
readonly watchTriggerable: UnwrapRef<typeof import('@vueuse/core')['watchTriggerable']>
readonly watchWithFilter: UnwrapRef<typeof import('@vueuse/core')['watchWithFilter']>
readonly whenever: UnwrapRef<typeof import('@vueuse/core')['whenever']>
}
}

28
src/components.d.ts vendored Normal file
View File

@ -0,0 +1,28 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
BackToTop: typeof import('./components/BackToTop.vue')['default']
FilterBar: typeof import('./components/FilterBar.vue')['default']
README: typeof import('./components/README.md')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ScoreForm: typeof import('./components/ScoreForm.vue')['default']
TheCounter: typeof import('./components/TheCounter.vue')['default']
TheFooter: typeof import('./components/TheFooter.vue')['default']
TheInput: typeof import('./components/TheInput.vue')['default']
TheNavigation: typeof import('./components/TheNavigation.vue')['default']
WMessage: typeof import('./components/ui/WMessage.vue')['default']
WOption: typeof import('./components/ui/WOption.vue')['default']
WPopconfirm: typeof import('./components/ui/WPopconfirm.vue')['default']
WRadioButton: typeof import('./components/ui/WRadioButton.vue')['default']
WRadioGroup: typeof import('./components/ui/WRadioGroup.vue')['default']
WSelect: typeof import('./components/ui/WSelect.vue')['default']
}
}

View File

@ -0,0 +1,34 @@
<script setup lang="ts">
import { useWindowScroll } from '@vueuse/core'
const { y } = useWindowScroll()
function scrollToTop() {
window.scrollTo({
top: 0,
behavior: 'smooth',
})
}
</script>
<template>
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="translate-y-10 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-300 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-10 opacity-0"
>
<button
v-if="y > 500"
class="fixed bottom-5 right-5 z-40 rounded-full bg-blue-600 p-3 text-white shadow-lg transition-colors dark:bg-blue-500 hover:bg-blue-700 focus:outline-none dark:hover:bg-blue-600"
title="返回顶部"
@click="scrollToTop"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</button>
</Transition>
</template>

View File

@ -0,0 +1,298 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
// (便)
export interface FilterState {
location: string
type: string
major: string
sort: string
}
interface Props {
sortEnabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
sortEnabled: false,
})
// Emits ()
const emit = defineEmits<{
(e: 'change', payload: { keyword: string, filters: FilterState }): void
// //
// (e: 'confirm', filters: FilterState): void;
// //
// (e: 'search', keyword: string): void;
}>()
// --- ---
type FilterKey = 'location' | 'type' | 'major' | 'sort'
interface FilterConfig {
key: FilterKey
label: string
}
// --- ---
const filters: FilterConfig[] = [
{ key: 'location', label: '位置' },
{ key: 'type', label: '类型' },
{ key: 'major', label: '专业' },
{ key: 'sort', label: '排序' },
]
//
const locations = [
'不限',
'北京',
'天津',
'河北',
'山西',
'内蒙古',
'辽宁',
'吉林',
'黑龙江',
'上海',
'江苏',
'浙江',
'安徽',
'福建',
'江西',
'山东',
'河南',
'湖北',
'湖南',
'广东',
'广西',
'海南',
'重庆',
'四川',
'贵州',
'云南',
'西藏',
'陕西',
'甘肃',
'青海',
'宁夏',
'新疆',
'台湾',
'香港',
'澳门',
]
// --- ---
const containerRef = ref<HTMLElement | null>(null) //
const activeFilter = ref<FilterKey | null>(null) //
const searchQuery = ref('')
//
const selectedFilters = reactive({
location: '不限',
type: '不限',
major: '不限',
sort: '默认',
})
// selectedFilters
//
// --- ---
function getLabel(key: FilterKey) {
//
//
const map = { location: '位置', type: '类型', major: '专业', sort: '排序' }
return map[key]
}
const countSelected = computed(() => {
// 10
if (activeFilter.value === 'location') {
return selectedFilters.location !== '不限' ? 1 : 0
}
return 0
})
function shouldShowFilter(filter: FilterConfig) {
//
return filter.key !== 'sort' || (filter.key === 'sort' && props.sortEnabled)
}
// --- ---
//
function toggleFilter(key: FilterKey) {
if (activeFilter.value === key) {
activeFilter.value = null //
}
else {
activeFilter.value = key //
}
}
//
function selectOption(type: FilterKey, value: string) {
selectedFilters[type] = value
}
//
function clearCurrentFilter() {
if (activeFilter.value) {
selectedFilters[activeFilter.value] = '不限'
}
}
//
function confirmSelection() {
emit('change', { keyword: searchQuery.value, filters: { ...selectedFilters } })
// 使 {...}
// emit('confirm', { ...selectedFilters });
activeFilter.value = null //
}
//
function handleSearch() {
emit('change', { keyword: searchQuery.value, filters: { ...selectedFilters } })
// emit('search', searchQuery.value);
}
// --- ---
function handleClickOutside(event: MouseEvent) {
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
activeFilter.value = null
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<template>
<!-- 最外层容器用于定位下拉菜单和监听点击外部事件 -->
<div ref="containerRef" class="relative mx-auto max-w-5xl w-full select-none text-sm text-gray-600 font-sans">
<!-- 顶部栏筛选按钮组 + 搜索框 -->
<div class="flex flex-wrap items-center justify-between gap-4">
<!-- 左侧4个下拉筛选器 -->
<div class="flex flex-wrap items-center gap-3">
<div
v-for="filter in filters"
v-show="shouldShowFilter(filter)"
:key="filter.key"
class="relative"
>
<!-- 筛选器按钮 -->
<button
class="h-9 w-24 flex items-center justify-between border rounded bg-white px-3 transition-colors hover:border-blue-400"
:class="activeFilter === filter.key ? 'border-blue-500 ring-1 ring-blue-200' : 'border-gray-200'"
@click="toggleFilter(filter.key)"
>
<span class="truncate">{{ getLabel(filter.key) }}</span>
<!-- 下箭头图标 -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 text-gray-400 transition-transform duration-200" :class="{ 'rotate-180': activeFilter === filter.key }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</div>
<!-- 右侧搜索框 -->
<div class="flex flex-wrap items-center">
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</div>
<input
v-model="searchQuery"
type="text"
class="h-9 w-50 border border-gray-300 rounded-l bg-white py-2 pl-9 pr-4 text-gray-700 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-200 placeholder-gray-400"
placeholder="输入院校名称"
>
</div>
<button
class="h-9 rounded-r bg-blue-500 px-6 text-white transition-colors active:bg-blue-700 hover:bg-blue-600 focus:outline-none"
@click="handleSearch"
>
搜索
</button>
</div>
</div>
<!-- 下拉菜单面板 (绝对定位) -->
<!-- 使用 Transition 添加简单的淡入淡出效果 -->
<transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<!-- style="min-width: 600px;" -->
<div
v-if="activeFilter"
class="absolute left-0 top-full z-50 mt-2 border border-gray-100 rounded-lg bg-white p-5 shadow-xl"
>
<!-- 下拉内容区域 -->
<!-- 1. 位置 (Location) 内容 -->
<div v-if="activeFilter === 'location'">
<div class="flex items-start gap-4">
<span class="mt-1 shrink-0 text-gray-400 font-medium">院校所属</span>
<div class="xs:grid-cols-3 grid gap-2 lg:grid-cols-6 md:grid-cols-5 sm:grid-cols-3">
<button
v-for="city in locations"
:key="city"
class="rounded px-3 py-1.5 text-center transition-colors hover:text-blue-500"
:class="selectedFilters.location === city
? 'bg-blue-50 text-blue-500 font-medium'
: 'text-gray-600 bg-gray-50 hover:bg-gray-100'"
@click="selectOption('location', city)"
>
{{ city }}
</button>
</div>
</div>
</div>
<!-- 2. 其他筛选器占位内容 (类型专业排序) -->
<div v-else class="p-4 text-center text-gray-400">
这里是 {{ filters.find(f => f.key === activeFilter)?.label }} 的筛选选项
</div>
<!-- 底部操作按钮 -->
<div class="mt-6 flex items-center justify-between border-t border-gray-100 pt-4">
<div class="text-gray-500">
已选 <span class="text-blue-500 font-bold">{{ countSelected }}</span>
</div>
<div class="flex gap-3">
<button
class="border border-blue-500 rounded px-4 py-1.5 text-blue-500 transition-colors hover:bg-blue-50"
@click="clearCurrentFilter"
>
清空已选
</button>
<button
class="rounded bg-blue-500 px-6 py-1.5 text-white transition-colors hover:bg-blue-600"
@click="confirmSelection"
>
确定
</button>
</div>
</div>
</div>
</transition>
</div>
</template>
<style scoped>
/*
如果你的 tailwind 配置没有自动移除按钮的默认样式可能需要以下代码
一般在 tailwind base 中已经处理好了
*/
</style>

9
src/components/README.md Normal file
View File

@ -0,0 +1,9 @@
## Components
Components in this dir will be auto-registered and on-demand, powered by [`unplugin-vue-components`](https://github.com/antfu/unplugin-vue-components).
### Icons
You can use icons from almost any icon sets by the power of [Iconify](https://iconify.design/).
It will only bundle the icons you use. Check out [`unplugin-icons`](https://github.com/antfu/unplugin-icons) for more details.

View File

@ -0,0 +1,391 @@
<script setup lang="ts">
import { ref } from 'vue'
//
export interface ScoreFormData {
examType: string
selectedElectives: string[]
majorCategory: string
selectedSubMajors: string[]
scores: {
unified: string
culture: string
chinese: string
english: string
}
}
// Emits
const emit = defineEmits<{
(e: 'confirm', data: ScoreFormData): void
}>()
// --- Form State ---
const examType = ref('历史组')
const selectedElectives = ref<string[]>([])
const majorCategory = ref('')
const selectedSubMajors = ref<string[]>([])
// Score inputs
const scores = ref({
unified: '', // /
culture: '', //
chinese: '', //
english: '', //
})
// Errors state
const errors = ref({
examType: '',
electives: '',
majorCategory: '',
subMajors: '',
scores: {
unified: '',
culture: '',
chinese: '',
english: '',
},
})
// --- Options Data ---
const electiveOptions = [
{ label: '地理', value: '地理' },
{ label: '政治', value: '政治' },
{ label: '化学', value: '化学' },
{ label: '生物', value: '生物' },
]
const majorCategoryOptions = [
{ label: '美术与设计类', value: '美术与设计类' },
{ label: '播音与主持类', value: '播音与主持类' },
{ label: '表演类', value: '表演类' },
{ label: '音乐类', value: '音乐类' },
{ label: '舞蹈类', value: '舞蹈类' },
{ label: '书法类', value: '书法类' },
{ label: '戏曲类', value: '戏曲类' },
{ label: '体育类', value: '体育类' },
]
// --- Logic Methods ---
function getSubMajorOptions() {
switch (majorCategory.value) {
case '表演类':
return [
{ label: '服装表演', value: '服装表演' },
{ label: '戏剧影视导演', value: '戏剧影视导演' },
{ label: '戏剧影视表演', value: '戏剧影视表演' },
]
case '音乐类':
return [
{ label: '音乐表演声乐', value: '音乐表演声乐', disabled: selectedSubMajors.value.includes('音乐表演器乐') },
{ label: '音乐表演器乐', value: '音乐表演器乐', disabled: selectedSubMajors.value.includes('音乐表演声乐') },
{ label: '音乐教育', value: '音乐教育' },
]
default:
return []
}
}
function handleElectiveChange(value: string) {
const index = selectedElectives.value.indexOf(value)
if (index === -1) {
if (selectedElectives.value.length < 2) {
selectedElectives.value.push(value)
}
}
else {
selectedElectives.value.splice(index, 1)
}
//
if (errors.value.electives)
validateForm()
}
function handleMajorCategoryChange(val: any) {
majorCategory.value = val
selectedSubMajors.value = [] //
errors.value.majorCategory = ''
}
function handleSubMajorChange(val: any) {
//
console.warn(val)
errors.value.subMajors = ''
}
// --- Validation Logic ---
function validateForm() {
// Reset errors
errors.value = {
examType: '',
electives: '',
majorCategory: '',
subMajors: '',
scores: { unified: '', culture: '', chinese: '', english: '' },
}
let isValid = true
if (!examType.value) {
errors.value.examType = '请选择考试类型'
isValid = false
}
// 21
if (selectedElectives.value.length === 0) {
errors.value.electives = '请选择至少1门选考科目'
isValid = false
}
if (!majorCategory.value) {
errors.value.majorCategory = '请选择专业类别'
isValid = false
}
// 'music' value ''
if (majorCategory.value === '音乐类') {
const hasVocalOrInstrumental = selectedSubMajors.value.some(item =>
item === '音乐表演声乐' || item === '音乐表演器乐',
)
if (!hasVocalOrInstrumental) {
errors.value.subMajors = '音乐类必须选择声乐或器乐'
isValid = false
}
}
// Validate scores
if (scores.value.unified) {
const unifiedScore = Number(scores.value.unified)
// const maxUnified = majorCategory.value === '' ? 150 : 300 //
if (Number.isNaN(unifiedScore) || unifiedScore < 0 || unifiedScore > 300) {
errors.value.scores.unified = '统考成绩格式不正确'
isValid = false
}
}
if (scores.value.culture) {
const cultureScore = Number(scores.value.culture)
// 'sports' ''
// const maxCultureScore = majorCategory.value === '' ? 150 : 300 //
if (Number.isNaN(cultureScore) || cultureScore < 0) { //
errors.value.scores.culture = '文化成绩格式不正确'
isValid = false
}
}
if (scores.value.chinese) {
const s = Number(scores.value.chinese)
if (Number.isNaN(s) || s < 0 || s > 150) {
errors.value.scores.chinese = '语文成绩必须在0-150之间'
isValid = false
}
}
if (scores.value.english) {
const s = Number(scores.value.english)
if (Number.isNaN(s) || s < 0 || s > 150) {
errors.value.scores.english = '英语成绩必须在0-150之间'
isValid = false
}
}
return isValid
}
// --- Submit ---
function handleSubmit() {
if (validateForm()) {
//
const formData: ScoreFormData = {
examType: examType.value,
selectedElectives: [...selectedElectives.value], //
majorCategory: majorCategory.value,
selectedSubMajors: [...selectedSubMajors.value],
scores: { ...scores.value },
}
//
emit('confirm', formData)
}
}
//
defineExpose({
resetForm: () => {
examType.value = '历史组'
selectedElectives.value = []
majorCategory.value = ''
selectedSubMajors.value = []
scores.value = { unified: '', culture: '', chinese: '', english: '' }
},
})
</script>
<template>
<div class="exam-form-container">
<!-- Exam Type -->
<div class="mb-6">
<label class="mb-2 block text-sm font-medium">考试类型</label>
<div class="flex gap-4">
<!-- 注意确保项目中已全局注册 w-radio-group 或在此组件import -->
<w-radio-group v-model="examType" size="small">
<w-radio-button value="历史组" label="历史组" />
<w-radio-button value="物理组" label="物理组" />
</w-radio-group>
</div>
<div v-if="errors.examType" class="mt-2 text-sm text-red-600">
{{ errors.examType }}
</div>
</div>
<!-- Elective Subjects (4选2) -->
<div class="mb-6">
<label class="mb-2 block text-sm font-medium">选考科目</label>
<div class="grid grid-cols-4 gap-2">
<button
v-for="option in electiveOptions"
:key="option.value"
type="button"
class="border rounded-lg px-4 py-2 text-sm font-medium transition-colors"
:class="selectedElectives.includes(option.value)
? 'bg-blue-600 text-white border-blue-600'
: 'bg-gray-50 dark:bg-gray-300 dark:text-gray-800 border-gray-300 hover:bg-gray-100'"
@click="handleElectiveChange(option.value)"
>
{{ option.label }}
</button>
</div>
<div v-if="errors.electives" class="mt-2 text-sm text-red-600">
{{ errors.electives }}
</div>
</div>
<!-- Major Category -->
<div class="mb-6">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-2 block text-sm font-medium">专业类别</label>
<div class="flex gap-4">
<w-select
v-model:value="majorCategory"
placeholder="请选择"
:options="majorCategoryOptions"
@change="handleMajorCategoryChange"
/>
</div>
<div v-if="errors.majorCategory" class="mt-2 text-sm text-red-600">
{{ errors.majorCategory }}
</div>
</div>
<!-- Sub-major Category (条件显示) -->
<div v-if="majorCategory === '表演类' || majorCategory === '音乐类'">
<label class="mb-2 block text-sm font-medium">
{{ majorCategory === '表演类' ? '表演类' : '音乐类' }}专业选课
</label>
<div class="flex gap-4">
<w-select
v-model:value="selectedSubMajors"
mode="multiple"
placeholder="请选择..."
:max-tag-count="1"
:max-select-count="3"
:options="getSubMajorOptions()"
@change="handleSubMajorChange"
/>
</div>
<div v-if="errors.subMajors" class="mt-2 text-sm text-red-600">
{{ errors.subMajors }}
</div>
</div>
</div>
</div>
<!-- Scores Section -->
<div class="mb-6">
<h3 class="mb-3 text-sm font-semibold">
成绩输入
</h3>
<!-- Unified Exam Score -->
<div class="grid grid-cols-2 gap-4">
<div class="mb-4">
<label class="mb-2 block text-sm font-medium">
{{ majorCategory === '音乐类' ? '主项成绩' : '统考成绩' }}
</label>
<input
v-model="scores.unified"
type="number"
min="0"
:max="majorCategory === '体育类' ? 150 : 300"
:placeholder="majorCategory === '体育类' ? '0-150' : '0-300'"
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<div v-if="errors.scores.unified" class="mt-2 text-sm text-red-600">
{{ errors.scores.unified }}
</div>
</div>
<!-- Culture Score -->
<div class="mb-4">
<label class="mb-2 block text-sm font-medium">文化成绩</label>
<input
v-model="scores.culture"
type="number"
min="0"
max="300"
placeholder="0-300"
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<div v-if="errors.scores.culture" class="mt-2 text-sm text-red-600">
{{ errors.scores.culture }}
</div>
</div>
</div>
<!-- Chinese & English Scores -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-2 block text-sm font-medium">语文成绩</label>
<input
v-model="scores.chinese"
type="number"
min="0"
max="150"
placeholder="0-150"
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<div v-if="errors.scores.chinese" class="mt-2 text-sm text-red-600">
{{ errors.scores.chinese }}
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium">英语成绩</label>
<input
v-model="scores.english"
type="number"
min="0"
max="150"
placeholder="0-150"
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<div v-if="errors.scores.english" class="mt-2 text-sm text-red-600">
{{ errors.scores.english }}
</div>
</div>
</div>
</div>
<!-- Submit Button -->
<button
class="w-full rounded-full bg-blue-600 px-6 py-3 text-lg text-white font-semibold shadow-lg transition-colors hover:bg-blue-700 hover:shadow-xl"
@click="handleSubmit"
>
确认
</button>
</div>
</template>
<style scoped>
/* 你的样式代码 */
</style>

View File

@ -0,0 +1,19 @@
<script setup lang="ts">
const props = defineProps<{
initial: number
}>()
const { count, inc, dec } = useCounter(props.initial)
</script>
<template>
<div>
{{ count }}
<button class="inc" @click="inc()">
+
</button>
<button class="dec" @click="dec()">
-
</button>
</div>
</template>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
const { modelValue } = defineModels<{
modelValue: string
}>()
</script>
<template>
<input
id="input"
v-model="modelValue"
v-bind="$attrs"
type="text"
p="x-4 y-2"
w="250px"
text="center"
bg="transparent"
border="~ rounded gray-200 dark:gray-700"
outline="none active:none"
>
</template>

View File

@ -0,0 +1,348 @@
<script setup lang="ts">
import type { ScoreFormData } from './ScoreForm.vue'
import { useWindowScroll } from '@vueuse/core' // 使 VueUse
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useUserStore } from '../stores/user'
const userStore = useUserStore()
const isLoginModalOpen = ref(false)
//
const isMobileMenuOpen = ref(false)
const username = ref('')
const password = ref('')
const error = ref('')
const route = useRoute()
const routerLinkList = [
{ path: '/', name: 'home', label: '首页' },
{ path: '/simulate', name: 'simulate', label: '模拟填志愿' },
{ path: '/universities', name: 'universities', label: '找大学' },
{ path: '/majors', name: 'majors', label: '找专业' },
] as const
//
const isScoreModalOpen = ref(false)
//
function handleScoreFormConfirm(data: ScoreFormData) {
console.warn('接收到表单数据:', data)
// API
// api.submit(data).then(...)
}
function openScoreFormModal() {
isScoreModalOpen.value = true
}
function closeScoreFormModal() {
isScoreModalOpen.value = false
}
// Scroll handling
const { y } = useWindowScroll()
const isVisible = ref(true)
watch(y, (newY, oldY) => {
//
if (isMobileMenuOpen.value)
return
if (newY <= 0) {
isVisible.value = true
return
}
const dy = newY - oldY
if (dy > 0 && newY > 50) {
isVisible.value = false
}
if (dy < 0) {
isVisible.value = true
}
})
function openLoginModal() {
isLoginModalOpen.value = true
// 便
isMobileMenuOpen.value = false
username.value = ''
password.value = ''
error.value = ''
}
function closeLoginModal() {
isLoginModalOpen.value = false
username.value = ''
password.value = ''
error.value = ''
}
function handleLogin() {
if (!username.value || !password.value) {
error.value = '请输入用户名和密码'
return
}
userStore.login(username.value, password.value)
closeLoginModal()
}
function handleLogout() {
userStore.logout()
//
isMobileMenuOpen.value = false
}
//
function toggleMobileMenu() {
isMobileMenuOpen.value = !isMobileMenuOpen.value
}
//
function handleMobileLinkClick() {
isMobileMenuOpen.value = false
}
</script>
<template>
<nav
class="fixed left-0 right-0 top-0 z-40 select-none bg-white shadow-md transition-transform duration-300 dark:bg-gray-900"
:class="isVisible ? 'translate-y-0' : '-translate-y-full'"
>
<div class="mx-auto max-w-7xl px-4 lg:px-8 sm:px-6">
<div class="h-16 flex items-center justify-between">
<!-- 1. Logo (左侧始终显示) -->
<div class="w-48 flex flex-none items-center">
<img
src="https://yitisheng.vip/assets/logo.0e7b79fc.png"
alt="艺体志愿宝logo"
class="h-8 w-auto"
>
</div>
<!-- 2. 桌面端菜单 (在大屏幕 md 以上显示小屏幕隐藏) -->
<div class="hidden flex-1 items-center md:flex space-x-8">
<router-link
v-for="item in routerLinkList"
:id="item.name"
:key="item.name"
:to="item.path"
class="rounded-md px-3 py-2 text-sm font-medium transition-colors hover:text-blue-600 dark:hover:text-blue-400"
:class="{ 'text-blue-600 dark:text-blue-400': route.path === item.path, 'text-gray-700 dark:text-gray-200': route.path !== item.path }"
>
{{ item.label }}
</router-link>
</div>
<!-- 3. 桌面端按钮 (在大屏幕 md 以上显示小屏幕隐藏) -->
<div class="hidden items-center md:flex space-x-4">
<template v-if="!userStore.user">
<button
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white font-medium transition-colors dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600"
@click="openLoginModal"
>
登录
</button>
<button
class="border border-blue-600 rounded-md bg-white px-4 py-2 text-sm text-blue-600 font-medium transition-colors dark:border-blue-400 dark:bg-gray-800 hover:bg-gray-100 dark:text-blue-400 dark:hover:bg-gray-700"
>
注册
</button>
</template>
<div v-else class="flex items-center space-x-6">
<div class="flex items-center space-x-2">
<span class="border-r-2 border-gray-3 pr-2 text-sm text-gray-700 dark:text-gray-200">音乐类</span>
<span class="border-r-2 border-gray-3 pr-2 text-sm text-gray-700 dark:text-gray-200">文化成绩</span>
<span class="text-sm text-gray-700 dark:text-gray-200">334</span>
<a target="_blank" class="cursor-pointer text-gray-400 transition-colors hover:text-gray-500 dark:hover:text-gray-300" @click="openScoreFormModal">
<div i-carbon:edit class="text-xs" />
</a>
</div>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-700 dark:text-gray-200">欢迎, {{ userStore.user.username }}</span>
<a href="https://github.com/antfu/vitesse" target="_blank" class="text-gray-400 transition-colors hover:text-gray-500 dark:hover:text-gray-300">
<span class="sr-only">GitHub</span>
<div i-carbon:document-horizontal class="text-xl text-gray-8" />
</a>
<button
v-show="false"
class="rounded-md bg-red-600 px-4 py-2 text-sm text-white font-medium transition-colors dark:bg-red-500 hover:bg-red-700 dark:hover:bg-red-600"
@click="handleLogout"
>
退出
</button>
</div>
</div>
</div>
<!-- 4. 移动端汉堡包按钮 (仅在小屏幕 md 以下显示) -->
<div class="flex md:hidden">
<button
type="button"
class="inline-flex items-center justify-center rounded-md p-2 text-gray-700 hover:bg-gray-100 dark:text-gray-200 hover:text-gray-900 focus:outline-none dark:hover:bg-gray-800 dark:hover:text-white"
@click="toggleMobileMenu"
>
<span class="sr-only">打开主菜单</span>
<!-- 菜单关闭时显示汉堡图标 -->
<svg
v-if="!isMobileMenuOpen"
class="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<!-- 菜单打开时显示 X 图标 -->
<svg
v-else
class="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<!-- 5. 移动端菜单下拉面板 (仅在 isMobileMenuOpen 为真且屏幕较小时显示) -->
<div v-show="isMobileMenuOpen" class="border-t border-gray-200 bg-white md:hidden dark:border-gray-700 dark:bg-gray-900">
<div class="px-2 pb-3 pt-2 space-y-1">
<router-link
v-for="item in routerLinkList"
:key="item.name"
:to="item.path"
class="block rounded-md px-3 py-2 text-base font-medium"
:class="{ 'bg-blue-50 text-blue-600 dark:bg-gray-800 dark:text-blue-400': route.path === item.path, 'text-gray-700 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 dark:hover:text-white': route.path !== item.path }"
@click="handleMobileLinkClick"
>
{{ item.label }}
</router-link>
</div>
<!-- 移动端底部的登录/用户信息区域 -->
<div class="border-t border-gray-200 pb-3 pt-4 dark:border-gray-700">
<div class="px-2 space-y-2">
<template v-if="!userStore.user">
<button
class="block w-full rounded-md px-3 py-2 text-left text-base text-gray-700 font-medium hover:bg-gray-50 dark:text-gray-200 hover:text-gray-900 dark:hover:bg-gray-800 dark:hover:text-white"
@click="openLoginModal"
>
登录
</button>
<button
class="block w-full rounded-md px-3 py-2 text-left text-base text-blue-600 font-medium hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-gray-800"
>
注册
</button>
</template>
<template v-else>
<div class="mb-3 flex items-center px-3">
<span class="border-r-2 border-gray-3 pr-2 text-sm text-gray-700 dark:text-gray-200">音乐类</span>
<span class="border-r-2 border-gray-3 pl-2 pr-2 text-sm text-gray-700 dark:text-gray-200">文化成绩</span>
<span class="pl-2 pr-2 text-sm text-gray-700 dark:text-gray-200">334</span>
<a target="_blank" class="cursor-pointer text-gray-400 transition-colors hover:text-gray-500 dark:hover:text-gray-300" @click="openScoreFormModal">
<div i-carbon:edit class="text-xs" />
</a>
</div>
<div class="mb-3 flex items-center px-3">
<span>欢迎, {{ userStore.user.username }}</span>
<button
class="block rounded-md px-3 py-2 text-left text-base text-red-600 font-medium hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-800"
@click="handleLogout"
>
退出登录
</button>
</div>
</template>
</div>
</div>
</div>
</nav>
<!-- Login Modal (Z-index 调高确保覆盖导航栏) -->
<div
v-if="isLoginModalOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 px-4"
>
<!-- Modal Content -->
<div class="max-w-md w-full border rounded-lg bg-white p-6 shadow-xl transition-all dark:border-gray-700 dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl text-gray-900 font-bold dark:text-white">
登录
</h2>
<button
class="text-2xl text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
@click="closeLoginModal"
>
×
</button>
</div>
<div v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">
{{ error }}
</div>
<div class="mb-4">
<label class="mb-1 block text-sm text-gray-700 font-medium dark:text-gray-300">用户名</label>
<input
v-model="username"
type="text"
class="w-full border border-gray-300 rounded-md bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="请输入用户名"
@keyup.enter="handleLogin"
>
</div>
<div class="mb-4">
<label class="mb-1 block text-sm text-gray-700 font-medium dark:text-gray-300">密码</label>
<input
v-model="password"
type="password"
class="w-full border border-gray-300 rounded-md bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="请输入密码"
@keyup.enter="handleLogin"
>
</div>
<div class="flex justify-end pt-2 space-x-3">
<button
class="rounded-md bg-gray-200 px-4 py-2 text-sm text-gray-800 font-medium transition-colors dark:bg-gray-700 hover:bg-gray-300 dark:text-gray-200 dark:hover:bg-gray-600"
@click="closeLoginModal"
>
取消
</button>
<button
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white font-medium transition-colors dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600"
@click="handleLogin"
>
登录
</button>
</div>
</div>
</div>
<div v-if="isScoreModalOpen" class="fixed inset-0 z-50 flex select-none items-center justify-center bg-black bg-opacity-50 px-4">
<div class="max-w-md w-full border rounded-lg bg-white p-6 shadow-xl transition-all dark:border-gray-700 dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-xl text-gray-900 font-bold dark:text-white">
修改你的高考信息
</h3>
<button
class="text-3xl text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
@click="closeScoreFormModal"
>
×
</button>
</div>
<!-- 使用组件并监听 confirm 事件 -->
<ScoreForm @confirm="handleScoreFormConfirm" />
</div>
</div>
</template>

View File

@ -0,0 +1,95 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
interface Props {
id: string
type?: 'success' | 'error' | 'warning' | 'info'
message: string
duration?: number
onClose?: (id: string) => void
}
const props = withDefaults(defineProps<Props>(), {
type: 'info',
duration: 3000,
onClose: () => {},
})
const visible = ref(false)
const timer = ref<any>(null)
// Icon mapping
const icons = {
success: 'i-carbon-checkmark-filled',
error: 'i-carbon-close-filled',
warning: 'i-carbon-warning-filled',
info: 'i-carbon-information-filled',
}
// Color mapping
const colors = {
success: 'text-green-500 bg-green-50 dark:bg-green-900/20 dark:text-green-400',
error: 'text-red-500 bg-red-50 dark:bg-red-900/20 dark:text-red-400',
warning: 'text-orange-500 bg-orange-50 dark:bg-orange-900/20 dark:text-orange-400',
info: 'text-blue-500 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-400',
}
function startTimer() {
if (props.duration > 0) {
timer.value = setTimeout(() => {
close()
}, props.duration)
}
}
function clearTimer() {
if (timer.value) {
clearTimeout(timer.value)
timer.value = null
}
}
function close() {
visible.value = false
// Give time for transition to finish before removing
setTimeout(() => {
// props.onClose(props.id)
}, 300)
}
onMounted(() => {
// Small delay to trigger enter transition
setTimeout(() => {
visible.value = true
}, 10)
startTimer()
})
</script>
<template>
<div class="pointer-events-auto mb-3 max-w-sm w-full flex overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-300 dark:bg-gray-800" :class="[visible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0']" @mouseenter="clearTimer" @mouseleave="startTimer">
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<div :class="[icons[type], colors[type]]" class="w-6' h-6" />
</div>
<div class="ml-3 flex-1 pt-0.5">
<p class="text-sm text-gray-900 font-medium dark:text-gray-100">
{{ message }}
</p>
</div>
<div class="ml-4 flex flex-shrink-0">
<!-- focus:outline-none focus:ring-2 focus:ring-offset-2 -->
<button
type="button"
class="inline-flex rounded-md bg-white text-gray-400 dark:bg-gray-800 dark:text-gray-500 hover:text-gray-500 focus:ring-indigo-500 dark:hover:text-gray-400"
@click="close"
>
<span class="sr-only">Close</span>
<div class="i-carbon-close h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,44 @@
<script setup lang="ts">
import { inject, onMounted, onUnmounted } from 'vue'
// Props
const props = defineProps<{
label: string
value: string | number
disabled?: boolean
}>()
//
// WSelect provide key
const selectContext = inject<{
registerOption: (opt: any) => void
unregisterOption: (val: string | number) => void
}>('w-select-context')
if (!selectContext) {
console.warn('WOption must be used inside WSelect')
}
//
const optionData = {
label: props.label,
value: props.value,
disabled: props.disabled,
}
// 1.
onMounted(() => {
selectContext?.registerOption(optionData)
})
// 2.
onUnmounted(() => {
selectContext?.unregisterOption(props.value)
})
// 3. () props value
</script>
<template>
<div style="display: none;" />
</template>

View File

@ -0,0 +1,251 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
// --- ---
type Placement =
| 'top' | 'top-left' | 'top-right'
| 'bottom' | 'bottom-left' | 'bottom-right'
| 'left' | 'left-top' | 'left-bottom'
| 'right' | 'right-top' | 'right-bottom'
interface ButtonProps {
size?: 'small' | 'medium'
disabled?: boolean
loading?: boolean
}
interface Props {
title?: string
description?: string
okText?: string
cancelText?: string
okType?: 'primary' | 'danger' | 'default'
showCancel?: boolean
icon?: string
disabled?: boolean
open?: boolean
//
placement?: Placement
arrowPointAtCenter?: boolean
okButtonProps?: ButtonProps
cancelButtonProps?: ButtonProps
}
const props = withDefaults(defineProps<Props>(), {
description: '',
okText: '确定',
cancelText: '取消',
okType: 'primary',
showCancel: true,
disabled: false,
open: undefined,
placement: 'top', //
arrowPointAtCenter: false, //
okButtonProps: () => ({}),
cancelButtonProps: () => ({}),
})
const emit = defineEmits<{
(e: 'update:open', visible: boolean): void
(e: 'confirm', ev: MouseEvent): void
(e: 'cancel', ev: MouseEvent): void
(e: 'openChange', visible: boolean): void
}>()
// --- ---
const internalVisible = ref(false)
const containerRef = ref<HTMLElement | null>(null)
const isVisible = computed({
get: () => props.open !== undefined ? props.open : internalVisible.value,
set: (val) => {
internalVisible.value = val
emit('update:open', val)
emit('openChange', val)
},
})
function handleTriggerClick() {
if (props.disabled)
return
isVisible.value = !isVisible.value
}
function handleCancel(e: MouseEvent) {
isVisible.value = false
emit('cancel', e)
}
function handleConfirm(e: MouseEvent) {
isVisible.value = false
emit('confirm', e)
}
function handleClickOutside(event: MouseEvent) {
if (!isVisible.value)
return
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
isVisible.value = false
}
}
onMounted(() => document.addEventListener('click', handleClickOutside))
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
// --- ---
// 1. ( Trigger)
const overlayPositionClass = computed(() => {
const map: Record<Placement, string> = {
'top': 'bottom-full left-1/2 -translate-x-1/2 mb-2.5',
'top-left': 'bottom-full left-0 mb-2.5',
'top-right': 'bottom-full right-0 mb-2.5',
'bottom': 'top-full left-1/2 -translate-x-1/2 mt-2.5',
'bottom-left': 'top-full left-0 mt-2.5',
'bottom-right': 'top-full right-0 mt-2.5',
'left': 'right-full top-1/2 -translate-y-1/2 mr-2.5',
'left-top': 'right-full top-0 mr-2.5',
'left-bottom': 'right-full bottom-0 mr-2.5',
'right': 'left-full top-1/2 -translate-y-1/2 ml-2.5',
'right-top': 'left-full top-0 ml-2.5',
'right-bottom': 'left-full bottom-0 ml-2.5',
}
return map[props.placement] || map.top
})
// 2. ( Popover)
const arrowPositionClass = computed(() => {
const base = 'absolute h-3 w-3 bg-white border-slate-200 z-[-1]' // z-index -1
//
// const centerOffset = props.arrowPointAtCenter ? 'left-1/2 -translate-x-1/2' : ''
// PointAtCenter Left/Right
const hAlign = props.arrowPointAtCenter ? '' : (props.placement.includes('left') ? 'left-4' : props.placement.includes('right') ? 'right-4' : '')
const vAlign = props.arrowPointAtCenter ? '' : (props.placement.includes('top') ? 'top-3' : props.placement.includes('bottom') ? 'bottom-3' : '')
// 12
// Top Bottom (45)
if (props.placement.startsWith('top')) {
return `${base} -bottom-1.5 border-b border-r rotate-45 ${props.placement === 'top' || props.arrowPointAtCenter ? 'left-1/2 -translate-x-1/2' : hAlign}`
}
if (props.placement.startsWith('bottom')) {
return `${base} -top-1.5 border-t border-l rotate-45 ${props.placement === 'bottom' || props.arrowPointAtCenter ? 'left-1/2 -translate-x-1/2' : hAlign}`
}
if (props.placement.startsWith('left')) {
return `${base} -right-1.5 border-t border-r rotate-45 ${props.placement === 'left' || props.arrowPointAtCenter ? 'top-1/2 -translate-y-1/2' : vAlign}`
}
if (props.placement.startsWith('right')) {
return `${base} -left-1.5 border-b border-l rotate-45 ${props.placement === 'right' || props.arrowPointAtCenter ? 'top-1/2 -translate-y-1/2' : vAlign}`
}
return ''
})
// 3. (Transform Origin)
const transitionOriginClass = computed(() => {
if (props.placement.startsWith('top'))
return 'origin-bottom'
if (props.placement.startsWith('bottom'))
return 'origin-top'
if (props.placement.startsWith('left'))
return 'origin-right'
if (props.placement.startsWith('right'))
return 'origin-left'
return 'origin-center'
})
//
const okBtnClass = computed(() => {
const base = 'rounded px-3 py-1 text-xs text-white transition-colors'
switch (props.okType) {
case 'danger': return `${base} bg-red-500 hover:bg-red-600 border border-red-500`
case 'default': return 'rounded px-3 py-1 text-xs text-slate-600 border border-slate-200 hover:border-blue-400 hover:text-blue-500 transition-colors bg-white'
default: return `${base} bg-blue-600 hover:bg-blue-700 border border-blue-600`
}
})
</script>
<template>
<div ref="containerRef" class="relative inline-block">
<!-- Trigger -->
<div class="inline-block cursor-pointer" @click.stop="handleTriggerClick">
<slot />
</div>
<!-- Popover -->
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="isVisible"
class="absolute z-50 w-45 cursor-default border border-slate-200 rounded-lg bg-white p-3 shadow-xl"
:class="[overlayPositionClass, transitionOriginClass]"
@click.stop
>
<!-- Arrow -->
<div :class="arrowPositionClass" />
<!-- Content -->
<div class="relative z-10 flex items-start gap-3">
<div class="mt-0.5 flex-shrink-0">
<slot name="icon">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-orange-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</slot>
</div>
<div class="flex-1">
<div class="break-words text-sm text-slate-800 font-bold">
<slot name="title">
{{ title }}
</slot>
</div>
<div v-if="description || $slots.description" class="mt-1 break-words text-xs text-slate-500">
<slot name="description">
{{ description }}
</slot>
</div>
</div>
</div>
<!-- Buttons -->
<div class="relative z-10 mt-2 flex justify-end gap-2">
<slot name="cancelButton">
<button
v-if="showCancel"
class="rounded px-2 py-1 text-xs text-slate-500 transition-colors hover:bg-slate-100"
:disabled="cancelButtonProps.disabled"
@click="handleCancel"
>
<slot name="cancelText">
{{ cancelText }}
</slot>
</button>
</slot>
<slot name="okButton">
<button
:class="okBtnClass"
:disabled="okButtonProps.disabled || okButtonProps.loading"
@click="handleConfirm"
>
<span v-if="okButtonProps.loading" class="mr-1 inline-block h-3 w-3 animate-spin border-2 border-white/30 border-t-white rounded-full" />
<slot name="okText">
{{ okText }}
</slot>
</button>
</slot>
</div>
</div>
</Transition>
</div>
</template>

View File

@ -0,0 +1,95 @@
<!-- WRadioButton.vue -->
<script setup lang="ts">
import { computed, inject, onMounted, onUnmounted } from 'vue'
interface Props {
value: string | number
label: string
}
const props = defineProps<Props>()
//
const radioGroupContext: any = inject('radioGroupContext')
// 使
const modelValue = computed(() => {
return radioGroupContext?.modelValue?.value
})
function handleChange() {
if (radioGroupContext?.updateValue) {
radioGroupContext.updateValue(props.value)
}
}
//
const positionClass = computed(() => {
if (!radioGroupContext || !radioGroupContext.getOptionIndex)
return 'rounded-md'
const index = radioGroupContext.getOptionIndex(props.value)
const totalCount = radioGroupContext.getOptionCount()
if (totalCount === 1)
return 'rounded-md border-x'
if (index === 0) {
return 'rounded-l-md border-r-0'
}
else if (index === totalCount - 1) {
return 'rounded-r-md border-l-0'
}
else {
return 'border-l-0 border-r-0'
}
})
const sizeClasses = computed(() => {
// padding
return 'px-4 py-2 border'
})
onMounted(() => {
//
if (radioGroupContext?.registerOption) {
radioGroupContext.registerOption({
value: props.value,
label: props.label,
})
}
})
onUnmounted(() => {
//
if (radioGroupContext?.unregisterOption) {
radioGroupContext.unregisterOption(props.value)
}
})
</script>
<template>
<div class="relative">
<input
:id="`radio-${value}`"
type="radio"
:value="value"
:checked="modelValue === value"
class="peer absolute h-0 w-0 opacity-0"
@change="handleChange"
>
<label
:for="`radio-${value}`"
class="flex cursor-pointer items-center justify-center transition-colors duration-150 ease-in-out"
:class="[
sizeClasses,
modelValue === value
? 'bg-blue-600 text-white border-blue-600 z-10'
: 'bg-white dark:bg-gray-300 text-gray-700 border-gray-300 hover:bg-gray-50 dark:hover:bg-gray-200',
positionClass,
]"
>
<slot>{{ label }}</slot>
</label>
</div>
</template>

View File

@ -0,0 +1,77 @@
<!-- eslint-disable no-console -->
<!-- WRadioGroup.vue -->
<script setup lang="ts">
import { provide, ref, watch } from 'vue'
interface Props {
modelValue: string | number
size?: 'large' | 'middle' | 'small'
}
interface Emits {
(e: 'update:modelValue', value: string | number): void
}
const props = withDefaults(defineProps<Props>(), {
size: 'middle',
})
const emit = defineEmits<Emits>()
const selectedValue = ref(props.modelValue)
const options = ref<Array<{ value: string | number, label: string }>>([])
//
provide('radioGroupContext', {
modelValue: selectedValue,
updateValue: (value: string | number) => {
selectedValue.value = value
emit('update:modelValue', value)
},
registerOption: (option: { value: string | number, label: string }) => {
options.value.push(option)
},
unregisterOption: (value: string | number) => {
const index = options.value.findIndex(opt => opt.value === value)
if (index > -1) {
options.value.splice(index, 1)
}
},
getOptionIndex: (value: string | number) => {
return options.value.findIndex(opt => opt.value === value)
},
getOptionCount: () => {
return options.value.length
},
})
watch(() => props.modelValue, (newValue) => {
selectedValue.value = newValue
})
const sizeClass = computed(() => {
console.log(props.size)
switch (props.size) {
case 'large':
return 'px-6 py-3'
case 'middle':
return 'px-4 py-2'
case 'small':
return 'text-sm'
default:
return 'px-4 py-2'
}
})
</script>
<template>
<div class="w-radio-group flex select-none overflow-hidden rounded-md shadow-sm" :class="[sizeClass]">
<slot />
</div>
</template>
<style scoped>
.w-radio-group {
/* 样式已通过 Tailwind 类定义,在这里不需要额外样式 */
}
</style>

View File

@ -0,0 +1,276 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
// --- Types ---
export interface OptionItem {
label: string
value: string | number
disabled?: boolean
}
// --- Props ---
const props = withDefaults(defineProps<{
// v-model:value
value?: string | number | (string | number)[]
options?: OptionItem[]
mode?: 'single' | 'multiple'
placeholder?: string
disabled?: boolean
maxTagCount?: number // Input tag
maxSelectCount?: number //
}>(), {
value: undefined,
options: () => [],
mode: 'single',
placeholder: 'Select Item...',
disabled: false,
maxTagCount: undefined,
maxSelectCount: undefined,
})
// --- Emits ---
const emit = defineEmits<{
(e: 'update:value', val: string | number | (string | number)[] | undefined): void
(e: 'change', val: string | number | (string | number)[] | undefined): void
}>()
// --- State ---
const isOpen = ref(false)
const containerRef = ref<HTMLElement | null>(null)
// --- Computed Helper ---
const isMultiple = computed(() => props.mode === 'multiple')
//
const internalValue = computed(() => {
return props.value
})
const isEmpty = computed(() => {
if (isMultiple.value) {
return !Array.isArray(props.value) || props.value.length === 0
}
return props.value === undefined || props.value === null || props.value === ''
})
// Tag
const selectedOptions = computed(() => {
if (!isMultiple.value)
return []
const vals = Array.isArray(props.value) ? props.value : []
// options label
return vals.map(val => props.options.find(opt => opt.value === val) || { label: String(val), value: val })
})
// Tags (Max Tag Count Logic)
const visibleTags = computed(() => {
if (!props.maxTagCount)
return selectedOptions.value
return selectedOptions.value.slice(0, props.maxTagCount)
})
// Tags
const hiddenTagCount = computed(() => {
if (!props.maxTagCount)
return 0
return Math.max(0, selectedOptions.value.length - props.maxTagCount)
})
// --- Actions ---
function toggleDropdown() {
if (props.disabled)
return
isOpen.value = !isOpen.value
}
function closeDropdown() {
isOpen.value = false
}
function getLabel(val: string | number | undefined) {
const opt = props.options.find(o => o.value === val)
return opt ? opt.label : val
}
function isSelected(val: string | number) {
if (isMultiple.value) {
return Array.isArray(props.value) && props.value.includes(val)
}
return props.value === val
}
//
const isMaxLimitReached = computed(() => {
if (!isMultiple.value || !props.maxSelectCount)
return false
const currentLen = Array.isArray(props.value) ? props.value.length : 0
return currentLen >= props.maxSelectCount
})
//
function isDisabledOption(option: OptionItem) {
if (option.disabled)
return true
//
if (isMultiple.value && isMaxLimitReached.value && !isSelected(option.value)) {
return true
}
return false
}
function handleSelect(option: OptionItem) {
if (isDisabledOption(option))
return
if (isMultiple.value) {
//
const currentList = Array.isArray(props.value) ? [...props.value] : []
const index = currentList.indexOf(option.value)
if (index > -1) {
//
currentList.splice(index, 1)
}
else {
// ( Limit)
if (props.maxSelectCount && currentList.length >= props.maxSelectCount)
return
currentList.push(option.value)
}
emit('update:value', currentList)
emit('change', currentList)
// 便
}
else {
//
if (props.value !== option.value) {
emit('update:value', option.value)
emit('change', option.value)
}
closeDropdown()
}
}
function removeTag(val: string | number) {
if (!isMultiple.value)
return
const currentList = Array.isArray(props.value) ? [...props.value] : []
const index = currentList.indexOf(val)
if (index > -1) {
currentList.splice(index, 1)
emit('update:value', currentList)
emit('change', currentList)
}
}
// --- Click Outside Logic ---
//
function handleClickOutside(event: MouseEvent) {
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
closeDropdown()
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<template>
<div ref="containerRef" class="relative w-full">
<div
class="min-h-[40px] w-full flex cursor-pointer items-center justify-between border rounded-md bg-white px-3 py-1.5 transition-colors"
:class="[
isOpen ? 'border-blue-500 ring-1 ring-blue-500' : 'border-gray-300 hover:border-blue-400',
disabled ? 'bg-gray-100 cursor-not-allowed opacity-75' : '',
]"
@click="toggleDropdown"
>
<div class="flex flex-1 flex-wrap items-center gap-2 overflow-hidden">
<span v-if="isEmpty" class="select-none text-gray-400">{{ placeholder }}</span>
<span v-else-if="!isMultiple" class="select-none text-sm text-gray-700">
{{ getLabel(Array.isArray(internalValue) ? internalValue[0] : internalValue) }}
</span>
<template v-else>
<div
v-for="item in visibleTags"
:key="item.value"
class="flex items-center gap-1 border border-gray-200 rounded bg-gray-100 px-2 py-0.5 text-sm text-gray-700"
>
<span>{{ item.label }}</span>
<button
class="hover:text-red-500 focus:outline-none"
@click.stop="removeTag(item.value)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
<div v-if="hiddenTagCount > 0" class="border border-gray-200 rounded bg-gray-100 px-2 py-0.5 text-sm text-gray-500">
+{{ hiddenTagCount }}
</div>
</template>
</div>
<div class="text-gray-400">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }"
viewBox="0 0 20 20"
fill="currentColor"
>
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</div>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-if="isOpen"
class="absolute z-50 mt-1 max-h-60 w-full overflow-y-auto border border-gray-200 rounded-md bg-white shadow-lg"
>
<ul class="py-1">
<li v-if="options.length === 0" class="px-4 py-2 text-center text-sm text-gray-400">
No data
</li>
<li
v-for="option in options"
:key="option.value"
class="group flex cursor-pointer items-center justify-between py-2 pl-3 pr-2 text-sm transition-colors"
:class="[
isSelected(option.value) ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-700 hover:bg-gray-100',
isDisabledOption(option) ? 'opacity-50 cursor-not-allowed bg-gray-50' : '',
]"
@click="handleSelect(option)"
>
<span>{{ option.label }}</span>
<span v-if="isSelected(option.value)" class="text-blue-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</span>
</li>
</ul>
</div>
</transition>
</div>
</template>

4
src/composables/dark.ts Normal file
View File

@ -0,0 +1,4 @@
// these APIs are auto-imported from @vueuse/core
export const isDark = useDark()
export const toggleDark = useToggle(isDark)
export const preferredDark = usePreferredDark()

186
src/composables/request.ts Normal file
View File

@ -0,0 +1,186 @@
// 定义请求配置类型
export interface RequestConfig extends RequestInit {
// 请求URL
url?: string
// 自动拼接的URL前缀
baseURL?: string
// 可以添加自定义配置
showLoading?: boolean
showError?: boolean
// 请求参数
params?: Record<string, any>
}
// 定义响应类型
export interface RequestResponse<T = any> {
code: number
message: string
data: T
}
// 创建请求方法
async function request<T = any>(config: RequestConfig): Promise<T> {
const {
url,
baseURL = import.meta.env.VITE_API_BASE_URL || '',
showLoading = true,
showError = true,
params,
...init
} = config
// 拼接完整URL
let fullUrl = ''
if (url?.startsWith('http')) {
fullUrl = url
}
else {
fullUrl = baseURL + (url || '')
}
// 处理GET请求参数
if (params && init.method?.toUpperCase() === 'GET') {
const searchParams = new URLSearchParams()
for (const key in params) {
if (params[key] !== undefined && params[key] !== null) {
searchParams.append(key, params[key].toString())
}
}
const paramsString = searchParams.toString()
if (paramsString) {
fullUrl += (fullUrl.includes('?') ? '&' : '?') + paramsString
}
}
// 可以添加请求加载动画
if (showLoading) {
// 显示加载动画
}
try {
// 发送请求
const response = await fetch(fullUrl, init)
// 检查响应状态
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
// 解析响应数据
const responseData = await response.json() as RequestResponse<T>
// 隐藏加载动画
if (showLoading) {
// 隐藏加载动画
}
// 统一处理响应数据
const { code, message, data } = responseData
if (code === 200) {
return data
}
else {
// 统一处理错误信息
if (showError) {
console.error('Request error:', message)
}
throw new Error(message || 'Request failed')
}
}
catch (error) {
// 隐藏加载动画
if (showLoading) {
// 隐藏加载动画
}
// 统一处理网络错误
const errorMessage = error instanceof Error ? error.message : 'Network error'
if (showError) {
console.error('Network error:', errorMessage)
}
throw new Error(errorMessage)
}
}
// 封装GET请求
function get<T = any>(url: string, params?: Record<string, any>, config?: Omit<RequestConfig, 'url' | 'method' | 'params'>): Promise<T> {
return request<T>({
method: 'GET',
url,
params,
...config,
})
}
// 封装POST请求
function post<T = any>(url: string, data?: any, config?: Omit<RequestConfig, 'url' | 'method' | 'body'>): Promise<T> {
return request<T>({
method: 'POST',
url,
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
...config?.headers,
},
...config,
})
}
// 封装PUT请求
function put<T = any>(url: string, data?: any, config?: Omit<RequestConfig, 'url' | 'method' | 'body'>): Promise<T> {
return request<T>({
method: 'PUT',
url,
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
...config?.headers,
},
...config,
})
}
// 封装DELETE请求
function del<T = any>(url: string, config?: Omit<RequestConfig, 'url' | 'method'>): Promise<T> {
return request<T>({
method: 'DELETE',
url,
...config,
})
}
// 请求取消机制 (使用AbortController)
class CancelToken {
private controller: AbortController
constructor() {
this.controller = new AbortController()
}
get signal() {
return this.controller.signal
}
cancel(reason?: string) {
this.controller.abort(reason)
}
}
function createCancelToken() {
return new CancelToken()
}
// 导出所有方法
export {
CancelToken,
createCancelToken,
del,
get,
post,
put,
request,
}
// 默认导出
export default request

21
src/layouts/404.vue Normal file
View File

@ -0,0 +1,21 @@
<script setup lang="ts">
const router = useRouter()
const { t } = useI18n()
useHead({
title: () => t('not-found'),
})
</script>
<template>
<main p="x4 y10" text="center teal-700 dark:gray-200">
<div text-4xl>
<div i-carbon-warning inline-block />
</div>
<RouterView />
<div>
<button text-sm btn m="3 t8" @click="router.back()">
{{ t('button.back') }}
</button>
</div>
</main>
</template>

14
src/layouts/README.md Normal file
View File

@ -0,0 +1,14 @@
## Layouts
Vue components in this dir are used as layouts.
By default, `default.vue` will be used unless an alternative is specified in the route meta.
With [`unplugin-vue-router`](https://github.com/posva/unplugin-vue-router) and [`vite-plugin-vue-layouts`](https://github.com/JohnCampionJr/vite-plugin-vue-layouts), you can specify the layout in the page's SFCs like this:
```vue
<route lang="yaml">
meta:
layout: home
</route>
```

10
src/layouts/default.vue Normal file
View File

@ -0,0 +1,10 @@
<template>
<div class="min-h-screen flex flex-col bg-gray-50 text-gray-700 dark:bg-gray-900 dark:text-gray-200">
<TheNavigation />
<main class="flex-grow px-4 py-10 pt-16 text-center">
<RouterView />
</main>
<TheFooter />
<BackToTop />
</div>
</template>

10
src/layouts/home.vue Normal file
View File

@ -0,0 +1,10 @@
<template>
<div class="min-h-screen flex flex-col bg-gray-50 text-gray-700 dark:bg-gray-900 dark:text-gray-200">
<TheNavigation />
<main class="flex-grow pt-16">
<RouterView />
</main>
<TheFooter />
<BackToTop />
</div>
</template>

25
src/main.ts Normal file
View File

@ -0,0 +1,25 @@
import type { UserModule } from './types'
import { setupLayouts } from 'virtual:generated-layouts'
import { ViteSSG } from 'vite-ssg'
import { routes } from 'vue-router/auto-routes'
import App from './App.vue'
import '@unocss/reset/tailwind.css'
import './styles/main.css'
import 'uno.css'
// https://github.com/antfu/vite-ssg
export const createApp = ViteSSG(
App,
{
routes: setupLayouts(routes),
base: import.meta.env.BASE_URL,
},
(ctx) => {
// install all modules under `modules/`
Object.values(import.meta.glob<{ install: UserModule }>('./modules/*.ts', { eager: true }))
.forEach(i => i.install?.(ctx))
// ctx.app.use(Previewer)
},
)

11
src/modules/README.md Normal file
View File

@ -0,0 +1,11 @@
## Modules
A custom user module system. Place a `.ts` file with the following template, it will be installed automatically.
```ts
import type { UserModule } from '~/types'
export const install: UserModule = ({ app, router, isClient }) => {
// do something
}
```

50
src/modules/i18n.ts Normal file
View File

@ -0,0 +1,50 @@
import type { Locale } from 'vue-i18n'
import type { UserModule } from '~/types'
import { createI18n } from 'vue-i18n'
// Import i18n resources
// https://vitejs.dev/guide/features.html#glob-import
//
// Don't need this? Try vitesse-lite: https://github.com/antfu/vitesse-lite
const i18n = createI18n({
legacy: false,
locale: '',
messages: {},
})
const localesMap = Object.fromEntries(
Object.entries(import.meta.glob('../../locales/*.yml'))
.map(([path, loadLocale]) => [path.match(/([\w-]*)\.yml$/)?.[1], loadLocale]),
) as Record<Locale, () => Promise<{ default: Record<string, string> }>>
export const availableLocales = Object.keys(localesMap)
const loadedLanguages: string[] = []
function setI18nLanguage(lang: Locale) {
i18n.global.locale.value = lang as any
if (typeof document !== 'undefined')
document.querySelector('html')?.setAttribute('lang', lang)
return lang
}
export async function loadLanguageAsync(lang: string): Promise<Locale> {
// If the same language
if (i18n.global.locale.value === lang)
return setI18nLanguage(lang)
// If the language was already loaded
if (loadedLanguages.includes(lang))
return setI18nLanguage(lang)
// If the language hasn't been loaded yet
const messages = await localesMap[lang]()
i18n.global.setLocaleMessage(lang, messages.default)
loadedLanguages.push(lang)
return setI18nLanguage(lang)
}
export const install: UserModule = ({ app }) => {
app.use(i18n)
loadLanguageAsync('en')
}

14
src/modules/nprogress.ts Normal file
View File

@ -0,0 +1,14 @@
import type { UserModule } from '~/types'
import NProgress from 'nprogress'
export const install: UserModule = ({ isClient, router }) => {
if (isClient) {
router.beforeEach((to, from) => {
if (to.path !== from.path)
NProgress.start()
})
router.afterEach(() => {
NProgress.done()
})
}
}

17
src/modules/pinia.ts Normal file
View File

@ -0,0 +1,17 @@
import type { UserModule } from '~/types'
import { createPinia } from 'pinia'
// Setup Pinia
// https://pinia.vuejs.org/
export const install: UserModule = ({ isClient, initialState, app }) => {
const pinia = createPinia()
app.use(pinia)
// Refer to
// https://github.com/antfu/vite-ssg/blob/main/README.md#state-serialization
// for other serialization strategies.
if (isClient)
pinia.state.value = (initialState.pinia) || {}
else
initialState.pinia = pinia.state.value
}

14
src/modules/pwa.ts Normal file
View File

@ -0,0 +1,14 @@
import type { UserModule } from '~/types'
// https://github.com/antfu/vite-plugin-pwa#automatic-reload-when-new-content-available
export const install: UserModule = ({ isClient, router }) => {
if (!isClient)
return
router.isReady()
.then(async () => {
const { registerSW } = await import('virtual:pwa-register')
registerSW({ immediate: true })
})
.catch(() => {})
}

20
src/pages/README.md Normal file
View File

@ -0,0 +1,20 @@
## File-based Routing
Routes will be auto-generated for Vue files in this dir with the same file structure.
Check out [`unplugin-vue-router`](https://github.com/posva/unplugin-vue-router) for more details.
### Path Aliasing
`~/` is aliased to `./src/` folder.
For example, instead of having
```ts
import { isDark } from '../../../../composables'
```
now, you can use
```ts
import { isDark } from '~/composables'
```

14
src/pages/[...all].vue Normal file
View File

@ -0,0 +1,14 @@
<script setup lang="ts">
const { t } = useI18n()
</script>
<template>
<div>
{{ t('not-found') }}
</div>
</template>
<route lang="yaml">
meta:
layout: 404
</route>

26
src/pages/about.md Normal file
View File

@ -0,0 +1,26 @@
---
title: About
---
<script setup>
const { t } = useI18n()
useHead({ title: () => t('button.about') })
</script>
<div class="text-center">
<!-- You can use Vue components inside markdown -->
<div i-carbon-dicom-overlay class="text-4xl -mb-6 m-auto" />
<h3>{{ t('button.about') }}</h3>
</div>
[Vitesse](https://github.com/antfu/vitesse) is an opinionated [Vite](https://github.com/vitejs/vite) starter template made by [@antfu](https://github.com/antfu) for mocking apps swiftly. With **file-based routing**, **components auto importing**, **markdown support**, I18n, PWA and uses **UnoCSS** for styling and icons.
```js
// syntax highlighting example
function vitesse() {
const foo = 'bar'
console.log(foo)
}
```
Check out the [GitHub repo](https://github.com/antfu/vitesse) for more details.

336
src/pages/about.vue Normal file
View File

@ -0,0 +1,336 @@
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { onMounted, onUnmounted, ref } from 'vue'
defineOptions({
name: 'AboutPage',
})
useHead({
title: '关于艺体志愿宝平台 - 艺体志愿宝',
meta: [
{
name: 'description',
content: '了解艺体志愿宝平台的创立背景、服务宗旨、核心功能以及联系方式',
},
],
})
// Team members data
const teamMembers = [
{
id: 1,
name: '张三',
position: '创始人兼CEO',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2070&q=80',
bio: '毕业于清华大学计算机系拥有10年互联网行业经验专注于教育科技领域。',
},
{
id: 2,
name: '李四',
position: '技术总监',
avatar: 'https://images.unsplash.com/photo-1500648767291-0a08a98c3703?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2071&q=80',
bio: '前BAT高级工程师拥有8年前端开发经验擅长Vue.js和React等现代前端框架。',
},
{
id: 3,
name: '王五',
position: '产品经理',
avatar: 'https://images.unsplash.com/photo-1522529599102-193c0d76b5b6?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2071&q=80',
bio: '毕业于北京大学光华管理学院拥有6年教育产品经验深刻理解学生需求。',
},
]
// Timeline data
const timeline = [
{
year: '2022.03',
title: '公司成立',
description: '艺体志愿宝正式成立,专注于艺术体育类志愿填报辅助平台的开发。',
},
{
year: '2022.12',
title: '产品上线',
description: '艺体志愿宝1.0版本正式上线,支持全国各大艺术体育类院校的数据查询。',
},
{
year: '2023.06',
title: '功能升级',
description: '推出AI智能匹配系统为用户提供个性化的志愿填报方案。',
},
{
year: '2023.12',
title: '用户突破',
description: '平台用户突破10万服务覆盖全国20多个省份。',
},
{
year: '2024.09',
title: '技术创新',
description: '优化大数据算法提高志愿匹配准确率至98%。',
},
{
year: '2025.01',
title: '服务升级',
description: '新增在线客服系统和专家咨询服务,提升用户体验。',
},
]
// Select component state
const selectedValue = ref<string>('platform-intro')
// Smooth scroll to section
function scrollToSection(section: string) {
const element = document.getElementById(section)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
}
// Smooth scroll behavior
onMounted(() => {
document.documentElement.style.scrollBehavior = 'smooth'
})
onUnmounted(() => {
document.documentElement.style.scrollBehavior = 'auto'
})
</script>
<template>
<div class="mx-auto max-w-7xl px-4 py-8 lg:px-8 sm:px-6">
<!-- Hero Section -->
<section class="mb-16 text-center">
<h1 class="mb-4 text-4xl text-gray-900 font-bold sm:text-5xl dark:text-white">
关于艺体志愿宝平台
</h1>
<p class="mx-auto max-w-3xl text-xl text-gray-600 dark:text-gray-300">
艺体志愿宝是专为艺术体育类考生打造的志愿填报辅助平台致力于为考生提供全方位的数据支持和个性化的志愿填报方案
</p>
<!-- Quick Navigation Select -->
<div class="mx-auto mt-8 max-w-md">
<select
v-model="selectedValue"
class="w-full border border-gray-300 rounded-lg bg-white px-4 py-3 text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="scrollToSection(selectedValue)"
>
<option value="platform-intro">
平台介绍
</option>
<option value="development-history">
发展历程
</option>
<option value="team-intro">
团队介绍
</option>
</select>
</div>
</section>
<!-- Platform Introduction Section -->
<section id="platform-intro" class="mb-20">
<h2 class="mb-8 text-2xl text-gray-900 font-bold sm:text-3xl dark:text-white">
平台介绍
</h2>
<!-- Founding Background -->
<div class="mb-12">
<h3 class="mb-4 text-xl text-gray-900 font-semibold dark:text-white">
创立背景
</h3>
<p class="text-gray-600 leading-relaxed dark:text-gray-300">
随着艺术体育类高考的不断发展考生和家长面临着越来越复杂的志愿填报决策传统的志愿填报方式往往依赖经验和有限的信息容易导致误判和滑档为了解决这一痛点艺体志愿宝于2022年应运而生我们汇聚了教育专家数据分析师和技术团队旨在为艺体考生提供科学智能精准的志愿填报辅助服务
</p>
</div>
<!-- Service Purpose -->
<div class="mb-12">
<h3 class="mb-4 text-xl text-gray-900 font-semibold dark:text-white">
服务宗旨
</h3>
<p class="text-gray-600 leading-relaxed dark:text-gray-300">
我们的服务宗旨是"以考生为中心,以数据为依据,以服务为宗旨"我们致力于为每一位艺体考生提供个性化的志愿填报方案帮助他们找到最适合自己的大学和专业我们的目标是让志愿填报变得简单科学高效让每一位考生都能实现自己的大学梦想
</p>
</div>
<!-- Core Features -->
<div class="mb-12">
<h3 class="mb-4 text-xl text-gray-900 font-semibold dark:text-white">
核心功能
</h3>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="rounded-xl bg-white p-5 shadow-lg transition-all duration-300 dark:bg-gray-800 hover:shadow-xl">
<div class="mb-3 text-3xl text-blue-600 dark:text-blue-400">
📊
</div>
<h4 class="mb-2 text-lg text-gray-900 font-bold dark:text-white">
数据查询
</h4>
<p class="text-gray-600 dark:text-gray-300">
提供全国各大艺术体育类院校的最新招生简章录取分数线和招生计划
</p>
</div>
<div class="rounded-xl bg-white p-5 shadow-lg transition-all duration-300 dark:bg-gray-800 hover:shadow-xl">
<div class="mb-3 text-3xl text-blue-600 dark:text-blue-400">
🤖
</div>
<h4 class="mb-2 text-lg text-gray-900 font-bold dark:text-white">
智能匹配
</h4>
<p class="text-gray-600 dark:text-gray-300">
基于AI智能算法结合考生的文化课成绩和专业课成绩智能匹配最适合的大学和专业
</p>
</div>
<div class="rounded-xl bg-white p-5 shadow-lg transition-all duration-300 dark:bg-gray-800 hover:shadow-xl">
<div class="mb-3 text-3xl text-blue-600 dark:text-blue-400">
📈
</div>
<h4 class="mb-2 text-lg text-gray-900 font-bold dark:text-white">
录取预测
</h4>
<p class="text-gray-600 dark:text-gray-300">
根据历年录取数据和当前考生情况预测录取概率降低滑档风险
</p>
</div>
<div class="rounded-xl bg-white p-5 shadow-lg transition-all duration-300 dark:bg-gray-800 hover:shadow-xl">
<div class="mb-3 text-3xl text-blue-600 dark:text-blue-400">
👨🏫
</div>
<h4 class="mb-2 text-lg text-gray-900 font-bold dark:text-white">
专家咨询
</h4>
<p class="text-gray-600 dark:text-gray-300">
提供在线专家咨询服务为考生解答志愿填报中的各种问题
</p>
</div>
</div>
</div>
<!-- Platform Advantages -->
<div>
<h3 class="mb-4 text-xl text-gray-900 font-semibold dark:text-white">
平台优势
</h3>
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<div class="flex items-start gap-3">
<div class="mt-1 text-2xl text-blue-600 dark:text-blue-400">
</div>
<div>
<h4 class="mb-1 text-lg text-gray-900 font-bold dark:text-white">
数据准确
</h4>
<p class="text-gray-600 dark:text-gray-300">
实时更新全国各大艺术体育类院校的最新数据
</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="mt-1 text-2xl text-blue-600 dark:text-blue-400">
</div>
<div>
<h4 class="mb-1 text-lg text-gray-900 font-bold dark:text-white">
算法智能
</h4>
<p class="text-gray-600 dark:text-gray-300">
采用先进的AI智能算法提高志愿匹配准确率
</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="mt-1 text-2xl text-blue-600 dark:text-blue-400">
</div>
<div>
<h4 class="mb-1 text-lg text-gray-900 font-bold dark:text-white">
服务专业
</h4>
<p class="text-gray-600 dark:text-gray-300">
拥有专业的教育专家和技术团队为用户提供优质服务
</p>
</div>
</div>
</div>
</div>
</section>
<!-- Development History Timeline Section -->
<section id="development-history" class="mb-20">
<h2 class="mb-8 text-2xl text-gray-900 font-bold sm:text-3xl dark:text-white">
发展历程
</h2>
<div class="relative">
<div class="absolute left-1/2 h-full w-1 transform bg-gray-200 -translate-x-1/2 dark:bg-gray-700" />
<div class="space-y-12">
<div
v-for="(item, index) in timeline"
:key="index"
class="relative flex items-center"
>
<div class="absolute left-1/2 z-10 h-8 w-8 transform border-4 border-white rounded-full bg-blue-600 -translate-x-1/2 dark:border-gray-800 dark:bg-blue-400" />
<div
:class="{
'order-1 ml-auto w-5/12': index % 2 === 0,
'order-3 mr-auto w-5/12': index % 2 !== 0,
}"
>
<div class="rounded-lg bg-white p-6 shadow-lg dark:bg-gray-800">
<div class="mb-2 text-blue-600 font-bold dark:text-blue-400">
{{ item.year }}
</div>
<h3 class="mb-2 text-xl text-gray-900 font-bold dark:text-white">
{{ item.title }}
</h3>
<p class="text-gray-600 dark:text-gray-300">
{{ item.description }}
</p>
</div>
</div>
<div class="order-2 hidden w-1/12 md:block" />
</div>
</div>
</div>
</section>
<!-- Team Introduction Section -->
<section id="team-intro" class="mb-20">
<h2 class="mb-8 text-2xl text-gray-900 font-bold sm:text-3xl dark:text-white">
团队介绍
</h2>
<div class="grid grid-cols-1 gap-8 md:grid-cols-3">
<div
v-for="member in teamMembers"
:key="member.id"
class="rounded-xl bg-white p-6 shadow-lg transition-all duration-300 dark:bg-gray-800 hover:shadow-xl"
>
<div class="relative mx-auto mb-4 h-32 w-32">
<img
:src="member.avatar"
:alt="member.name"
class="h-full w-full rounded-full object-cover"
>
</div>
<h3 class="mb-1 text-xl text-gray-900 font-bold dark:text-white">
{{ member.name }}
</h3>
<p class="mb-3 text-blue-600 dark:text-blue-400">
{{ member.position }}
</p>
<p class="text-gray-600 dark:text-gray-300">
{{ member.bio }}
</p>
</div>
</div>
</section>
</div>
</template>
<style scoped>
/* Smooth transition for page sections */
section {
transition:
opacity 0.5s ease,
transform 0.5s ease;
}
</style>

296
src/pages/contact-us.vue Normal file
View File

@ -0,0 +1,296 @@
<script setup lang="ts">
import { ref } from 'vue'
defineOptions({
name: 'ContactUsPage',
})
useHead({
title: '联系我们 - 艺体志愿宝',
meta: [
{
name: 'description',
content: '艺体志愿宝平台联系方式,包括地址、电话、邮箱以及在线留言功能',
},
],
})
// Contact info
const contactInfo = {
address: '河南省郑州市金水区文化路100号创新大厦A座12层',
phone: '400-123-4567',
email: 'contact@yitisheng.com',
workingHours: '周一至周五 9:00-18:00',
social: [
{ name: '微信', icon: 'i-carbon-logo-wechat', url: '#', qrCode: 'https://via.placeholder.com/150' },
{ name: '微博', icon: 'i-carbon-logo-sina-weibo', url: '#', qrCode: 'https://via.placeholder.com/150' },
{ name: '知乎', icon: 'i-carbon-logo-zhihu', url: '#', qrCode: 'https://via.placeholder.com/150' },
{ name: '抖音', icon: 'i-carbon-logo-douyin', url: '#', qrCode: 'https://via.placeholder.com/150' },
],
}
// Form state
const formData = ref({
name: '',
email: '',
phone: '',
message: '',
})
const formErrors = ref({
name: '',
email: '',
phone: '',
message: '',
})
// Validate email
function validateEmail(email: string) {
const emailRegex = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/
return emailRegex.test(email)
}
// Validate phone
function validatePhone(phone: string) {
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(phone)
}
// Validate form
function validateForm() {
formErrors.value = { name: '', email: '', phone: '', message: '' }
let isValid = true
if (!formData.value.name.trim()) {
formErrors.value.name = '请输入您的姓名'
isValid = false
}
if (!formData.value.email.trim()) {
formErrors.value.email = '请输入您的邮箱'
isValid = false
}
else if (!validateEmail(formData.value.email)) {
formErrors.value.email = '请输入有效的邮箱地址'
isValid = false
}
if (!formData.value.phone.trim()) {
formErrors.value.phone = '请输入您的手机号码'
isValid = false
}
else if (!validatePhone(formData.value.phone)) {
formErrors.value.phone = '请输入有效的手机号码'
isValid = false
}
if (!formData.value.message.trim()) {
formErrors.value.message = '请输入您的留言内容'
isValid = false
}
return isValid
}
// Form submission
function handleSubmit() {
if (validateForm()) {
// Here you would typically send the form data to your backend
console.warn('Form submitted:', formData.value)
// Reset form
formData.value = { name: '', email: '', phone: '', message: '' }
// Show success message
console.warn('留言提交成功!我们将尽快与您联系。')
}
}
</script>
<template>
<div class="mx-auto max-w-7xl px-4 py-8 lg:px-8 sm:px-6">
<!-- Hero Section -->
<section class="mb-16 text-center">
<h1 class="mb-4 text-4xl text-gray-900 font-bold sm:text-5xl dark:text-white">
联系我们
</h1>
<p class="mx-auto max-w-3xl text-xl text-gray-600 dark:text-gray-300">
欢迎您通过以下方式与我们联系我们将竭诚为您服务
</p>
</section>
<!-- Contact Us Section -->
<section id="contact-us" class="mb-20">
<div class="grid grid-cols-1 gap-8 md:grid-cols-3">
<!-- Contact Information -->
<div class="md:col-span-2 space-y-8">
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<div class="flex items-start gap-3">
<div class="mt-1 text-2xl text-blue-600 dark:text-blue-400">
📞
</div>
<div>
<h4 class="mb-1 text-lg text-gray-900 font-semibold dark:text-white">
联系电话
</h4>
<p class="text-gray-600 dark:text-gray-300">
{{ contactInfo.phone }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ contactInfo.workingHours }}
</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="mt-1 text-2xl text-blue-600 dark:text-blue-400">
📧
</div>
<div>
<h4 class="mb-1 text-lg text-gray-900 font-semibold dark:text-white">
电子邮箱
</h4>
<p class="text-gray-600 dark:text-gray-300">
{{ contactInfo.email }}
</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="mt-1 text-2xl text-blue-600 dark:text-blue-400">
📍
</div>
<div>
<h4 class="mb-1 text-lg text-gray-900 font-semibold dark:text-white">
公司地址
</h4>
<p class="text-gray-600 dark:text-gray-300">
{{ contactInfo.address }}
</p>
</div>
</div>
</div>
<!-- Map Component -->
<div class="rounded-xl bg-white p-4 shadow-lg dark:bg-gray-800">
<h4 class="mb-4 text-lg text-gray-900 font-semibold dark:text-white">
办公地点
</h4>
<div class="relative h-80">
<iframe
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3678.151107753289!2d113.62540441531296!3d34.80746378036473!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x35b5535493c1a78b%3A0x9c6b3f7150250000!2sZhengzhou+University!5e0!3m2!1sen!2scn!4v1577848192056!5m2!1sen!2scn"
width="100%"
height="100%"
style="border: 0"
loading="lazy"
/>
</div>
</div>
</div>
<!-- Social Media Links -->
<div>
<h4 class="mb-4 text-lg text-gray-900 font-semibold dark:text-white">
社交媒体
</h4>
<div class="grid grid-cols-2 gap-4">
<div
v-for="(item, index) in contactInfo.social"
:key="index"
class="rounded-xl bg-white p-4 text-center shadow-lg transition-all duration-300 dark:bg-gray-800 hover:shadow-xl"
>
<div :class="item.icon" class="mx-auto mb-2 text-3xl" />
<p class="mb-3 text-sm text-gray-600 dark:text-gray-300">
{{ item.name }}
</p>
<div class="relative mx-auto h-32 w-32">
<img
:src="item.qrCode"
:alt="`${item.name}二维码`"
class="h-full w-full rounded-lg object-cover"
>
</div>
<a
:href="item.url"
target="_blank"
rel="noopener noreferrer"
class="mt-3 block text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
关注我们
</a>
</div>
</div>
</div>
</div>
</section>
<!-- Message Form Section -->
<section id="message-form" class="mb-20">
<h2 class="mb-8 text-2xl text-gray-900 font-bold sm:text-3xl dark:text-white">
在线留言
</h2>
<div class="mx-auto max-w-4xl">
<form class="rounded-xl bg-white p-6 shadow-lg dark:bg-gray-800 md:p-8" @submit.prevent="handleSubmit">
<div class="grid grid-cols-1 mb-6 gap-6 md:grid-cols-2">
<div>
<label for="name" class="mb-2 block text-sm text-gray-700 font-medium dark:text-gray-300">姓名</label>
<input
id="name"
v-model="formData.name"
type="text"
class="w-full border border-gray-300 rounded-lg bg-white px-4 py-3 text-gray-900 dark:border-gray-700 dark:bg-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="请输入您的姓名"
>
<div v-if="formErrors.name" class="mt-2 text-sm text-red-600">
{{ formErrors.name }}
</div>
</div>
<div>
<label for="email" class="mb-2 block text-sm text-gray-700 font-medium dark:text-gray-300">邮箱</label>
<input
id="email"
v-model="formData.email"
type="email"
class="w-full border border-gray-300 rounded-lg bg-white px-4 py-3 text-gray-900 dark:border-gray-700 dark:bg-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="请输入您的邮箱"
>
<div v-if="formErrors.email" class="mt-2 text-sm text-red-600">
{{ formErrors.email }}
</div>
</div>
<div class="md:col-span-2">
<label for="phone" class="mb-2 block text-sm text-gray-700 font-medium dark:text-gray-300">手机号码</label>
<input
id="phone"
v-model="formData.phone"
type="tel"
class="w-full border border-gray-300 rounded-lg bg-white px-4 py-3 text-gray-900 dark:border-gray-700 dark:bg-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="请输入您的手机号码"
>
<div v-if="formErrors.phone" class="mt-2 text-sm text-red-600">
{{ formErrors.phone }}
</div>
</div>
<div class="md:col-span-2">
<label for="message" class="mb-2 block text-sm text-gray-700 font-medium dark:text-gray-300">留言内容</label>
<textarea
id="message"
v-model="formData.message"
rows="6"
class="w-full border border-gray-300 rounded-lg bg-white px-4 py-3 text-gray-900 dark:border-gray-700 dark:bg-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="请输入您的留言内容"
/>
<div v-if="formErrors.message" class="mt-2 text-sm text-red-600">
{{ formErrors.message }}
</div>
</div>
</div>
<button
type="submit"
class="w-full rounded-lg bg-blue-600 px-6 py-3 text-lg text-white font-semibold shadow-lg transition-colors hover:bg-blue-700 hover:shadow-xl"
>
提交留言
</button>
</form>
</div>
</section>
</div>
</template>

View File

@ -0,0 +1,106 @@
<script setup lang="ts">
//
const isDeleting = ref(false)
function confirmDelete() {
isDeleting.value = true
setTimeout(() => {
console.warn('删除成功')
isDeleting.value = false
}, 1000)
}
function handleCancel() {
console.warn('用户取消了操作')
}
</script>
<template>
<div class="h-screen flex flex-wrap items-center justify-center gap-4 p-20">
<!-- 1. 标准上方 (Top) -->
<w-popconfirm title="默认上方" placement="top">
<button class="btn">
Top
</button>
</w-popconfirm>
<!-- 2. 左上对齐 (Top Left) -->
<w-popconfirm title="左上对齐" placement="top-left">
<button class="btn">
Top Left
</button>
</w-popconfirm>
<!-- 3. 左上对齐 + 箭头指向中心 -->
<w-popconfirm
title="左上 + 箭头居中"
placement="top-left"
arrow-point-at-center
>
<button class="w-32 btn">
Top Left Center
</button>
</w-popconfirm>
<!-- 4. 右侧 (Right) -->
<w-popconfirm title="右侧显示" placement="right">
<button class="btn">
Right
</button>
</w-popconfirm>
<!-- 5. 底部右对齐 (Bottom Right) -->
<w-popconfirm title="底部右对齐" placement="bottom-right">
<button class="btn">
Bottom Right
</button>
</w-popconfirm>
<!-- 1. 基础用法 -->
<w-popconfirm
title="确认移除此专业?"
@confirm="confirmDelete"
>
<a href="#" class="text-blue-600 hover:underline">移除 (基础)</a>
</w-popconfirm>
<!-- 2. 危险操作 + 异步加载 + 描述 -->
<w-popconfirm
title="确定要删除这个任务吗?"
description="删除后无法恢复,请谨慎操作。"
ok-text="是的,删除"
ok-type="danger"
:ok-button-props="{ loading: isDeleting }"
@confirm="confirmDelete"
@cancel="handleCancel"
>
<button class="rounded bg-red-100 px-4 py-2 text-red-600 hover:bg-red-200">
删除任务 (高级)
</button>
</w-popconfirm>
<!-- 3. 自定义 Icon Slot 内容 -->
<w-popconfirm title="" ok-text="立即保存">
<!-- Trigger -->
<button class="border border-slate-300 rounded px-4 py-2 shadow-sm">
自定义插槽
</button>
<!-- Slots -->
<template #icon>
<span class="text-xl">🎉</span>
</template>
<template #title>
<span class="text-purple-600">恭喜你发现彩蛋</span>
</template>
<template #description>
这里可以放很长很长的<br>HTML内容哦
</template>
</w-popconfirm>
</div>
</template>
<style scoped>
.btn {
@apply px-4 py-2 bg-white border border-slate-300 rounded hover:border-blue-500 transition-colors shadow-sm;
}
</style>

50
src/pages/hi/[name].vue Normal file
View File

@ -0,0 +1,50 @@
<script setup lang="ts">
const router = useRouter()
const route = useRoute('/hi/[name]')
const user = useUserStore()
const { t } = useI18n()
watchEffect(() => {
user.setNewName(route.params.name)
})
useHead({
title: () => t('intro.hi', { name: user.savedName }),
})
</script>
<template>
<div>
<div text-4xl>
<div i-carbon-pedestrian inline-block />
</div>
<p>
{{ t('intro.hi', { name: user.savedName }) }}
</p>
<p text-sm opacity-75>
<em>{{ t('intro.dynamic-route') }}</em>
</p>
<template v-if="user.otherNames.length">
<div mt-4 text-sm>
<span opacity-75>{{ t('intro.aka') }}:</span>
<ul>
<li v-for="otherName in user.otherNames" :key="otherName">
<RouterLink :to="`/hi/${otherName}`" replace>
{{ otherName }}
</RouterLink>
</li>
</ul>
</div>
</template>
<div>
<button
m="3 t6" text-sm btn
@click="router.back()"
>
{{ t('button.back') }}
</button>
</div>
</div>
</template>

594
src/pages/index.vue Normal file
View File

@ -0,0 +1,594 @@
<!-- eslint-disable no-console -->
<script setup lang="ts">
defineOptions({
name: 'IndexPage',
})
useHead({
title: '艺体志愿宝 - 专业的艺术体育类志愿填报辅助平台',
})
// User store for login status
const userStore = useUserStore()
const responseMenus = [
{
url: '/universities',
label: '查大学',
icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
animationDelay: '0.1s',
darkMode: true,
},
{
url: '/majors',
label: '查专业',
icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
animationDelay: '0.2s',
darkMode: true,
},
{
url: '/cutoff-scores',
label: '省控线',
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
animationDelay: '0.3s',
darkMode: true,
},
{
url: '/culture-test',
label: '测文化',
icon: 'M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253',
animationDelay: '0.4s',
darkMode: true,
},
{
url: '/rank-checker',
label: '查位次',
icon: 'M16 8v8m-4-5v5m-4-2v2m-2 4h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z',
animationDelay: '0.5s',
darkMode: true,
},
{
url: '/calculator',
label: '算投档',
icon: 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z',
animationDelay: '0.6s',
darkMode: true,
},
{
url: '/sports-calculator',
label: '体育计分器',
icon: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 1-4 4-4 1.657 0 3 .895 3 2 0 1-1 2-1 2s1.5.5 2 1.5c.5 1 .5 2.5-.5 3.5zM10 11l2 2 4-4',
animationDelay: '0.7s',
darkMode: true,
},
{
url: '/regulations',
label: '招生章程',
icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
animationDelay: '0.8s',
darkMode: true,
},
]
// Carousel images
const carouselImages = [
{
id: 1,
imageUrl: 'https://cdn.jsdelivr.net/gh/zwt13703/note-gen-image-sync@main/d7e9af38-9292-4e7a-bb24-9321c8e1b931.png',
linkUrl: '/simulate',
altText: '艺术考生',
},
{
id: 2,
imageUrl: 'https://cdn.jsdelivr.net/gh/zwt13703/note-gen-image-sync@main/13274cb8-2c88-452b-b70b-9ca4e603606a.jpeg',
linkUrl: '/universities',
altText: '体育考生',
},
]
// Carousel state
const currentSlide = ref(0)
let carouselInterval: any
// Auto-play carousel
function startCarousel() {
carouselInterval = setInterval(() => {
currentSlide.value = (currentSlide.value + 1) % carouselImages.length
}, 5000)
}
function stopCarousel() {
if (carouselInterval) {
clearInterval(carouselInterval)
}
}
// Navigation to previous slide
function prevSlide() {
stopCarousel()
currentSlide.value = (currentSlide.value - 1 + carouselImages.length) % carouselImages.length
startCarousel()
}
// Navigation to next slide
function nextSlide() {
stopCarousel()
currentSlide.value = (currentSlide.value + 1) % carouselImages.length
startCarousel()
}
// Navigation to specific slide
function goToSlide(index: number) {
stopCarousel()
currentSlide.value = index
startCarousel()
}
// Start carousel on mount
onMounted(() => {
startCarousel()
})
// Cleanup on unmount
onUnmounted(() => {
stopCarousel()
})
//
const admissionRegulations = ref([
{ title: '2025年艺术类本科招生章程', link: '/regulations/art-2025' },
{ title: '2025年体育类本科招生章程', link: '/regulations/sports-2025' },
{ title: '2025年音乐类专业招生章程', link: '/regulations/music-2025' },
{ title: '2025年舞蹈类专业招生章程', link: '/regulations/dance-2025' },
{ title: '2025年美术类专业招生章程', link: '/regulations/art-2025' },
])
//
const examNews = ref([
{ title: '2025年高考政策解读', link: '/news/policy-2025' },
{ title: '艺术类招生趋势分析', link: '/news/art-trends' },
{ title: '体育类专业录取规则', link: '/news/sports-rules' },
{ title: '新高考选科指导', link: '/news/new-gaokao' },
{ title: '志愿填报技巧分享', link: '/news/volunteer-tips' },
])
//
const popularSchools = ref([
{
name: '中央美术学院',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
major: '美术学、设计学',
tag: '艺术类名校',
region: '省外本科', //
},
{
name: '北京体育大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
major: '体育教育、运动训练',
tag: '体育类名校',
region: '省外本科', //
},
{
name: '中国音乐学院',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
major: '音乐表演、音乐学',
tag: '音乐类名校',
region: '省外本科', //
},
{
name: '上海戏剧学院',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
major: '表演、导演',
tag: '艺术类名校',
region: '省外本科', //
},
{
name: '河南大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
major: '美术学、音乐学',
tag: '河南名校',
region: '河南本科', //
},
{
name: '郑州大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
major: '体育教育、设计学',
tag: '河南名校',
region: '河南本科', //
},
{
name: '河南师范大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
major: '音乐学、美术学',
tag: '河南名校',
region: '河南本科', //
},
{
name: '河南职业技术学院',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
major: '艺术设计、音乐表演',
tag: '河南专科',
region: '河南专科', //
},
])
//
const regionFilter = ref('全部') //
//
const regionOptions = [
{ label: '全部', value: '全部' },
{ label: '河南本科', value: '河南本科' },
{ label: '河南专科', value: '河南专科' },
{ label: '省外本科', value: '省外本科' },
{ label: '省外专科', value: '省外专科' },
]
//
const filteredSchools = computed(() => {
if (regionFilter.value === '全部') {
return popularSchools.value
}
return popularSchools.value.filter(school => school.region === regionFilter.value)
})
</script>
<template>
<div class="mx-auto max-w-7xl select-none px-4 py-8 lg:px-8 sm:px-6">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-10">
<!-- Left Carousel (70% width on lg) -->
<div class="lg:col-span-7">
<div class="relative overflow-hidden rounded-2xl bg-white shadow-xl">
<div class="relative aspect-video overflow-hidden">
<!-- Carousel images -->
<transition-group name="fade" tag="div">
<div
v-for="(image, index) in carouselImages"
v-show="index === currentSlide"
:key="image.id"
class="absolute inset-0"
>
<a :href="image.linkUrl" class="block h-full w-full">
<img
:src="image.imageUrl"
:alt="image.altText"
class="h-full w-full object-cover"
>
</a>
</div>
</transition-group>
</div>
<!-- Carousel navigation -->
<div class="absolute bottom-4 left-1/2 flex transform -translate-x-1/2 space-x-2">
<button
v-for="(image, index) in carouselImages"
:key="image.id"
class="h-3 w-3 rounded-full bg-dark-1 hover:bg-dark-3 focus:outline-none"
:class="index === currentSlide ? 'opacity-100' : 'opacity-50'"
@click="goToSlide(index)"
>
<span class="sr-only">Slide {{ index + 1 }}</span>
</button>
</div>
<!-- Previous/Next buttons -->
<button
class="absolute left-4 top-1/2 transform rounded-full bg-gray/20 p-2 text-gray-800 transition-all-300 -translate-y-1/2 hover:bg-gray focus:outline-none"
@click="prevSlide"
>
<span class="sr-only">Previous</span>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
class="absolute right-4 top-1/2 transform rounded-full bg-gray/20 p-2 text-gray-800 transition-all-300 -translate-y-1/2 hover:bg-gray focus:outline-none"
@click="nextSlide"
>
<span class="sr-only">Next</span>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<!-- Responsive Menu with 8 Functional Buttons -->
<div class="grid grid-cols-2 mt-4 gap-4 lg:grid-cols-4 md:grid-cols-4 sm:grid-cols-3">
<a
v-for="(menu, index) in responseMenus"
:key="index"
:href="menu.url"
class="group relative flex flex-col transform animate-fade-in-up items-center justify-center border border-gray-100 rounded-xl bg-white p-4 text-gray-700 shadow-md transition-all duration-300 hover:border-blue-200 dark:text-light-700 group-hover:text-blue-800 hover:shadow-lg hover:-translate-y-1"
:style="{ 'animation-delay': menu.animationDelay }"
:class="{
'dark:bg-gray-900 dark:border-gray-800 dark:hover:border-gray-500': menu.darkMode,
}"
:aria-label="menu.label"
>
<div class="mb-2 h-12 w-12 flex items-center justify-center">
<svg class="h-6 w-6 text-blue-600 transition-colors group-hover:text-blue-800" :class="{ 'dark:text-light': menu.darkMode }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="menu.icon" />
</svg>
</div>
<span class="text-sm text-gray-700 font-medium group-hover:text-blue-800" :class="{ 'dark:text-light-700': menu.darkMode }"> {{ menu.label }} </span>
</a>
</div>
</div>
<!-- Right Form (30% width on lg) -->
<div class="select-none lg:col-span-3">
<div class="rounded-2xl bg-white p-6 text-gray-700 shadow-xl dark:border-gray-700 dark:bg-gray-800 dark:text-white">
<!-- Show different content based on login status -->
<div v-if="!userStore.user">
<div class="mb-4 text-center">
<p class="mb-4 text-gray-600">
请先登录开始志愿填报
</p>
<button
class="w-full rounded-full bg-blue-600 px-6 py-3 text-lg text-white font-semibold shadow-lg transition-colors hover:bg-blue-700 hover:shadow-xl"
@click="$router.push('/login')"
>
登录
</button>
</div>
</div>
<div v-else>
<score-form />
</div>
</div>
</div>
</div>
<!-- 招生章程高考动态和热门院校推荐部分 -->
<div class="content-section mt-12 select-none">
<div class="dual-column-layout mb-10">
<div class="left-column border-r-2 border-r-gray-800 border-dashed pr-4 dark:border-r-gray-400">
<h3 class="mb-4 text-xl text-gray-800 font-bold dark:text-white">
招生章程
</h3>
<ul class="article-list space-y-2">
<li v-for="(item, index) in admissionRegulations" :key="`reg-${index}`">
<a :href="item.link" :title="item.title" class="block rounded-lg px-3 py-2 text-gray-700 transition-colors duration-200 hover:bg-gray-100 dark:text-gray-300 hover:text-blue-600 dark:hover:bg-gray-700 dark:hover:text-blue-400">
<div class="flex items-center">
<svg class="mr-2 h-4 w-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="truncate">{{ item.title }}</span>
</div>
</a>
</li>
</ul>
</div>
<div class="right-column">
<h3 class="mb-4 text-xl text-gray-800 font-bold dark:text-white">
高考动态
</h3>
<ul class="article-list space-y-2">
<li v-for="(item, index) in examNews" :key="`news-${index}`">
<a :href="item.link" :title="item.title" class="block rounded-lg px-3 py-2 text-gray-700 transition-colors duration-200 hover:bg-gray-100 dark:text-gray-300 hover:text-blue-600 dark:hover:bg-gray-700 dark:hover:text-blue-400">
<div class="flex items-center">
<svg class="mr-2 h-4 w-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="truncate">{{ item.title }}</span>
</div>
</a>
</li>
</ul>
</div>
</div>
<div class="popular-schools">
<h3 class="mb-4 text-xl text-gray-800 font-bold dark:text-white">
热门院校推荐
</h3>
<!-- 区域过滤器 -->
<div class="region-filter mb-6">
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label="选择院校区域">
<button
v-for="option in regionOptions"
:key="option.value"
class="region-filter-btn transform rounded-full px-4 py-2 text-sm font-medium transition-all duration-300 hover:scale-105 aria-checked:bg-blue-600 aria-checked:text-white aria-checked:font-semibold hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" :class="[
regionFilter === option.value
? 'bg-blue-600 text-white font-semibold shadow-md'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600',
]"
:aria-checked="regionFilter === option.value"
role="radio"
@click="regionFilter = option.value"
>
{{ option.label }}
</button>
</div>
</div>
<div class="school-grid grid grid-cols-1 gap-4 lg:grid-cols-3 md:grid-cols-2 xl:grid-cols-4">
<div v-for="(school, index) in filteredSchools" :key="`school-${index}`" class="border border-gray-200 rounded-xl bg-white p-4 transition-all duration-300 dark:border-gray-700 hover:border-blue-300 dark:bg-gray-800 hover:shadow-lg dark:hover:border-blue-500 dark:hover:shadow-xl">
<div class="flex">
<div class="school-logo mr-4 h-16 w-16 flex-shrink-0">
<img :src="school.logo" :alt="school.name" class="h-full w-full rounded-full object-contain">
</div>
<div class="school-info min-w-0 flex-1">
<div class="school-name mb-1 truncate text-gray-800 font-bold dark:text-white" :title="school.name">
{{ school.name }}
</div>
<div class="school-major mb-2 truncate text-sm text-gray-600 dark:text-gray-400" :title="school.major">
热门专业{{ school.major }}
</div>
<div class="flex items-center justify-between">
<span class="school-tag max-w-[80px] truncate rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300" :title="school.tag">{{ school.tag }}</span>
<button class="view-btn rounded-lg bg-blue-600 px-3 py-1 text-sm text-white transition-colors duration-200 hover:bg-blue-700">
查看院校
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Staggered animation for menu items */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.6s ease-out forwards;
opacity: 0;
}
/* 招生章程、高考动态和热门院校推荐部分样式 */
.dual-column-layout {
display: flex;
gap: 30px;
}
.left-column,
.right-column {
flex: 1;
}
.article-list {
list-style: none;
padding: 0;
}
.article-list li {
margin-bottom: 15px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.popular-schools h3 {
margin-bottom: 20px;
}
.school-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.school-logo {
width: 60px;
height: 60px;
margin-right: 15px;
}
.school-logo img {
width: 100%;
height: 100%;
object-fit: contain;
}
.school-info {
flex: 1;
}
.school-name {
font-weight: bold;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.school-major {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.school-tag {
font-size: 12px;
color: #888;
}
.view-btn {
padding: 4px 12px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
@media (max-width: 768px) {
.dual-column-layout {
flex-direction: column;
}
.school-grid {
grid-template-columns: 1fr;
}
}
/* 区域过滤器样式 */
.region-filter-btn {
position: relative;
overflow: hidden;
border: none;
outline: none;
cursor: pointer;
transition: all 0.3s ease;
min-width: 80px;
white-space: nowrap;
}
.region-filter-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.region-filter-btn:hover::before {
left: 100%;
}
.region-filter-btn:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* 为活动状态添加更明显的视觉效果 */
.region-filter-btn.active {
background: linear-gradient(135deg, #3b82f6, #2563eb);
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.4);
transform: translateY(-2px);
}
</style>
<route lang="yaml">
meta:
layout: home
</route>

286
src/pages/majors.vue Normal file
View File

@ -0,0 +1,286 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
// --- Types ---
interface SubCategory {
id: string
name: string
}
interface Category {
id: string
name: string
isOpen: boolean
subCategories?: SubCategory[]
}
interface Major {
id: string
name: string
code: string
subCategoryName: string
duration: string
genderRatio: string
avgSalary: string
}
// --- Mock Data ---
const categories = ref<Category[]>([
{ id: '1', name: '热门', isOpen: false },
{
id: '2',
name: '医药卫生大类',
isOpen: true,
subCategories: [
{ id: '2-1', name: '中医药类' },
{ id: '2-2', name: '临床医学类' },
{ id: '2-3', name: '健康管理与促进类' },
{ id: '2-4', name: '医学技术类' },
{ id: '2-5', name: '护理类' },
{ id: '2-6', name: '康复治疗类' },
{ id: '2-7', name: '公共卫生与卫生管理类' },
{ id: '2-8', name: '眼视光类' },
{ id: '2-9', name: '药学类' },
],
},
{ id: '3', name: '文化艺术大类', isOpen: false, subCategories: [] },
{ id: '4', name: '交通运输大类', isOpen: false, subCategories: [] },
{ id: '5', name: '装备制造大类', isOpen: false, subCategories: [] },
])
const majorsData: Major[] = [
{ id: 'm1', name: '中医学', code: '520401K', subCategoryName: '中医药类', duration: '三年', genderRatio: '40:60', avgSalary: '¥-' },
{ id: 'm2', name: '中医康复技术', code: '520416', subCategoryName: '中医药类', duration: '三年', genderRatio: '--', avgSalary: '¥8300' },
{ id: 'm3', name: '朝医学', code: '520409K', subCategoryName: '中医药类', duration: '三年', genderRatio: '--', avgSalary: '¥-' },
{ id: 'm4', name: '中药材生产与加工', code: '520414', subCategoryName: '中医药类', duration: '三年', genderRatio: '--', avgSalary: '¥6800' },
{ id: 'm5', name: '口腔医学', code: '520102K', subCategoryName: '临床医学类', duration: '三年', genderRatio: '42:58', avgSalary: '¥-' },
{ id: 'm6', name: '临床医学', code: '520101K', subCategoryName: '临床医学类', duration: '三年', genderRatio: '44:56', avgSalary: '¥-' },
]
// --- State ---
const activeTab = ref<'general' | 'vocational_bs' | 'vocational_hs'>('vocational_hs')
const searchQuery = ref('')
const activeCategory = ref('医药卫生大类')
const activeSubCategory = ref('')
// --- Logic ---
function toggleCategory(cat: Category) {
cat.isOpen = !cat.isOpen
}
const groupedMajors = computed(() => {
const groups: Record<string, Major[]> = {}
majorsData.forEach((major) => {
if (!groups[major.subCategoryName]) {
groups[major.subCategoryName] = []
}
groups[major.subCategoryName].push(major)
})
return groups
})
</script>
<template>
<!-- Global Wrapper with Dark Mode Background -->
<div class="min-h-screen bg-[#f9f9f9] text-gray-700 font-sans transition-colors duration-300 dark:bg-gray-900 dark:text-gray-200">
<div class="mx-auto max-w-7xl px-4 lg:px-8 sm:px-6">
<!-- Top Header / Tabs Area -->
<div class="mb-6 mt-6 rounded-lg bg-white p-4 shadow transition-colors dark:bg-gray-800 sm:p-6">
<div class="mx-auto max-w-7xl">
<!-- Flex Container: Stacks on mobile, Row on md+ -->
<div class="flex flex-col justify-between bg-white pt-2 md:flex-row md:items-center dark:bg-gray-800 sm:pt-4">
<!-- Tabs (Scrollable on mobile) -->
<div class="scrollbar-hide flex overflow-x-auto pb-2 space-x-1 md:pb-0">
<button
class="whitespace-nowrap rounded-t-lg px-6 py-3 text-base font-medium transition-colors sm:px-8 sm:text-lg" :class="[activeTab === 'general' ? 'bg-blue-500 text-white' : 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-600']"
@click="activeTab = 'general'"
>
本科(普通)
</button>
<button
class="whitespace-nowrap rounded-t-lg px-6 py-3 text-base font-medium transition-colors sm:px-8 sm:text-lg" :class="[activeTab === 'vocational_bs' ? 'bg-blue-500 text-white' : 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-600']"
@click="activeTab = 'vocational_bs'"
>
本科(职业)
</button>
<button
class="relative whitespace-nowrap rounded-t-lg px-6 py-3 text-base font-medium transition-colors sm:px-8 sm:text-lg" :class="[activeTab === 'vocational_hs' ? 'bg-blue-500 text-white' : 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-600']"
@click="activeTab = 'vocational_hs'"
>
专科(高职)
<!-- blue bottom line fix for active state -->
<span v-if="activeTab === 'vocational_hs'" class="absolute bottom-[-1px] left-0 h-1 w-full bg-blue-500" />
</button>
</div>
<!-- Search & Sort -->
<div class="mt-4 w-full flex flex-col items-start pb-2 md:mt-0 md:w-auto sm:flex-row sm:items-center space-y-3 sm:space-x-4 sm:space-y-0">
<div class="hidden cursor-pointer text-sm text-gray-500 sm:block dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-600">
默认排序
</div>
<div class="w-full flex sm:w-auto">
<input
v-model="searchQuery"
type="text"
placeholder="输入专业名称"
class="w-full flex-grow border rounded-l px-3 py-2 text-sm transition-colors sm:w-48 dark:border-gray-600 focus:border-blue-500 dark:bg-gray-700 light:bg-white dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500 dark:placeholder-gray-400"
>
<button class="whitespace-nowrap rounded-r bg-blue-500 px-5 py-2 text-sm text-white transition-colors hover:bg-blue-600">
搜索
</button>
</div>
</div>
</div>
<!-- blue Bar Line -->
<div class="h-1 w-full bg-blue-500" />
</div>
</div>
<!-- Main Content Area -->
<!-- Flex-col on mobile (sidebar stacks on top), lg:flex-row on desktop -->
<div class="mx-auto flex flex-col gap-6 rounded-lg py-2 lg:flex-row sm:py-6">
<!-- Left Sidebar -->
<div class="w-full flex-shrink-0 self-start overflow-hidden rounded-lg bg-white pb-4 shadow-sm transition-colors lg:w-64 dark:bg-gray-800">
<ul>
<li v-for="cat in categories" :key="cat.id" class="border-b border-gray-100 border-dashed last:border-0 dark:border-gray-700">
<!-- Main Category Header -->
<div
class="flex cursor-pointer items-center justify-between px-4 py-4 transition-colors hover:text-blue-600" :class="[
activeCategory === cat.name
? 'text-blue-500 bg-blue-50 dark:bg-blue-900/20 border-l-4 border-blue-500'
: 'text-gray-700 dark:text-gray-300 border-l-4 border-transparent',
]"
@click="toggleCategory(cat)"
>
<span class="font-medium">{{ cat.name }}</span>
<span v-if="cat.subCategories && cat.subCategories.length > 0" class="text-xs">
<!-- Icons -->
<svg v-if="cat.isOpen" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
</span>
</div>
<!-- Sub Categories -->
<ul v-show="cat.isOpen && cat.subCategories" class="bg-white dark:bg-gray-800">
<li
v-for="sub in cat.subCategories"
:key="sub.id"
class="cursor-pointer py-3 pl-8 pr-4 text-sm text-gray-600 transition-colors hover:bg-gray-50 dark:text-gray-400 hover:text-blue-600 dark:hover:bg-gray-700 dark:hover:text-blue-600"
@click.stop="activeSubCategory = sub.name"
>
{{ sub.name }}
</li>
</ul>
</li>
</ul>
</div>
<!-- Right Content List -->
<div class="flex-grow space-y-6">
<!-- Loop through Groups -->
<div v-for="(majors, groupName) in groupedMajors" :key="groupName" class="overflow-hidden rounded-sm bg-white shadow-sm transition-colors dark:bg-gray-800">
<!-- Group Header (Banner) -->
<div class="border-b border-blue-100 bg-blue-50 px-6 py-3 dark:border-gray-600 dark:bg-gray-700">
<h2 class="text-center text-lg text-blue-500 font-bold">
{{ groupName }}
</h2>
</div>
<!-- List Items -->
<div class="p-4 space-y-8 sm:p-6">
<div v-for="major in majors" :key="major.id" class="flex flex-col gap-3">
<!-- Top Row: Title + Code + Action -->
<div class="flex flex-col justify-between gap-2 sm:flex-row sm:items-center sm:gap-0">
<div class="flex flex-wrap items-center gap-3">
<h3 class="text-lg text-gray-800 font-normal transition-colors sm:text-xl dark:text-gray-100">
{{ major.name }}
</h3>
<span class="flex items-center gap-1 border border-gray-300 rounded bg-gray-50 px-1 py-0.5 text-xs text-gray-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400">
<svg class="h-3 w-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
专业代码:{{ major.code }}
</span>
</div>
<!-- Mobile: Button moves to its own row if needed, but flex-col handles it.
Here we make it stretch on very small screens or auto on sm -->
<button class="mt-2 w-full border border-blue-500 rounded px-4 py-1.5 text-center text-sm text-blue-500 transition-colors sm:mt-0 sm:w-auto hover:bg-blue-50 dark:hover:bg-blue-900/30">
开设院校
</button>
</div>
<!-- Bottom Row: Stats -->
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<div class="rounded-sm bg-gray-100 px-3 py-1 transition-colors dark:bg-gray-700">
修业年限{{ major.duration }}
</div>
<div class="rounded-sm bg-gray-100 px-3 py-1 transition-colors dark:bg-gray-700">
男女比例{{ major.genderRatio }}
</div>
<div class="rounded-sm bg-gray-100 px-3 py-1 transition-colors dark:bg-gray-700">
平均薪酬{{ major.avgSalary }}
</div>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div class="flex items-center justify-center py-8 space-x-2">
<span class="cursor-pointer text-sm text-gray-500 dark:text-gray-400 hover:text-blue-600">首页</span>
<span class="flex cursor-pointer items-center text-sm text-gray-500 dark:text-gray-400 hover:text-blue-600"><svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg> 上一页</span>
<button class="h-8 w-8 flex items-center justify-center rounded bg-blue-500 text-sm text-white shadow-sm">
1
</button>
<button class="h-8 w-8 flex items-center justify-center border border-gray-200 rounded bg-white text-sm text-gray-600 transition-colors dark:border-gray-600 hover:border-blue-500 dark:bg-gray-800 dark:text-gray-300 hover:text-blue-600">
2
</button>
<button class="h-8 w-8 flex items-center justify-center border border-gray-200 rounded bg-white text-sm text-gray-600 transition-colors dark:border-gray-600 hover:border-blue-500 dark:bg-gray-800 dark:text-gray-300 hover:text-blue-600">
3
</button>
<span class="flex cursor-pointer items-center text-sm text-gray-500 dark:text-gray-400 hover:text-blue-600">下一页 <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg></span>
<span class="cursor-pointer text-sm text-gray-500 dark:text-gray-400 hover:text-blue-600">尾页</span>
</div>
</div>
</div>
</div>
<!-- Fixed Buttons Area -->
<div class="fixed bottom-10 right-6 z-20 flex flex-col gap-4 sm:right-10">
<!-- Back to Top -->
<!-- <button @click="scrollToTop" class="bg-white dark:bg-gray-800 border border-blue-200 dark:border-gray-600 shadow-lg text-blue-500 w-12 h-12 rounded-full flex items-center justify-center hover:bg-blue-50 dark:hover:bg-gray-700 transition-all">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path></svg>
</button> -->
</div>
</div>
</template>
<style scoped>
/* Custom scrollbar for webkit */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 4px;
}
.dark ::-webkit-scrollbar-thumb {
background: #4b5563;
}
::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* Hide scrollbar for tabs on mobile but keep functionality */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>

View File

@ -0,0 +1,170 @@
<script setup lang="ts">
import { useHead } from '@unhead/vue'
defineOptions({
name: 'PrivacyPolicyPage',
})
useHead({
title: '隐私政策 - 艺体志愿宝',
meta: [
{
name: 'description',
content: '艺体志愿宝平台隐私政策,详细说明我们如何收集、使用和保护您的个人信息',
},
],
})
</script>
<template>
<div class="mx-auto max-w-6xl px-4 py-8 container">
<!-- Hero Section -->
<section class="mb-16 text-center">
<h1 class="mb-4 text-4xl text-gray-900 font-bold sm:text-5xl dark:text-white">
隐私政策
</h1>
<p class="text-lg text-gray-600 dark:text-gray-300">
生效日期2025年1月1日
</p>
</section>
<!-- Privacy Policy Content -->
<section class="mb-20 rounded-xl bg-white p-8 shadow-lg dark:bg-gray-800">
<div class="text-gray-600 leading-relaxed space-y-8 dark:text-gray-300">
<div>
<h2 class="mb-4 text-2xl text-gray-900 font-bold dark:text-white">
引言
</h2>
<p>
艺体志愿宝以下简称"我们"非常重视用户的隐私保护本隐私政策将详细说明我们在使用艺体志愿宝平台以下简称"平台"时收集使用存储和保护用户个人信息的政策和措施
</p>
<p class="mt-4">
请您在使用平台前仔细阅读本隐私政策特别是加粗或下划线的内容如果您不同意本隐私政策的任何内容请立即停止使用平台
</p>
</div>
<div>
<h2 class="mb-4 text-2xl text-gray-900 font-bold dark:text-white">
我们收集的信息
</h2>
<h3 class="mb-3 text-xl text-gray-900 font-semibold dark:text-white">
2.1 您主动提供的信息
</h3>
<p>
当您使用平台的某些功能时您可能需要向我们提供个人信息包括但不限于姓名邮箱地址手机号码身份证号码学校信息考试成绩等
</p>
<h3 class="mb-3 mt-6 text-xl text-gray-900 font-semibold dark:text-white">
2.2 我们自动收集的信息
</h3>
<p>
当您访问或使用平台时我们可能会自动收集某些信息包括但不限于IP地址浏览器类型操作系统访问时间访问页面设备信息等
</p>
</div>
<div>
<h2 class="mb-4 text-2xl text-gray-900 font-bold dark:text-white">
我们如何使用您的信息
</h2>
<p>
我们收集您的信息主要用于以下目的
</p>
<ul class="mt-4 list-disc pl-8 space-y-2">
<li>为您提供平台的核心功能和服务</li>
<li>处理您的请求和提供客户支持</li>
<li>改进平台的功能和用户体验</li>
<li>向您发送重要通知和更新</li>
<li>开展市场调研和分析</li>
<li>防止和打击欺诈滥用等违法行为</li>
</ul>
</div>
<div>
<h2 class="mb-4 text-2xl text-gray-900 font-bold dark:text-white">
信息分享与披露
</h2>
<p>
我们不会将您的个人信息出售或出租给第三方在以下情况下我们可能会分享您的信息
</p>
<ul class="mt-4 list-disc pl-8 space-y-2">
<li>获得您的明确同意</li>
<li>根据法律法规或政府要求</li>
<li>保护我们或他人的合法权益</li>
<li>与我们的合作伙伴共享但需遵守严格的保密协议</li>
</ul>
</div>
<div>
<h2 class="mb-4 text-2xl text-gray-900 font-bold dark:text-white">
数据安全
</h2>
<p>
我们采取了合理的技术和管理措施来保护您的个人信息防止其被未经授权的访问使用披露或篡改这些措施包括但不限于
</p>
<ul class="mt-4 list-disc pl-8 space-y-2">
<li>使用加密技术保护数据传输和存储</li>
<li>限制访问个人信息的人员范围</li>
<li>定期进行安全审计和评估</li>
<li>制定严格的安全管理制度</li>
</ul>
</div>
<div>
<h2 class="mb-4 text-2xl text-gray-900 font-bold dark:text-white">
您的权利
</h2>
<p>
根据相关法律法规您有权
</p>
<ul class="mt-4 list-disc pl-8 space-y-2">
<li>访问和获取您的个人信息</li>
<li>更正或补充您的个人信息</li>
<li>删除您的个人信息</li>
<li>限制或拒绝我们使用您的信息</li>
<li>撤回您的同意</li>
<li>提出投诉和请求协助</li>
</ul>
</div>
<div>
<h2 class="mb-4 text-2xl text-gray-900 font-bold dark:text-white">
儿童隐私
</h2>
<p>
平台不面向14周岁以下的儿童提供服务如果我们发现自己在未事先获得可验证的家长同意的情况下收集了儿童的个人信息我们将立即删除相关信息
</p>
</div>
<div>
<h2 class="mb-4 text-2xl text-gray-900 font-bold dark:text-white">
隐私政策的变更
</h2>
<p>
我们可能会不时更新本隐私政策当政策发生变更时我们会在平台上发布更新通知并修改"生效日期"建议您定期查看本隐私政策以了解最新情况
</p>
</div>
<div>
<h2 class="mb-4 text-2xl text-gray-900 font-bold dark:text-white">
联系我们
</h2>
<p>
如果您对本隐私政策有任何疑问或建议请通过以下方式联系我们
</p>
<div class="mt-4 space-y-2">
<p><strong>电子邮箱</strong>privacy@yitisheng.com</p>
<p><strong>联系电话</strong>400-123-4567</p>
<p><strong>联系地址</strong>河南省郑州市金水区文化路100号创新大厦A座12层</p>
</div>
</div>
<div class="border-t border-gray-300 pt-8 dark:border-gray-700">
<p class="text-center text-sm text-gray-500 dark:text-gray-400">
© 2025 艺体志愿宝 保留所有权利
</p>
</div>
</div>
</section>
</div>
</template>

View File

@ -0,0 +1,504 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
// --- ---
// interface StatItem {
// label: string;
// value: string | number;
// icon?: string;
// }
interface Ranking {
label: string
value: number
trend?: 'up' | 'down' | 'flat'
}
interface Major {
college: string
majors: string[]
}
// --- ---
const route = useRoute('/school/[schoolCode]')
const schoolCode = ref('')
const activeLocationTab = ref('唐岛湾校区')
//
// const stats: StatItem[] = [
// { label: '', value: '1953', icon: '📅' },
// { label: '', value: '5000', icon: '' },
// { label: '', value: '', icon: '🏛' },
// { label: '', value: '16', icon: '🎓' },
// { label: '', value: '33', icon: '📜' },
// { label: '', value: '5', icon: '' },
// ];
const rankings: Ranking[] = [
{ label: '软科综合', value: 67 },
{ label: '校友会综合', value: 80 },
{ label: 'US世界', value: 500 },
{ label: '人气值排名', value: 70 },
]
// ()
const departments: Major[] = [
{ college: '地球科学与技术学院', majors: ['勘查技术与工程', '地质学', '资源勘查工程', '地球物理学'] },
{ college: '石油工程学院', majors: ['海洋油气工程', '石油工程', '碳储科学与工程'] },
{ college: '化学化工学院', majors: ['化学', '化学工程与工艺', '应用化学', '环境工程', '能源化学工程'] },
{ college: '机电工程学院', majors: ['安全工程', '工业设计', '智能制造工程', '机械设计制造及其自动化'] },
{ college: '储运与建筑工程学院', majors: ['土木工程', '工程力学', '建筑环境与能源应用工程', '油气储运工程'] },
]
//
const facilities = [
{ name: '4人/间', icon: '🛏️' },
{ name: '5个食堂', icon: '🍚' },
{ name: '上床下桌', icon: '🪑' },
{ name: '独立卫浴', icon: '🚿' },
{ name: '有空调', icon: '❄️' },
{ name: '有游泳馆', icon: '🏊' },
]
// (使)
const bgHeader = 'https://via.placeholder.com/1200x300/e6f2ff/0056b3?text=University+Header'
const sceneryImages = [
'https://via.placeholder.com/300x200/ffedcc/e67e22?text=Scenery+1',
'https://via.placeholder.com/300x200/d4edda/155724?text=Scenery+2',
'https://via.placeholder.com/300x200/f8d7da/721c24?text=Scenery+3',
'https://via.placeholder.com/300x200/cce5ff/004085?text=Scenery+4',
]
//
const locationInfo = computed(() => {
return activeLocationTab.value === '唐岛湾校区'
? { address: '山东省青岛市黄岛区长江西路66号', details: '附近3km内分布着 58个餐饮场所...' }
: { address: '山东省东营市...', details: '老校区历史悠久...' }
})
watchEffect(() => {
schoolCode.value = route.params.schoolCode
})
useHead({
title: () => { return `${schoolCode.value}|艺体志愿宝` },
})
</script>
<template>
<div class="mx-auto max-w-7xl min-h-screen bg-gray-50 px-4 transition-colors duration-300 dark:bg-slate-900 lg:px-8 sm:px-6">
<!-- 顶部导航 & Header sticky -->
<header class="top-0 z-50 shadow-sm">
<div class="mb-8 mt-8 rounded-lg bg-white dark:bg-slate-800">
<div class="h-16 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="h-12 w-12 flex items-center justify-center rounded-full bg-blue-900 text-xs text-white font-bold">
Logo
</div>
<div>
<h1 class="text-xl text-gray-900 font-bold dark:text-white">
中国石油大学华东
</h1>
<div class="flex gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>公办</span><span>|</span><span>理工类</span><span>|</span><span>教育部直属</span>
</div>
</div>
<!-- 标签 -->
<div class="ml-4 hidden gap-1 md:flex">
<span class="border border-orange-500 rounded px-1.5 py-0.5 text-xs text-orange-500">211</span>
<span class="border border-orange-500 rounded px-1.5 py-0.5 text-xs text-orange-500">双一流</span>
</div>
</div>
</div>
<!-- 导航菜单 -->
<nav class="scrollbar-hide flex overflow-x-auto pb-2 text-sm text-gray-500 font-medium space-x-8 sm:pb-0 dark:text-gray-400">
<a href="#" class="whitespace-nowrap border-b-2 border-orange-500 pb-3 text-orange-600 dark:text-orange-400">学校概况</a>
<a href="#" class="whitespace-nowrap pb-3 hover:text-gray-700 dark:hover:text-gray-200">历年分数</a>
<a href="#" class="whitespace-nowrap pb-3 hover:text-gray-700 dark:hover:text-gray-200">招生计划</a>
<a href="#" class="whitespace-nowrap pb-3 hover:text-gray-700 dark:hover:text-gray-200">开设专业</a>
<a href="#" class="whitespace-nowrap pb-3 hover:text-gray-700 dark:hover:text-gray-200">录取预测</a>
</nav>
</div>
</header>
<!-- 主要内容区域 -->
<main class="mx-auto max-w-7xl px-4 py-6 lg:px-8 sm:px-6">
<!-- 顶部 Banner 信息卡片 -->
<div class="mb-6 overflow-hidden rounded-lg bg-white shadow dark:bg-slate-800">
<!-- Banner 图片 -->
<div class="relative h-48 bg-cover bg-center md:h-64" :style="{ backgroundImage: `url(${bgHeader})` }">
<div class="absolute bottom-4 right-4 flex gap-2">
<button class="rounded bg-black/50 px-3 py-1 text-sm text-white backdrop-blur-sm hover:bg-black/70">
📺 视频
</button>
<button class="rounded bg-white/90 px-3 py-1 text-sm text-gray-800 backdrop-blur-sm hover:bg-white">
📷 校园风光
</button>
</div>
</div>
<!-- 快速统计信息栏 -->
<div class="grid grid-cols-2 gap-4 border-b border-gray-100 p-3 md:grid-cols-5 sm:grid-cols-4 dark:border-slate-700">
<div class="flex items-start gap-2">
<div class="text-xl text-orange-500">
🕒
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">
建校时间
</p>
<p class="text-gray-900 font-medium dark:text-white">
1953
</p>
</div>
</div>
<div class="flex items-start gap-2">
<div class="text-xl text-purple-500">
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">
占地面积
</p>
<p class="text-gray-900 font-medium dark:text-white">
5000
</p>
</div>
</div>
<div class="flex items-start gap-2">
<div class="text-xl text-blue-500">
🎓
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">
主管部门
</p>
<p class="text-gray-900 font-medium dark:text-white">
教育部
</p>
</div>
</div>
<div class="flex items-start gap-2">
<div class="text-xl text-blue-500">
📞
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">
联系电话
</p>
<p class="text-gray-900 font-medium dark:text-white">
0532-86983086
</p>
</div>
</div>
<div class="flex items-start gap-2">
<div class="text-xl text-blue-500">
📧
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">
邮箱
</p>
<p class="text-gray-900 font-medium dark:text-white">
zhaosheng@upc.edu.cn
</p>
</div>
</div>
</div>
</div>
<!-- 网格布局左侧主内容右侧简单侧边栏(可选) -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-4">
<!-- 左侧/主体内容 (占3份宽度) -->
<div class="lg:col-span-3 space-y-6">
<!-- 1. 基本信息模块 -->
<section class="rounded-lg bg-white p-6 shadow-sm dark:bg-slate-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="border-l-4 border-orange-500 pl-3 text-lg text-gray-900 font-bold dark:text-white">
基本信息
</h2>
<a href="#" class="text-xs text-gray-500 hover:text-orange-500">[详情]</a>
</div>
<p class="mb-6 text-sm text-gray-600 leading-relaxed dark:text-gray-300">
中国石油大学华东是教育部直属全国重点大学是国家211工程重点建设和开展985工程优势学科创新平台建设并建有研究生院的高校之一学校是教育部和五大能源企业集团公司教育部和山东省人民政府共建的高校...
</p>
<!-- 排名与数据 -->
<div class="mb-6 rounded-lg bg-orange-50 p-4 dark:bg-slate-700/50">
<div class="grid grid-cols-2 gap-4 text-center md:grid-cols-4">
<div v-for="(rank, idx) in rankings" :key="idx" class="border-r border-orange-200 last:border-0 dark:border-slate-600">
<div class="text-xl text-orange-600 font-bold dark:text-orange-400">
{{ rank.value }}
</div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ rank.label }}
</div>
</div>
</div>
</div>
<!-- 地理位置 & 地图 -->
<div class="overflow-hidden border border-gray-200 rounded-lg dark:border-slate-700">
<div class="flex border-b border-gray-200 bg-gray-50 dark:border-slate-700 dark:bg-slate-700">
<button
v-for="tab in ['唐岛湾校区', '东营科教园区', '古镇口校区']" :key="tab"
class="px-4 py-2 text-sm transition-colors"
:class="activeLocationTab === tab ? 'bg-white dark:bg-slate-800 text-orange-500 border-t-2 border-orange-500' : 'text-gray-600 dark:text-gray-400'"
@click="activeLocationTab = tab"
>
{{ tab }}
</button>
</div>
<div class="h-64 flex flex-col md:flex-row">
<!-- 模拟地图 -->
<div class="group relative w-full flex items-center justify-center bg-blue-50 md:w-1/2 dark:bg-slate-900">
<span class="text-4xl text-blue-300">MAP</span>
<div class="absolute inset-0 bg-black/5 transition dark:bg-white/5 group-hover:bg-transparent" />
<div class="absolute bottom-2 right-2 rounded bg-white px-2 py-1 text-xs shadow dark:bg-slate-700">
地图详情 >
</div>
</div>
<!-- 地址详情 -->
<div class="w-full flex flex-col justify-center p-4 md:w-1/2">
<h4 class="mb-2 flex items-center gap-2 text-gray-800 font-bold dark:text-white">
📍 {{ activeLocationTab }}
</h4>
<p class="mb-3 text-sm text-orange-500">
{{ locationInfo.address }}
</p>
<p class="text-xs text-gray-500 leading-relaxed dark:text-gray-400">
{{ locationInfo.details }}
</p>
<div class="mt-4 flex gap-4 text-center">
<div>
<div class="text-sm font-bold dark:text-white">
58
</div><div class="text-xs text-gray-400">
餐饮场所
</div>
</div>
<div>
<div class="text-sm font-bold dark:text-white">
27
</div><div class="text-xs text-gray-400">
酒店
</div>
</div>
<div>
<div class="text-sm font-bold dark:text-white">
7
</div><div class="text-xs text-gray-400">
购物中心
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 2. 院系设置模块 -->
<section class="rounded-lg bg-white p-6 shadow-sm dark:bg-slate-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="border-l-4 border-orange-500 pl-3 text-lg text-gray-900 font-bold dark:text-white">
院系设置
</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="bg-gray-50 text-xs text-gray-500 dark:bg-slate-700 dark:text-gray-400">
<tr>
<th class="w-1/4 px-4 py-3">
学院
</th>
<th class="px-4 py-3">
专业
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-slate-700">
<tr v-for="(dept, idx) in departments" :key="idx" class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
<td class="px-4 py-3 text-gray-900 font-medium dark:text-white">
{{ dept.college }}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">
<div class="flex flex-wrap gap-2">
<span v-for="major in dept.majors" :key="major" class="rounded bg-gray-100 px-2 py-0.5 text-xs dark:bg-slate-600">
{{ major }} <span class="ml-1 text-gray-300">本科</span>
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页模拟 -->
<div class="mt-4 flex justify-center gap-2 text-xs text-gray-500">
<button class="px-2 py-1 hover:text-orange-500">
首页
</button>
<button class="px-2 py-1 hover:text-orange-500">
上一页
</button>
<button class="h-6 w-6 flex items-center justify-center rounded-full bg-orange-500 text-white">
1
</button>
<button class="h-6 w-6 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-slate-700">
2
</button>
<button class="px-2 py-1 hover:text-orange-500">
下一页
</button>
</div>
</section>
<!-- 3. 校园风光 -->
<section class="rounded-lg bg-white p-6 shadow-sm dark:bg-slate-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="border-l-4 border-orange-500 pl-3 text-lg text-gray-900 font-bold dark:text-white">
校园风光
</h2>
<a href="#" class="text-xs text-orange-500">更多 ></a>
</div>
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<div v-for="(img, i) in sceneryImages" :key="i" class="group relative h-32 cursor-pointer overflow-hidden rounded-lg">
<img :src="img" class="h-full w-full transform object-cover transition group-hover:scale-110" alt="scenery">
<div class="absolute inset-0 bg-black/10 transition group-hover:bg-black/0" />
</div>
</div>
</section>
<!-- 4. 校园配置 & 奖学金 (两列并排) -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- 校园配置 -->
<section class="rounded-lg bg-white p-6 shadow-sm dark:bg-slate-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="border-l-4 border-orange-500 pl-3 text-lg text-gray-900 font-bold dark:text-white">
校园配置
</h2>
<!-- 简单下拉模拟 -->
<span class="cursor-pointer rounded bg-gray-100 px-2 py-1 text-xs text-gray-500 dark:bg-slate-700">唐岛湾校区 </span>
</div>
<div class="grid grid-cols-3 mb-4 gap-4">
<div v-for="item in facilities" :key="item.name" class="flex flex-col items-center rounded bg-gray-50 p-2 dark:bg-slate-700/30">
<span class="mb-1 text-2xl">{{ item.icon }}</span>
<span class="text-xs text-gray-600 dark:text-gray-300">{{ item.name }}</span>
</div>
</div>
<div class="flex gap-2 overflow-x-auto pb-2">
<div class="h-24 w-24 flex flex-shrink-0 items-center justify-center rounded bg-gray-200 text-xs text-gray-400 dark:bg-slate-700">
食堂图片
</div>
<div class="h-24 w-24 flex flex-shrink-0 items-center justify-center rounded bg-gray-200 text-xs text-gray-400 dark:bg-slate-700">
宿舍图片
</div>
</div>
</section>
<!-- 奖学金设置 -->
<section class="rounded-lg bg-white p-6 shadow-sm dark:bg-slate-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="border-l-4 border-orange-500 pl-3 text-lg text-gray-900 font-bold dark:text-white">
奖学金设置
</h2>
<span class="text-xs text-gray-400">更新: 2024-06-18</span>
</div>
<div class="custom-scrollbar h-48 overflow-y-auto pr-2 text-sm text-gray-600 space-y-4 dark:text-gray-300">
<div>
<h4 class="mb-1 text-gray-800 font-bold dark:text-gray-200">
奖学金设置
</h4>
<p class="text-xs leading-relaxed">
目前学校已经建立起以各类奖学金助学金助学贷款勤工助学困难补助为主体的多元化资助体系
</p>
</div>
<div>
<h4 class="mb-1 text-gray-800 font-bold dark:text-gray-200">
困难资助办法
</h4>
<p class="text-xs leading-relaxed">
家庭经济特别困难的新生如暂时筹集不齐学费和住宿费可在报到当天通过学校开设的绿色通道办理入学手续
</p>
</div>
</div>
</section>
</div>
</div>
<!-- 右侧侧边栏 (保留男女比例其他已移除) -->
<div class="hidden lg:col-span-1 lg:block space-y-6">
<!-- 男女比例 -->
<div class="rounded-lg bg-white p-6 shadow-sm dark:bg-slate-800">
<h3 class="mb-4 text-gray-900 font-bold dark:text-white">
男女比例
</h3>
<div class="mb-2 flex items-end justify-between">
<div class="text-xl text-blue-500 font-bold">
70.12%
</div>
<div class="text-xl text-pink-500 font-bold">
29.88%
</div>
</div>
<!-- 进度条 -->
<div class="h-2 w-full flex overflow-hidden rounded-full bg-gray-200 dark:bg-slate-700">
<div class="h-full bg-blue-500" style="width: 70%" />
<div class="h-full bg-pink-500" style="width: 30%" />
</div>
<div class="mt-1 flex justify-between text-xs text-gray-400">
<span>男生</span>
<span>女生</span>
</div>
</div>
<!-- 此处原有毕业去向分数推荐留言板高考工具箱等均已移除 -->
<!-- 占位如果觉得右侧太空可以放联系方式或者简单的文字链接 -->
<div class="border border-blue-100 rounded-lg bg-blue-50 p-4 dark:border-slate-600 dark:bg-slate-700/50">
<h4 class="mb-2 text-sm text-blue-800 font-bold dark:text-blue-300">
招生办联系方式
</h4>
<p class="mb-1 text-xs text-gray-600 dark:text-gray-400">
电话0532-86983086
</p>
<p class="text-xs text-gray-600 dark:text-gray-400">
邮箱zhaosheng@upc.edu.cn
</p>
</div>
</div>
</div>
</main>
</div>
</template>
<style scoped>
/* 隐藏滚动条但保留滚动功能 */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* 自定义滚动条样式 */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 2px;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background: #475569;
}
</style>

1411
src/pages/simulate.vue Normal file

File diff suppressed because it is too large Load Diff

514
src/pages/universities.vue Normal file
View File

@ -0,0 +1,514 @@
<script setup lang="ts">
import type { FilterState } from '~/components/FilterBar.vue'
defineOptions({
name: 'UniversitiesPage',
})
useHead({
title: '找大学',
})
const currentFilters = ref<FilterState | null>(null)
const currentKeyword = ref('')
function handleDataChange(data: { keyword: string, filters: FilterState }) {
console.warn('发起请求:', data.keyword, data.filters)
currentKeyword.value = data.keyword
currentFilters.value = data.filters
}
// Define reactive data for filters
const selectedRegions = ref<string[]>([])
const selectedTags = ref<string[]>([])
const selectedSchoolTypes = ref<string[]>([])
const selectedCategories = ref<string[]>([])
const universityName = ref('')
// Sample university data
const universities = ref([
{
id: 1,
name: '中国石油大学(华东)',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
location: '山东青岛',
tags: ['STEM', 'Public', 'Ministry-affiliated'],
rankTags: ['211', 'Double First-Class'],
campus: 'East China',
},
{
id: 2,
name: '清华大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
location: '北京',
tags: ['Comprehensive', 'Public', 'Ministry-affiliated'],
rankTags: ['985', '211', 'Double First-Class'],
campus: '',
},
{
id: 3,
name: '北京大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
location: '北京',
tags: ['Comprehensive', 'Public', 'Ministry-affiliated'],
rankTags: ['985', '211', 'Double First-Class'],
campus: '',
},
{
id: 4,
name: '上海交通大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
location: '上海',
tags: ['Comprehensive', 'Public', 'Ministry-affiliated'],
rankTags: ['985', '211', 'Double First-Class'],
campus: '',
},
{
id: 5,
name: '复旦大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
location: '上海',
tags: ['Comprehensive', 'Public', 'Ministry-affiliated'],
rankTags: ['985', '211', 'Double First-Class'],
campus: '',
},
{
id: 6,
name: '浙江大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
location: '浙江杭州',
tags: ['Comprehensive', 'Public', 'Ministry-affiliated'],
rankTags: ['985', '211', 'Double First-Class'],
campus: '',
},
{
id: 7,
name: '南京大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
location: '江苏南京',
tags: ['Comprehensive', 'Public', 'Ministry-affiliated'],
rankTags: ['985', '211', 'Double First-Class'],
campus: '',
},
{
id: 8,
name: '中山大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
location: '广东广州',
tags: ['Comprehensive', 'Public', 'Ministry-affiliated'],
rankTags: ['985', '211', 'Double First-Class'],
campus: '',
},
{
id: 9,
name: '华中科技大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
location: '湖北武汉',
tags: ['STEM', 'Public', 'Ministry-affiliated'],
rankTags: ['985', '211', 'Double First-Class'],
campus: '',
},
{
id: 10,
name: '西安交通大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
location: '陕西西安',
tags: ['Comprehensive', 'Public', 'Ministry-affiliated'],
rankTags: ['985', '211', 'Double First-Class'],
campus: '',
},
{
id: 11,
name: '哈尔滨工业大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
location: '黑龙江哈尔滨',
tags: ['STEM', 'Public', 'Ministry-affiliated'],
rankTags: ['985', '211', 'Double First-Class'],
campus: '',
},
{
id: 12,
name: '北京理工大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
location: '北京',
tags: ['STEM', 'Public', 'Ministry-affiliated'],
rankTags: ['985', '211', 'Double First-Class'],
campus: '',
},
{
id: 13,
name: '电子科技大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
location: '四川成都',
tags: ['STEM', 'Public', 'Ministry-affiliated'],
rankTags: ['985', '211', 'Double First-Class'],
campus: '',
},
{
id: 14,
name: '南开大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
location: '天津',
tags: ['Comprehensive', 'Public', 'Ministry-affiliated'],
rankTags: ['985', '211', 'Double First-Class'],
campus: '',
},
{
id: 15,
name: '天津大学',
logo: 'http://img1.youzy.cn/content/media/thumbs/p00026840.jpeg',
location: '天津',
tags: ['STEM', 'Public', 'Ministry-affiliated'],
rankTags: ['985', '211', 'Double First-Class'],
campus: '',
},
])
// New filter data for the updated UI
const selectedProvinces = ref<string[]>([])
// Updated filter function to include province filtering
const filteredUniversities = computed(() => {
return universities.value.filter((university) => {
// Filter by name
if (
universityName.value
&& !university.name
.toLowerCase()
.includes(universityName.value.toLowerCase())
) {
return false
}
// Filter by province if not ''
if (
selectedProvinces.value.length > 0
&& !selectedProvinces.value.includes('不限')
) {
// Extract province from location (first character or first two characters)
const universityProvince = extractProvinceFromLocation(
university.location,
)
if (!selectedProvinces.value.includes(universityProvince)) {
return false
}
}
// Filter by region (simplified - in real app, would match location to regions)
if (selectedRegions.value.length > 0) {
// For demo, we'll skip region filtering
}
// Filter by tags
if (
selectedTags.value.length > 0
&& !selectedTags.value.some(tag => university.rankTags.includes(tag))
) {
return false
}
// Filter by school type (simplified - in real app, would match school type)
if (selectedSchoolTypes.value.length > 0) {
// For demo, we'll skip school type filtering
}
// Filter by category (simplified - in real app, would match category)
if (selectedCategories.value.length > 0) {
// For demo, we'll skip category filtering
}
return true
})
})
// Helper function to extract province from location
function extractProvinceFromLocation(location: string): string {
// Common provinces that are 2 characters
const twoCharProvinces = [
'北京',
'上海',
'天津',
'重庆',
'香港',
'澳门',
'台湾',
]
for (const province of twoCharProvinces) {
if (location.startsWith(province)) {
return province
}
}
// For other provinces, take the first character
if (location.length > 0) {
return location.charAt(0)
}
return ''
}
// Pagination data
const currentPage = ref(1)
const itemsPerPage = 5
const totalPages = computed(() =>
Math.ceil(universities.value.length / itemsPerPage),
)
// Add helper function for pagination
function getPageNumber(index: number) {
if (totalPages.value <= 5) {
return index
}
const currentPageValue = currentPage.value
const totalPagesValue = totalPages.value
if (currentPageValue <= 3) {
// First few pages: show 1, 2, 3, 4, 5
return index
}
else if (currentPageValue >= totalPagesValue - 2) {
// Last few pages: show last 5 pages
return totalPagesValue - 5 + index
}
else {
// Middle pages: show current page in center
return currentPageValue - 3 + index
}
}
// Get universities for current page
const paginatedUniversities = computed(() => {
const startIndex = (currentPage.value - 1) * itemsPerPage
return filteredUniversities.value.slice(
startIndex,
startIndex + itemsPerPage,
)
})
// Search function
</script>
<template>
<div class="mx-auto max-w-7xl px-4 lg:px-8 sm:px-6">
<!-- Top Section: Filters -->
<div class="mb-8 mt-8 rounded-lg bg-white p-6 shadow">
<div class="grid grid-cols-1 select-none gap-6 lg:grid-cols-6">
<!-- Top Toolbar -->
<div class="lg:col-span-4">
<FilterBar @change="handleDataChange" />
<div class="mb-4 mt-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">
大学: {{ filteredUniversities.length }}
</h2>
</div>
<div class="space-y-4">
<div
v-for="university in paginatedUniversities"
:key="university.id"
class="flex justify-between border border-gray-200 rounded-lg p-4 transition-shadow hover:shadow-md"
>
<!-- Left: Logo and Name -->
<div class="mr-4 flex items-center">
<img
:src="university.logo"
:alt="university.name"
class="mr-3 h-12 w-12 object-contain"
>
<div class="flex flex-col space-y-2">
<!-- name location -->
<div class="w-full flex items-center">
<h3 class="text-gray-900 font-bold">
{{ university.name }}
</h3>
<div class="ml-5 flex items-center text-gray-600">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-1 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span>{{ university.location }}</span>
</div>
</div>
<div class="w-full flex flex-wrap gap-2">
<span
v-for="tag in university.tags"
:key="tag"
class="rounded bg-gray-100 px-2 py-1 text-xs text-gray-700"
>
{{ tag }}
</span>
</div>
<!-- Middle: Location and Tags -->
<div class="w-full flex flex-wrap gap-2">
<span
v-for="rankTag in university.rankTags"
:key="rankTag"
class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700"
>
{{ rankTag }}
</span>
</div>
</div>
</div>
<!-- Right: Action Button -->
<div class="flex-3">
<button
class="flex items-center rounded-md bg-orange-100 px-3 py-2 text-orange-700 transition-colors hover:bg-orange-200"
>
<span>录取概率</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="ml-1 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</button>
</div>
</div>
</div>
<!-- Pagination -->
<div class="mt-8 flex items-center justify-center space-x-2">
<button
:disabled="currentPage === 1"
class="border border-gray-300 rounded-md bg-white px-4 py-2 text-sm text-gray-700 font-medium hover:bg-gray-50 disabled:opacity-50"
@click="currentPage = 1"
>
首页
</button>
<button
:disabled="currentPage === 1"
class="border border-gray-300 rounded-md bg-white px-4 py-2 text-sm text-gray-700 font-medium hover:bg-gray-50 disabled:opacity-50"
@click="currentPage = currentPage - 1"
>
上一页
</button>
<!-- Page numbers -->
<div class="flex space-x-1">
<button
v-for="page in Math.min(5, totalPages)"
:key="page"
:class="[
currentPage === getPageNumber(page)
? 'bg-blue-500 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50',
]"
class="border border-gray-300 rounded-md px-3 px-3 py-2 py-2 text-sm text-sm font-medium"
@click="currentPage = getPageNumber(page)"
>
{{ getPageNumber(page) }}
</button>
</div>
<button
:disabled="currentPage === totalPages"
class="border border-gray-300 rounded-md bg-white px-4 py-2 text-sm text-gray-700 font-medium hover:bg-gray-50 disabled:opacity-50"
@click="currentPage = currentPage + 1"
>
下一页
</button>
<button
:disabled="currentPage === totalPages"
class="border border-gray-300 rounded-md bg-white px-4 py-2 text-sm text-gray-700 font-medium hover:bg-gray-50 disabled:opacity-50"
@click="currentPage = totalPages"
>
末页
</button>
</div>
</div>
<div class="lg:col-span-2">
<div class="h-full flex items-center justify-center border border-gray-200 rounded-lg bg-gray-50 p-6">
<p class="text-gray-500">
预留空间
</p>
</div>
</div>
</div>
<!-- <div class="flex space-x-4">
<div class="mt-8 p-4 border border-dashed border-gray-300 rounded">
<p>当前筛选条件: {{ JSON.stringify(currentFilters) }}</p>
<p>当前搜索词: {{ currentKeyword }}</p>
</div>
</div> -->
</div>
<!-- Bottom Section: Reserved Space -->
<div class="mb-8 border border-gray-200 rounded-lg bg-gray-50 p-6">
<p class="text-center text-gray-500">
预留空间
</p>
</div>
</div>
</template>
<style scoped>
/* Additional styling for the university cards */
.university-card {
transition: box-shadow 0.2s ease;
}
.university-card:hover {
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* Pagination styling */
.pagination-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
border: 1px solid #d1d5db;
background-color: #ffffff;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
padding: 0.5rem 0.75rem;
transition: all 0.2s ease;
}
.pagination-btn:hover {
background-color: #f3f4f6;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.current-page {
background-color: #f97316;
color: white;
border-color: #f97316;
}
</style>

18
src/shims.d.ts vendored Normal file
View File

@ -0,0 +1,18 @@
declare interface Window {
// extend the window
}
// with unplugin-vue-markdown, markdown files can be treated as Vue components
declare module '*.md' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, any>
export default component
}
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, any>
export default component
}

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

@ -0,0 +1,50 @@
import { acceptHMRUpdate, defineStore } from 'pinia'
interface User {
username: string
password: string
}
export const useUserStore = defineStore('user', () => {
/**
* Current logged-in user.
*/
const user = ref<User | null>(null)
// Initialize user from localStorage
if (typeof window !== 'undefined') {
const savedUser = localStorage.getItem('user')
if (savedUser) {
user.value = JSON.parse(savedUser)
}
}
/**
* Login the user.
*
* @param username - username to login with
* @param password - password to login with
*/
function login(username: string, password: string) {
const newUser = { username, password }
user.value = newUser
localStorage.setItem('user', JSON.stringify(newUser))
}
/**
* Logout the user.
*/
function logout() {
user.value = null
localStorage.removeItem('user')
}
return {
user,
login,
logout,
}
})
if (import.meta.hot)
import.meta.hot.accept(acceptHMRUpdate(useUserStore as any, import.meta.hot))

89
src/styles/main.css Normal file
View File

@ -0,0 +1,89 @@
@import './markdown.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
html.dark {
background: #121212;
color-scheme: dark;
}
#nprogress {
pointer-events: none;
}
#nprogress .bar {
background: rgb(13, 148, 136);
opacity: 0.75;
position: fixed;
z-index: 1031;
top: 0;
left: 0;
width: 100%;
height: 2px;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}

25
src/styles/markdown.css Normal file
View File

@ -0,0 +1,25 @@
.prose pre:not(.shiki) {
padding: 0;
}
.prose .shiki {
font-family: 'DM Mono', monospace;
font-size: 1.2em;
line-height: 1.4;
}
.prose img {
width: 100%;
}
.shiki,
.shiki span {
color: var(--shiki-light);
background: var(--shiki-light-bg);
}
html.dark .shiki,
html.dark .shiki span {
color: var(--shiki-dark);
background: var(--shiki-dark-bg);
}

34
src/typed-router.d.ts vendored Normal file
View File

@ -0,0 +1,34 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
// It's recommended to commit this file.
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
declare module 'vue-router/auto-routes' {
import type {
RouteRecordInfo,
ParamValue,
ParamValueOneOrMore,
ParamValueZeroOrMore,
ParamValueZeroOrOne,
} from 'vue-router'
/**
* Route name map generated by unplugin-vue-router
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/[...all]': RouteRecordInfo<'/[...all]', '/:all(.*)', { all: ParamValue<true> }, { all: ParamValue<false> }>,
'/about': RouteRecordInfo<'/about', '/about', Record<never, never>, Record<never, never>>,
'/contact-us': RouteRecordInfo<'/contact-us', '/contact-us', Record<never, never>, Record<never, never>>,
'/demo/pop-confirm': RouteRecordInfo<'/demo/pop-confirm', '/demo/pop-confirm', Record<never, never>, Record<never, never>>,
'/hi/[name]': RouteRecordInfo<'/hi/[name]', '/hi/:name', { name: ParamValue<true> }, { name: ParamValue<false> }>,
'/majors': RouteRecordInfo<'/majors', '/majors', Record<never, never>, Record<never, never>>,
'/privacy-policy': RouteRecordInfo<'/privacy-policy', '/privacy-policy', Record<never, never>, Record<never, never>>,
'/README': RouteRecordInfo<'/README', '/README', Record<never, never>, Record<never, never>>,
'/school/[schoolCode]': RouteRecordInfo<'/school/[schoolCode]', '/school/:schoolCode', { schoolCode: ParamValue<true> }, { schoolCode: ParamValue<false> }>,
'/simulate': RouteRecordInfo<'/simulate', '/simulate', Record<never, never>, Record<never, never>>,
'/universities': RouteRecordInfo<'/universities', '/universities', Record<never, never>, Record<never, never>>,
}
}

3
src/types.ts Normal file
View File

@ -0,0 +1,3 @@
import type { ViteSSGContext } from 'vite-ssg'
export type UserModule = (ctx: ViteSSGContext) => void

20
tailwind.config.js Normal file
View File

@ -0,0 +1,20 @@
export default {
content: [
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx,md}',
],
theme: {
extend: {},
screens: {
'xs': '375px',
'sm': '640px',
'md': '768px',
'lg': '1024px',
'xl': '1280px',
'2xl': '1536px',
'3xl': '1800px', // 自定义断点
'tablet': '768px', // 自定义命名
},
},
plugins: [],
}

0
tasks/Task1.md Normal file
View File

Some files were not shown because too many files have changed in this diff Show More