提交开源版本

This commit is contained in:
2025-08-16 14:25:11 +08:00
parent 5255e39e91
commit 68fc9a960d
307 changed files with 38816 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
logs
.git
.github
.vscode
.idea
.env
.env.*
*.md
+45
View File
@@ -0,0 +1,45 @@
NODE_ENV=development
# PORT
PORT=3001
VITE_VERSON_ABOUT=2.1.2
# 高德地图密钥
VITE_MAMP_KEY=your_amap_key_here
# 三个模式:beta测试、production发布模式、none不显示版本Avatar模式
VITE_ABOUTSERVER=beta
# 开发水印
VITE_DEV_WHATER=true
#极验验证信息
VITE_GEETEST_CAPTCHA_ID=your_geetest_id_here
VITE_GEETEST_CAPTCHA_KEY=your_geetest_key_here
# OneAccount 授权信息
VITE_OA_ID=your_oa_id_here
VITE_OA_SECRET=your_oa_secret_here
VITE_OA_URL=your_oa_url_here
# Database configuration
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=your_mysql_user_here
MYSQL_PASSWORD=your_mysql_password_here
MYSQL_DATABASE=your_mysql_database_here
# Redis configuration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=your_redis_password_here
REDIS_DATABASE=0
# Email configuration
EMAIL_HOST=your_email_host_here
EMAIL_PORT=your_email_port_here
# JWT configuration
JWT_SECRET=your_jwt_secret_here
JWT_EXPIRES_IN=72h
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+32
View File
@@ -0,0 +1,32 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# 环境变量文件
.env
.env.*
!.env.example
# Editor directories and files
.vscode/*
.idea/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.pnpm-store
+137
View File
@@ -0,0 +1,137 @@
# 极验验证码配置说明
## 1. 获取极验配置
1. 访问 [极验官网](https://www.geetest.com/) 注册账号
2. 在控制台创建应用,获取 `captchaId`
3. 配置域名白名单
## 2. 环境变量配置
在项目根目录创建 `.env` 文件:
```env
# 极验验证码配置
VITE_GEETEST_CAPTCHA_KEY=你的captchaId
# OAuth配置
VITE_OA_ID=你的OAuth客户端ID
VITE_OA_URL=https://oauth.example.com
```
## 3. CSP配置(如需要)
如果遇到CSP策略阻止脚本加载,需要在服务器配置中添加以下域名到白名单:
```html
<!-- 在HTML的meta标签中 -->
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline' 'unsafe-eval' https://static.geetest.com https://gcaptcha4.geetest.com https://gcaptcha4.geevisit.com https://gcaptcha4.gsensebot.com https://static.geevisit.com;">
<!-- 或者在服务器配置中 -->
Content-Security-Policy: script-src 'self' 'unsafe-inline' 'unsafe-eval' https://static.geetest.com https://gcaptcha4.geetest.com https://gcaptcha4.geevisit.com https://gcaptcha4.gsensebot.com https://static.geevisit.com;
```
## 4. 后端验证
在登录接口中需要验证极验返回的验证结果:
```javascript
// 后端验证示例
const verifyGeetest = async (captchaResult) => {
const { lot_number, captcha_output, pass_token, gen_time } = captchaResult
// 调用极验服务端验证接口
const response = await fetch('https://gcaptcha4.geetest.com/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
lot_number,
captcha_output,
pass_token,
gen_time,
captcha_id: process.env.GEETEST_CAPTCHA_ID,
sign_token: process.env.GEETEST_SECRET_KEY
})
})
const result = await response.json()
return result.status === 'success'
}
```
## 5. 组件使用
```vue
<template>
<GeetestCaptcha
:captcha-id="captchaId"
@success="onCaptchaSuccess"
@error="onCaptchaError"
@close="onCaptchaClose"
/>
</template>
<script setup>
import GeetestCaptcha from '@/components/GeetestCaptcha.vue'
const onCaptchaSuccess = (result) => {
console.log('验证成功:', result)
// 将验证结果发送到后端
}
const onCaptchaError = (error) => {
console.error('验证失败:', error)
}
const onCaptchaClose = () => {
console.log('验证关闭')
}
</script>
```
## 6. 安全注意事项
- 不要在前端代码中硬编码 `captchaId`
- 使用环境变量管理敏感配置
- 后端必须验证极验返回的验证结果
- 定期更新极验配置和密钥
## 7. 故障排除
### 常见问题
1. **验证码不显示**
- 检查 `captchaId` 是否正确
- 检查域名是否在极验白名单中
- 检查网络连接
2. **脚本加载失败**
- 检查CSP策略是否阻止了极验域名
- 确认环境变量 `VITE_GEETEST_CAPTCHA_KEY` 已正确设置
- 尝试刷新页面或点击重试按钮
- 检查浏览器控制台错误信息
3. **验证失败**
- 检查后端验证逻辑
- 确认极验密钥配置正确
- 检查网络连接
4. **CSP策略问题**
- 在服务器配置中添加极验域名到白名单
- 或者使用 `unsafe-inline` 策略(不推荐)
### 调试步骤
1. 打开浏览器开发者工具
2. 查看Console标签页的错误信息
3. 查看Network标签页的请求状态
4. 检查Application标签页的CSP策略
### 联系支持
如果问题仍然存在,请:
1. 收集浏览器控制台的错误信息
2. 检查网络请求状态
3. 联系极验技术支持或项目管理员
+152
View File
@@ -0,0 +1,152 @@
<p align="center">
<img src="https://www.lingke.ink/wp-content/uploads/2025/08/space-scaled.png" alt="pARPxN8.png" border="0" />
<img src="https://www.lingke.ink/wp-content/uploads/2025/08/work-scaled.png" alt="pARPxN8.png" border="0" />
</p>
# Space.Tab
Space.Tab是一个现代化的Web应用程序,提供多种功能,包括用户认证、服务器管理、设备信息收集、天气查询、搜索引擎集成等功能。
## 技术栈
- 前端:Vue 3、Tailwind CSS、GSAP
- 后端:Express.js、MySQL、Redis
- 构建工具:Vite
- 其他:JWT认证、Swagger API文档
## 项目设置
### 环境要求
- Node.js (推荐v18+)
- MySQL
- Redis
- PNPM (推荐v10+)
### 安装依赖
```bash
# 安装依赖
pnpm install
```
### 环境变量配置
1. 复制环境变量示例文件作为基础
```bash
cp .env.example .env
```
2. 编辑`.env`文件,填入你的实际配置信息:
- 数据库连接信息(MySQL和Redis
- API密钥(高德地图、极验验证等)
- JWT密钥
- 其他应用配置
### 数据库初始化
1. 确保MySQL服务已启动
2. 创建数据库并导入初始数据
```bash
mysql -u your_username -p < sql/spacetab-data.sql
```
## 开发
启动开发服务器:
```bash
pnpm dev
```
应用将在 http://localhost:3001 (或环境变量中配置的端口) 上运行。
## 构建
构建生产版本:
```bash
pnpm build
```
## 部署
### 标准部署
```bash
# 构建项目
pnpm build
# 启动生产服务器
pnpm start
```
### Docker部署
```bash
# 使用生产环境配置启动Docker容器
pnpm docker
```
## 项目结构
- `src/client/` - 前端Vue应用
- `src/server/` - 后端Express服务器
- `sql/` - 数据库初始化脚本
- `public/` - 静态资源文件
## 环境变量说明
项目使用了多种环境变量来配置不同的功能。请参考`.env.example`文件了解所有可用的配置选项。关键配置包括:
### 基础配置
- `NODE_ENV` - 运行环境(development/production
- `PORT` - 服务器端口
### 数据库配置
- `MYSQL_HOST` - MySQL主机地址
- `MYSQL_PORT` - MySQL端口
- `MYSQL_USER` - MySQL用户名
- `MYSQL_PASSWORD` - MySQL密码
- `MYSQL_DATABASE` - MySQL数据库名
### Redis配置
- `REDIS_HOST` - Redis主机地址
- `REDIS_PORT` - Redis端口
- `REDIS_PASSWORD` - Redis密码
- `REDIS_DATABASE` - Redis数据库索引
### 认证配置
- `JWT_SECRET` - JWT签名密钥
- `JWT_EXPIRES_IN` - JWT过期时间
### 第三方服务配置
- `VITE_MAMP_KEY` - 高德地图API密钥
- `VITE_GEETEST_CAPTCHA_ID` - 极验验证ID
- `VITE_GEETEST_CAPTCHA_KEY` - 极验验证密钥
## 安全注意事项
1. **环境变量文件**
- `.env``.env.*` 文件包含敏感信息,已在`.gitignore`中配置为不上传
- 永远不要将包含实际密钥的环境文件提交到版本控制系统
2. **数据库凭证**
- 生产环境中使用强密码
- 限制数据库用户权限
3. **API密钥**
- 定期轮换第三方服务的API密钥
- 实施适当的访问控制和速率限制
## 贡献指南
1. Fork项目
2. 创建功能分支 (`git checkout -b feature/amazing-feature`)
3. 提交更改 (`git commit -m 'Add some amazing feature'`)
4. 推送到分支 (`git push origin feature/amazing-feature`)
5. 创建Pull Request
## 许可证
本项目遵守 GPL-3.0 license 协议,在协议规范内,禁止用于商业用途
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/client/style.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="http://lunova.studio/wp-content/uploads/2025/08/space.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 只用本地极验JS -->
<script src="/gt4.js" async></script>
<title>Space | 搜索</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/client/main.ts"></script>
</body>
</html>
+6192
View File
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
{
"name": "space.tab",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "nodemon -w src/server -x tsx src/server/main.ts",
"start": "cross-env NODE_ENV=production tsx src/server/main.ts",
"build": "vite build",
"build:extension": "node scripts/build-extension.js",
"docker": "docker-compose --env-file .env.production up -d --build"
},
"dependencies": {
"@barba/core": "^2.10.3",
"@supabase/supabase-js": "^2.53.0",
"@tailwindcss/vite": "^4.1.10",
"@tanstack/vue-table": "^8.21.3",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"@vueuse/core": "^13.6.0",
"@waline/client": "^3.5.7",
"bcryptjs": "^3.0.2",
"blueimp-md5": "^2.19.0",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"cross-env": "^7.0.3",
"dayjs": "^1.11.13",
"dotenv": "^16.5.0",
"echarts": "^5.6.0",
"express": "^5.1.0",
"gsap": "^3.13.0",
"helmet": "^8.1.0",
"html2canvas": "^1.4.1",
"ioredis": "^5.6.1",
"js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.2",
"lodash-es": "^4.17.21",
"lucide-vue-next": "^0.523.0",
"moment-timezone": "^0.6.0",
"morgan": "^1.10.0",
"mysql2": "^3.14.1",
"pinia": "^3.0.3",
"reka-ui": "^2.4.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.10",
"tsx": "^4.19.3",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3",
"uuid": "^11.1.0",
"vaul-vue": "^0.4.1",
"vite-express": "*",
"vue": "^3.5.13",
"vue-router": "^4.5.1",
"vue-sonner": "^2.0.2",
"vuex": "^4.1.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.1",
"@types/js-cookie": "^3.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/morgan": "^1.9.10",
"@types/node": "^22.15.2",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-vue": "^5.2.3",
"axios": "^1.11.0",
"canvas": "^3.1.2",
"cheerio": "^1.1.2",
"nodemon": "^3.1.10",
"vite": "^6.3.5",
"vue-tsc": "^2.2.10"
},
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
}
+4220
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
<svg viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" height="500" fill="none" clip-path="url(#clipPath_0)" customFrame="url(#clipPath_0)">
<defs>
<clipPath id="clipPath_0">
<rect width="500" height="500" x="0" y="0" rx="168" fill="rgb(255,255,255)" />
</clipPath>
</defs>
<rect id="aoteam" width="500" height="500" x="0" y="0" rx="168" fill="rgb(255,255,255)" />
<path id="矢量 21" d="M43.8823 376.549C116.924 157.916 343.381 157.135 419.14 304.651C519.783 78.6034 216.003 -92.8292 0 55.523" stroke="rgb(48,58,70)" stroke-linecap="round" stroke-width="120" transform="matrix(0.224951,-0.974371,0.974371,0.224951,-62,550.749)" />
</svg>

After

Width:  |  Height:  |  Size: 713 B

+487
View File
@@ -0,0 +1,487 @@
"v4.2.0 Geetest Inc.";
(function (window) {
"use strict";
if (typeof window === 'undefined') {
throw new Error('Geetest requires browser environment');
}
var document = window.document;
var Math = window.Math;
var head = document.getElementsByTagName("head")[0];
var TIMEOUT = 10000;
function _Object(obj) {
this._obj = obj;
}
_Object.prototype = {
_each: function (process) {
var _obj = this._obj;
for (var k in _obj) {
if (_obj.hasOwnProperty(k)) {
process(k, _obj[k]);
}
}
return this;
},
_extend: function (obj){
var self = this;
new _Object(obj)._each(function (key, value){
self._obj[key] = value;
})
}
};
var uuid = function () {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0;
var v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
function Config(config) {
var self = this;
new _Object(config)._each(function (key, value) {
self[key] = value;
});
}
Config.prototype = {
apiServers: ['gcaptcha4.geetest.com','gcaptcha4.geevisit.com','gcaptcha4.gsensebot.com'],
staticServers: ["static.geetest.com",'static.geevisit.com'],
protocol: 'http://',
typePath: '/load',
fallback_config: {
bypass: {
staticServers: ["static.geetest.com",'static.geevisit.com'],
type: 'bypass',
bypass: '/v4/bypass.js'
}
},
_get_fallback_config: function () {
var self = this;
if (isString(self.type)) {
return self.fallback_config[self.type];
} else {
return self.fallback_config.bypass;
}
},
_extend: function (obj) {
var self = this;
new _Object(obj)._each(function (key, value) {
self[key] = value;
})
}
};
var isNumber = function (value) {
return (typeof value === 'number');
};
var isString = function (value) {
return (typeof value === 'string');
};
var isBoolean = function (value) {
return (typeof value === 'boolean');
};
var isObject = function (value) {
return (typeof value === 'object' && value !== null);
};
var isFunction = function (value) {
return (typeof value === 'function');
};
var MOBILE = /Mobi/i.test(navigator.userAgent);
var callbacks = {};
var status = {};
var random = function () {
return parseInt(Math.random() * 10000) + (new Date()).valueOf();
};
// bind 函数polify, 不带new功能的bind
var bind = function(target,context){
if(typeof target !== 'function'){
return;
}
var args = Array.prototype.slice.call(arguments,2);
if(Function.prototype.bind){
return target.bind(context, args);
}else {
return function(){
var _args = Array.prototype.slice.call(arguments);
return target.apply(context,args.concat(_args));
}
}
}
var toString = Object.prototype.toString;
var _isFunction = function(obj) {
return typeof(obj) === 'function';
};
var _isObject = function(obj) {
return obj === Object(obj);
};
var _isArray = function(obj) {
return toString.call(obj) == '[object Array]';
};
var _isDate = function(obj) {
return toString.call(obj) == '[object Date]';
};
var _isRegExp = function(obj) {
return toString.call(obj) == '[object RegExp]';
};
var _isBoolean = function(obj) {
return toString.call(obj) == '[object Boolean]';
};
function resolveKey(input){
return input.replace(/(\S)(_([a-zA-Z]))/g, function(match, $1, $2, $3){
return $1 + $3.toUpperCase() || "";
})
}
function camelizeKeys(input, convert){
if(!_isObject(input) || _isDate(input) || _isRegExp(input) || _isBoolean(input) || _isFunction(input)){
return convert ? resolveKey(input) : input;
}
if(_isArray(input)){
var temp = [];
for(var i = 0; i < input.length; i++){
temp.push(camelizeKeys(input[i]));
}
}else {
var temp = {};
for(var prop in input){
if(input.hasOwnProperty(prop)){
temp[camelizeKeys(prop, true)] = camelizeKeys(input[prop]);
}
}
}
return temp;
}
var loadScript = function (url, cb, timeout) {
var script = document.createElement("script");
script.charset = "UTF-8";
script.async = true;
// 对geetest的静态资源添加 crossOrigin
if ( /static\.geetest\.com/g.test(url)) {
script.crossOrigin = "anonymous";
}
script.onerror = function () {
cb(true);
// 错误触发了,超时逻辑就不用了
loaded = true;
};
var loaded = false;
script.onload = script.onreadystatechange = function () {
if (!loaded &&
(!script.readyState ||
"loaded" === script.readyState ||
"complete" === script.readyState)) {
loaded = true;
setTimeout(function () {
cb(false);
}, 0);
}
};
script.src = url;
head.appendChild(script);
setTimeout(function () {
if (!loaded) {
script.onerror = script.onload = null;
script.remove && script.remove();
cb(true);
}
}, timeout || TIMEOUT);
};
var normalizeDomain = function (domain) {
// special domain: uems.sysu.edu.cn/jwxt/geetest/
// return domain.replace(/^https?:\/\/|\/.*$/g, ''); uems.sysu.edu.cn
return domain.replace(/^https?:\/\/|\/$/g, ''); // uems.sysu.edu.cn/jwxt/geetest
};
var normalizePath = function (path) {
path = path && path.replace(/\/+/g, '/');
if (path.indexOf('/') !== 0) {
path = '/' + path;
}
return path;
};
var normalizeQuery = function (query) {
if (!query) {
return '';
}
var q = '?';
new _Object(query)._each(function (key, value) {
if (isString(value) || isNumber(value) || isBoolean(value)) {
q = q + encodeURIComponent(key) + '=' + encodeURIComponent(value) + '&';
}
});
if (q === '?') {
q = '';
}
return q.replace(/&$/, '');
};
var makeURL = function (protocol, domain, path, query) {
domain = normalizeDomain(domain);
var url = normalizePath(path) + normalizeQuery(query);
if (domain) {
url = protocol + domain + url;
}
return url;
};
var load = function (config, protocol, domains, path, query, cb, handleCb) {
var tryRequest = function (at) {
// 处理jsonp回调,这里为了保证每个不同jsonp都有唯一的回调函数
if(handleCb){
var cbName = "geetest_" + random();
// 需要与预先定义好cbname参数,删除对象
window[cbName] = bind(handleCb, null, cbName);
query.callback = cbName;
}
var url = makeURL(protocol, domains[at], path, query);
loadScript(url, function (err) {
if (err) {
// 超时或者出错的时候 移除回调
if(cbName){
try {
window[cbName] = function(){
window[cbName] = null;
}
} catch (e) {}
}
if (at >= domains.length - 1) {
cb(true);
// report gettype error
} else {
tryRequest(at + 1);
}
} else {
cb(false);
}
}, config.timeout);
};
tryRequest(0);
};
var jsonp = function (domains, path, config, callback) {
var handleCb = function (cbName, data) {
// 保证只执行一次,全部超时的情况下不会再触发;
if (data.status == 'success') {
callback(data.data);
} else if (!data.status) {
callback(data);
} else {
//接口有返回,但是返回了错误状态,进入报错逻辑
callback(data);
}
window[cbName] = undefined;
try {
delete window[cbName];
} catch (e) {
}
};
load(config, config.protocol, domains, path, {
callback: '',
captcha_id: config.captchaId,
challenge: config.challenge || uuid(),
client_type: config.clientType ? config.clientType : (MOBILE? 'h5':'web'),
risk_type: config.riskType,
user_info: config.userInfo,
call_type: config.callType,
lang: config.language? config.language : navigator.appName === 'Netscape' ? navigator.language.toLowerCase() : navigator.userLanguage.toLowerCase()
}, function (err) {
// 网络问题接口没有返回,直接使用本地验证码,走宕机模式
// 这里可以添加用户的逻辑
if(err && typeof config.offlineCb === 'function'){
// 执行自己的宕机
config.offlineCb();
return;
}
if(err){
callback(config._get_fallback_config());
}
}, handleCb);
};
var reportError = function (config, url) {
load(config, config.protocol, ['monitor.geetest.com'], '/monitor/send', {
time: Date.now().getTime(),
captcha_id: config.gt,
challenge: config.challenge,
exception_url: url,
error_code: config.error_code
}, function (err) {})
}
var throwError = function (errorType, config, errObj) {
var errors = {
networkError: '网络错误',
gtTypeError: 'gt字段不是字符串类型'
};
if (typeof config.onError === 'function') {
config.onError({
desc: errObj.desc,
msg: errObj.msg,
code: errObj.code
});
} else {
throw new Error(errors[errorType]);
}
};
var detect = function () {
return window.Geetest || document.getElementById("gt_lib");
};
if (detect()) {
status.slide = "loaded";
}
var GeetestIsLoad = function (fname) {
var GeetestIsLoad = false;
var tags = { js: 'script', css: 'link' };
var tagname = fname && tags[fname.split('.').pop()];
if (tagname !== undefined) {
var elts = document.getElementsByTagName(tagname);
for (var i in elts) {
if ((elts[i].href && elts[i].href.toString().indexOf(fname) > 0)
|| (elts[i].src && elts[i].src.toString().indexOf(fname) > 0)) {
GeetestIsLoad = true;
}
}
}
return GeetestIsLoad;
};
window.initGeetest4 = function (userConfig,callback) {
var config = new Config(userConfig);
if (userConfig.https) {
config.protocol = 'https://';
} else if (!userConfig.protocol) {
config.protocol = window.location.protocol + '//';
}
if (isObject(userConfig.getType)) {
config._extend(userConfig.getType);
}
jsonp(config.apiServers , config.typePath, config, function (newConfig) {
//错误捕获,第一个load请求可能直接报错
var newConfig = camelizeKeys(newConfig);
if(newConfig.status === 'error'){
return throwError('networkError', config, newConfig);
}
var type = newConfig.type;
if(config.debug){
new _Object(newConfig)._extend(config.debug)
}
var init = function () {
config._extend(newConfig);
callback(new window.Geetest4(config));
};
callbacks[type] = callbacks[type] || [];
var s = status[type] || 'init';
if (s === 'init') {
status[type] = 'loading';
callbacks[type].push(init);
if(newConfig.gctPath){
load(config, config.protocol, Object.hasOwnProperty.call(config, 'staticServers') ? config.staticServers : newConfig.staticServers || config.staticServers , newConfig.gctPath, null, function (err){
if(err){
throwError('networkError', config, {
code: '60205',
msg: 'Network failure',
desc: {
detail: 'gct resource load timeout'
}
});
}
})
}
load(config, config.protocol, Object.hasOwnProperty.call(config, 'staticServers') ? config.staticServers : newConfig.staticServers || config.staticServers, newConfig.bypass || (newConfig.staticPath + newConfig.js), null, function (err) {
if (err) {
status[type] = 'fail';
throwError('networkError', config, {
code: '60204',
msg: 'Network failure',
desc: {
detail: 'js resource load timeout'
}
});
} else {
status[type] = 'loaded';
var cbs = callbacks[type];
for (var i = 0, len = cbs.length; i < len; i = i + 1) {
var cb = cbs[i];
if (isFunction(cb)) {
cb();
}
}
callbacks[type] = [];
status[type] = 'init';
}
});
} else if (s === "loaded") {
// 判断gct是否需要重新加载
if(newConfig.gctPath && !GeetestIsLoad(newConfig.gctPath)){
load(config, config.protocol, Object.hasOwnProperty.call(config, 'staticServers') ? config.staticServers : newConfig.staticServers || config.staticServers , newConfig.gctPath, null, function (err){
if(err){
throwError('networkError', config, {
code: '60205',
msg: 'Network failure',
desc: {
detail: 'gct resource load timeout'
}
});
}
})
}
return init();
} else if (s === "fail") {
throwError('networkError', config, {
code: '60204',
msg: 'Network failure',
desc: {
detail: 'js resource load timeout'
}
});
} else if (s === "loading") {
callbacks[type].push(init);
}
});
};
})(window);
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+3
View File
@@ -0,0 +1,3 @@
CREATE USER IF NOT EXISTS 'root'@'%' IDENTIFIED BY 'GUYJ45G3KU45HG3K245JH2G52UJ3Y45G2U34Y2IK';
GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;
FLUSH PRIVILEGES;
+112
View File
@@ -0,0 +1,112 @@
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
CREATE DATABASE IF NOT EXISTS `lunovastudio-wlserver-data`;
USE `lunovastudio-wlserver-data`;
-- ----------------------------
-- Table structure for equipment_disposition
-- ----------------------------
DROP TABLE IF EXISTS `equipment_disposition`;
CREATE TABLE `equipment_disposition` (
`info_uuid` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`equipment_uuid` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`system_architecture` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`system` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`system_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`ram_info` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`date` datetime NOT NULL,
`wonderlab_run_file` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`wonderlab_v` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
PRIMARY KEY (`info_uuid`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for equipment_info
-- ----------------------------
DROP TABLE IF EXISTS `equipment_info`;
CREATE TABLE `equipment_info` (
`info_uuid` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`bios_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`country` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`user_id` int NOT NULL,
`date` datetime NOT NULL,
PRIMARY KEY (`info_uuid`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for github_mirror_info
-- ----------------------------
DROP TABLE IF EXISTS `github_mirror_info`;
CREATE TABLE `github_mirror_info` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '镜像源名称',
`url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '镜像源URL',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `name`(`name` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'GitHub镜像源信息表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for server_about
-- ----------------------------
DROP TABLE IF EXISTS `server_about`;
CREATE TABLE `server_about` (
`id` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '记录ID(UUID)',
`about` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '服务器描述(md)',
`docs_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '服务器文档链接',
`info_id` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '关联服务器ID',
`ip` json NOT NULL COMMENT '服务器地址(支持多个IP)',
`server_type` enum('原版服务器','插件服务器','MOD服务器','插件MOD混合服务器') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '服务器类型',
`server_oauth` enum('mojang','none','yggdrasil') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '验证类型',
`qq_group` json NULL COMMENT '服务器群(支持多个群)',
`date` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '信息记录日期',
`audit_status` enum('yes','no','lock') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'no' COMMENT '审核状态',
`audit_reason` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '审核原因',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for server_info
-- ----------------------------
DROP TABLE IF EXISTS `server_info`;
CREATE TABLE `server_info` (
`id` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '服务器记录ID(UUID)',
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '服务器名称',
`server_icon_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '服务器图标URL',
`server_about_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '服务器当前数据表ID',
`country` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '设备地区',
`user_id` bigint NOT NULL COMMENT '关联用户id',
`date` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '服务器登记日期(UTC+8)',
`todo` int NULL DEFAULT 0 COMMENT '服务器被访问数',
`audit_status` enum('yes','no','lock') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'no' COMMENT '审核状态',
`audit_reason` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '审核原因',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for users
-- ----------------------------
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`is_banned` tinyint(1) NOT NULL DEFAULT 0,
`ban_reason` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL,
`ban_expiration` datetime NULL DEFAULT NULL,
`is_admin` tinyint(1) NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NULL DEFAULT NULL,
`last_login` datetime NULL DEFAULT NULL,
`avatar` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`qq_num` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`email_verified` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `email`(`email` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 10006 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
INSERT INTO `lunovastudio-wlserver-data`.`users` (`id`, `username`, `email`, `password`, `is_banned`, `ban_reason`, `ban_expiration`, `is_admin`, `created_at`, `updated_at`, `last_login`, `avatar`, `qq_num`, `email_verified`) VALUES (10000, 'admin', 'support@lunova.studio', '$2b$12$R847j17qb/aUoQ4sEAPDdOXuFfg6Vb.HGiUMrjyB5Xrv9OI9.kvG2', 0, NULL, NULL, 1, '2025-06-25 15:41:58', '2025-06-28 21:21:01', '2025-07-30 20:00:36', NULL, '1723327932', 0);
+7
View File
@@ -0,0 +1,7 @@
// 浏览器扩展的后台脚本
console.log('Space.Tab 扩展已加载');
// 监听扩展安装事件
chrome.runtime.onInstalled.addListener(() => {
console.log('Space.Tab 扩展已安装');
});
+561
View File
@@ -0,0 +1,561 @@
<script setup lang="ts">
import { Toaster } from '@/components/ui/sonner'
import 'vue-sonner/style.css'
import AdminPermissionCheck from '@/components/AdminPermissionCheck.vue'
import { ref, computed, onMounted, nextTick, onBeforeUnmount, watch, onBeforeMount } from 'vue'
import Cookies from 'js-cookie'
import {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
} from '@/components/ui/navigation-menu'
import { useUserStore } from '@/store/user'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { ChevronsUpDown } from 'lucide-vue-next'
import { useRouter, useRoute } from 'vue-router'
import Watermark from '@/components/Watermark.vue'
import { useThemeStore } from '@/store/theme'
import gsap from 'gsap'
import AppFooter from '@/components/AppFooter.vue'
const userStore = useUserStore()
const isLoggedIn = computed(() => !!userStore.token)
const user = computed(() => ({
username: userStore.userInfo.username || '未登录',
email: userStore.userInfo.email || '',
avatar: userStore.userInfo.avatar || '/avatars/default.jpg',
}))
const router = useRouter()
const route = useRoute()
const themeStore = useThemeStore()
const isLoading = ref(false)
const initialPageLoad = ref(true)
const pageTransition = ref(false)
const showWatermark = import.meta.env.VITE_DEV_WHATER === 'true'
const version = '0.1.0' // 可替换为动态读取
// 安全的localStorage访问方法
const safeLocalStorage = {
getItem(key: string, defaultValue: any = null): any {
if (typeof window !== 'undefined' && window.localStorage) {
return localStorage.getItem(key) || defaultValue;
}
return defaultValue;
},
setItem(key: string, value: any): void {
if (typeof window !== 'undefined' && window.localStorage) {
localStorage.setItem(key, value);
}
},
removeItem(key: string): void {
if (typeof window !== 'undefined' && window.localStorage) {
localStorage.removeItem(key);
}
}
};
// 移除全局壁纸相关变量和函数
const fillMode = computed(() => {
const path = route.path
const themeValue = themeStore.getCurrentTheme()
if (path === '/login' || path === '/register') {
if (themeValue === 'dark') {
return 'dark'
}
return 'light'
}
return 'auto'
})
// 使用 GSAP 为页面元素添加动画效果,分批次加载
function animatePageElements() {
nextTick(() => {
// 获取主内容区域(排除顶栏)
const mainContent = document.querySelector('main') || document.querySelector('.main-content') || document.querySelector('.page-content')
// 如果找到主内容区域,则只在主内容区域内查找元素
const contentScope = mainContent || document
// 获取顶栏元素,以便后续排除
const headerElements = document.querySelectorAll('header, nav, .navbar, .nav-bar, .top-bar, .header')
// 分类获取不同类型的元素
const headings = contentScope.querySelectorAll('h1, h2, h3, h4')
const cards = contentScope.querySelectorAll('.card')
const buttons = contentScope.querySelectorAll('.btn, button:not([type="hidden"])')
const paragraphs = contentScope.querySelectorAll('p:not(:empty)')
const sections = contentScope.querySelectorAll('section > *, article > *')
const otherElements = contentScope.querySelectorAll('.grid > *, .container > *:not(h1):not(h2):not(h3):not(p):not(.card):not(.btn)')
// 将NodeList转换为数组,以便过滤
const filterFromHeader = (elements: NodeListOf<Element>) => {
const elementsArray = Array.from(elements)
return elementsArray.filter(el => {
// 检查元素是否在顶栏内
for (const header of headerElements) {
if (header.contains(el)) {
return false
}
}
return true
})
}
// 过滤掉顶栏中的元素
const filteredHeadings = filterFromHeader(headings)
const filteredCards = filterFromHeader(cards)
const filteredButtons = filterFromHeader(buttons)
const filteredParagraphs = filterFromHeader(paragraphs)
const filteredSections = filterFromHeader(sections)
const filteredOtherElements = filterFromHeader(otherElements)
// 检查是否有元素需要动画
if (filteredHeadings.length === 0 && filteredCards.length === 0 && filteredParagraphs.length === 0 &&
filteredButtons.length === 0 && filteredSections.length === 0 && filteredOtherElements.length === 0) {
return; // 如果没有元素,直接返回
}
// 创建动画时间轴
const tl = gsap.timeline();
// 第一批:标题元素(最先显示)
if (filteredHeadings.length > 0) {
gsap.set(filteredHeadings, { opacity: 0, y: 15 })
tl.to(filteredHeadings, {
opacity: 1,
y: 0,
duration: 0.25,
stagger: 0.03,
ease: 'power1.out'
}, 0); // 0表示动画开始时间
}
// 第二批:卡片元素(重点元素)
if (filteredCards.length > 0) {
gsap.set(filteredCards, { opacity: 0, y: 20, scale: 0.98 })
tl.to(filteredCards, {
opacity: 1,
y: 0,
scale: 1,
duration: 0.35,
stagger: {
each: 0.05,
from: "start", // 从第一个元素开始
grid: "auto" // 自动检测网格布局
},
ease: 'back.out(1.02)' // 使用弹性效果,让卡片有轻微的弹跳感
}, 0.1); // 延迟0.1秒开始
}
// 第三批:段落文本
if (filteredParagraphs.length > 0) {
gsap.set(filteredParagraphs, { opacity: 0, y: 15 })
tl.to(filteredParagraphs, {
opacity: 1,
y: 0,
duration: 0.3,
stagger: 0.02,
ease: 'power1.out'
}, 0.15); // 延迟0.15秒开始
}
// 第四批:按钮元素
if (filteredButtons.length > 0) {
gsap.set(filteredButtons, { opacity: 0, y: 10, scale: 0.95 })
tl.to(filteredButtons, {
opacity: 1,
y: 0,
scale: 1,
duration: 0.25,
stagger: 0.03,
ease: 'power2.out'
}, 0.2); // 延迟0.2秒开始
}
// 第五批:其他区块元素
if (filteredSections.length > 0 || filteredOtherElements.length > 0) {
const remainingElements = [...filteredSections, ...filteredOtherElements];
gsap.set(remainingElements, { opacity: 0, y: 15 })
tl.to(remainingElements, {
opacity: 1,
y: 0,
duration: 0.3,
stagger: 0.02,
ease: 'power1.out'
}, 0.25); // 延迟0.25秒开始
}
})
}
// 计算壁纸样式(已移除实际使用,保留接口兼容性)
const wallpaperStyle = computed(() => {
return {
opacity: 0.8, // 默认值替代 wallpaperOpacity.value / 100
filter: `blur(0px)` // 默认值替代 wallpaperBlur.value
}
})
// 移除全局壁纸更新函数,壁纸将只在首页处理
// 移除全局壁纸监听器
// 检查是否需要进行初始化设置
const checkSetupRequired = () => {
// 检查是否已完成初始化设置(同时检查localStorage和Cookie
const setupCompletedLocal = safeLocalStorage.getItem('setupCompleted', 'false')
const setupCompletedCookie = Cookies.get('setupCompleted')
const setupCompleted = setupCompletedLocal === 'true' || setupCompletedCookie === 'true'
// 如果未完成初始化设置且当前不在setup页面,则重定向到setup页面
/*if (!setupCompleted && route.path !== '/setup') {
router.push('/setup')
return true
}
return false*/
}
onMounted(async () => {
userStore.loadFromCookie()
// 如果用户已登录,获取最新的用户信息
if (userStore.token && userStore.userId) {
await userStore.getUserInfo()
}
// 检查是否需要初始化设置
checkSetupRequired()
// 移除壁纸相关初始化代码
// 初始页面加载动画 - 减少延迟时间
setTimeout(() => {
initialPageLoad.value = false
// 初始页面加载完成后立即执行动画
animatePageElements()
}, 300) // 减少延迟时间
// 添加路由切换时的加载状态控制
router.beforeEach((to, from, next) => {
// 只有在实际路由变化时才显示加载
if (to.path !== from.path) {
// 开始页面过渡
pageTransition.value = true
isLoading.value = true
// 预先清除可能存在的动画,确保新页面动画能正确执行
gsap.killTweensOf("*") // 清除所有GSAP动画
// 检查是否需要初始化设置(同时检查localStorage和Cookie
const setupCompletedLocal = safeLocalStorage.getItem('setupCompleted', 'false')
const setupCompletedCookie = Cookies.get('setupCompleted')
const setupCompleted = setupCompletedLocal === 'true' || setupCompletedCookie === 'true'
if (!setupCompleted && to.path !== '/setup' && to.path !== '/terms-of-service' && to.path !== '/privacy-policy') {
next('/setup')
return
}
}
next()
})
router.afterEach((to) => {
// 立即隐藏加载遮罩,不等待API请求完成
isLoading.value = false
// 如果是访问首页,重新从cookie加载用户信息
if (to.path === '/' || to.path === '/home') {
userStore.loadFromCookie()
// 如果用户已登录,获取最新的用户信息
if (userStore.token && userStore.userId) {
userStore.getUserInfo()
}
}
// 创建一个独立的过渡动画控制流程,完全与API请求分离
// 这确保了即使API请求延迟,页面过渡动画也能流畅完成
// 第一步:立即开始隐藏过渡遮罩的动画
const transitionOverlay = document.querySelector('.page-transition-overlay')
if (transitionOverlay) {
transitionOverlay.classList.add('fade-out')
}
// 第二步:使用双重requestAnimationFrame确保在下一帧渲染前执行动画
// 这比setTimeout更可靠,因为它会在浏览器准备好渲染时执行
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// 立即执行页面元素动画,不等待API请求
// 这样可以确保页面过渡动画不受API延迟影响
animatePageElements()
// 触发一个自定义事件,通知页面组件路由切换已完成
// 这样各个页面组件可以在自己的时机重新触发动画
document.dispatchEvent(new CustomEvent('route-transition-complete'))
// 第三步:在动画完成后完全移除过渡遮罩
// 使用与CSS动画相匹配的时间(200ms)
setTimeout(() => {
pageTransition.value = false
}, 200)
})
})
})
})
// 移除壁纸相关清理代码
function goDashboard() {
router.push('/dashboard')
}
function logout() {
userStore.logout()
router.push('/login')
}
// 需要暴露给模板
defineExpose({ isLoggedIn, user })
</script>
<template>
<!-- 背景图片容器 -->
<!-- 移除全局背景图片容器壁纸将只在首页显示 -->
<!-- 初始页面加载动画 -->
<div v-if="initialPageLoad" class="initial-page-load">
<div class="page-loading-spinner"></div>
</div>
<!-- 页面切换加载过渡效果 -->
<div v-if="isLoading" class="page-loading-overlay">
<div class="page-loading-spinner"></div>
</div>
<!-- 页面过渡遮罩层 -->
<div v-if="pageTransition" class="page-transition-overlay" :class="{ 'fade-out': !isLoading }"></div>
<transition name="app-fade" appear>
<div v-show="!initialPageLoad" class="app-container">
<router-view v-slot="{ Component }">
<div :key="route.fullPath">
<component :is="Component" />
</div>
</router-view>
<AdminPermissionCheck />
<Toaster />
<AppFooter
v-if="!route.path.startsWith('/dashboard') && route.path !== '/setup'"
:sticky="route.path === '/' || route.path === '/home'"
/>
<!--<Watermark v-if="showWatermark" :version="version" :fillMode="fillMode" :user="user" :isLoggedIn="isLoggedIn" />-->
</div>
</transition>
</template>
<style scoped>
/* 定义CSS变量 */
:root {
--background-rgb: 255, 255, 255;
--card-rgb: 255, 255, 255;
--border-rgb: 0, 0, 0;
}
:root.dark {
--background-rgb: 10, 10, 10;
--card-rgb: 20, 20, 20;
--border-rgb: 255, 255, 255;
}
/* 移除全局背景图片样式 */
.background-image {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.8; /* 调整透明度 */
transition: opacity 0.5s ease;
}
/* 当有壁纸时,为顶栏和底栏添加模糊效果
:root[data-has-wallpaper="true"] header,
:root[data-has-wallpaper="true"] footer {
background-color: rgba(var(--background-rgb), 0.7) !important;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(var(--border-rgb), 0.1);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.05);
}*/
/* 当有壁纸时,为内容区域添加模糊背景 */
:root[data-has-wallpaper="true"] .card,
:root[data-has-wallpaper="true"] .content-card,
:root[data-has-wallpaper="true"] .glass-card {
background-color: rgba(var(--card-rgb), 0.7) !important;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(var(--border-rgb), 0.2);
}
/* 渐变模糊效果 - 顶部和底部的渐变过渡
:root[data-has-wallpaper="true"] header::after,
:root[data-has-wallpaper="true"] footer::before {
content: "";
position: absolute;
left: 0;
width: 100%;
height: 20px;
pointer-events: none;
}*/
:root[data-has-wallpaper="true"] header::after {
bottom: -20px;
background: linear-gradient(to bottom, rgba(var(--background-rgb), 0.7), transparent);
}
:root[data-has-wallpaper="true"] footer::before {
top: -20px;
background: linear-gradient(to top, rgba(var(--background-rgb), 0.7), transparent);
}
/* 页面过渡遮罩层 */
.page-transition-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--background, #ffffff);
opacity: 0;
z-index: 9998;
animation: fade-in-overlay 0.15s forwards; /* 快速淡入 */
pointer-events: none; /* 确保不阻止用户交互 */
will-change: opacity, transform; /* 优化动画性能 */
transform: translateZ(0); /* 启用GPU加速 */
backface-visibility: hidden; /* 防止闪烁 */
-webkit-font-smoothing: antialiased; /* 文字渲染优化 */
}
.page-transition-overlay.fade-out {
animation: fade-out-overlay 0.2s forwards; /* 平滑淡出 */
}
@keyframes fade-in-overlay {
from { opacity: 0; }
to { opacity: 0.7; } /* 适中的不透明度 */
}
@keyframes fade-out-overlay {
from { opacity: 0.7; }
to { opacity: 0; }
}
/* 暗色模式适配 */
:root.dark .page-transition-overlay {
background-color: #000;
}
/* 初始页面加载动画 */
.initial-page-load {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--background, #ffffff);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
}
.app-container {
min-height: 100vh;
width: 100%;
position: relative;
display: flex;
flex-direction: column;
}
/* 整个应用的渐入效果 */
.app-fade-enter-active {
transition: opacity 0.4s ease; /* 减少过渡时间 */
}
.app-fade-enter-from {
opacity: 0;
}
/* 页面过渡效果 */
.page-transition-enter-active {
transition: opacity 0.4s ease, transform 0.4s ease;
}
.page-transition-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.page-transition-enter-from {
opacity: 0;
transform: translateY(10px);
}
.page-transition-leave-to {
opacity: 0;
transform: translateY(-10px);
}
/* 加载遮罩层 */
.page-loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(3px);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
pointer-events: none; /* 允许点击穿透 */
}
.page-loading-spinner {
width: 50px;
height: 50px;
border: 5px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top-color: #000000;
animation: spin 1s ease-in-out infinite;
display: block; /* 确保显示 */
visibility: visible; /* 确保可见 */
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 暗色模式适配 */
:root.dark .page-loading-overlay {
background-color: rgba(0, 0, 0, 0.7);
}
:root.dark .page-loading-spinner {
border-color: rgba(255, 255, 255, 0.1);
border-top-color: #3498db;
}
</style>
+9
View File
@@ -0,0 +1,9 @@
<svg viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" height="500" fill="none" clip-path="url(#clipPath_0)" customFrame="url(#clipPath_0)">
<defs>
<clipPath id="clipPath_0">
<rect width="500" height="500" x="0" y="0" rx="168" fill="rgb(255,255,255)" />
</clipPath>
</defs>
<rect id="aoteam" width="500" height="500" x="0" y="0" rx="168" fill="rgb(255,255,255)" />
<path id="矢量 21" d="M43.8823 376.549C116.924 157.916 343.381 157.135 419.14 304.651C519.783 78.6034 216.003 -92.8292 0 55.523" stroke="rgb(48,58,70)" stroke-linecap="round" stroke-width="120" transform="matrix(0.224951,-0.974371,0.974371,0.224951,-62,550.749)" />
</svg>

After

Width:  |  Height:  |  Size: 713 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { Button } from '@/components/ui/button'
import { AlertTriangle } from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
// 检查当前用户是否为管理员
const isAdmin = computed(() => {
return userStore.userInfo?.is_admin || false
})
// 检查当前页面是否为仪表盘
const isDashboardPage = computed(() => {
return route.path === '/dashboard' || route.path.startsWith('/dashboard/')
})
// 检查当前页面是否为GitHub页面
const isGithubPage = computed(() => {
return route.path === '/github-releases' || route.path.startsWith('/github-releases')
})
// 检查当前页面是否为设置页面
//const isSettingsPage = computed(() => {
// return route.path === '/dashboard/settings'
//})
// 检查当前页面是否为管理页面
const isAdminPage = computed(() => {
return route.path === '/dashboard/users' ||
route.path === '/dashboard/permissions' ||
route.path === '/dashboard/audit'
})
// 检查是否需要显示权限警告
const showPermissionWarning = computed(() => {
// 非管理员用户,已登录,且访问设置页面或管理页面时显示警告
return !isAdmin.value && ( isAdminPage.value) && userStore.token
})
// 返回仪表盘
function goToDashboard() {
router.push('/dashboard')
}
</script>
<template>
<div v-if="showPermissionWarning" class="fixed inset-0 z-[9999] flex items-center justify-center">
<!-- 红色透明模糊遮罩 -->
<div class="absolute inset-0 bg-red-500/80 backdrop-blur-sm"></div>
<!-- 警告内容 -->
<div class="relative bg-card border border-red-200 rounded-lg p-8 max-w-md mx-4 text-center shadow-xl">
<!-- 警告图标 -->
<div class="flex justify-center mb-4">
<AlertTriangle class="h-12 w-12 text-red-500" />
</div>
<!-- 警告标题 -->
<h2 class="text-xl font-semibold text-red-600 mb-3">
访问权限不足
</h2>
<!-- 警告信息 -->
<p class="text-muted-foreground mb-6 leading-relaxed">
您的帐户无运营权限请向在职运营请求运营权限
</p>
<!-- 返回按钮 -->
<Button
@click="goToDashboard"
variant="default"
class="w-full"
>
回到仪表盘
</Button>
</div>
</div>
</template>
+183
View File
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import VersionAvatar from './VersionAvatar.vue'
// 定义props
const props = defineProps({
sticky: {
type: Boolean,
default: false
}
})
// 获取当前路由
const route = useRoute()
// 判断是否是首页
const isHomePage = computed(() => route.path === '/' || route.path === '/home')
// 是否应该固定在底部
const shouldStick = computed(() => props.sticky || isHomePage.value)
// 获取当前年份
const currentYear = computed(() => new Date().getFullYear())
// 版权信息
const copyright = computed(() => `© ${currentYear.value} Lingke Network.`)
// 版本信息
const version = import.meta.env.VITE_VERSON_ABOUT || '无法加载版本信息'
// 服务器模式
const serverMode = import.meta.env.VITE_ABOUTSERVER || 'none'
</script>
<template>
<footer class="app-footer" :class="{ 'sticky-footer': shouldStick, 'home-footer': isHomePage }">
<div class="footer-container">
<div class="footer-content">
<div class="copyright">
{{ copyright }}
</div>
<div class="links ">
<router-link to="/privacy-policy" class="footer-link">隐私</router-link>
<router-link to="/terms-of-service" class="footer-link">条款</router-link>
<router-link to="/about-version" class="footer-link">版本</router-link>
<router-link to="/about-us" class="footer-link">关于</router-link>
</div>
<div class="version">
<router-link to="/about-version">
<div class="flex items-center">
<VersionAvatar :mode="serverMode" size="small" />
<span>V.{{ version }}</span>
</div>
</router-link>
</div>
</div>
</div>
</footer>
</template>
<style scoped>
.app-footer {
width: 100%;
padding: 1rem 0;
background-color: var(--background);
border-top: 0px solid var(--border, rgba(0, 0, 0, 0.1));
margin-top: auto;
position: relative;
}
/* 固定在底部的样式 */
.sticky-footer {
position: fixed;
bottom: 0;
left: 0;
z-index: 10;
/*box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);*/
}
/* 首页底栏样式 */
.home-footer {
background-color: transparent;
}
/* 暗色模式适配 */
:root.dark .home-footer {
background-color: transparent;
}
/* 首页底栏文字颜色增强 */
.home-footer .footer-content {
color: rgba(0, 0, 0, 0.7);
}
/* 深色模式下首页底栏文字颜色增强 */
:root.dark .home-footer .footer-content {
color: rgba(255, 255, 255, 0.8);
}
/* 为固定页脚添加页面底部间距 */
:global(.home-page) {
padding-bottom: 60px; /* 确保内容不被页脚遮挡 */
}
.footer-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
.footer-content {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
font-size: 0.875rem;
color: var(--muted-foreground);
width: 100%;
}
.copyright {
text-align: left;
justify-self: start;
}
.links {
display: flex;
gap: 1.5rem;
justify-content: center;
}
.version {
text-align: right;
justify-self: end;
}
.footer-link {
color: rgba(0, 0, 0, 0.7);
text-decoration: none;
transition: color 0.2s ease;
}
:root.dark .footer-link {
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
transition: color 0.2s ease;
}
.footer-link:hover {
color: var(--foreground);
text-decoration: underline;
}
.version {
font-size: 0.75rem;
}
/* 响应式调整 */
@media (max-width: 640px) {
.footer-content {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
gap: 0.75rem;
text-align: center;
}
.copyright {
justify-self: center;
order: 2;
}
.links {
justify-self: center;
order: 1;
gap: 1rem;
}
.version {
justify-self: center;
order: 3;
}
}
</style>
+426
View File
@@ -0,0 +1,426 @@
<script setup lang="ts">
import 'vue-sonner/style.css'
import {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
} from '@/components/ui/navigation-menu'
import { useUserStore } from '@/store/user'
import { computed, onMounted, ref, inject, nextTick, onBeforeUnmount } from 'vue'
import { gsap } from 'gsap'
import { useMediaQuery } from '@vueuse/core'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { BadgeCheck, Bell, ChevronsUpDown, LogOut, Menu, LayoutDashboard, User, Settings, Globe } from 'lucide-vue-next'
import { useRouter, useRoute } from 'vue-router'
import { Button } from '@/components/ui/button'
import { Sun, Moon, Monitor } from 'lucide-vue-next'
import { useThemeStore } from '@/store/theme'
const userStore = useUserStore()
const themeStore = useThemeStore()
const isLoggedIn = computed(() => !!userStore.token)
const router = useRouter()
const route = useRoute()
// 使用vueuse的useMediaQuery检测是否为移动设备
const isMobile = useMediaQuery('(max-width: 768px)')
const userAvatar = computed(() => {
// 优先使用QQ头像
if (userStore.userInfo?.qq_num && userStore.userInfo.qq_num.trim()) {
const qqAvatarUrl = `http://q1.qlogo.cn/g?b=qq&nk=${userStore.userInfo.qq_num.trim()}&s=100`
console.log('AppHeader QQ头像URL:', qqAvatarUrl)
return qqAvatarUrl
}
// 如果没有QQ号,返回空字符串让AvatarFallback显示用户名首字母
return ''
})
const userInitials = computed(() => {
return userStore.userInfo?.username?.charAt(0).toUpperCase() || 'U'
})
const isAdmin = computed(() => {
return userStore.userInfo?.is_admin || false
})
const mode = ref('system')
// 创建一个全局动画ID引用,用于跟踪当前动画状态
const currentAnimationId = ref(0)
// 创建一个函数来执行头部动画
const animateHeader = () => {
// 创建一个新的动画ID
const animationId = Date.now()
// 更新当前动画ID
currentAnimationId.value = animationId
// 使用双重requestAnimationFrame确保在下一帧渲染前执行动画
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// 检查当前动画ID是否仍然是最新的
// 如果不是,说明在此期间发生了新的动画请求,应该放弃当前动画
if (currentAnimationId.value !== animationId) {
console.log('头部动画已被更新的请求替代,跳过旧动画')
return
}
try {
// 先完成UI渲染和动画,不等待任何API请求
// 使用gsap添加简单的渐入动画,确保无论API响应如何,动画都能执行
const headerElements = document.querySelectorAll('.header-element, nav, .nav-item, .avatar-container, .flex.items-center, .dropdown-menu-trigger, button')
// 先清除可能存在的动画状态 - 使用具体元素而不是字符串选择器
gsap.killTweensOf(headerElements)
// 强制重置所有元素状态,确保动画可以重新执行
// 避免使用clearProps,这是导致props.split错误的原因
gsap.set(headerElements, {
opacity: 0,
y: -10,
immediateRender: true // 立即渲染初始状态
})
// 创建动画时间轴
const tl = gsap.timeline({
onComplete: () => {
console.log('头部动画完成')
}
})
// 执行动画 - 使用时间轴统一管理
tl.to(headerElements, {
opacity: 1,
y: 0,
duration: 0.3,
stagger: 0.03, // 使用stagger替代随机延迟,更加可控
ease: "power2.out",
overwrite: true // 覆盖任何现有的动画
})
} catch (error) {
console.error('执行头部动画时出错:', error)
}
})
})
}
onMounted(async () => {
userStore.loadFromCookie()
// 简化动画逻辑,只在组件挂载时执行一次
userStore.loadFromCookie()
// 延迟执行动画,确保DOM已完全渲染
setTimeout(() => {
animateHeader()
}, 100)
// 异步获取用户信息,不阻塞UI渲染
if (userStore.token && userStore.userId) {
try {
userStore.getUserInfo()
} catch (err) {
console.error('获取用户信息失败:', err)
}
}
})
function goDashboard() {
router.push('/dashboard')
}
function logout() {
userStore.logout()
router.push('/login')
}
function setMode(val: string) {
mode.value = val
if (val === 'light') {
document.documentElement.classList.remove('dark')
} else if (val === 'dark') {
document.documentElement.classList.add('dark')
} else {
// system: 跟随系统
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
}
defineExpose({ isLoggedIn })
</script>
<template>
<div class="fixed top-0 left-0 w-full header-container" :class="{
'home-header': $route.path === '/' || $route.path === '/home',
'bg-card': $route.path === '/login' || $route.path === '/register',
'page-header': $route.path !== '/' && $route.path !== '/home' && $route.path !== '/login' && $route.path !== '/register'
}">
<nav class="container mx-auto flex items-center justify-between h-16 px-4">
<div class="flex items-center gap-2">
<router-link to="/" class="flex items-center gap-2">
<div class="h-8 w-8 text-primary">
<svg viewBox="0 0 191.908 174" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32" fill="none">
<path id="spacetab" d="M165.949 88.3112L171.779 91.8085C172.457 92.2139 173.017 92.7874 173.406 93.474C173.795 94.1606 174 94.9367 174 95.7256C174 96.5145 173.795 97.2907 173.406 97.9773C173.017 98.663 172.457 99.2373 171.779 99.6428L92.1449 147.423C88.978 149.324 85.0218 149.324 81.8549 147.423L2.22043 99.6428C1.54338 99.2373 0.982929 98.663 0.593775 97.9773C0.20462 97.2907 0 96.5145 0 95.7256C0 94.9367 0.20462 94.1606 0.593775 93.474C0.982929 92.7874 1.54338 92.2139 2.22043 91.8085L8.05093 88.3112C11.2177 86.4117 15.1735 86.412 18.3399 88.3119L81.8548 126.422C85.0217 128.323 88.9781 128.323 92.145 126.422L155.66 88.3119C158.826 86.412 162.782 86.4117 165.949 88.3112ZM160.804 128.14L171.779 134.723C172.457 135.129 173.017 135.702 173.406 136.389C173.795 137.075 174 137.851 174 138.64C174 139.429 173.795 140.205 173.406 140.892C173.017 141.578 172.457 142.152 171.779 142.558L91.7023 190.604C90.2824 191.457 88.6562 191.908 86.9999 191.908C85.3436 191.908 83.7174 191.457 82.2975 190.604L2.22043 142.558C1.54338 142.152 0.982929 141.578 0.593775 140.892C0.20462 140.205 0 139.429 0 138.64C0 137.851 0.20462 137.075 0.593775 136.389C0.982929 135.702 1.54338 135.129 2.22043 134.723L8.05093 131.226C11.2177 129.326 15.1735 129.327 18.3399 131.227L81.8548 169.337C85.0217 171.237 88.9781 171.237 92.145 169.337L160.804 128.14L160.804 128.14ZM91.6931 1.30397L171.779 49.3502C172.457 49.7556 173.017 50.3295 173.406 51.016C173.795 51.7026 174 52.4783 174 53.2673C174 54.0565 173.795 54.8322 173.406 55.5187C173.017 56.2052 172.457 56.7791 171.779 57.1845L92.1449 104.965C88.9781 106.865 85.0218 106.865 81.8549 104.965L2.22043 57.1845C1.54338 56.7791 0.982929 56.2052 0.593775 55.5187C0.20462 54.8322 0 54.0565 0 53.2673C0 52.4783 0.20462 51.7026 0.593775 51.016C0.982929 50.3295 1.54338 49.7556 2.22043 49.3502L82.2975 1.30397C83.7174 0.450787 85.3436 0 86.9999 0C88.6547 0 90.2794 0.449959 91.6984 1.30162L91.7023 1.30397L91.6931 1.30397L91.6931 1.30397Z" fill="currentColor" fill-rule="evenodd" transform="matrix(0,1,-1,0,191.908,0)" />
</svg>
</div>
<!--<div class="font-bold text-xl text-primary hidden md:block">Space Tab</div>-->
</router-link>
</div>
<div class="flex items-center gap-4">
<!-- 桌面导航菜单 - 在中等屏幕及以上显示-->
<!-- 移动端导航菜单 - 在小屏幕上显示 <DropdownMenu v-if="isMobile || isLoggedIn" class="md:hidden">-->
<DropdownMenu class="md:hidden">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" aria-label="导航菜单">
<Menu class="w-5 h-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56">
<DropdownMenuItem as-child>
<router-link to="/" class="flex w-full">
搜索
</router-link>
</DropdownMenuItem>
<DropdownMenuItem as-child>
<router-link to="/quality-content" class="flex w-full">
Today
</router-link>
</DropdownMenuItem>
<DropdownMenuItem as-child>
<router-link to="/hot" class="flex w-full">
今日热点
</router-link>
</DropdownMenuItem>
<DropdownMenuItem as-child>
<router-link to="/settings" class="flex w-full">
设置
</router-link>
</DropdownMenuItem>
<template v-if="!isLoggedIn">
<DropdownMenuSeparator />
<div class="flex items-center justify-between px-2 py-2">
<span class="text-xs text-muted-foreground font-medium select-none">显示模式</span>
<div class="flex gap-1 bg-muted rounded-lg px-1 py-0.5 dark:bg-muted">
<button :class="['rounded-md p-1 transition', themeStore.theme === 'auto' ? 'bg-primary text-primary-foreground shadow' : 'hover:bg-accent dark:hover:bg-accent']" @click="themeStore.setTheme('auto')" title="自动跟随系统">
<Monitor :class="themeStore.theme === 'auto' ? 'text-primary-foreground' : 'text-muted-foreground'" class="size-4" />
</button>
<button :class="['rounded-md p-1 transition', themeStore.theme === 'light' ? 'bg-primary text-primary-foreground shadow' : 'hover:bg-accent dark:hover:bg-accent']" @click="themeStore.setTheme('light')" title="明亮模式">
<Sun :class="themeStore.theme === 'light' ? 'text-primary-foreground' : 'text-muted-foreground'" class="size-4" />
</button>
<button :class="['rounded-md p-1 transition', themeStore.theme === 'dark' ? 'bg-primary text-primary-foreground shadow' : 'hover:bg-accent dark:hover:bg-accent']" @click="themeStore.setTheme('dark')" title="黑暗模式">
<Moon :class="themeStore.theme === 'dark' ? 'text-primary-foreground' : 'text-muted-foreground'" class="size-4" />
</button>
</div>
</div>
</template>
</DropdownMenuContent>
</DropdownMenu>
<!-- 桌面端导航菜单 - 仅在未登录时显示
<NavigationMenu v-if="!isLoggedIn && !isMobile" class="hidden md:flex flex-1 justify-end">
<NavigationMenuList class="flex gap-4">
<NavigationMenuItem>
<NavigationMenuLink as-child>
<router-link to="/" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background hover:bg-accent hover:text-accent-foreground h-10 py-2 px-4">
搜索
</router-link>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
<NavigationMenuIndicator />
<NavigationMenuViewport />
</NavigationMenu>-->
<!-- 用户头像下拉菜单 - 仅在登录状态下显示 -->
<template v-if="isLoggedIn">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button class="flex items-center gap-2 px-2 py-1 rounded hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent dark:hover:text-accent-foreground transition-colors">
<Avatar class="h-8 w-8 rounded-full bg-card">
<AvatarImage :src="userAvatar" @error="() => console.log('QQ头像加载失败,使用回退头像')" />
<AvatarFallback>{{ userInitials }}</AvatarFallback>
</Avatar>
<!--<div class="grid flex-1 text-left text-sm leading-tight md:block hidden">
<div class="flex items-center gap-2">
<span class="truncate font-medium">{{ userStore.userInfo?.username || '用户' }}</span>
<span
v-if="isAdmin"
class="inline-flex items-center rounded-full bg-blue-500 px-2 py-0.5 text-xs font-medium text-white"
>
管理员
</span>
</div>
<span class="truncate text-xs">{{ userStore.userInfo?.email || '' }}</span>
</div>-->
<!--<ChevronsUpDown class="ml-auto size-4" />-->
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg bg-card text-card-foreground dark:bg-card dark:text-card-foreground shadow-lg border border-border"
align="end"
:side-offset="4"
>
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-9 w-9 rounded-full">
<AvatarImage :src="userAvatar" @error="() => console.log('QQ头像加载失败,使用回退头像')" />
<AvatarFallback>{{ userInitials }}</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<div class="flex items-center gap-2">
<span class="truncate font-semibold">{{ userStore.userInfo?.username || '用户' }}</span>
<span
v-if="isAdmin"
class="inline-flex items-center rounded-full bg-orange-500 px-2 py-0.5 text-xs font-medium text-white"
>
PRO
</span>
</div>
<span class="truncate text-xs">{{ userStore.userInfo?.email || '' }}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<!-- 仪表盘 - 仅管理员可见 -->
<DropdownMenuItem
v-if="isAdmin"
as-child
class="hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent dark:hover:text-accent-foreground"
>
<router-link to="/dashboard">
<LayoutDashboard class="h-4 w-4 mr-2" />
仪表盘
</router-link>
</DropdownMenuItem>
<DropdownMenuItem as-child class="hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent dark:hover:text-accent-foreground">
<router-link to="/quality-content">
<Globe class="h-4 w-4 mr-2" />
Today At Space
</router-link>
</DropdownMenuItem>
<DropdownMenuItem as-child class="hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent dark:hover:text-accent-foreground">
<router-link to="/settings">
<Settings class="h-4 w-4 mr-2" />
系统设置
</router-link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<div class="flex items-center justify-between px-2 py-2">
<span class="text-xs text-muted-foreground font-medium select-none">显示模式</span>
<div class="flex gap-1 bg-muted rounded-lg px-1 py-0.5 dark:bg-muted">
<button :class="['rounded-md p-1 transition', themeStore.theme === 'auto' ? 'bg-primary text-primary-foreground shadow' : 'hover:bg-accent dark:hover:bg-accent']" @click="themeStore.setTheme('auto')" title="自动跟随系统">
<Monitor :class="themeStore.theme === 'auto' ? 'text-primary-foreground' : 'text-muted-foreground'" class="size-4" />
</button>
<button :class="['rounded-md p-1 transition', themeStore.theme === 'light' ? 'bg-primary text-primary-foreground shadow' : 'hover:bg-accent dark:hover:bg-accent']" @click="themeStore.setTheme('light')" title="明亮模式">
<Sun :class="themeStore.theme === 'light' ? 'text-primary-foreground' : 'text-muted-foreground'" class="size-4" />
</button>
<button :class="['rounded-md p-1 transition', themeStore.theme === 'dark' ? 'bg-primary text-primary-foreground shadow' : 'hover:bg-accent dark:hover:bg-accent']" @click="themeStore.setTheme('dark')" title="黑暗模式">
<Moon :class="themeStore.theme === 'dark' ? 'text-primary-foreground' : 'text-muted-foreground'" class="size-4" />
</button>
</div>
</div>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout" class="hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent dark:hover:text-accent-foreground">
<LogOut class="mr-2 h-4 w-4" />
退出登录
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<!-- 登录/注册按钮 - 仅在未登录状态下显示 -->
<template v-else>
<router-link to="/login">
<Button variant="default" size="sm">登录</Button>
</router-link>
<!--<router-link to="/register">
<Button variant="secondary" size="sm">注册</Button>
</router-link>-->
</template>
</div>
</nav>
<slot />
<Toaster />
</div>
</template>
<style scoped>
/* 头部容器基础样式 */
.header-container {
z-index: 50;
backdrop-filter: blur(8px);
transition: all 0.3s ease;
}
/* 首页头部样式 - 透明背景 */
.home-header {
background: transparent;
box-shadow: none;
}
/* 登录/注册页面头部样式 - 使用bg-card背景
.auth-header {
background: hsl(var(--card));
border-bottom: 1px solid hsl(var(--border));
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}*/
/* 其他页面头部样式 - 带背景和阴影 */
.page-header {
background: hsl(var(--background) / 0.95);
backdrop-filter: blur(12px);
border-bottom: 1px solid hsl(var(--border));
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
/* 暗色模式下的页面头部 */
.dark .page-header {
background: hsl(var(--background) / 0.95);
border-bottom: 1px solid hsl(var(--border));
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3);
}
/* 暗色模式下的登录/注册页面头部
.dark .auth-header {
background: hsl(var(--card));
border-bottom: 1px solid hsl(var(--border));
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3);
}*/
/* 确保登录/注册页面头部的文字可读性
.auth-header nav {
position: relative;
z-index: 1;
}*/
/* 确保导航内容在背景上清晰可见 */
.page-header nav {
position: relative;
z-index: 1;
}
</style>
+169
View File
@@ -0,0 +1,169 @@
<script setup lang="ts">
import {
BookOpen,
Bot,
Command,
Frame,
LifeBuoy,
Map,
PieChart,
Send,
Settings2,
SquareTerminal,
Users,
UserCheck,
Shield,
Info,
Database,
Download,
Server,
} from 'lucide-vue-next'
import NavMain from '@/components/NavMain.vue'
import NavSecondary from '@/components/NavSecondary.vue'
import NavUser from '@/components/NavUser.vue'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarGroup,
SidebarGroupLabel,
type SidebarProps,
} from '@/components/ui/sidebar'
import { useUserStore } from '@/store/user'
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const props = withDefaults(defineProps<SidebarProps>(), {
variant: 'inset',
})
const userStore = useUserStore()
const route = useRoute()
const isLoggedIn = computed(() => !!userStore.token)
const isAdmin = computed(() => userStore.userInfo?.is_admin || false)
const navMain = computed(() => [
{
title: '仪表盘',
url: '/dashboard',
icon: SquareTerminal,
isActive: route.path === '/dashboard',
},
{
title: '用户设置',
url: '/dashboard/profile',
icon: BookOpen,
isActive: route.path === '/dashboard/profile',
},
])
const navAdmin = computed(() => [
{
title: '用户管理',
url: '/dashboard/users',
icon: Users,
isActive: route.path === '/dashboard/users',
},
{
title: '权限管理',
url: '/dashboard/permissions',
icon: Shield,
isActive: route.path === '/dashboard/permissions',
},
{
title: '审核管理',
url: '/dashboard/audit',
icon: UserCheck,
isActive: route.path === '/dashboard/audit',
},
])
const data = {
navSecondary: [
{
title: '关于',
url: '/dashboard/about',
icon: Info,
},
{
title: '反馈',
url: '#',
icon: Send,
},
],
}
onMounted(() => {
userStore.loadFromCookie()
})
</script>
<template>
<Sidebar v-bind="props" collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" as-child>
<a href="/" class="h-15 w-auto group-data-[collapsible=icon]:hidden">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">
Space.Tab
</h3>
</a>
<a href="/" class="h-8 w-8 hidden group-data-[collapsible=icon]:block">
<img
src="https://lunova.studio/wp-content/uploads/2025/03/lunova_icon_bold_black@svg.svg"
alt="Lunova Studio"
/>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain :items="navMain" />
<!-- 管理员分组 -->
<SidebarGroup v-if="isAdmin">
<SidebarGroupLabel>管理</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in navAdmin" :key="item.title">
<SidebarMenuButton as-child :tooltip="item.title" :data-active="item.isActive">
<router-link :to="item.url" :class="{ 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground': item.isActive }">
<component :is="item.icon" />
<span>{{ item.title }}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
<NavSecondary :items="data.navSecondary" class="mt-auto" />
</SidebarContent>
<SidebarFooter>
<template v-if="isLoggedIn">
<NavUser />
</template>
<template v-else>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton as-child>
<a href="/login">登录</a>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton as-child>
<a href="/register">注册</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</template>
</SidebarFooter>
</Sidebar>
</template>
+332
View File
@@ -0,0 +1,332 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { debounce } from 'lodash-es'
interface Props {
captchaId?: string
width?: string
onSuccess?: (result: any) => void
onError?: (error: any) => void
onClose?: () => void
}
const props = withDefaults(defineProps<Props>(), {
captchaId: '',
width: '310px'
})
const emit = defineEmits<{
success: [result: any]
error: [error: any]
close: []
}>()
const captchaContainer = ref<HTMLElement>()
const captchaInstance = ref<any>(null)
const loading = ref(false)
const error = ref('')
// 声明全局类型
declare global {
interface Window {
initGeetest4: (config: any, callback: (captcha: any) => void) => void
}
}
// 使用单例模式跟踪全局实例状态
let isInitializing = false;
let globalCaptchaInstance: any = null;
const initCaptcha = async () => {
// 如果已经有实例,先销毁
if (captchaInstance.value) {
destroy()
}
if (!props.captchaId) {
error.value = '缺少验证码配置,请联系管理员'
console.error('极验captchaId未配置:', props.captchaId)
return
}
// 如果全局已经有实例或正在初始化中,等待一下
if (isInitializing) {
console.log('极验验证码正在初始化中,等待...')
await new Promise(resolve => setTimeout(resolve, 500))
if (globalCaptchaInstance) {
console.log('使用已存在的全局实例')
captchaInstance.value = globalCaptchaInstance
// 重新绑定事件
captchaInstance.value.onSuccess((result: any) => {
console.log('极验验证成功:', result)
emit('success', result)
props.onSuccess?.(result)
})
captchaInstance.value.onError((error: any) => {
console.error('极验验证失败:', error)
emit('error', error)
props.onError?.(error)
})
captchaInstance.value.onClose(() => {
console.log('极验验证关闭')
emit('close')
props.onClose?.()
})
// 将验证码插入到容器中
if (captchaContainer.value) {
console.log('将验证码插入到容器:', captchaContainer.value)
captchaContainer.value.innerHTML = ''
captchaInstance.value.appendTo(captchaContainer.value)
}
loading.value = false
return
}
}
console.log('开始初始化极验验证码,captchaId:', props.captchaId)
loading.value = true
error.value = ''
isInitializing = true
try {
// 动态加载极验JS
console.log('等待极验脚本加载...')
await loadGeetestScript()
console.log('极验脚本加载完成')
// 初始化极验验证码
console.log('开始初始化极验验证码实例...')
window.initGeetest4({
captchaId: props.captchaId,
language: 'zh-cn',
https: true, // 强制使用HTTPS
width: props.width // 设置宽度
}, (captcha: any) => {
console.log('极验验证码实例创建成功:', captcha)
captchaInstance.value = captcha
globalCaptchaInstance = captcha
// 监听验证成功事件
captcha.onSuccess((result: any) => {
console.log('极验验证成功:', result)
emit('success', result)
props.onSuccess?.(result)
})
// 监听验证失败事件
captcha.onError((error: any) => {
console.error('极验验证失败:', error)
emit('error', error)
props.onError?.(error)
})
// 监听关闭事件
captcha.onClose(() => {
console.log('极验验证关闭')
emit('close')
props.onClose?.()
})
// 将验证码插入到容器中
if (captchaContainer.value) {
console.log('将验证码插入到容器:', captchaContainer.value)
// 确保容器是空的
captchaContainer.value.innerHTML = ''
captcha.appendTo(captchaContainer.value)
} else {
console.error('验证码容器未找到')
}
loading.value = false
isInitializing = false
})
} catch (err: any) {
console.error('极验初始化失败:', err)
// 提供更友好的错误信息
if (err.message.includes('CSP')) {
error.value = '验证码加载被安全策略阻止,请联系管理员'
} else if (err.message.includes('超时')) {
error.value = '验证码加载超时,请检查网络连接或刷新页面'
} else if (err.message.includes('网络')) {
error.value = '网络连接异常,请检查网络设置或稍后重试'
} else {
error.value = err.message || '验证码加载失败,请刷新页面重试'
}
loading.value = false
isInitializing = false
emit('error', err)
props.onError?.(err)
}
}
// 使用单例模式确保脚本只加载一次
let scriptLoadPromise: Promise<void> | null = null;
const loadGeetestScript = (): Promise<void> => {
// 如果已经有加载中的Promise,直接返回
if (scriptLoadPromise) {
return scriptLoadPromise;
}
scriptLoadPromise = new Promise((resolve, reject) => {
console.log('开始检查极验脚本...')
// 检查是否已经加载
if (typeof window.initGeetest4 === 'function') {
console.log('极验脚本已加载')
resolve()
return
}
console.log('极验脚本未加载,开始等待...')
// 脚本已经在HTML中预加载,等待加载完成
const checkScript = () => {
if (typeof window.initGeetest4 === 'function') {
console.log('极验脚本加载完成')
resolve()
} else {
// 如果还没加载完成,继续等待
setTimeout(checkScript, 200)
}
}
// 设置更长的超时时间
const timeout = setTimeout(() => {
console.error('极验脚本加载超时')
scriptLoadPromise = null; // 重置Promise以便下次重试
reject(new Error('极验脚本加载超时,请刷新页面重试'))
}, 20000) // 增加到20秒
// 开始检查
checkScript()
// 清理超时
const originalResolve = resolve
resolve = () => {
clearTimeout(timeout)
originalResolve()
}
});
return scriptLoadPromise;
}
const reset = () => {
if (captchaInstance.value) {
captchaInstance.value.reset()
}
}
const destroy = () => {
if (captchaInstance.value) {
try {
captchaInstance.value.destroy()
} catch (err) {
console.error('销毁验证码实例失败:', err)
}
captchaInstance.value = null
// 如果是全局实例,也需要清理
if (globalCaptchaInstance === captchaInstance.value) {
globalCaptchaInstance = null
}
}
if (captchaContainer.value) {
captchaContainer.value.innerHTML = ''
}
}
// 暴露方法给父组件
defineExpose({
reset,
destroy
})
onMounted(() => {
initCaptcha()
})
onUnmounted(() => {
destroy()
})
const debouncedReinit = debounce(() => {
destroy()
initCaptcha()
}, 300)
watch(
() => props.width,
(newWidth, oldWidth) => {
if (newWidth !== oldWidth) {
debouncedReinit()
}
}
)
</script>
<template>
<div class="geetest-captcha" :style="{ width: props.width }">
<div v-if="loading" class="flex items-center justify-center py-4">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span class="ml-2 text-sm text-muted-foreground">加载验证码中...</span>
</div>
<div v-if="error" class="text-center py-4">
<div class="text-red-500 text-sm mb-2">{{ error }}</div>
<button
@click="initCaptcha"
class="text-sm text-primary hover:text-primary/80 underline"
:disabled="loading"
>
{{ loading ? '重试中...' : '点击重试' }}
</button>
</div>
<!-- 始终保持容器存在但通过CSS控制显示/隐藏 -->
<div
ref="captchaContainer"
class="captcha-container"
:class="{ 'hidden': loading || error }"
:style="{ width: props.width }"
></div>
</div>
</template>
<style scoped>
.geetest-captcha {
width: 100%;
position: relative;
min-height: 44px; /* 设置最小高度,防止抖动 */
margin: 10px 0; /* 添加上下间距 */
z-index: 20; /* 确保验证码在上层 */
}
.captcha-container {
min-height: 44px;
display: flex;
justify-content: center;
position: relative;
z-index: 20; /* 确保验证码在上层 */
/* 不强制拉伸,内容自然宽度,居中显示 */
}
.hidden {
display: none;
}
/* 确保极验验证码弹窗显示在最上层 */
:deep(.geetest_popup_wrap) {
z-index: 9999 !important;
}
:deep(.geetest_box_wrap) {
z-index: 9999 !important;
}
</style>
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { ref } from "vue"
import { Button } from '@/components/ui/button'
defineProps<{ msg: string }>()
const count = ref(0);
</script>
<template>
<h1 class="text-5xl font-bold">{{ msg }}</h1>
<div class="flex flex-col items-center justify-center p-4 bg-gray-100">
<Button @click="count++" variant="default">count is {{ count }}</Button>
<p>
Edit <code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank">create-vue</a>, the official Vue + Vite
starter
</p>
<p>
Install
<a href="https://github.com/johnsoncodehk/volar" target="_blank">Volar</a>
in your IDE for a better DX
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>
+282
View File
@@ -0,0 +1,282 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ref, onMounted, onBeforeMount, nextTick, watch } from 'vue'
import request from '@/lib/axiosConfig'
import { useUserStore } from '../store/user'
import { toast } from 'vue-sonner'
import { useRouter } from 'vue-router'
import { Loader2 } from 'lucide-vue-next'
import AppHeader from '@/components/AppHeader.vue'
import GeetestCaptcha from './GeetestCaptcha.vue'
import AppFooter from '@/components/AppFooter.vue'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const loading = ref(false)
const errorMsg = ref('')
const account = ref('')
const password = ref('')
const userStore = useUserStore()
const backgroundLoading = ref(true)
const router = useRouter()
const oaClientId = import.meta.env.VITE_OA_ID
const oaUrl = import.meta.env.VITE_OA_URL
const redirectUri = encodeURIComponent(window.location.origin + '/oauth-callback')
// 极验验证相关
const captchaRef = ref()
const captchaResult = ref<any>(null)
const captchaVerified = ref(false)
const captchaId = import.meta.env.VITE_GEETEST_CAPTCHA_ID || ''
// 调试信息
console.log('极验环境变量检查:', {
VITE_GEETEST_CAPTCHA_ID: import.meta.env.VITE_GEETEST_CAPTCHA_ID,
captchaId: captchaId,
hasCaptchaId: !!captchaId
})
const inputRef = ref<any>(null)
// const captchaWidth = ref('310px')
// const updateCaptchaWidth = () => {
// if (inputRef.value && inputRef.value.inputEl) {
// const rect = inputRef.value.inputEl.getBoundingClientRect()
// captchaWidth.value = rect.width + 'px'
// }
// }
async function onSubmit(e: Event) {
e.preventDefault()
errorMsg.value = ''
// 检查验证码
if (!captchaVerified.value) {
toast.error('请完成验证码验证', {
description: '请先完成人机验证'
})
return
}
loading.value = true
console.log('🔐 登录尝试:', { account: account.value, hasPassword: !!password.value })
try {
const { data } = await request.post('/user/login', {
account: account.value,
password: password.value,
captcha: captchaResult.value // 添加验证码结果
})
console.log('✅ 登录响应接收:', { code: data.code, hasToken: !!data.data?.token })
if (data.code === 200 && data.data?.token) {
console.log('🎉 登录成功, 设置用户数据')
userStore.setUser(
data.data.token,
data.data.userId,
data.data.userInfo
)
toast.success('登录成功')
router.push('/')
} else {
console.error('❌ 登录失败:', { code: data.code, message: data.message })
if (data.code === 400) {
toast.error('登录失败', {
description: "缺少必要字段,请检查输入"
})
}
if (data.code === 401) {
toast.error('登录失败', {
description: "用户未注册或密码错误"
})
}
if (data.code === 402) {
toast.error('登录失败', {
description: "用户名或密码错误"
})
}
if (data.code === 403) {
console.warn('🚫 User banned:', data.data)
// 用户被封禁,跳转到封禁页面
const banReason = data.data?.banReason || '违反社区规定'
const banExpiration = data.data?.banExpiration || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
router.push({
path: '/banned',
query: {
banReason: banReason,
banExpiration: banExpiration
}
})
return
}
}
} catch (err: any) {
console.error('❌ 登录异常:', err)
const errorMessage = err?.response?.data?.message || err?.message || '未知原因,请联系管理员'
toast.error('登录异常', {
description: errorMessage
})
} finally {
loading.value = false
}
}
function loginWithMCDA() {
console.log('🔗 重定向到 MCDA OAuth')
const authUrl = `${oaUrl}/oauth/authorize?client_id=${oaClientId}&redirect_uri=${redirectUri}&response_type=code&scope=`
window.location.href = authUrl
}
// 极验验证回调
const onCaptchaSuccess = (result: any) => {
console.log('极验验证成功:', result)
captchaResult.value = result
captchaVerified.value = true
toast.success('验证码验证成功')
}
const onCaptchaError = (error: any) => {
console.error('极验验证失败:', error)
captchaVerified.value = false
captchaResult.value = null
toast.error('验证码验证失败', {
description: '请重新验证'
})
}
const onCaptchaClose = () => {
console.log('极验验证关闭')
captchaVerified.value = false
captchaResult.value = null
}
// 在组件挂载前加载 Spline 查看器脚本
onBeforeMount(() => {
const script = document.createElement('script')
script.type = 'module'
script.src = 'https://unpkg.com/@splinetool/viewer@1.10.38/build/spline-viewer.js'
document.head.appendChild(script)
})
onMounted(() => {
userStore.loadFromCookie()
// nextTick(updateCaptchaWidth)
// window.addEventListener('resize', updateCaptchaWidth)
})
// watch([account], () => nextTick(updateCaptchaWidth))
</script>
<template>
<!-- 顶部安全区遮罩和顶栏单独一层 -->
<!--<div class="fixed top-0 left-0 w-full h-16 bg-white z-40 pointer-events-none"></div>-->
<AppHeader class="fixed top-0 left-0 w-full z-50" />
<!-- 登录表单区域单独一层始终居中自适应 -->
<div class="fixed inset-0 flex items-center justify-center overflow-hidden z-20">
<!-- Spline 3D背景 -->
<video
src="https://lunova.studio/wp-content/uploads/2025/03/dropbox_-_work_in_progress_keynote_opening_2019-1080p.mp4#t=9"
autoplay loop muted playsinline disablePictureInPicture
controlslist="nodownload noremoteplayback nofullscreen noplaybackrate"
class="absolute inset-0 w-full h-full object-cover z-0" @canplay="videoLoading = false"
@error="videoLoading = false"></video>
<!--<spline-viewer loading-anim-type="spinner-big-dark" url="https://prod.spline.design/xkzt01c4g-JkvLwB/scene.splinecode" class="absolute inset-0 w-full h-full z-0"></spline-viewer>-->
<div class="relative bg-card rounded-lg shadow-md p-8 max-w-md w-full flex flex-col items-center mx-auto z-10">
<div :class="cn('flex flex-col gap-6', props.class)">
<div v-if="userStore.token" class="flex flex-col items-center gap-2 text-center">
<Loader2 class="size-6 animate-spin text-primary" />
<span>正在登录...</span>
</div>
<form v-else @submit.prevent="onSubmit" class="flex flex-col gap-4 w-full">
<div class="flex flex-col items-center text-center">
<h1 class="text-2xl font-bold">欢迎回来</h1>
<p class="text-muted-foreground text-balance">请登录你的 Lingke 账户</p>
</div>
<div class="grid gap-3">
<Label for="account">用户名/邮箱</Label>
<Input id="account" type="text" placeholder="用户名或邮箱" required v-model="account" :disabled="loading" ref="inputRef" />
</div>
<div class="grid gap-3">
<div class="flex items-center">
<Label for="password">密码</Label>
<a href="#" class="ml-auto text-sm underline-offset-2 hover:underline">遗失了你的密码?</a>
</div>
<Input id="password" type="password" required v-model="password" :disabled="loading" />
</div>
<!-- 极验验证码 -->
<div class="grid gap-3 w-full">
<GeetestCaptcha
ref="captchaRef"
:captcha-id="captchaId"
@success="onCaptchaSuccess"
@error="onCaptchaError"
@close="onCaptchaClose"
/>
</div>
<Button type="submit" class="" :disabled="loading || !captchaVerified">
<Loader2 class="h-4 mr-2 animate-spin" v-if="loading" />
<span v-if="!loading">登录</span>
</Button>
<div v-if="errorMsg" class="text-red-500 text-center text-sm">{{ errorMsg }}</div>
<!--<div class="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span class="bg-transparent text-muted-foreground relative z-10 px-2">第三方登录</span>
</div>
<div class="grid-cols-3">
<Button variant="outline" type="button" class="w-full" @click="loginWithMCDA">
<img src="https://lunova.studio/wp-content/uploads/2025/03/lunova_icon_bold_black@svg.svg" class="h-5 w-5 mr-2 inline" />
使用 MCDA One Account 登录
</Button>
</div>-->
<div class="text-center text-sm">没有帐户<router-link to="/register" class="underline underline-offset-4">立即注册</router-link></div>
</form>
<div class="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
点击继续表示您同意并遵守 <router-link to="/privacy-policy" class="underline underline-offset-4">Lingke Network 隐私策略</router-link> <router-link to="/terms-of-service" class="underline underline-offset-4">Lingke Network 产品使用条款</router-link>.
</div>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.geetest-captcha) {
width: 100% !important;
margin-bottom: 10px; /* 添加底部间距 */
position: relative; /* 确保定位正确 */
z-index: 10; /* 确保验证码在上层 */
}
:deep(.captcha-container) {
width: 100% !important;
position: relative; /* 确保定位正确 */
z-index: 10; /* 确保验证码在上层 */
}
:deep(.geetest_holder) {
width: 100% !important;
}
/* 确保表单元素之间有足够的间距
.grid.gap-3 {
margin-bottom: 16px;
}*/
/* 确保按钮与验证码之间有足够的间距 */
button[type="submit"] {
margin-top: 10px;
}
</style>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { ChevronRight, type LucideIcon } from 'lucide-vue-next'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@/components/ui/sidebar'
defineProps<{
items: {
title: string
url: string
icon: LucideIcon
isActive?: boolean
items?: {
title: string
url: string
}[]
}[]
}>()
</script>
<template>
<SidebarGroup>
<SidebarGroupLabel>综合</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton as-child :tooltip="item.title" :data-active="item.isActive">
<router-link :to="item.url" :class="{ 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground': item.isActive }">
<component :is="item.icon" />
<span>{{ item.title }}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</template>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import {
Folder,
Forward,
type LucideIcon,
MoreHorizontal,
Trash2,
} from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'
defineProps<{
projects: {
name: string
url: string
icon: LucideIcon
}[]
}>()
const { isMobile } = useSidebar()
</script>
<template>
<SidebarGroup class="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>项目</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in projects" :key="item.name">
<SidebarMenuButton as-child>
<a :href="item.url">
<component :is="item.icon" />
<span>{{ item.name }}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuAction show-on-hover>
<MoreHorizontal />
<span class="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-48 rounded-lg"
:side="isMobile ? 'bottom' : 'right'"
:align="isMobile ? 'end' : 'start'"
>
<DropdownMenuItem>
<Folder class="text-muted-foreground" />
<span>View Project</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Forward class="text-muted-foreground" />
<span>Share Project</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2 class="text-muted-foreground" />
<span>Delete Project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton>
<MoreHorizontal />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { LucideIcon } from 'lucide-vue-next'
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar'
const props = defineProps<{
items: {
title: string
url: string
icon: LucideIcon
}[]
}>()
</script>
<template>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton as-child size="sm">
<a :href="item.url">
<component :is="item.icon" />
<span>{{ item.title }}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</template>
+224
View File
@@ -0,0 +1,224 @@
<script setup lang="ts">
import {
BadgeCheck,
Bell,
ChevronsUpDown,
CreditCard,
LogOut,
Sparkles,
Sun,
Moon,
Monitor,
Settings,
User,
LayoutDashboard,
} from 'lucide-vue-next'
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'
import { useUserStore } from '@/store/user'
import { useThemeStore } from '@/store/theme'
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const { isMobile } = useSidebar()
const userStore = useUserStore()
const themeStore = useThemeStore()
const router = useRouter()
const userAvatar = computed(() => {
// 优先使用QQ头像
if (userStore.userInfo?.qq_num && userStore.userInfo.qq_num.trim()) {
// 添加时间戳参数以防止缓存
const timestamp = new Date().getTime()
// 尝试使用更可靠的QQ头像API格式
const qqAvatarUrl = `https://q1.qlogo.cn/g?b=qq&nk=${userStore.userInfo.qq_num.trim()}&s=100&t=${timestamp}`
console.log('QQ头像URL:', qqAvatarUrl)
return qqAvatarUrl
}
// 如果没有QQ号,返回空字符串让AvatarFallback显示用户名首字母
return ''
})
const fallbackAvatar = computed(() => {
// 当QQ头像加载失败时的回退头像
return userStore.userInfo?.username?.charAt(0).toUpperCase() || 'U'
})
const userInitials = computed(() => {
return userStore.userInfo?.username?.charAt(0).toUpperCase() || 'U'
})
const isAdmin = computed(() => {
return userStore.userInfo?.is_admin || false
})
// 主题相关计算属性
const themeIcon = computed(() => {
switch (themeStore.theme) {
case 'light':
return Sun
case 'dark':
return Moon
case 'auto':
return Monitor
default:
return Monitor
}
})
const themeLabel = computed(() => {
switch (themeStore.theme) {
case 'light':
return '明亮模式'
case 'dark':
return '黑暗模式'
case 'auto':
return '自动模式'
default:
return '自动模式'
}
})
// 在组件挂载时获取用户信息
onMounted(async () => {
if (userStore.token && userStore.userId) {
await userStore.getUserInfo()
}
})
// 退出登录
function logout() {
userStore.logout()
router.push('/login')
}
// 切换主题
function toggleTheme() {
themeStore.toggleTheme()
}
</script>
<template>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar class="h-8 w-8 rounded-full bg-card">
<AvatarImage :src="userAvatar" @error="() => console.log('QQ头像加载失败,使用回退头像')" />
<AvatarFallback>{{ fallbackAvatar || userInitials }}</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<div class="flex items-center gap-2">
<span class="truncate font-medium">{{ userStore.userInfo?.username || '用户' }}</span>
<span
v-if="isAdmin"
class="inline-flex items-center rounded-full bg-orange-500 px-2 py-0.5 text-xs font-medium text-white"
>
PRO
</span>
</div>
<span class="truncate text-xs">{{ userStore.userInfo?.email || '' }}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg bg-card text-card-foreground dark:bg-card dark:text-card-foreground shadow-lg border border-border"
:side="isMobile ? 'bottom' : 'right'"
align="end"
:side-offset="4"
>
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-9 w-9 rounded-full">
<AvatarImage :src="userAvatar" @error="() => console.log('QQ头像加载失败,使用回退头像')" />
<AvatarFallback>{{ fallbackAvatar || userInitials }}</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<div class="flex items-center gap-2">
<span class="truncate font-semibold">{{ userStore.userInfo?.username || '用户' }}</span>
<span
v-if="isAdmin"
class="inline-flex items-center rounded-full bg-orange-500 px-2 py-0.5 text-xs font-medium text-white"
>
PRO
</span>
</div>
<span class="truncate text-xs">{{ userStore.userInfo?.email || '' }}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<!-- 仪表盘 - 仅管理员可见 -->
<DropdownMenuItem
v-if="isAdmin"
as-child
class="hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent dark:hover:text-accent-foreground"
>
<router-link to="/dashboard">
<LayoutDashboard class="h-4 w-4 mr-2" />
仪表盘
</router-link>
</DropdownMenuItem>
<DropdownMenuItem as-child class="hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent dark:hover:text-accent-foreground">
<router-link to="/quality-content">
<Globe class="h-4 w-4 mr-2" />
Today At Space
</router-link>
</DropdownMenuItem>
<DropdownMenuItem as-child class="hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent dark:hover:text-accent-foreground">
<router-link to="/settings">
<Settings class="h-4 w-4 mr-2" />
系统设置
</router-link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<div class="flex items-center justify-between px-2 py-2">
<span class="text-xs text-muted-foreground font-medium select-none">显示模式</span>
<div class="flex gap-1 bg-muted rounded-lg px-1 py-0.5 dark:bg-muted">
<button :class="['rounded-md p-1 transition', themeStore.theme === 'auto' ? 'bg-primary text-primary-foreground shadow' : 'hover:bg-accent dark:hover:bg-accent']" @click="themeStore.setTheme('auto')" title="自动跟随系统">
<Monitor :class="themeStore.theme === 'auto' ? 'text-primary-foreground' : 'text-muted-foreground'" class="size-4" />
</button>
<button :class="['rounded-md p-1 transition', themeStore.theme === 'light' ? 'bg-primary text-primary-foreground shadow' : 'hover:bg-accent dark:hover:bg-accent']" @click="themeStore.setTheme('light')" title="明亮模式">
<Sun :class="themeStore.theme === 'light' ? 'text-primary-foreground' : 'text-muted-foreground'" class="size-4" />
</button>
<button :class="['rounded-md p-1 transition', themeStore.theme === 'dark' ? 'bg-primary text-primary-foreground shadow' : 'hover:bg-accent dark:hover:bg-accent']" @click="themeStore.setTheme('dark')" title="黑暗模式">
<Moon :class="themeStore.theme === 'dark' ? 'text-primary-foreground' : 'text-muted-foreground'" class="size-4" />
</button>
</div>
</div>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout" class="hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent dark:hover:text-accent-foreground">
<LogOut class="h-4 w-4 mr-2" />
退出登录
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</template>
+266
View File
@@ -0,0 +1,266 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { ref, computed, h } from 'vue'
import request from '@/lib/axiosConfig'
import { useUserStore } from '../store/user'
import { toast } from 'vue-sonner'
import { useRouter } from 'vue-router'
import { Loader2, ShieldAlert } from 'lucide-vue-next'
import AppHeader from '@/components/AppHeader.vue'
import { AlertCircle } from "lucide-vue-next"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import AppFooter from '@/components/AppFooter.vue'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const loading = ref(false)
const errorMsg = ref('')
const username = ref('')
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const qqNum = ref('')
const showConfirmDialog = ref(false)
const userStore = useUserStore()
const router = useRouter()
const passwordFocused = ref(false)
const passwordStrength = computed(() => {
const val = password.value
if (!val) return { level: 0, text: '请输入密码', color: 'bg-gray-200' }
let score = 0
if (val.length >= 8) score++
if (/[A-Z]/.test(val)) score++
if (/[a-z]/.test(val)) score++
if (/[0-9]/.test(val)) score++
if (/[^A-Za-z0-9]/.test(val)) score++
if (score <= 2) return { level: 1, text: '弱', color: 'bg-red-400' }
if (score === 3 || score === 4) return { level: 2, text: '中', color: 'bg-yellow-400' }
if (score === 5) return { level: 3, text: '强', color: 'bg-green-500' }
return { level: 0, text: '请输入密码', color: 'bg-gray-200' }
})
function onSubmit(e: Event) {
e.preventDefault()
errorMsg.value = ''
console.log('📝 注册尝试:', {
username: username.value,
email: email.value,
hasPassword: !!password.value,
hasQQ: !!qqNum.value
})
if (!username.value || !email.value || !password.value || !confirmPassword.value || !qqNum.value) {
errorMsg.value = '请填写所有字段'
console.warn('⚠️ 注册验证失败: 缺少字段')
return
}
if (password.value !== confirmPassword.value) {
errorMsg.value = '两次输入的密码不一致'
console.warn('⚠️ 注册验证失败: 密码不一致')
return
}
console.log('✅ 注册验证通过')
// 显示确认对话框
showConfirmDialog.value = true
}
// 确认注册
async function confirmRegister() {
loading.value = true
try {
console.log('🔄 提交注册...')
const { data } = await request.post('/user/register', {
username: username.value,
email: email.value,
password: password.value,
qq_num: qqNum.value
})
console.log('✅ 注册响应接收:', {
code: data.code,
hasToken: !!data.data?.token
})
if (data.code === 200 && data.data?.token) {
console.log('🎉 注册成功, 设置用户数据')
userStore.setUser(
data.data.token,
data.data.userId,
data.data.userInfo
)
toast.success('注册成功')
router.push('/dashboard')
} else {
console.error('❌ 注册失败:', { code: data.code, message: data.message })
errorMsg.value = data.message || '注册失败'
toast.error('注册失败', {
description: data.message || "请稍后重试"
})
}
} catch (err: any) {
console.error('❌ 注册异常:', err)
const errorMessage = err?.response?.data?.message || '注册失败'
errorMsg.value = errorMessage
toast.error('注册异常', {
description: errorMessage
})
} finally {
loading.value = false
}
}
</script>
<template>
<!-- 顶部安全区遮罩和顶栏单独一层 -->
<div class="fixed top-0 left-0 w-full h-16 z-40 pointer-events-none"></div>
<AppHeader class="fixed top-0 left-0 w-full z-50" />
<!-- 注册表单区域单独一层始终居中自适应 -->
<div class="fixed inset-0 flex items-center justify-center overflow-hidden z-20">
<!-- Spline 3D背景 -->
<video
src="https://lunova.studio/wp-content/uploads/2025/03/dropbox_-_work_in_progress_keynote_opening_2019-1080p.mp4#t=9"
autoplay loop muted playsinline disablePictureInPicture
controlslist="nodownload noremoteplayback nofullscreen noplaybackrate"
class="absolute inset-0 w-full h-full object-cover z-0" @canplay="videoLoading = false"
@error="videoLoading = false"></video>
<!--<spline-viewer loading-anim-type="spinner-big-dark" url="https://prod.spline.design/xkzt01c4g-JkvLwB/scene.splinecode" class="absolute inset-0 w-full h-full z-0"></spline-viewer>-->
<div class="relative bg-card rounded-lg shadow-md p-8 max-w-md w-full flex flex-col items-center mx-auto z-10">
<div :class="cn('flex flex-col gap-6', props.class)">
<!-- 注册表单已被暂时禁用
<div class="flex flex-col items-center text-center gap-4">
<ShieldAlert class="w-16 h-16 text-amber-500" />
<h1 class="text-2xl font-bold">我们仍在测试</h1>
<p class="text-muted-foreground text-balance">如需参加测试请联系我们的人员</p>
<div class="p-4 border-2 border-amber-400 rounded-lg max-w-xs">
<p class="text-sm text-muted-foreground text-balance">
我们仍在测试同步服务如果您需要尝试我们的服务可以加入我们的测试群来提前获取账户
</p>
</div>
<a href="https://qm.qq.com/q/7xV43DaOBi" target="_blank" rel="noopener noreferrer">
<Button class="mt-2">加群联系负责人</Button>
</a>
<div class="text-center text-sm mt-4">已有帐户<router-link to="/login" class="underline underline-offset-4">去登录</router-link></div>
</div>-->
<!-- 注册表单 -->
<form class="flex flex-col gap-4 w-full" @submit="onSubmit">
<div class="flex flex-col items-center text-center gap-4">
<h1 class="text-2xl font-bold">注册新账户</h1>
<p class="text-muted-foreground text-balance">请填写信息以注册 Lingke · Space 账户</p>
</div>
<div class="grid gap-3">
<Label for="username">登录ID</Label>
<Input id="username" type="text" placeholder="登录ID" required v-model="username" :disabled="loading" />
</div>
<div class="grid gap-3">
<Label for="email">邮箱</Label>
<Input id="email" type="email" placeholder="邮箱" required v-model="email" :disabled="loading" />
</div>
<div class="grid gap-3">
<Label for="password">密码</Label>
<Input id="password" type="password" required v-model="password" :disabled="loading"
@focus="passwordFocused = true" @blur="passwordFocused = false" />
<div v-if="passwordFocused" class="h-2 w-full rounded bg-gray-200 mt-1">
<div :class="['h-2 rounded transition-all', passwordStrength.color]" :style="{ width: passwordStrength.level === 1 ? '33%' : passwordStrength.level === 2 ? '66%' : passwordStrength.level === 3 ? '100%' : '0%' }"></div>
</div>
<div v-if="passwordFocused" class="text-xs mt-1 text-gray-500">{{ passwordStrength.text }}</div>
</div>
<div class="grid gap-3">
<Label for="confirmPassword">确认密码</Label>
<Input id="confirmPassword" type="password" required v-model="confirmPassword" :disabled="loading" />
</div>
<div class="grid gap-3">
<Label for="qqNum">QQ号码</Label>
<Input id="qqNum" type="text" placeholder="QQ号码" required v-model="qqNum" :disabled="loading" />
<div class="text-xs text-muted-foreground">头像将自动获取QQ头像</div>
</div>
<Button type="submit" class="w-full" :disabled="loading">
<Loader2 class="w-4 h-4 mr-1 animate-spin" v-if="loading" />
<span v-if="!loading">注册</span>
</Button>
<!-- 注册确认对话框 -->
<AlertDialog :open="showConfirmDialog" @update:open="showConfirmDialog = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认注册</AlertDialogTitle>
<AlertDialogDescription>
点击继续注册表示同意您同意我们的所有用户策略与条款同时承诺您的操作或者行为与 Lingke Network 无关
</AlertDialogDescription>
</AlertDialogHeader>
<div class="flex flex-col gap-4 py-4">
<Alert variant="destructive">
<AlertCircle class="w-4 h-4" />
<AlertTitle class="font-bold">我们仍在测试</AlertTitle>
<AlertDescription>
由于我们仍在测试部分功能您的账户可能会在我们未通知的情况下被删除或者关停
</AlertDescription>
</Alert>
</div>
<AlertDialogFooter>
<AlertDialogCancel @click="showConfirmDialog = false">取消</AlertDialogCancel>
<AlertDialogAction @click="confirmRegister">继续注册</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div v-if="errorMsg" class="text-red-500 text-center text-sm">{{ errorMsg }}</div>
<div class="text-center text-sm">已有帐户<a href="/login" class="underline underline-offset-4">去登录</a></div>
</form>
<div class="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
点击继续表示您同意并遵守 <router-link to="/privacy-policy" class="underline underline-offset-4">Lingke Network 隐私策略</router-link> <router-link to="/terms-of-service" class="underline underline-offset-4">Lingke Network 产品使用条款</router-link>.
</div>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.geetest-captcha) {
width: 100% !important;
margin-bottom: 10px; /* 添加底部间距 */
position: relative; /* 确保定位正确 */
z-index: 10; /* 确保验证码在上层 */
}
:deep(.captcha-container) {
width: 100% !important;
position: relative; /* 确保定位正确 */
z-index: 10; /* 确保验证码在上层 */
}
:deep(.geetest_holder) {
width: 100% !important;
}
/* 确保表单元素之间有足够的间距
.grid.gap-3 {
margin-bottom: 16px;
}*/
/* 确保按钮与验证码之间有足够的间距 */
button[type="submit"] {
margin-top: 10px;
}
</style>
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { computed } from 'vue'
// 定义props
const props = defineProps({
mode: {
type: String,
default: 'none' // 默认不显示
},
size: {
type: String,
default: 'small' // small, medium, large
}
})
// 根据模式计算显示内容和样式
const avatarText = computed(() => {
switch (props.mode) {
case 'beta':
return 'BETA'
case 'production':
return 'VERSION'
case 'coomingsoon':
return 'COOMING SOON'
default:
return ''
}
})
// 根据大小计算样式类
const sizeClass = computed(() => {
switch (props.size) {
case 'large':
return 'version-avatar-large'
case 'medium':
return 'version-avatar-medium'
default:
return 'version-avatar-small'
}
})
// 根据模式计算颜色类
const colorClass = computed(() => {
switch (props.mode) {
case 'beta':
return 'version-avatar-beta'
case 'production':
return 'version-avatar-production'
case 'coomingsoon':
return 'version-avatar-coomingsoon'
default:
return 'version-avatar-none'
}
})
// 是否显示
const shouldShow = computed(() => props.mode !== 'none')
</script>
<template>
<span v-if="shouldShow" class="version-avatar" :class="[sizeClass, colorClass]">
{{ avatarText }}
</span>
</template>
<style scoped>
.version-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
font-weight: bold;
margin-right: 0.5rem;
padding: 0 0.5rem;
font-size: 0.65rem;
}
.version-avatar-small {
height: 1.2rem;
min-width: 2.5rem;
}
.version-avatar-medium {
height: 1.5rem;
min-width: 3rem;
font-size: 0.75rem;
}
.version-avatar-large {
height: 1.8rem;
min-width: 3.5rem;
font-size: 0.85rem;
}
.version-avatar-beta {
background-color: #84cc16; /* 黄绿色 */
color: white;
}
.version-avatar-production {
background-color: #3b82f6; /* 蓝色 */
color: white;
}
.version-avatar-coomingsoon {
background-color: #cc7116; /* 黄绿色 */
color: white;
}
.version-avatar-none {
display: none;
}
</style>
+227
View File
@@ -0,0 +1,227 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { toast } from 'vue-sonner'
import { useThemeStore } from '@/store/theme'
import { Slider } from '@/components/ui/slider'
import { RefreshCw, Image, Check } from 'lucide-vue-next'
import { AlertCircle } from "lucide-vue-next"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import VersionAvatar from './VersionAvatar.vue'
const themeStore = useThemeStore()
// 壁纸类型选项
const wallpaperTypes = [
{ id: 'pure', name: '纯净模式', description: '无背景壁纸' },
{ id: 'bing-daily', name: '每日推荐', description: 'Bing今日壁纸' },
{ id: 'bing-random', name: 'Bing随机壁纸', description: '随机获取Bing历史壁纸' },
{ id: 'custom', name: '自定义图片', description: '选择本地图片作为壁纸' }
]
// 当前选择的壁纸类型
const selectedWallpaperType = ref('pure')
// 壁纸设置
const wallpaperOpacity = ref(80) // 默认80%透明度
const wallpaperBlur = ref(0) // 默认不模糊
const showPreview = ref(false) // 预览控制
// 自定义壁纸预览
const customBgPreview = ref('')
const fileInputRef = ref<HTMLInputElement | null>(null)
// 计算当前壁纸URL
const currentWallpaperUrl = computed(() => {
if (selectedWallpaperType.value === 'bing-daily') {
return "https://bing.img.run/uhd.php"
} else if (selectedWallpaperType.value === 'bing-random') {
return "https://bing.img.run/rand_uhd.php"
} else if (selectedWallpaperType.value === 'custom' && customBgPreview.value) {
return customBgPreview.value
}
return ''
})
// 预览样式
const previewStyle = computed(() => {
return {
opacity: wallpaperOpacity.value / 100,
filter: `blur(${wallpaperBlur.value}px)`
}
})
// 安全的localStorage访问方法
const safeLocalStorage = {
getItem(key: string, defaultValue: any = null): any {
if (typeof window !== 'undefined' && window.localStorage) {
return localStorage.getItem(key) || defaultValue;
}
return defaultValue;
},
setItem(key: string, value: any): void {
if (typeof window !== 'undefined' && window.localStorage) {
localStorage.setItem(key, value);
}
},
removeItem(key: string): void {
if (typeof window !== 'undefined' && window.localStorage) {
localStorage.removeItem(key);
}
}
};
// 初始化设置
onMounted(() => {
// 加载保存的壁纸类型
const savedType = safeLocalStorage.getItem('wallpaperType', 'pure')
if (savedType) {
selectedWallpaperType.value = savedType
}
// 加载自定义壁纸预览
if (safeLocalStorage.getItem('customBgImage')) {
customBgPreview.value = safeLocalStorage.getItem('customBgImage')
}
})
// 选择壁纸类型
const selectWallpaperType = (typeId: string) => {
selectedWallpaperType.value = typeId
safeLocalStorage.setItem('wallpaperType', typeId)
// 触发全局事件,通知App.vue更新壁纸
window.dispatchEvent(new CustomEvent('wallpaper-changed'))
// 显示提示
toast.success('壁纸设置已更新')
}
// 选择自定义壁纸
function selectCustomWallpaper() {
if (fileInputRef.value) {
fileInputRef.value.click()
}
}
// 处理文件选择
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement
if (input.files && input.files[0]) {
const file = input.files[0]
// 检查文件类型
if (!file.type.startsWith('image/')) {
toast.error('请选择图片文件')
return
}
// 检查文件大小(限制为5MB
if (file.size > 5 * 1024 * 1024) {
toast.error('图片大小不能超过5MB')
return
}
// 读取文件并转换为Data URL
const reader = new FileReader()
reader.onload = (e) => {
if (e.target && typeof e.target.result === 'string') {
customBgPreview.value = e.target.result
safeLocalStorage.setItem('customBgImage', e.target.result)
selectedWallpaperType.value = 'custom'
safeLocalStorage.setItem('wallpaperType', 'custom')
// 触发全局事件,通知App.vue更新壁纸
window.dispatchEvent(new CustomEvent('wallpaper-changed'))
toast.success('自定义壁纸已设置')
}
}
reader.readAsDataURL(file)
}
}
const betamode = 'beta'
</script>
<template>
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-xl font-medium">壁纸设置</h3>
<p class="text-sm text-muted-foreground">个性化您的首页背景</p>
</div>
</div>
<div class="flex flex-col gap-4 py-4">
<Alert variant="destructive">
<AlertCircle class="w-4 h-4" />
<AlertTitle class="font-bold">功能正在调试</AlertTitle>
<AlertDescription>
此功能仍然在测试我们正在优化体验在后续版本可能会有更多改动如有问题请多多包涵
</AlertDescription>
</Alert>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card
v-for="type in wallpaperTypes"
:key="type.id"
:class="[
'cursor-pointer transition-all hover:shadow-md',
selectedWallpaperType === type.id ? 'ring-2 ring-primary' : ''
]"
@click="selectWallpaperType(type.id)"
>
<CardContent class="p-4">
<div class="flex flex-col gap-1">
<h4 class="font-medium">{{ type.name }}</h4>
<p class="text-sm text-muted-foreground">{{ type.description }}</p>
</div>
</CardContent>
</Card>
</div>
<!-- 自定义壁纸上传 -->
<div class="mt-6" v-if="selectedWallpaperType === 'custom'">
<Button @click="selectCustomWallpaper" variant="outline" class="mb-4">
选择图片
</Button>
<input
ref="fileInputRef"
type="file"
accept="image/*"
class="hidden"
@change="handleFileSelect"
/>
<!-- 自定义壁纸预览 -->
<div v-if="customBgPreview" class="mt-4">
<p class="text-sm mb-2">预览</p>
<div class="wallpaper-preview">
<img :src="customBgPreview" alt="自定义壁纸预览" class="max-w-full h-auto rounded-md" />
</div>
</div>
</div>
</template>
<style scoped>
.wallpaper-settings {
padding: 1rem;
border-radius: 0.5rem;
background-color: var(--background);
}
.wallpaper-preview {
max-width: 300px;
max-height: 200px;
overflow: hidden;
border-radius: 0.5rem;
border: 1px solid var(--border);
}
.wallpaper-preview img {
width: 100%;
height: auto;
object-fit: cover;
}
</style>
+66
View File
@@ -0,0 +1,66 @@
<template>
<div class="watermark" :class="fillClass">
<h2>Space Tab</h2>
<h4>测试版本不代表最终品质</h4>
<h4 v-if="isLoggedIn && user">USER·{{ user.username }}·<span v-if="user.email">{{ user.email }}</span></h4>
<h4>测试版本: V.{{ versionNumber }}</h4>
</div>
</template>
<script setup lang="ts">
import { defineProps, computed } from 'vue'
const props = defineProps<{
version: string,
fillMode?: 'auto' | 'light' | 'dark',
isLoggedIn?: boolean,
user?: { username?: string, email?: string }
}>()
const fillClass = computed(() => {
if (props.fillMode === 'light') return 'watermark-light'
if (props.fillMode === 'dark') return 'watermark-dark'
return 'watermark-auto'
})
// 获取环境变量中的版本号
const versionNumber = import.meta.env.VITE_VERSON
</script>
<style scoped>
.watermark {
position: fixed;
right: 32px;
bottom: 32px;
z-index: 9999;
text-align: right;
pointer-events: none;
user-select: none;
padding: 12px 24px;
border-radius: 12px;
transition: background 0.2s, color 0.2s;
}
.watermark-auto {
background: none;
color: rgba(255,255,255,0.18);
mix-blend-mode: difference;
}
.watermark-light {
background: #fff;
color: #222;
box-shadow: 0 2px 8px 0 rgba(0,0,0,0.04);
}
.watermark-dark {
background: #111;
color: #fff;
box-shadow: 0 2px 8px 0 rgba(0,0,0,0.08);
}
.watermark h2 {
font-size: 2rem;
margin: 0;
font-weight: bold;
}
.watermark h4 {
font-size: 1rem;
margin: 0;
font-weight: normal;
}
</style>
+301
View File
@@ -0,0 +1,301 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Button } from '@/components/ui/button'
import { toast } from 'vue-sonner'
import Cookies from 'js-cookie'
// 导入模块化的工具
import { useWeather, getWeatherIcon, loadWeatherSettings, weatherIcons } from '../utils/weather'
import { useLocation, commonCities } from '../utils/location'
// 高德地图API密钥
const AMAP_KEY = import.meta.env.VITE_MAMP_KEY
// 使用位置服务
const location = useLocation(AMAP_KEY)
// 使用天气功能
const weather = useWeather(AMAP_KEY)
// 定义props
const props = defineProps({
size: {
type: String,
default: 'medium' // small, medium, large
}
})
// 从localStorage中获取城市设置
const loadCitySettings = () => {
const cityAdcode = localStorage.getItem('weatherLocation')
const cityName = localStorage.getItem('weatherLocationName')
if (cityAdcode && cityName) {
return {
adcode: cityAdcode,
city: cityName
}
}
return null
}
// 初始化
onMounted(() => {
console.log('WeatherWidget 组件已挂载')
// 检查是否已完成初始化设置
const setupCompleted = localStorage.getItem('setupCompleted')
if (setupCompleted !== 'true') {
console.log('未完成初始化设置,不加载天气数据')
return
}
// 检查位置授权状态
if (weather.locationAuthorized.value) {
console.log('位置已授权')
// 首先尝试从Setup.vue中设置的城市获取位置数据
const citySettings = loadCitySettings()
if (citySettings) {
console.log('使用Setup中设置的城市数据:', citySettings)
weather.locationData.value = {
latitude: 0,
longitude: 0,
city: citySettings.city,
adcode: citySettings.adcode
}
// 确保位置数据加载后再获取天气
setTimeout(() => {
weather.fetchWeather()
}, 100)
}
// 如果没有Setup设置的城市,尝试从Cookie中获取位置数据
else {
const locationDataCookie = Cookies.get('locationData')
if (locationDataCookie) {
try {
weather.locationData.value = JSON.parse(locationDataCookie)
console.log('使用Cookie中的位置数据:', weather.locationData.value)
// 确保位置数据加载后再获取天气
setTimeout(() => {
weather.fetchWeather()
}, 100)
} catch (error) {
console.error('解析位置数据失败:', error)
// 解析失败时重置授权状态
weather.locationAuthorized.value = false
}
} else {
console.log('未找到位置数据Cookie')
weather.locationAuthorized.value = false
}
}
} else {
console.log('位置未授权')
// 未授权状态下,关闭天气功能并显示提示
const currentSettings = { ...weather.weatherSettings.value, enabled: false }
Cookies.set('weatherSettings', JSON.stringify(currentSettings), { expires: 30 })
weather.weatherSettings.value = currentSettings
/*toast.error('无法使用天气组件', {
description: "禁止了定位将无法使用天气组件",
action: {
label: "前往设置",
onClick: () => {
// 跳转到设置页面
window.location.href = '/settings'
}
}
})*/
}
})
</script>
<template>
<div class="weather-widget" :class="`weather-widget-${size}`">
<!-- 未授权状态 - 不显示任何内容 -->
<div v-if="!weather.locationAuthorized" class="hidden">
<!-- 当组件挂载时不显示任何内容 -->
</div>
<!-- 已授权状态 -->
<div v-else-if="weather.weatherSettings.enabled" class="authorized-state">
<!-- 加载状态 -->
<div v-if="weather.weatherData.loading" class="loading-state">
<div class="skeleton-temp"></div>
<div class="skeleton-info"></div>
</div>
<!-- 错误状态 -->
<div v-else-if="weather.weatherData.error" class="error-state">
<div class="error-message">{{ weather.weatherData.error }}</div>
<Button variant="outline" size="sm" @click="weather.fetchWeather">重试</Button>
</div>
<!-- 天气数据 -->
<div v-else class="weather-data">
<transition name="fade">
<div v-if="!weather.weatherData.loading && !weather.weatherData.error && weather.weatherSettings.enabled && weather.weatherData.temperature"
class="flex flex-col items-end p-2">
<div class="flex items-center">
<span class="text-2xl font-medium mr-2 weather-temp">
{{ weather.weatherData.temperature }}{{ weather.weatherSettings.unit === 'celsius' ? '℃' : '℉' }}
</span>
<span class="text-primary weather-icon" v-html="getWeatherIcon(weather.weatherData.weather)"></span>
</div>
<div class="text-xs weather-city">{{ weather.weatherData.city }} · {{ weather.weatherData.weather }}</div>
<div v-if="weather.weatherSettings.showHumidity || weather.weatherSettings.showWind" class="text-xs weather-details">
<template v-if="weather.weatherSettings.showHumidity">湿度: {{ weather.weatherData.humidity }}%</template>
<template v-if="weather.weatherSettings.showHumidity && weather.weatherSettings.showWind"> · </template>
<template v-if="weather.weatherSettings.showWind">{{ weather.weatherData.windDirection }} {{ weather.weatherData.windPower }}</template>
</div>
</div>
</transition>
</div>
</div>
</div>
</template>
<style scoped>
.weather-widget {
border-radius: 0.75rem;
overflow: hidden;
transition: all 0.3s ease;
max-width: 180px; /* 限制最大宽度 */
background-color: transparent;
border: none;
box-shadow: none;
}
.weather-widget-small {
padding: 0.4rem;
min-height: 50px;
}
.weather-widget-medium {
padding: 0.5rem;
min-height: 60px;
}
.weather-widget-large {
padding: 0.6rem;
min-height: 70px;
}
.unauthorized-state,
.authorized-state {
width: 100%;
height: 100%;
}
.unauthorized-state {
position: relative;
background-color: transparent;
border-radius: 0.5rem;
}
.skeleton-temp {
height: 1.2rem;
width: 2.5rem;
margin-bottom: 0.3rem;
border-radius: 0.25rem;
background-color: rgba(var(--muted-rgb), 0.7);
}
.skeleton-info {
height: 0.8rem;
width: 70%;
border-radius: 0.25rem;
margin-bottom: 0.3rem;
background-color: rgba(var(--muted-rgb), 0.7);
}
.hidden {
display: none;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: flex-end;
background-color: transparent;
border-radius: 0.5rem;
padding: 0.4rem;
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.4rem;
height: 100%;
}
.error-message {
color: var(--destructive);
font-size: 0.75rem;
font-weight: 500;
}
.weather-data {
display: flex;
align-items: center;
justify-content: flex-end;
}
.weather-content {
width: 100%;
}
/* 天气数据样式 */
.weather-temp {
color: var(--foreground);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.weather-icon {
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.1));
}
.weather-city {
color: var(--foreground);
font-weight: 500;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
}
.weather-details {
color: var(--foreground);
opacity: 0.9;
font-weight: 400;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
}
/* 淡入淡出动画 */
.fade-enter-active, .fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
/* 提高文字对比度,适配暗色模式 */
:root.dark .weather-city,
:root.dark .weather-details {
color: rgba(255, 255, 255, 0.95);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
:root.dark .weather-temp {
color: rgba(255, 255, 255, 1);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
@media (max-width: 640px) {
.weather-widget {
width: 100%;
max-width: 150px;
}
}
</style>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { type AlertDialogEmits, type AlertDialogProps, AlertDialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AlertDialogRoot data-slot="alert-dialog" v-bind="forwarded">
<slot />
</AlertDialogRoot>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
props.class,
)"
>
<slot />
</AlertDialogCancel>
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
AlertDialogContent,
type AlertDialogContentEmits,
type AlertDialogContentProps,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<AlertDialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
data-slot="alert-dialog-overlay"
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
/>
<AlertDialogContent
data-slot="alert-dialog-content"
v-bind="forwarded"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
AlertDialogDescription,
type AlertDialogDescriptionProps,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogDescription
data-slot="alert-dialog-description"
v-bind="delegatedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</AlertDialogDescription>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="alert-dialog-footer"
:class="
cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="alert-dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogTitle, type AlertDialogTitleProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogTitle
data-slot="alert-dialog-title"
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { AlertDialogTrigger, type AlertDialogTriggerProps } from 'reka-ui'
const props = defineProps<AlertDialogTriggerProps>()
</script>
<template>
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
<slot />
</AlertDialogTrigger>
</template>
@@ -0,0 +1,9 @@
export { default as AlertDialog } from './AlertDialog.vue'
export { default as AlertDialogAction } from './AlertDialogAction.vue'
export { default as AlertDialogCancel } from './AlertDialogCancel.vue'
export { default as AlertDialogContent } from './AlertDialogContent.vue'
export { default as AlertDialogDescription } from './AlertDialogDescription.vue'
export { default as AlertDialogFooter } from './AlertDialogFooter.vue'
export { default as AlertDialogHeader } from './AlertDialogHeader.vue'
export { default as AlertDialogTitle } from './AlertDialogTitle.vue'
export { default as AlertDialogTrigger } from './AlertDialogTrigger.vue'
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { type AlertVariants, alertVariants } from '.'
const props = defineProps<{
class?: HTMLAttributes['class']
variant?: AlertVariants['variant']
}>()
</script>
<template>
<div
data-slot="alert"
:class="cn(alertVariants({ variant }), props.class)"
role="alert"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="alert-description"
:class="cn('text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="alert-title"
:class="cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', props.class)"
>
<slot />
</div>
</template>
+23
View File
@@ -0,0 +1,23 @@
import { cva, type VariantProps } from 'class-variance-authority'
export { default as Alert } from './Alert.vue'
export { default as AlertDescription } from './AlertDescription.vue'
export { default as AlertTitle } from './AlertTitle.vue'
export const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type AlertVariants = VariantProps<typeof alertVariants>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { AvatarRoot } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<AvatarRoot
data-slot="avatar"
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
>
<slot />
</AvatarRoot>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AvatarFallback, type AvatarFallbackProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AvatarFallback
data-slot="avatar-fallback"
v-bind="delegatedProps"
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
>
<slot />
</AvatarFallback>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { AvatarImageProps } from 'reka-ui'
import { AvatarImage } from 'reka-ui'
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage
data-slot="avatar-image"
v-bind="props"
class="aspect-square size-full"
>
<slot />
</AvatarImage>
</template>
+3
View File
@@ -0,0 +1,3 @@
export { default as Avatar } from './Avatar.vue'
export { default as AvatarFallback } from './AvatarFallback.vue'
export { default as AvatarImage } from './AvatarImage.vue'
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { type BadgeVariants, badgeVariants } from '.'
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants['variant']
class?: HTMLAttributes['class']
}>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>
+25
View File
@@ -0,0 +1,25 @@
import { cva, type VariantProps } from 'class-variance-authority'
export { default as Badge } from './Badge.vue'
export const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
:class="props.class"
>
<slot />
</nav>
</template>
@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { MoreHorizontal } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
:class="cn('flex size-9 items-center justify-center', props.class)"
>
<slot>
<MoreHorizontal class="size-4" />
</slot>
<span class="sr-only">More</span>
</span>
</template>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<li
data-slot="breadcrumb-item"
:class="cn('inline-flex items-center gap-1.5', props.class)"
>
<slot />
</li>
</template>
@@ -0,0 +1,20 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { Primitive, type PrimitiveProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes['class'] }>(), {
as: 'a',
})
</script>
<template>
<Primitive
data-slot="breadcrumb-link"
:as="as"
:as-child="asChild"
:class="cn('hover:text-foreground transition-colors', props.class)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<ol
data-slot="breadcrumb-list"
:class="cn('text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5', props.class)"
>
<slot />
</ol>
</template>
@@ -0,0 +1,20 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
:class="cn('text-foreground font-normal', props.class)"
>
<slot />
</span>
</template>
@@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { ChevronRight } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
:class="cn('[&>svg]:size-3.5', props.class)"
>
<slot>
<ChevronRight />
</slot>
</li>
</template>
@@ -0,0 +1,7 @@
export { default as Breadcrumb } from './Breadcrumb.vue'
export { default as BreadcrumbEllipsis } from './BreadcrumbEllipsis.vue'
export { default as BreadcrumbItem } from './BreadcrumbItem.vue'
export { default as BreadcrumbLink } from './BreadcrumbLink.vue'
export { default as BreadcrumbList } from './BreadcrumbList.vue'
export { default as BreadcrumbPage } from './BreadcrumbPage.vue'
export { default as BreadcrumbSeparator } from './BreadcrumbSeparator.vue'
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { Primitive, type PrimitiveProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { type ButtonVariants, buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
})
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+36
View File
@@ -0,0 +1,36 @@
import { cva, type VariantProps } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-content"
:class="cn('px-6', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<p
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-footer"
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-header"
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<h3
data-slot="card-title"
:class="cn('leading-none font-semibold', props.class)"
>
<slot />
</h3>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as Card } from './Card.vue'
export { default as CardAction } from './CardAction.vue'
export { default as CardContent } from './CardContent.vue'
export { default as CardDescription } from './CardDescription.vue'
export { default as CardFooter } from './CardFooter.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { CollapsibleRootEmits, CollapsibleRootProps } from 'reka-ui'
import { CollapsibleRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<CollapsibleRootProps>()
const emits = defineEmits<CollapsibleRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<CollapsibleRoot
v-slot="{ open }"
data-slot="collapsible"
v-bind="forwarded"
>
<slot :open="open" />
</CollapsibleRoot>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { CollapsibleContent, type CollapsibleContentProps } from 'reka-ui'
const props = defineProps<CollapsibleContentProps>()
</script>
<template>
<CollapsibleContent
data-slot="collapsible-content"
v-bind="props"
>
<slot />
</CollapsibleContent>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { CollapsibleTrigger, type CollapsibleTriggerProps } from 'reka-ui'
const props = defineProps<CollapsibleTriggerProps>()
</script>
<template>
<CollapsibleTrigger
data-slot="collapsible-trigger"
v-bind="props"
>
<slot />
</CollapsibleTrigger>
</template>
@@ -0,0 +1,3 @@
export { default as Collapsible } from './Collapsible.vue'
export { default as CollapsibleContent } from './CollapsibleContent.vue'
export { default as CollapsibleTrigger } from './CollapsibleTrigger.vue'
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, PropType } from 'vue'
const props = defineProps({
modelValue: String,
options: {
type: Array as PropType<{ value: string, label: string }[]>,
required: true
},
placeholder: {
type: String,
default: '请选择...'
},
by: {
type: String as PropType<'value' | 'label'>,
default: 'value'
}
})
const emit = defineEmits(['update:modelValue'])
const open = ref(false)
const search = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
const filteredOptions = computed(() => {
if (!search.value) return props.options
return props.options.filter((opt) =>
opt.label.toLowerCase().includes(search.value.toLowerCase()) ||
opt.value.toLowerCase().includes(search.value.toLowerCase())
)
})
const select = (val: string) => {
emit('update:modelValue', val)
open.value = false
}
watch(open, (val) => {
if (val) nextTick(() => inputRef.value?.focus())
})
</script>
<template>
<div class="relative w-full">
<div @click="open = !open" class="w-full cursor-pointer">
<slot name="trigger">
<div class="border rounded px-3 py-2 flex items-center justify-between bg-white dark:bg-black">
<span>{{ options.find(opt => opt.value === modelValue)?.label ?? placeholder }}</span>
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
</slot>
</div>
<div v-if="open" class="absolute z-10 w-full bg-white dark:bg-black border rounded mt-1 shadow-lg">
<div class="flex items-center px-2 py-1">
<input ref="inputRef" v-model="search" class="w-full border-0 outline-none bg-transparent py-1" :placeholder="placeholder" />
</div>
<ul class="max-h-48 overflow-auto">
<li v-for="opt in filteredOptions" :key="opt.value" @click="select(opt.value)" class="px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer flex items-center">
<slot name="item" :option="opt">
<span>{{ opt.label }}</span>
<span v-if="opt.value === modelValue" class="ml-auto text-green-500"></span>
</slot>
</li>
<li v-if="filteredOptions.length === 0" class="px-3 py-2 text-gray-400">暂无选项</li>
</ul>
</div>
</div>
</template>
<style scoped>
input::-webkit-input-placeholder { color: #aaa; }
input:-moz-placeholder { color: #aaa; }
input::-moz-placeholder { color: #aaa; }
input:-ms-input-placeholder { color: #aaa; }
</style>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { ContextMenuRootEmits, ContextMenuRootProps } from "reka-ui"
import { ContextMenuRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<ContextMenuRootProps>()
const emits = defineEmits<ContextMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ContextMenuRoot
data-slot="context-menu"
v-bind="forwarded"
>
<slot />
</ContextMenuRoot>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { ContextMenuCheckboxItemEmits, ContextMenuCheckboxItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import {
ContextMenuCheckboxItem,
ContextMenuItemIndicator,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ContextMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<ContextMenuCheckboxItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuCheckboxItem
data-slot="context-menu-checkbox-item"
v-bind="forwarded"
:class="cn(
`focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
props.class,
)"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuItemIndicator>
<Check class="size-4" />
</ContextMenuItemIndicator>
</span>
<slot />
</ContextMenuCheckboxItem>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { ContextMenuContentEmits, ContextMenuContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
ContextMenuContent,
ContextMenuPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ContextMenuContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<ContextMenuContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuPortal>
<ContextMenuContent
data-slot="context-menu-content"
v-bind="forwarded"
:class="cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-context-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
props.class,
)"
>
<slot />
</ContextMenuContent>
</ContextMenuPortal>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { ContextMenuGroupProps } from "reka-ui"
import { ContextMenuGroup } from "reka-ui"
const props = defineProps<ContextMenuGroupProps>()
</script>
<template>
<ContextMenuGroup
data-slot="context-menu-group"
v-bind="props"
>
<slot />
</ContextMenuGroup>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { ContextMenuItemEmits, ContextMenuItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
ContextMenuItem,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<ContextMenuItemProps & {
class?: HTMLAttributes["class"]
inset?: boolean
variant?: "default" | "destructive"
}>(), {
variant: "default",
})
const emits = defineEmits<ContextMenuItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuItem
data-slot="context-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwarded"
:class="cn(
`focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
props.class,
)"
>
<slot />
</ContextMenuItem>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { ContextMenuLabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ContextMenuLabel } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ContextMenuLabelProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ContextMenuLabel
data-slot="context-menu-label"
:data-inset="inset ? '' : undefined"
v-bind="delegatedProps"
:class="cn('text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)"
>
<slot />
</ContextMenuLabel>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { ContextMenuPortalProps } from "reka-ui"
import { ContextMenuPortal } from "reka-ui"
const props = defineProps<ContextMenuPortalProps>()
</script>
<template>
<ContextMenuPortal
data-slot="context-menu-portal"
v-bind="props"
>
<slot />
</ContextMenuPortal>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { ContextMenuRadioGroupEmits, ContextMenuRadioGroupProps } from "reka-ui"
import {
ContextMenuRadioGroup,
useForwardPropsEmits,
} from "reka-ui"
const props = defineProps<ContextMenuRadioGroupProps>()
const emits = defineEmits<ContextMenuRadioGroupEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ContextMenuRadioGroup
data-slot="context-menu-radio-group"
v-bind="forwarded"
>
<slot />
</ContextMenuRadioGroup>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { ContextMenuRadioItemEmits, ContextMenuRadioItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Circle } from "lucide-vue-next"
import {
ContextMenuItemIndicator,
ContextMenuRadioItem,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ContextMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<ContextMenuRadioItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuRadioItem
data-slot="context-menu-radio-item"
v-bind="forwarded"
:class="cn(
`focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
props.class,
)"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuItemIndicator>
<Circle class="size-2 fill-current" />
</ContextMenuItemIndicator>
</span>
<slot />
</ContextMenuRadioItem>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { ContextMenuSeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
ContextMenuSeparator,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ContextMenuSeparatorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ContextMenuSeparator
data-slot="context-menu-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
/>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span
data-slot="context-menu-shortcut"
:class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)"
>
<slot />
</span>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { ContextMenuSubEmits, ContextMenuSubProps } from "reka-ui"
import {
ContextMenuSub,
useForwardPropsEmits,
} from "reka-ui"
const props = defineProps<ContextMenuSubProps>()
const emits = defineEmits<ContextMenuSubEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ContextMenuSub
data-slot="context-menu-sub"
v-bind="forwarded"
>
<slot />
</ContextMenuSub>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
ContextMenuSubContent,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuSubContent
data-slot="context-menu-sub-content"
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
props.class,
)
"
>
<slot />
</ContextMenuSubContent>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { ContextMenuSubTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronRight } from "lucide-vue-next"
import {
ContextMenuSubTrigger,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ContextMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<ContextMenuSubTrigger
data-slot="context-menu-sub-trigger"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn(
`focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
props.class,
)"
>
<slot />
<ChevronRight class="ml-auto" />
</ContextMenuSubTrigger>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { ContextMenuTriggerProps } from "reka-ui"
import { ContextMenuTrigger, useForwardProps } from "reka-ui"
const props = defineProps<ContextMenuTriggerProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<ContextMenuTrigger
data-slot="context-menu-trigger"
v-bind="forwardedProps"
>
<slot />
</ContextMenuTrigger>
</template>
@@ -0,0 +1,14 @@
export { default as ContextMenu } from "./ContextMenu.vue"
export { default as ContextMenuCheckboxItem } from "./ContextMenuCheckboxItem.vue"
export { default as ContextMenuContent } from "./ContextMenuContent.vue"
export { default as ContextMenuGroup } from "./ContextMenuGroup.vue"
export { default as ContextMenuItem } from "./ContextMenuItem.vue"
export { default as ContextMenuLabel } from "./ContextMenuLabel.vue"
export { default as ContextMenuRadioGroup } from "./ContextMenuRadioGroup.vue"
export { default as ContextMenuRadioItem } from "./ContextMenuRadioItem.vue"
export { default as ContextMenuSeparator } from "./ContextMenuSeparator.vue"
export { default as ContextMenuShortcut } from "./ContextMenuShortcut.vue"
export { default as ContextMenuSub } from "./ContextMenuSub.vue"
export { default as ContextMenuSubContent } from "./ContextMenuSubContent.vue"
export { default as ContextMenuSubTrigger } from "./ContextMenuSubTrigger.vue"
export { default as ContextMenuTrigger } from "./ContextMenuTrigger.vue"
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { DialogRoot, type DialogRootEmits, type DialogRootProps, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
data-slot="dialog"
v-bind="forwarded"
>
<slot />
</DialogRoot>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { DialogClose, type DialogCloseProps } from 'reka-ui'
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="dialog-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { X } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
type DialogContentEmits,
type DialogContentProps,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
import DialogOverlay from './DialogOverlay.vue'
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="forwarded"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)"
>
<slot />
<DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription, type DialogDescriptionProps, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</DialogDescription>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
data-slot="dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
</div>
</template>

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