init: tianshen-web project
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_APP_TITLE=天神
|
||||
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_APP_TITLE=天神
|
||||
@@ -0,0 +1,56 @@
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
on: [release]
|
||||
|
||||
env:
|
||||
REPO: sugarbecomer/tianshen-web
|
||||
REGISTRY: git.likedream.xyz
|
||||
|
||||
jobs:
|
||||
Build-And-Upload:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
- name: Build front
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Inspect builder
|
||||
run: |
|
||||
echo "Name: ${{ steps.buildx.outputs.name }}"
|
||||
echo "Endpoint: ${{ steps.buildx.outputs.endpoint }}"
|
||||
echo "Status: ${{ steps.buildx.outputs.status }}"
|
||||
echo "Flags: ${{ steps.buildx.outputs.flags }}"
|
||||
echo "Platforms: ${{ steps.buildx.outputs.platforms }}"
|
||||
- name: Login to ghcr
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: sugarBecomer
|
||||
password: ${{ secrets.PASSWORD }}
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.REPO }}
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM caddy:2.9.1-alpine
|
||||
|
||||
COPY dist /www
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
RUN ["chmod", "+x", "/entrypoint.sh"]
|
||||
|
||||
EXPOSE 3000 3001
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/assets/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"composables": "@/composables"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "Starting Caddy with backend URL: ${BACKEND_URL}"
|
||||
|
||||
cat << EOF | tee /etc/caddy/Caddyfile
|
||||
{
|
||||
auto_https off
|
||||
local_certs
|
||||
skip_install_trust
|
||||
http_port 3000
|
||||
}
|
||||
|
||||
http:// {
|
||||
encode gzip zstd
|
||||
|
||||
handle_path /api/* {
|
||||
reverse_proxy ${BACKEND_URL}
|
||||
}
|
||||
|
||||
handle {
|
||||
root * /www
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
|
||||
}
|
||||
EOF
|
||||
caddy run --config /etc/caddy/Caddyfile
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>天神</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "new-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"axios": "^1.13.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"md-editor-v3": "^6.4.1",
|
||||
"pinia": "^3.0.4",
|
||||
"radix-vue": "^1.9.17",
|
||||
"reka-ui": "^2.9.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"vee-validate": "^4.15.1",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^5.0.4",
|
||||
"vue-sonner": "^2.0.9",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^24.12.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.1",
|
||||
"vue-tsc": "^3.2.5"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider :delay-duration="200">
|
||||
<router-view />
|
||||
<Toaster position="top-center" :duration="3000" rich-colors />
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
import request from './index'
|
||||
import type { ApiResponse, PageParams, PageResponse } from '@/types/api'
|
||||
|
||||
// ---- 代理管理 ----
|
||||
export interface Agent {
|
||||
id: number
|
||||
email: string
|
||||
username: string
|
||||
inviteCode: string
|
||||
balance: string
|
||||
block: boolean
|
||||
level: number
|
||||
rate: string
|
||||
cashBack: string
|
||||
}
|
||||
|
||||
export interface AgentAddParams {
|
||||
username: string
|
||||
password: string
|
||||
email: string
|
||||
balance: string
|
||||
level: number
|
||||
rate: string
|
||||
}
|
||||
|
||||
export interface AgentUpdateParams {
|
||||
id: number
|
||||
level: number
|
||||
rate: string
|
||||
}
|
||||
|
||||
export const agentApi = {
|
||||
getList: (params: PageParams & { username?: string; email?: string }) =>
|
||||
request.get<any, ApiResponse<PageResponse<Agent>>>('/v1/agent/agent', { params }),
|
||||
add: (data: AgentAddParams) =>
|
||||
request.post<any, ApiResponse>('/v1/agent/agent', data),
|
||||
update: (data: AgentUpdateParams) =>
|
||||
request.put<any, ApiResponse>('/v1/agent/agent', data),
|
||||
ban: (id: number, block: boolean) =>
|
||||
request.put<any, ApiResponse>('/v1/agent/agent/ban', { id, block }),
|
||||
recharge: (id: number, amount: string) =>
|
||||
request.post<any, ApiResponse>('/v1/agent/agent/recharge', { id, amount }),
|
||||
}
|
||||
|
||||
// ---- 卡密管理 ----
|
||||
export interface Card {
|
||||
id: number
|
||||
content: string
|
||||
value: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const cardApi = {
|
||||
getList: (params: PageParams & { content?: string; status?: string }) =>
|
||||
request.get<any, ApiResponse<PageResponse<Card>>>('/v1/agent/card', { params }),
|
||||
add: (data: { value: number; length: number }) =>
|
||||
request.post<any, ApiResponse>('/v1/agent/card', data),
|
||||
ban: (id: number) =>
|
||||
request.put<any, ApiResponse>(`/v1/agent/card/ban/${id}`),
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import request from './index'
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
import type {
|
||||
LoginParams,
|
||||
RegisterParams,
|
||||
ForgotPasswordParams,
|
||||
ResetPasswordParams,
|
||||
ResendVerificationParams,
|
||||
BalanceMigrateRequestParams,
|
||||
BalanceMigrateParams,
|
||||
} from '@/types/user'
|
||||
|
||||
export const authApi = {
|
||||
/** 获取滑块验证码 */
|
||||
getCaptcha: () =>
|
||||
request.get<any, ApiResponse<{
|
||||
image: string
|
||||
thumb: string
|
||||
thumbX: number
|
||||
thumbY: number
|
||||
thumbWidth: number
|
||||
thumbHeight: number
|
||||
captchaKey: string
|
||||
}>>('/v1/user/captcha'),
|
||||
|
||||
/** 用户登录(token 通过响应头 likedream-go 返回) */
|
||||
login: (data: LoginParams) =>
|
||||
request.post<any, ApiResponse>('/v1/user/login', data),
|
||||
|
||||
/** 用户注册 */
|
||||
register: (data: RegisterParams) =>
|
||||
request.post<any, ApiResponse>('/v1/user/register', data),
|
||||
|
||||
/** 邮箱确认 */
|
||||
confirm: (token: string) =>
|
||||
request.post<any, ApiResponse>('/v1/user/confirm', { token }),
|
||||
|
||||
/** 重新发送验证邮件 */
|
||||
resendVerification: (data: ResendVerificationParams) =>
|
||||
request.post<any, ApiResponse>('/v1/user/resendVerification', data),
|
||||
|
||||
/** 忘记密码 - 发送重置邮件 */
|
||||
forgotPassword: (data: ForgotPasswordParams) =>
|
||||
request.post<any, ApiResponse>('/v1/user/forgotPassword', data),
|
||||
|
||||
/** 忘记密码 - 重置密码 */
|
||||
resetPassword: (data: ResetPasswordParams) =>
|
||||
request.post<any, ApiResponse>('/v1/user/forgetResetPassword', data),
|
||||
|
||||
/** 获取重置密码用户信息 */
|
||||
resetPasswordInfo: (token: string) =>
|
||||
request.post<any, ApiResponse>('/v1/user/resetPassword/info', { token }),
|
||||
|
||||
/** 请求积分转移链接 */
|
||||
requestBalanceMigrate: (data: BalanceMigrateRequestParams) =>
|
||||
request.post<any, ApiResponse>('/v1/user/requestBalanceMigrate', data),
|
||||
|
||||
/** 执行积分转移 */
|
||||
balanceMigrate: (data: BalanceMigrateParams) =>
|
||||
request.post<any, ApiResponse>('/v1/user/balanceMigrate', data),
|
||||
|
||||
/** 获取公开配置 */
|
||||
getPublicConfig: (key: string) =>
|
||||
request.get<any, ApiResponse>(`/v1/system/config/public/${key}`),
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import request from './index'
|
||||
import type { ApiResponse, PageParams, PageResponse } from '@/types/api'
|
||||
import type {
|
||||
CpUser,
|
||||
CpTask,
|
||||
CpTaskExecute,
|
||||
CpTaskLog,
|
||||
CpLeave,
|
||||
CpSchool,
|
||||
CpUserAddParams,
|
||||
CpUserConfirmParams,
|
||||
CpUserGetCodeParams,
|
||||
CpUserAddByCodeParams,
|
||||
CpTaskAddParams,
|
||||
CpTaskExecuteParams,
|
||||
CpTaskFormItem,
|
||||
CpLeaveReportParams,
|
||||
TaskStatisticsItem,
|
||||
TaskStatisticsV2Item,
|
||||
DashboardSummary,
|
||||
CpUserCustomDeviceParams,
|
||||
} from '@/types/cpdaily'
|
||||
|
||||
// ---- 学校 ----
|
||||
export const cpSchoolApi = {
|
||||
search: (name: string) =>
|
||||
request.get<any, ApiResponse<CpSchool[]>>('/v1/cpdaily/school', { params: { name } }),
|
||||
}
|
||||
|
||||
// ---- 用户管理 ----
|
||||
export const cpUserApi = {
|
||||
getList: (params: PageParams & { username?: string; email?: string }) =>
|
||||
request.get<any, ApiResponse<PageResponse<CpUser>>>('/v1/cpdaily/user', { params }),
|
||||
add: (data: CpUserAddParams) =>
|
||||
request.post<any, ApiResponse>('/v1/cpdaily/user', data),
|
||||
delete: (id: number) =>
|
||||
request.delete<any, ApiResponse>('/v1/cpdaily/user', { data: { id } }),
|
||||
updatePassword: (data: { id: number; newPassword: string }) =>
|
||||
request.put<any, ApiResponse>('/v1/cpdaily/user/updatePassword', data),
|
||||
getCode: (data: CpUserGetCodeParams) =>
|
||||
request.post<any, ApiResponse>('/v1/cpdaily/user/getCode', data),
|
||||
addByCode: (data: CpUserAddByCodeParams) =>
|
||||
request.post<any, ApiResponse>('/v1/cpdaily/user/addByCode', data),
|
||||
confirmAdd: (data: CpUserConfirmParams) =>
|
||||
request.post<any, ApiResponse>('/v1/cpdaily/user/confirm', data),
|
||||
authorizeDevice: (id: number) =>
|
||||
request.post<any, ApiResponse>('/v1/cpdaily/user/authorizeDevice', { id }),
|
||||
authorizeConfirm: (data: { id: number; dataKey: string }) =>
|
||||
request.post<any, ApiResponse>('/v1/cpdaily/user/authorizeConfirm', data),
|
||||
customDevice: (data: CpUserCustomDeviceParams) =>
|
||||
request.post<any, ApiResponse>('/v1/cpdaily/user/customDevice', data),
|
||||
}
|
||||
|
||||
// ---- 任务管理 ----
|
||||
export const cpTaskApi = {
|
||||
getList: (params: PageParams & { type?: string; username?: string; task_status?: string }) =>
|
||||
request.get<any, ApiResponse<PageResponse<CpTask>>>('/v1/cpdaily/task', { params }),
|
||||
getRecent: (params: { userId?: number; type?: string }) =>
|
||||
request.get<any, ApiResponse>('/v1/cpdaily/task/recent', { params: { user_id: params.userId, type: params.type } }),
|
||||
add: (data: CpTaskAddParams) =>
|
||||
request.post<any, ApiResponse>('/v1/cpdaily/task', data),
|
||||
delete: (id: number) =>
|
||||
request.delete<any, ApiResponse>(`/v1/cpdaily/task/${id}`),
|
||||
getForm: (id: number) =>
|
||||
request.get<any, ApiResponse<CpTaskFormItem[]>>(`/v1/cpdaily/task/form/${id}`),
|
||||
updateForm: (id: number, forms: CpTaskFormItem[]) =>
|
||||
request.put<any, ApiResponse>(`/v1/cpdaily/task/form/${id}`, { forms }),
|
||||
getExecute: (id: number) =>
|
||||
request.get<any, ApiResponse<CpTaskExecute>>(`/v1/cpdaily/task/execute/${id}`),
|
||||
updateExecute: (id: number, data: CpTaskExecuteParams) =>
|
||||
request.post<any, ApiResponse>(`/v1/cpdaily/task/execute/${id}`, data),
|
||||
executeNow: (id: number) =>
|
||||
request.post<any, ApiResponse>(`/v1/cpdaily/task/executeNow/${id}`),
|
||||
pause: (id: number) =>
|
||||
request.post<any, ApiResponse>(`/v1/cpdaily/task/pause/${id}`),
|
||||
findLocation: (address: string) =>
|
||||
request.get<any, ApiResponse>('/v1/cpdaily/task/location', { params: { address } }),
|
||||
getStatistics: () =>
|
||||
request.get<any, ApiResponse<TaskStatisticsItem[]>>('/v1/cpdaily/task/statistics'),
|
||||
getStatisticsV2: () =>
|
||||
request.get<any, ApiResponse<TaskStatisticsV2Item[]>>('/v1/cpdaily/task/statisticsV2'),
|
||||
getDashboardSummary: () =>
|
||||
request.get<any, ApiResponse<DashboardSummary>>('/v1/cpdaily/task/dashboard'),
|
||||
}
|
||||
|
||||
// ---- 任务日志 ----
|
||||
export const cpTaskLogApi = {
|
||||
getList: (params: PageParams & { task_id?: number }) =>
|
||||
request.get<any, ApiResponse<PageResponse<CpTaskLog>>>('/v1/cpdaily/taskLog', { params }),
|
||||
}
|
||||
|
||||
// ---- 图片 ----
|
||||
export const cpImageApi = {
|
||||
upload: (formData: FormData) =>
|
||||
request.post<any, ApiResponse<{ previewUrl: string }>>('/v1/cpdaily/image/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
getUploadPolicy: (data: { cpUserId: number; taskType: string }) =>
|
||||
request.post<any, ApiResponse<{ host: string; accessId: string; fileName: string; policy: string; signature: string }>>('/v1/cpdaily/image/policy', data),
|
||||
getPreviewUrl: (data: { cpUserId: number; taskType: string; fileKey: string }) =>
|
||||
request.post<any, ApiResponse<{ previewUrl: string }>>('/v1/cpdaily/image/previewUrl', data),
|
||||
preview: (key: string) =>
|
||||
request.get<any, ApiResponse>('/v1/cpdaily/image/preview', { params: { key } }),
|
||||
}
|
||||
|
||||
// ---- 请假销假 ----
|
||||
export const cpLeaveApi = {
|
||||
getList: (params: PageParams & { cpUserId?: number; username?: string; status?: string }) =>
|
||||
request.get<any, ApiResponse<PageResponse<CpLeave>>>('/v1/cpdaily/leave', { params }),
|
||||
getSystemLeaves: (cpUserId: number) =>
|
||||
request.get<any, ApiResponse>(`/v1/cpdaily/leave/system/${cpUserId}`),
|
||||
report: (data: CpLeaveReportParams) =>
|
||||
request.post<any, ApiResponse>('/v1/cpdaily/leave/report', data),
|
||||
uploadImage: (formData: FormData) =>
|
||||
request.post<any, ApiResponse>('/v1/cpdaily/leave/upload', formData),
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import axios from 'axios'
|
||||
import { message } from '@/composables/useMessage'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// ---- Token 自动刷新 ----
|
||||
let isRefreshing = false
|
||||
|
||||
/** 解析 JWT payload 中的 exp(秒级时间戳) */
|
||||
function getTokenExp(token: string): number | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
const payload = JSON.parse(atob(parts[1]))
|
||||
return typeof payload.exp === 'number' ? payload.exp : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** token 是否即将过期(剩余不足 2 小时) */
|
||||
function isTokenExpiringSoon(token: string): boolean {
|
||||
const exp = getTokenExp(token)
|
||||
if (!exp) return false
|
||||
const remaining = exp - Date.now() / 1000
|
||||
return remaining > 0 && remaining < 7200
|
||||
}
|
||||
|
||||
// 请求拦截器:自动携带 Bearer token + 检测是否需要刷新
|
||||
request.interceptors.request.use(async (config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers['likedream-go'] = `Bearer ${token}`
|
||||
|
||||
// 非刷新请求 && token 即将过期 → 触发后台刷新
|
||||
if (!config.url?.includes('/refreshToken') && isTokenExpiringSoon(token) && !isRefreshing) {
|
||||
isRefreshing = true
|
||||
try {
|
||||
// 直接用 axios 发请求避免循环
|
||||
const res = await axios.get(
|
||||
`${config.baseURL}/v1/user/refreshToken`,
|
||||
{ headers: { 'likedream-go': `Bearer ${token}` } }
|
||||
)
|
||||
const newToken = res.headers['likedream-go']
|
||||
if (newToken) {
|
||||
localStorage.setItem('token', newToken)
|
||||
}
|
||||
} catch {
|
||||
// 刷新失败不影响当前请求
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// 响应拦截器:统一错误处理 & 自动提取 token
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
// 后端通过响应头 likedream-go 返回 token
|
||||
const token = response.headers['likedream-go']
|
||||
if (token) {
|
||||
localStorage.setItem('token', token)
|
||||
}
|
||||
const data = response.data
|
||||
// 业务错误:HTTP 200 但 code 不是 200
|
||||
if (data && typeof data.code === 'number' && data.code !== 200) {
|
||||
const msg = data.message || '请求失败'
|
||||
message.error(msg)
|
||||
return Promise.reject(new Error(msg))
|
||||
}
|
||||
return data
|
||||
},
|
||||
async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
const { default: router } = await import('@/router')
|
||||
router.push('/login')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
@@ -0,0 +1,134 @@
|
||||
import request from './index'
|
||||
import type { ApiResponse, PageParams, PageResponse } from '@/types/api'
|
||||
import type { UserMenu } from '@/types/menu'
|
||||
|
||||
// ---- 用户管理 ----
|
||||
export const systemUserApi = {
|
||||
getList: (params: PageParams & { username?: string; email?: string }) =>
|
||||
request.get<any, ApiResponse<PageResponse<any>>>('/v1/system/user', { params }),
|
||||
blockUser: (id: number, block: boolean) =>
|
||||
request.patch<any, ApiResponse>(`/v1/system/user/${id}`, { block }),
|
||||
resetPassword: (id: number, password: string) =>
|
||||
request.put<any, ApiResponse>(`/v1/system/user/reset/${id}`, { password }),
|
||||
deleteUser: (id: number) =>
|
||||
request.delete<any, ApiResponse>(`/v1/system/user/${id}`),
|
||||
authorizeRole: (id: number, roleIds: number[]) =>
|
||||
request.put<any, ApiResponse>('/v1/system/user/authorize', { id, roleIds }),
|
||||
}
|
||||
|
||||
// ---- 角色管理 ----
|
||||
export interface RoleAddParams {
|
||||
label: string
|
||||
name: string
|
||||
description: string
|
||||
order: number
|
||||
}
|
||||
|
||||
export const systemRoleApi = {
|
||||
getList: (params: PageParams & { name?: string }) =>
|
||||
request.get<any, ApiResponse<PageResponse<any>>>('/v1/system/role', { params }),
|
||||
getAll: () =>
|
||||
request.get<any, ApiResponse<any[]>>('/v1/system/role', { params: { enable: 1 } }),
|
||||
getDetail: (id: number) =>
|
||||
request.get<any, ApiResponse>(`/v1/system/role/${id}`),
|
||||
add: (data: RoleAddParams) =>
|
||||
request.post<any, ApiResponse>('/v1/system/role', data),
|
||||
update: (data: RoleAddParams & { id: number }) =>
|
||||
request.put<any, ApiResponse>('/v1/system/role', data),
|
||||
delete: (id: number) =>
|
||||
request.delete<any, ApiResponse>(`/v1/system/role/${id}`),
|
||||
getMenu: (id: number) =>
|
||||
request.get<any, ApiResponse>(`/v1/system/role/${id}/menu`),
|
||||
authorizeMenu: (id: number, menuIds: number[]) =>
|
||||
request.put<any, ApiResponse>('/v1/system/role/menu/authorize', { id, menuIds }),
|
||||
}
|
||||
|
||||
// ---- 菜单管理 ----
|
||||
export interface MenuAddParams {
|
||||
name: string
|
||||
label: string
|
||||
type: string
|
||||
link: string
|
||||
permission: string
|
||||
route: string
|
||||
component: string
|
||||
hidden: boolean
|
||||
keepAlive: boolean
|
||||
icon: string
|
||||
order: number
|
||||
parentId: number
|
||||
}
|
||||
|
||||
export const systemMenuApi = {
|
||||
getList: (params: PageParams & { name?: string }) =>
|
||||
request.get<any, ApiResponse<PageResponse<any>>>('/v1/system/menu', { params }),
|
||||
getTree: () =>
|
||||
request.get<any, ApiResponse<UserMenu[]>>('/v1/system/menu/tree'),
|
||||
getButtons: (parentId: number) =>
|
||||
request.get<any, ApiResponse>(`/v1/system/menu/button/${parentId}`),
|
||||
add: (data: MenuAddParams) =>
|
||||
request.post<any, ApiResponse>('/v1/system/menu', data),
|
||||
update: (id: number, data: MenuAddParams) =>
|
||||
request.put<any, ApiResponse>(`/v1/system/menu/${id}`, data),
|
||||
delete: (id: number) =>
|
||||
request.delete<any, ApiResponse>(`/v1/system/menu/${id}`),
|
||||
}
|
||||
|
||||
// ---- 配置管理 ----
|
||||
export const systemConfigApi = {
|
||||
getList: (params: PageParams & { key?: string }) =>
|
||||
request.get<any, ApiResponse<PageResponse<any>>>('/v1/system/config', { params }),
|
||||
get: (key: string) =>
|
||||
request.get<any, ApiResponse>(`/v1/system/config/${key}`),
|
||||
update: (key: string, data: { value: string; description?: string }) =>
|
||||
request.put<any, ApiResponse>(`/v1/system/config/${key}`, { key, ...data }),
|
||||
}
|
||||
|
||||
// ---- AI 配置 ----
|
||||
export interface AIConfigParams {
|
||||
type: string
|
||||
url: string
|
||||
model: string
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export interface AIConfigResp {
|
||||
type: string
|
||||
url: string
|
||||
model: string
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export interface AITypeItem {
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
needUrl: boolean
|
||||
}
|
||||
|
||||
export const systemAIConfigApi = {
|
||||
get: () =>
|
||||
request.get<any, ApiResponse<AIConfigResp>>('/v1/system/ai-config'),
|
||||
update: (data: AIConfigParams) =>
|
||||
request.put<any, ApiResponse>('/v1/system/ai-config', data),
|
||||
test: (data: AIConfigParams) =>
|
||||
request.post<any, ApiResponse<{ success: boolean; message: string }>>('/v1/system/ai-config/test', data),
|
||||
getTypes: () =>
|
||||
request.get<any, ApiResponse<{ types: AITypeItem[] }>>('/v1/system/ai-config/types'),
|
||||
}
|
||||
|
||||
// ---- 费率配置 ----
|
||||
export interface PriceConfig {
|
||||
cpdaily_task: string
|
||||
cpdaily_leave: string
|
||||
cpdaily_phone: string
|
||||
xxt: string
|
||||
smartedu_sc: string
|
||||
}
|
||||
|
||||
export const systemPriceApi = {
|
||||
get: () =>
|
||||
request.get<any, ApiResponse<PriceConfig>>('/v1/system/config/price'),
|
||||
update: (data: PriceConfig) =>
|
||||
request.put<any, ApiResponse>('/v1/system/config/price', data),
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import request from './index'
|
||||
import type { ApiResponse, PageParams, PageResponse } from '@/types/api'
|
||||
import type {
|
||||
UserProfile,
|
||||
UserPermissionRes,
|
||||
ChangePasswordParams,
|
||||
BalanceRecord,
|
||||
} from '@/types/user'
|
||||
|
||||
export const userApi = {
|
||||
/** 获取用户信息 */
|
||||
getProfile: () =>
|
||||
request.get<any, ApiResponse<UserProfile>>('/v1/user/userInfo'),
|
||||
|
||||
/** 获取用户权限和菜单 */
|
||||
getPermissions: () =>
|
||||
request.get<any, ApiResponse<UserPermissionRes>>('/v1/user/permissionList'),
|
||||
|
||||
/** 刷新Token */
|
||||
refreshToken: () =>
|
||||
request.get<any, ApiResponse>('/v1/user/refreshToken'),
|
||||
|
||||
/** 修改密码 */
|
||||
resetPassword: (data: ChangePasswordParams) =>
|
||||
request.post<any, ApiResponse>('/v1/user/resetPassword', data),
|
||||
|
||||
/** 卡密充值 */
|
||||
cardRecharge: (card: string) =>
|
||||
request.post<any, ApiResponse>('/v1/user/card/recharge', { card }),
|
||||
|
||||
/** 获取余额变动记录 */
|
||||
getBalanceRecords: (params: PageParams & { type?: string }) =>
|
||||
request.get<any, ApiResponse<PageResponse<BalanceRecord>>>('/v1/user/balance/records', { params }),
|
||||
}
|
||||
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
t="1710490574771"
|
||||
class="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="4141"
|
||||
width="200"
|
||||
height="200"
|
||||
>
|
||||
<path
|
||||
d="M1022.934187 429.44a446.122667 446.122667 0 0 0-19.456-100.437333 175.786667 175.786667 0 0 1-15.530667 31.701333 401.92 401.92 0 0 0-164.224-267.264A399.701333 399.701333 0 0 0 724.566187 41.770667a313.6 313.6 0 0 0-14.421334-4.778667 300.458667 300.458667 0 0 1 60.8 148.096c0.256 1.877333 0.469333 3.754667 0.64 5.674667a2.56 2.56 0 0 1 0 0.426666c0.213333 1.92 0.426667 3.882667 0.554667 5.845334v1.024a223.957333 223.957333 0 0 1 0.426667 5.845333 246.357333 246.357333 0 0 1 0.426666 10.026667 8.277333 8.277333 0 0 0 0 0.682666v6.997334a300.672 300.672 0 0 1-54.101333 172.458666 270.293333 270.293333 0 0 1 3.2 93.653334 263.552 263.552 0 0 0 49.664-17.109334 263.850667 263.850667 0 0 0 34.133333-18.773333 268.074667 268.074667 0 0 1 10.368 74.410667 269.525333 269.525333 0 0 1-2.986666 40.362666 266.197333 266.197333 0 0 0 68.906666-16.64c11.605333-4.522667 22.869333-9.813333 33.706667-15.914666a266.410667 266.410667 0 0 1 4.266667 47.701333 269.141333 269.141333 0 0 1-5.76 55.381333 266.752 266.752 0 0 0 81.322666-16.213333A446.165333 446.165333 0 0 0 1024.000853 464.213333a366.933333 366.933333 0 0 0-1.024-34.816z m-119.808 247.210667a242.56 242.56 0 0 1-16.64 0.512h-5.504a266.666667 266.666667 0 0 0 8.448-66.858667 269.653333 269.653333 0 0 0-1.706667-30.08 265.813333 265.813333 0 0 1-82.944 25.344 257.152 257.152 0 0 1-24.448 2.133333 264.618667 264.618667 0 0 0 4.906667-39.594666 276.352 276.352 0 0 0 0.341333-13.312 268.373333 268.373333 0 0 0-6.058667-56.832 265.984 265.984 0 0 1-90.453333 33.109333l-0.213333-0.298667a263.594667 263.594667 0 0 0 4.736-37.802666 239.658667 239.658667 0 0 0 0.426666-14.506667c0-16.128-1.450667-32.256-4.352-48.128a301.098667 301.098667 0 0 1-218.325333 93.354667 300.501333 300.501333 0 0 1-138.538667-33.621334 267.221333 267.221333 0 0 0 55.210667-29.226666 268.8 268.8 0 0 1-152.448-242.346667 268.629333 268.629333 0 0 1 4.352-48.426667 264.362667 264.362667 0 0 1 12.885333-46.421333 264.832 264.832 0 0 1 20.650667-42.752A267.221333 267.221333 0 0 1 305.664853 37.546667 521.045333 521.045333 0 0 0 191.14752 108.8 520.832 520.832 0 0 0 13.99552 391.722667 520.362667 520.362667 0 0 0 0.000853 512.042667a518.528 518.528 0 0 0 63.573334 249.642666 266.709333 266.709333 0 0 1 18.986666-73.088 497.450667 497.450667 0 0 0 366.293334 298.410667 266.069333 266.069333 0 0 1-34.304-59.050667 469.674667 469.674667 0 0 0 133.376 22.784c4.010667 0 8.021333 0.128 12.074666 0.128a468.650667 468.650667 0 0 0 207.658667-48.170666 263.509333 263.509333 0 0 1-30.890667-20.394667 448.298667 448.298667 0 0 0 184.32-132.010667 446.677333 446.677333 0 0 0 57.856-89.301333 267.008 267.008 0 0 1-75.818666 15.616z"
|
||||
fill="#F44A53"
|
||||
p-id="4142"
|
||||
></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg t="1697877684615" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="19414" width="200" height="200"><path d="M810.6496 384l-53.3504-117.3504L640 213.3504l117.2992-53.3504L810.6496 42.6496l53.3504 117.3504 117.2992 53.3504-117.2992 53.2992L810.6496 384z m0 597.3504l-53.3504-117.3504-117.2992-53.3504 117.2992-53.2992 53.3504-117.3504 53.3504 117.3504 117.2992 53.2992-117.2992 53.3504-53.3504 117.3504z m-426.6496-128l-106.7008-234.7008L42.6496 512l234.6496-106.6496L384 170.6496l106.6496 234.7008L725.2992 512l-234.6496 106.6496L384 853.3504z m0-206.9504l42.6496-91.7504L518.4 512l-91.7504-42.6496L384 377.6l-42.7008 91.7504L249.6 512l91.6992 42.6496 42.7008 91.7504z" p-id="19415"></path></svg>
|
||||
|
After Width: | Height: | Size: 744 B |
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="64px" height="64px" viewBox="0 0 64 64" enable-background="new 0 0 64 64" xml:space="preserve"> <image id="image0" width="64" height="64" x="0" y="0"
|
||||
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAIGNIUk0AAHomAACAhAAA+gAAAIDo
|
||||
AAB1MAAA6mAAADqYAAAXcJy6UTwAAACoUExURQAAAN8QIO8aIu4bIucYIOwZI+0bI+0bI+0bIu8Q
|
||||
IOsaIu0bIu0aIu8gIO0aI+8YIOwdI+0aIuscJO0bI/V/g+83PvaNkf3j4+8pMP////vGyPebn+4p
|
||||
MPRwdvNiaP7x8v7x8f3j5PFUWvrGyPebnvNwdfm4uviprPzU1vJUWviqrfBGTPiqrPJiaPq4uvm4
|
||||
u/JiZ/ipre4pMfRwde83P/vU1vRxdvBGTdBknf4AAAATdFJOUwAQf88gUO/f3xCAz88Q3yBQ34B3
|
||||
aA+rAAAAAWJLR0QZ7G61iAAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAAd0SU1FB+kLGgwGIj/LeQYA
|
||||
AAMeSURBVFjDpZdpY5swDIZZr63H1q7GJiYQji2UI12bbN3+/z+bJVvGJBAC8YdylPfxK8kWwfM8
|
||||
79PFJZsxLi+uPBzXN3PkMD5fo36uHIYiXH05B3Bz692do2fszruf8LTPD249eFOmE8Hi4N40gAzP
|
||||
BcjluQAp6CLy41kAudLqUCZ8HiDloFbDnxeCIuBfmc3MgR18OiD/4eh/sokAEziNjE8D5MtUdsaa
|
||||
TQDsTe5k8CRAJFJ5OJanAvIC5ltzf5+QnwTg4D1RqzdWLl46gaQnlDFaYr0ilpdSlhXjpUsIRwE+
|
||||
xK50Ua33kJ/2pWEQ0IRmuayULmvQToLCF1aJMKE0DAEwaVmlwygitB9GeDPCByo/LPkgAJOnBIwH
|
||||
2n4ToBtnD2CShgA5Riu0fdi2UATAJE5DOVJGXXPfnKi+E2scYA6aYg8Aiwc64eg3+pDxccCmowfj
|
||||
OnNwkK9sDBCF+3rIo5qY0jkGCCl+1BeKGOjNG3T24CBA+4f8U9eAZfjL8PgoQDe9QvvGiWMt5HoJ
|
||||
jgGE7VaBESDoRfvoNdABNHqTVETipqSNNlCwEYC2jfN2TjLDWY8BahtASQZqk9JSGzkOiG27jcmA
|
||||
TkVuHLHjABNARjLY7bERVnAIRgD1ngEgvcFJaTbVCIC3/T60ESSm94kTAIU1YFDvVFYCyMUxQGUa
|
||||
JZUec74igN95E/QC6rZVm97NaOKSSOIIwNh2IoCIt/oNwtiOTgYByzaCVetFA8B6ORyDBgRtDZYH
|
||||
AEHBBIMASmFhi4gAYU93yWAWPDcC0eYQZvstbQxCDgWBAHppvtPi0Rkz2xveKMZCynsBO9lOxeh8
|
||||
YVUoezXGetv6ikSNA8jbfMhtG2bQ9ADsz78/DgDy4ffR/EPAmwswFcUyUAzofPdBfvg+oOwD4N6x
|
||||
3krAbQgX7wESF2DtCNeCrJnLcxCeEzaGSmtCph0LsobL16xFRAZw31ZR/nVLotfdh73ECvKNvU63
|
||||
MN+D+uzbVTTgkfbqH+7TqvNfteyrzp077+vsD18Y3x497+kcAH48P32fK7+51p/vj89TPl/tuH++
|
||||
VeL/+ONagQkznVoAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjUtMTEtMjZUMTI6MDY6MzQrMDA6MDD4
|
||||
sNSFAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDI1LTExLTI2VDEyOjA2OjM0KzAwOjAwie1sOQAAACh0
|
||||
RVh0ZGF0ZTp0aW1lc3RhbXAAMjAyNS0xMS0yNlQxMjowNjozNCswMDowMN74TeYAAAAASUVORK5C
|
||||
YII=" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,23 @@
|
||||
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="200" rx="40" fill="#3250E6"/>
|
||||
<path d="M67.7964 27.7712C67.7964 27.7712 65.2289 26.8084 58.81 23.9199C45.3303 48.6326 28.3203 59.2238 28.3203 59.2238C44.6885 56.9772 67.7964 27.7712 67.7964 27.7712Z" fill="white"/>
|
||||
<path d="M65.548 30.9824C65.227 30.9824 65.2271 31.3034 65.2271 31.3034C73.8925 48.3134 92.5073 57.6208 92.5073 57.6208L96.6796 52.4857C96.3586 52.4857 80.3114 45.7458 65.548 30.9824Z" fill="white"/>
|
||||
<path d="M56.2415 45.4238C56.2415 45.4238 65.2279 58.9035 66.1907 60.1873C68.1164 59.8663 70.363 58.9035 72.6096 56.6569C67.4745 49.5961 56.2415 45.4238 56.2415 45.4238Z" fill="white"/>
|
||||
<path d="M43.4012 65.6412H75.8165L76.7793 64.3574H42.7593L43.4012 65.6412Z" fill="white"/>
|
||||
<path d="M79.3514 65.0018C79.3514 65.0018 62.0204 92.603 61.6995 93.2448C62.0204 93.5658 66.1927 95.8124 67.4764 95.8124C68.4393 95.4915 84.4865 68.5322 84.4865 68.5322L86.7331 64.6808L88.3378 62.1133L79.3514 64.6808H83.5236H79.3514V65.0018Z" fill="white"/>
|
||||
<path d="M121.875 29.4242L115.43 27.5391V29.4242V92.5781L121.875 91.9497V29.4242Z" fill="white"/>
|
||||
<path d="M157.345 89.2931L163.803 91.2307V29.5528V27.6152L157.668 29.5528H157.345H126.344V31.1674H157.345V56.3552H126.344V57.9698H157.345V81.866H126.344V83.4806H157.345V89.2931Z" fill="white"/>
|
||||
<path d="M125.165 122.112H154.392L155.042 120.488H124.515L125.165 122.112Z" fill="white"/>
|
||||
<path d="M111.523 174.396L117.694 173.746V111.719H111.523V174.396Z" fill="white"/>
|
||||
<path d="M161.213 111.727H122.892V113.026H161.213V166.61H124.191V167.909H161.213V172.78L167.383 174.728V111.727H161.213Z" fill="white"/>
|
||||
<path d="M137.503 133.811H141.075C141.075 139.007 141.075 156.544 141.075 157.843C141.075 159.467 141.724 161.74 144.322 162.389C145.621 162.714 151.142 163.688 154.39 163.039C157.312 162.389 157.962 161.415 158.612 160.441C156.338 158.492 156.013 149.724 156.013 149.724C156.013 149.724 154.065 155.894 153.415 157.193C152.116 158.168 147.895 157.193 147.245 156.544C146.596 155.894 147.245 132.188 147.245 132.188H146.271H141.4H121.915L122.564 133.811H130.358C131.008 152.972 124.837 157.193 120.291 164.013L120.94 164.338C138.477 151.997 137.827 136.085 137.503 133.811Z" fill="white"/>
|
||||
<path d="M150.116 133.721H157.261L157.911 132.098H150.116V133.721Z" fill="white"/>
|
||||
<path d="M79.2732 152.822C78.6403 154.088 77.6908 155.987 77.0579 157.253C84.0204 167.38 91.2994 174.026 91.9324 174.343C93.1983 174.659 96.6796 169.912 96.6796 169.279C96.3631 169.279 88.4511 163.266 79.2732 152.822Z" fill="white"/>
|
||||
<path d="M74.2109 116.424C75.4768 116.108 77.0592 115.475 78.9581 113.893C75.1604 108.829 66.9319 105.664 66.9319 105.664C66.9319 105.664 73.578 115.475 74.2109 116.424Z" fill="white"/>
|
||||
<path d="M51.1047 124.338H58.7003L59.0167 122.756H51.1047V124.338Z" fill="white"/>
|
||||
<path d="M57.1206 117.689L57.7536 119.272H92.8828L93.8323 117.689H57.1206Z" fill="white"/>
|
||||
<path d="M59.336 144.908C59.336 144.591 59.0195 143.958 58.3866 143.325C64.7162 138.578 73.5776 125.286 73.5776 125.286C73.5776 125.286 72.9447 124.336 67.881 122.121C62.5008 136.046 59.6525 139.527 57.7536 142.059C55.8548 138.895 52.057 134.464 49.5251 130.983V138.578C51.1075 143.958 53.0064 149.022 53.6394 149.338C55.2218 149.022 59.336 146.174 59.336 144.908Z" fill="white"/>
|
||||
<path d="M48.575 106.613H43.1949V122.754H32.751L33.384 124.336H42.8784C36.2323 143.641 30.2192 155.035 28.3203 159.149C30.8522 157.883 37.8147 145.224 42.8784 135.729V175.289L48.2585 174.34V135.096V129.716V106.613H48.575Z" fill="white"/>
|
||||
<path d="M77.377 121.488V122.121C77.377 122.121 88.4538 140.161 89.4032 139.844C90.6691 139.211 94.1504 135.413 93.8339 134.147C92.8845 132.565 79.2758 122.438 77.377 121.488Z" fill="white"/>
|
||||
<path d="M76.108 155.983C77.0574 154.717 77.6904 153.135 78.6398 151.869C78.9563 151.236 79.2728 150.92 79.5893 150.287C83.387 143.324 85.6024 137.628 85.6024 137.628C85.6024 137.628 84.6529 136.362 79.5893 135.096C78.3233 139.526 76.741 143.324 75.1586 147.438C72.6267 144.274 70.4113 140.792 68.196 137.311C67.8795 137.311 67.563 137.628 67.563 137.628C69.1454 142.375 71.0443 146.805 73.5762 151.236C67.8795 162.629 60.284 171.174 55.8533 174.656C63.4488 173.073 70.0949 166.111 75.1586 158.832C75.1586 158.515 75.475 158.515 75.475 158.199C75.7915 157.882 75.7915 157.566 76.108 157.249C75.7915 156.616 75.7915 156.3 76.108 155.983Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg t="1699366856026" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="12522" width="32" height="32"><path d="M213.333333 256h85.333334v85.333333H213.333333zM384 256h85.333333v85.333333H384z" fill="#09121F" p-id="12523"></path><path d="M896 128H128c-25.6 0-42.666667 17.066667-42.666667 42.666667v640c0 25.6 17.066667 42.666667 42.666667 42.666666h768c25.6 0 42.666667-17.066667 42.666667-42.666666V170.666667c0-25.6-17.066667-42.666667-42.666667-42.666667z m-42.666667 640H170.666667v-298.666667h682.666666v298.666667z m0-384H170.666667V213.333333h682.666666v170.666667z" fill="#09121F" p-id="12524"></path></svg>
|
||||
|
After Width: | Height: | Size: 659 B |
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
t="1710490291582"
|
||||
class="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="3145"
|
||||
width="200"
|
||||
height="200"
|
||||
>
|
||||
<path
|
||||
d="M476.811454 234.638085S575.875862 38.761663 627.148553 9.152912A275.775 275.775 0 0 1 707.702374 0.204763c22.66728 0.839529 111.186386 4.218121 152.753304 63.804195a84.403367 84.403367 0 0 1 4.914315 7.719571 189.938291 189.938291 0 0 1-16.196764 145.996121 204.517427 204.517427 0 0 1-131.048411 98.900597l-179.822992 34.154493a62.616569 62.616569 0 0 0 51.190785 47.83267l163.953849-2.313824a443.353156 443.353156 0 0 1-66.302306 69.373753c-65.524206 55.081285-131.048411 85.242896-177.77536 91.140075-34.400208 2.3343-68.00184-1.474295-102.012998-2.190966 0 0 17.077246 96.812014 172.00104 79.734768a152.978544 152.978544 0 0 1-17.363915 47.382191 166.861485 166.861485 0 0 1-111.432102 79.243336c-52.624128 4.607171-103.917295 2.047631-155.88618 3.173829 0 0-52.378412 189.037333-84.260034 259.639665H176.956308s14.988662-92.143414 47.976004-262.956828A311.731408 311.731408 0 0 1 170.567698 423.634466s28.032074 159.838109 124.905517 181.481573c-7.350997-129.881261-43.55312-267.420664-15.930573-385.568997a382.907077 382.907077 0 0 1 50.105541-76.294747 400.844328 400.844328 0 0 1 166.533864-115.875463s-87.679578 63.763243-66.05659 214.100342z"
|
||||
p-id="3146"
|
||||
></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg t="1697982393842" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3088" width="200" height="200"><path d="M512 1024C229.222 1024 0 794.778 0 512S229.222 0 512 0s512 229.222 512 512-229.222 512-512 512z m259.149-568.883h-290.74a25.293 25.293 0 0 0-25.292 25.293l-0.026 63.206c0 13.952 11.315 25.293 25.267 25.293h177.024c13.978 0 25.293 11.315 25.293 25.267v12.646a75.853 75.853 0 0 1-75.853 75.853h-240.23a25.293 25.293 0 0 1-25.267-25.293V417.203a75.853 75.853 0 0 1 75.827-75.853h353.946a25.293 25.293 0 0 0 25.267-25.292l0.077-63.207a25.293 25.293 0 0 0-25.268-25.293H417.152a189.62 189.62 0 0 0-189.62 189.645V771.15c0 13.977 11.316 25.293 25.294 25.293h372.94a170.65 170.65 0 0 0 170.65-170.65V480.384a25.293 25.293 0 0 0-25.293-25.267z" fill="#C71D23" p-id="3089"></path></svg>
|
||||
|
After Width: | Height: | Size: 833 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1764160222915" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="19703" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M937.984 926.72H492.544c-40.96 0-74.752-32.768-74.752-74.752 0-40.96 32.768-74.752 74.752-74.752h445.44c40.96 0 74.752 32.768 74.752 74.752 0 40.96-33.792 74.752-74.752 74.752z" fill="#D7E4F9" p-id="19704"></path><path d="M438.272 497.664m-429.056 0a429.056 429.056 0 1 0 858.112 0 429.056 429.056 0 1 0-858.112 0Z" fill="#77A4FF" p-id="19705"></path><path d="M306.176 360.448m-74.752 0a74.752 74.752 0 1 0 149.504 0 74.752 74.752 0 1 0-149.504 0Z" fill="#FFFFFF" p-id="19706"></path><path d="M569.344 360.448m-74.752 0a74.752 74.752 0 1 0 149.504 0 74.752 74.752 0 1 0-149.504 0Z" fill="#FFFFFF" p-id="19707"></path><path d="M306.176 620.544m-74.752 0a74.752 74.752 0 1 0 149.504 0 74.752 74.752 0 1 0-149.504 0Z" fill="#FFFFFF" p-id="19708"></path><path d="M569.344 620.544m-74.752 0a74.752 74.752 0 1 0 149.504 0 74.752 74.752 0 1 0-149.504 0Z" fill="#FFFFFF" p-id="19709"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1764160110517" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7141" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M577.15712 340.12672l-19.54816-16.67584c-0.05632-0.05632-0.16384-0.11264-0.256-0.16896l-40.57088-34.51392-49.22368 42.42944-302.98624 258.32448v296.28928c0 40.92416 34.29888 74.12224 76.56448 74.12224H409.6v-237.98784c0-24.54528 20.5568-44.45696 45.93664-44.45696h122.496c25.37984 0 45.952 19.91168 45.952 44.45696v237.99296h168.46848c42.28096 0 76.56448-33.19808 76.56448-74.12736v-296.86784l-290.98496-248.064-0.87552-0.75264z" fill="#f6670B" p-id="7142"></path><path d="M950.30784 435.33312L863.8976 361.6512l-122.51648-104.45312-188.9024-161.06496c-23.15776-19.71712-57.84064-19.71712-80.96768 0L73.6768 435.33312c-25.3952 21.62688-27.84256 59.1104-5.4784 83.68128 22.37952 24.576 61.07136 26.96192 86.4512 5.29408L512 219.65312l357.36064 304.65536a62.16704 62.16704 0 0 0 40.45824 14.81216c16.98816 0 33.88928-6.78912 45.97248-20.10624 22.3744-24.58624 19.92192-62.0544-5.48352-83.68128z" fill="#f6670B" p-id="7143"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,37 @@
|
||||
<svg
|
||||
t="1710898099143"
|
||||
class="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="2321"
|
||||
width="200"
|
||||
height="200"
|
||||
>
|
||||
<path
|
||||
d="M335.657769 831.865977c0 10.134611 0 20.269222 0.202692 30.403833 0 2.189076 0 4.337614-0.202692 6.405074zM688.017924 152.765964v345.387543c-0.243231-110.426721-0.77023-220.893981-0.891846-331.320703a117.561487 117.561487 0 0 1 0.891846-14.06684z"
|
||||
fill="#93CEAA"
|
||||
p-id="2322"
|
||||
></path>
|
||||
<path
|
||||
d="M687.085539 508.49081a68.50997 68.50997 0 0 0 0.932385-10.337303v322.929244l-169.207465-154.73524-85.130733-77.631121-51.240593-47.105671-22.782605-20.796222-8.391458-7.661766-0.608077-0.527c-2.067461-1.662076-4.053844-3.405229-5.959151-4.986228s-3.567383-2.959306-5.837536-2.270153a5.067305 5.067305 0 0 0-3.202537 3.64846v-304.03833l1.743153 1.580999 34.741446 31.417294c18.282838 16.215378 36.72783 32.430755 54.97013 48.646133q38.916906 34.863062 77.590582 70.131508 30.606525 27.809373 61.131974 55.699822c24.323066 22.174529 48.321825 44.592288 72.644891 66.604664 10.94538 9.89138 21.971837 19.580068 33.038832 29.309295 2.35123 2.067461 4.661921 4.053844 7.053689 6.161843a5.959151 5.959151 0 0 0 5.675382 0.729692 4.702459 4.702459 0 0 0 2.270153-2.918768c0.324308-1.540461 0.324308-2.59446 0.567538-3.851152z"
|
||||
fill="#4C9717"
|
||||
p-id="2323"
|
||||
></path>
|
||||
<path
|
||||
d="M335.292923 510.801501a63.523742 63.523742 0 0 0-0.405385 11.107534c0 103.373032 0 206.746064 0.770231 310.078558v36.808907a60.199589 60.199589 0 0 1-23.796067 44.957134c-27.60668 23.066375-53.348592 48.281287-79.98235 72.523276-11.148072 10.134611-22.215067 20.269222-33.687447 30.160603s-40.984367 11.107534-57.321359-2.553922a318.145708 318.145708 0 0 1-34.619832-35.106293 51.727054 51.727054 0 0 1-15.120839-35.227908c0-2.918768-0.243231-6.121305 0-9.648149 0-6.080767 0-12.161533 0.202692-18.282838 0.202692-17.431531 0-34.9036 0-52.335132V294.772133a51.240593 51.240593 0 0 1 1.540461-12.891225 74.671814 74.671814 0 0 1 4.053844-10.702149 89.833192 89.833192 0 0 1 8.188766-14.512763l64.86151-64.86151 41.308675-41.268136a31.78214 31.78214 0 0 1 7.459073-5.594306 32.633447 32.633447 0 0 1 13.012841-4.053844 56.145745 56.145745 0 0 1 39.362829 7.256381 55.091745 55.091745 0 0 1 8.472535 6.364536l56.064668 50.348748v304.038329a13.701994 13.701994 0 0 0-0.364846 1.905307z"
|
||||
fill="#5FBC21"
|
||||
p-id="2324"
|
||||
></path>
|
||||
<path
|
||||
d="M335.292923 510.801501a13.701994 13.701994 0 0 1 0.364846-1.662076v322.726552c-0.608077-103.373032-0.567538-206.746064-0.770231-310.078558a63.523742 63.523742 0 0 1 0.405385-10.985918z"
|
||||
fill="#E8CEAA"
|
||||
opacity=".6"
|
||||
p-id="2325"
|
||||
></path>
|
||||
<path
|
||||
d="M924.357052 758.937317l-119.142487 109.453798a15.201916 15.201916 0 0 1-1.459384 0.972923l-0.445923 0.324308-0.810769 0.486461a56.753822 56.753822 0 0 1-67.942432-6.688843l-7.175304-6.283459-39.524983-36.119754V152.765964c0.648615-5.634844 1.621538-11.269687 2.716076-16.782916 2.837691-14.391148 15.485686-21.525914 25.133835-30.525448 20.958376-19.458453 42.565366-38.187214 63.929126-57.240283 14.715455-13.134456 29.390372-26.30945 44.227442-39.362829 12.931764-11.391303 37.984522-10.823765 50.146056-3.770075a40.984367 40.984367 0 0 1 8.431996 6.526689l13.701994 13.215533 11.066995 10.580534 10.094073 9.769765a46.416518 46.416518 0 0 1 7.215843 8.472535 47.551595 47.551595 0 0 1 6.891535 26.187834c-0.202692 59.429359 0.243231 118.858718 0.364846 178.369154q0.364846 151.411088 0.648615 302.822176 0 77.590582 0.283769 155.140625v7.094228c0.608077 15.404609 0.283769 25.579758-8.350919 35.673831z"
|
||||
fill="#5FBC21"
|
||||
p-id="2326"
|
||||
></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1764160190679" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="16455" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M818.390585 626.380426A238.328552 238.328552 0 0 1 991.98516 856.101247v57.182213C991.98516 974.369551 942.642702 1023.968001 881.780604 1023.968001H110.220716A110.460548 110.460548 0 0 1 0.01616 913.251461v-57.182213a238.328552 238.328552 0 0 1 173.594575-229.720821 1225.977688 1225.977688 0 0 1 167.514765-34.750914l115.900378 116.47636a54.942283 54.942283 0 0 0 77.949564 0l115.932377-116.47636a1225.241711 1225.241711 0 0 1 167.514765 34.782913h-0.031999zM496.00066 0c152.187244 0 275.543389 123.900128 275.543389 276.759351 0 152.827224-123.356145 276.727352-275.543389 276.727353S220.457271 429.586575 220.457271 276.79135C220.457271 123.900128 343.813416 0 496.00066 0z" fill="#32C8DA" p-id="16456"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1764160144411" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10986" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M450.8 932.8L175.4 775.4c-38.6-22.1-62.4-62.9-62.4-107.1V354c0-44.2 23.8-85 62.4-107.1L450.8 89.5c38.5-22 85.8-22 124.3 0l275.4 157.4C889.2 269 913 309.8 913 354v314.2c0 44.2-23.8 85-62.4 107.1L575.2 932.8c-38.5 22-85.9 22-124.4 0z" fill="#787AF6" p-id="10987"></path><path d="M118.7 317.1c7.6-15.5 16.1-30.5 25.5-44.9-11.6 13-20.3 28.3-25.5 44.9z" fill="#FEAC33" p-id="10988"></path><path d="M585.5 95.4l-10.3-5.9c-36.2-20.7-80.3-21.9-117.4-3.7 10.9-0.9 22-1.3 33.1-1.3 32.5 0.1 64.2 3.8 94.6 10.9zM113 668.3c0 44.2 23.8 85 62.4 107.1l16.6 9.5c-32.6-34-59.4-73.6-79-117.1v0.5zM913 668.3V354c0-44.2-23.8-85-62.4-107.1l-60.8-34.7c71.2 74.3 115 175.2 115 286.3 0 228.6-185.3 413.9-413.9 413.9-32.6 0-64.3-3.8-94.7-10.9l54.7 31.2c38.5 22 85.8 22 124.3 0l275.4-157.4c38.6-22 62.4-62.8 62.4-107z" fill="#787AF6" p-id="10989"></path><path d="M904.8 498.5c0-111.1-43.8-211.9-115-286.3L585.5 95.4c-30.4-7.1-62-10.9-94.6-10.9-11.1 0-22.2 0.4-33.1 1.3-2.3 1.1-4.6 2.4-6.9 3.7l-85.6 48.9c34.1-10.8 70.3-16.6 108-16.6 197.3 0 357.2 159.9 357.2 357.2s-160 357.3-357.3 357.3S116 676.3 116 479c0-105.8 46-200.8 119-266.2l-59.6 34.1c-11.9 6.8-22.4 15.4-31.2 25.3-9.4 14.4-17.9 29.3-25.5 44.9-3.7 11.8-5.7 24.2-5.7 36.9v313.7c19.5 43.6 46.4 83.1 79 117.1l204.2 116.7c30.4 7.1 62.1 10.9 94.7 10.9 228.6 0 413.9-185.3 413.9-413.9z" fill="#817EF8" p-id="10990"></path><path d="M116 479c0 197.3 159.9 357.2 357.2 357.2S830.5 676.3 830.5 479s-160-357.2-357.3-357.2c-37.6 0-73.9 5.8-108 16.6L235 212.9C162 278.3 116 373.3 116 479z m339.6-319.9c166 0 300.5 134.5 300.5 300.5S621.6 760.1 455.6 760.1 155.1 625.6 155.1 459.6s134.6-300.5 300.5-300.5z" fill="#8A81F9" p-id="10991"></path><path d="M455.6 760.1c166 0 300.5-134.5 300.5-300.5S621.6 159.1 455.6 159.1 155.1 293.6 155.1 459.6s134.6 300.5 300.5 300.5zM438 196.4c134.6 0 243.8 109.1 243.8 243.8S572.6 683.9 438 683.9 194.2 574.8 194.2 440.1 303.4 196.4 438 196.4z" fill="#9385FB" p-id="10992"></path><path d="M438 683.9c134.6 0 243.8-109.1 243.8-243.8S572.6 196.4 438 196.4 194.2 305.5 194.2 440.1 303.4 683.9 438 683.9z m-17.6-450.3c103.3 0 187.1 83.7 187.1 187.1s-83.7 187.1-187.1 187.1S233.3 524 233.3 420.7s83.8-187.1 187.1-187.1z" fill="#9D89FC" p-id="10993"></path><path d="M420.4 607.7c103.3 0 187.1-83.7 187.1-187.1s-83.7-187.1-187.1-187.1-187.1 83.7-187.1 187.1 83.8 187.1 187.1 187.1z m-17.7-336.8c72 0 130.3 58.4 130.3 130.3s-58.4 130.3-130.3 130.3-130.3-58.4-130.3-130.3 58.4-130.3 130.3-130.3z" fill="#A68CFD" p-id="10994"></path><path d="M402.7 401.2m-130.3 0a130.3 130.3 0 1 0 260.6 0 130.3 130.3 0 1 0-260.6 0Z" fill="#AF90FF" p-id="10995"></path><path d="M513 91.1c18.7 0 37.1 4.9 53.2 14.1l275.4 157.4c32.9 18.8 53.3 53.9 53.3 91.5v314.2c0 37.6-20.4 72.7-53.3 91.5L566.2 917.2c-16.2 9.2-34.6 14.1-53.2 14.1-18.7 0-37.1-4.9-53.2-14.1L184.3 759.8C151.4 741 131 705.9 131 668.3V354c0-37.6 20.4-72.7 53.3-91.5l275.5-157.4c16.1-9.2 34.5-14 53.2-14m0-18c-21.5 0-42.9 5.5-62.2 16.5L175.4 246.9C136.8 269 113 309.8 113 354v314.2c0 44.2 23.8 85 62.4 107.1l275.4 157.4c19.2 11 40.7 16.5 62.2 16.5s42.9-5.5 62.2-16.5l275.4-157.4c38.6-22.1 62.4-62.9 62.4-107.1V354c0-44.2-23.8-85-62.4-107.1L575.2 89.5c-19.3-10.9-40.7-16.4-62.2-16.4z" fill="#676EE5" p-id="10996"></path><path d="M483.3 712.1l-131.7-75c-18.5-10.5-29.8-30-29.8-51V436.3c0-21.1 11.4-40.5 29.8-51l131.7-75c18.4-10.5 41-10.5 59.4 0l131.7 75c18.5 10.5 29.8 30 29.8 51V586c0 21.1-11.4 40.5-29.8 51l-131.7 75c-18.4 10.6-41 10.6-59.4 0.1z" fill="#CDC5FF" p-id="10997"></path><path d="M172.2 417.5c-9.9 0-18-8.1-18-18V361c0-29.2 14.8-55.9 39.7-71.3l34.6-21.5c8.5-5.2 19.5-2.6 24.8 5.8 5.2 8.5 2.6 19.5-5.8 24.8l-34.6 21.5c-14.2 8.8-22.7 24-22.7 40.7v38.5c0 9.9-8.1 18-18 18zM172.2 487.1c-9.9 0-18-8.1-18-18v-10.5c0-9.9 8.1-18 18-18s18 8.1 18 18v10.5c0 9.9-8.1 18-18 18z" fill="#FFFFFF" p-id="10998"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="200px" height="200px" viewBox="0 0 200 200" enable-background="new 0 0 200 200" xml:space="preserve"> <image id="image0" width="200" height="200" x="0" y="0"
|
||||
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAMAAACahl6sAAAAIGNIUk0AAHomAACAhAAA+gAAAIDo
|
||||
AAB1MAAA6mAAADqYAAAXcJy6UTwAAAGwUExURQAAABKU2xKV2RKX2xKW3BKW3BKX3BKW2xKW2hOW
|
||||
2xOV2xGW3BKS2xKV2xKW2xKW2hKW2xKX2xKW2xei6BKW2xKV3A+b4BKW2hKW2xOV3BOZ3xKV3BCX
|
||||
1xKW3BCP3xKW2xKW2xGW3BKX2xKW2xKb2xKV2xKV2xSW2BOX2xKX2xKW2yCf3xWV3xGX2RKV3BKW
|
||||
2xGW2hKW2xGV2xKW2xGX3RSZ4BGW3hKX3BGX3BOX3BeX3BKV2hGW2xGV2xGW2RGW2w+Z2wCS2xCT
|
||||
2xKV2hSW3BOX2xGX2xKV3BKW2xOU2hKX3BSJ2BOV3BKX3BGW2xSU3RGV3RKX3BKW2xGX3BKX2wCA
|
||||
1RKW2hKW2xOV2hOW2xKW2hKW2xOW2g+S2xKV2xKW2hKW2xKS2xCW2xKW2hKY2xWV3BOW2xKW3BOV
|
||||
2hOW2xGX3BKV2xGW2xGX2xOW2hKV2hKW2xKW2xKW2xKV2xKW3BOW2hOX2wCq/xKW2xKX2w+W4RKX
|
||||
2xKW3BGZ3RKW2xGW2hGV2xGW2xGW2xGT3QyS2xGV2xGW2w+U2xKX2xOW3BOW2hKV2xKW2xOW2RKW
|
||||
2////4+11WMAAACOdFJOUwA5gJ272ffhw6WHSQ5jqu24R9QLnDoh0fNeN3QgZhDkVa9xzBzjjidd
|
||||
wfcIGFhIj2jPeO9MGS6mWIkWb6HqPb8jB0CuM9zNHbc3kA1svKImWZ/WO38G2MgpeuvFlyNGi/Qc
|
||||
TsoqJLLuUpZnulx4pXTnZMdUp0SHA/pWEcbfHuVLatyjLRWH+DLXl3yacFAkzTqMAAAAAWJLR0SP
|
||||
9QKD+QAAAAd0SU1FB+kMCQgXH8P/mroAAAN1SURBVHja7d3nU1NBFMbh9doFYkAUEFAQBEQh0lQU
|
||||
CQQL9l4BC2LB3nvvdf9mh+gMZJKFnHt39xyG9/eZWd5nmGiYyXKVQgghhBBCCCGEaM0L5i9YuEh7
|
||||
bvGSpcsKCu0pimLLfROmFg/sWIrjnIp0JUXRGStKuRXp4isjOlaVcRP+V14RybGae/+UKiM4qrjH
|
||||
Z1Qd2rFmLff2zGrCQmq5l1uSrOPenVVdKEf9eu7d2YV6mTRwr85RmB9JI/fonDXRIZXcm3MWo0M2
|
||||
cG/OWfNGqmMT92RDLVRIK/diQwkqpIV7saFaKmQz92JDbVRIO/diQx1USMC92BQg0gJEWoBICxBp
|
||||
AWIo6OzK76AtTVslQ7ZRzuqWC9lOO8zm7wRWITuop/XIhOykHqZ6ZUKSZEifTEgDGdIvExKQIQoQ
|
||||
QAABRAokFRiadRDDF2X+rwkIIIAAAggggAACCCCAAAIIIIAAMuchSUOzDZJfgAACCCCARIcMkB27
|
||||
ZEJ2kyHVMiH0m3R7hEL0Xtphg/Yctj/mtI9y1n6LDvsfPKupz+ucA6mDh2w68FFAcQEiLUCkBYi0
|
||||
AJEWINICRFqAGMr7Igytma/NWIaQLsLQ6vYJIV6EodXiD0K+CEOrxxeEfhGGVq8vCP0iDK0+XxD6
|
||||
RRha/b4ghx1DjviClDmGHPUF0cVuIce8QY47dZzQ3iD6pEPHqdMeIfpMpyPG2XPTf2P7737PxwIH
|
||||
1c30bfE2XlyASAsQaQEiLUCkBYi0AJGWbUj50LCLt/GxEs+QgRHqgfl2ocwnpNUVY6KEP8hFlw6l
|
||||
2nxBLl12Cxn1Bbni1qHGfEFKHUMKfUGGHUO6fEGGHEM6fUHKr7qFBL4g+ppTR+N1bxB9wyVkXPuD
|
||||
6JvOGGMzPCzEMkTfStxOOejO3Q7tF8IWINICRFqASAsQac1ZyD3uwYbuUyEPuBcbekiFPOJebOgx
|
||||
FfKEe7Ghp1TIs+fck3NXQIVM/6EpvuhPpn3BPTlncbJDvRT4OMRQfxNA4AMqtS4J9czjV9yzswv3
|
||||
xOMa7tlZhXiFpBP3D1cqJES95l6eWZhX+r9GuKdnRP/jH5O9ecu9frLwP49049EXWOntu2gOpd5/
|
||||
4DZM9PFTVIdSn7985WYkR6MzJvrWPtjMp/j+g/zk1ulKVfz8FU96rjbx+0+VTQVCCCGEEEJITH8B
|
||||
qjlHGZvzsuQAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjUtMTItMDlUMDg6MjM6MzArMDA6MDDsanQX
|
||||
AAAAJXRFWHRkYXRlOm1vZGlmeQAyMDI1LTEyLTA5VDA4OjIzOjMwKzAwOjAwnTfMqwAAACh0RVh0
|
||||
ZGF0ZTp0aW1lc3RhbXAAMjAyNS0xMi0wOVQwODoyMzozMCswMDowMMoi7XQAAAAASUVORK5CYII=" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,251 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 移动端 Dialog 全屏适配 ===== */
|
||||
@media (max-width: 639px) {
|
||||
[data-radix-dialog-content],
|
||||
[role="dialog"] {
|
||||
max-width: calc(100vw - 2rem) !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* 表单元素在移动端全宽展示 */
|
||||
[data-radix-dialog-content] input,
|
||||
[data-radix-dialog-content] select,
|
||||
[data-radix-dialog-content] textarea,
|
||||
[role="dialog"] input,
|
||||
[role="dialog"] select,
|
||||
[role="dialog"] textarea {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 移动端触摸交互优化 ===== */
|
||||
@media (max-width: 639px) {
|
||||
/* 按钮和交互元素最小触摸区域 44px */
|
||||
/* button,
|
||||
a,
|
||||
[role="button"],
|
||||
[role="tab"],
|
||||
[role="menuitem"],
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
} */
|
||||
|
||||
/* TabsTrigger 在 TabsList 内不需要 44px 最小尺寸 */
|
||||
[data-slot="tabs-list"] [role="tab"] {
|
||||
min-height: unset;
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
/* 表格内操作按钮保持紧凑但可触摸 */
|
||||
table button,
|
||||
table [role="button"] {
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 全局触摸优化 */
|
||||
@media (pointer: coarse) {
|
||||
/* 清除移动端点击高亮 */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* 防止双击缩放 */
|
||||
button,
|
||||
a,
|
||||
[role="button"],
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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,93 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button class="counter" @click="count++">Count is {{ count }}</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
variant?: 'default' | 'destructive'
|
||||
}>(),
|
||||
{
|
||||
title: '确认操作',
|
||||
description: '确定要执行此操作吗?',
|
||||
confirmText: '确认',
|
||||
cancelText: '取消',
|
||||
variant: 'default',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
confirm: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm')
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
emit('update:open', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialog :open="open" @update:open="emit('update:open', $event)">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ title }}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{{ description }}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel @click="handleCancel">{{ cancelText }}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
:class="variant === 'destructive' ? 'bg-destructive text-white hover:bg-destructive/90' : ''"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,249 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import { ref, watch, onMounted, computed, type Ref } from 'vue'
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
useVueTable,
|
||||
type ColumnDef,
|
||||
} from '@tanstack/vue-table'
|
||||
import { Search } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination'
|
||||
import PageLoading from './PageLoading.vue'
|
||||
import type { PageParams, PageResponse } from '@/types/api'
|
||||
|
||||
export interface SearchField {
|
||||
key: string
|
||||
label: string
|
||||
type: 'input' | 'select'
|
||||
options?: { label: string; value: string }[]
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
columns: ColumnDef<T, any>[]
|
||||
fetchFn: (params: PageParams) => Promise<PageResponse<T>>
|
||||
searchFields?: SearchField[]
|
||||
pageSize?: number
|
||||
}>(),
|
||||
{
|
||||
searchFields: () => [],
|
||||
pageSize: 10,
|
||||
},
|
||||
)
|
||||
|
||||
const page = ref(1)
|
||||
const size = ref(props.pageSize)
|
||||
const total = ref(0)
|
||||
const data = ref<T[]>([]) as Ref<T[]>
|
||||
const loading = ref(false)
|
||||
const searchParams = ref<Record<string, string>>({})
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / size.value)))
|
||||
|
||||
const table = useVueTable({
|
||||
get columns() {
|
||||
return props.columns
|
||||
},
|
||||
get data() {
|
||||
return data.value
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get rowCount() {
|
||||
return total.value
|
||||
},
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: PageParams = {
|
||||
page: page.value,
|
||||
size: size.value,
|
||||
...searchParams.value,
|
||||
}
|
||||
// 过滤空值和 'all' 占位值
|
||||
Object.keys(params).forEach((key) => {
|
||||
if (params[key] === '' || params[key] === undefined || params[key] === 'all') {
|
||||
delete params[key]
|
||||
}
|
||||
})
|
||||
const res = await props.fetchFn(params)
|
||||
data.value = res.list
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchParams.value = {}
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
page.value = newPage
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.fetchFn,
|
||||
() => {
|
||||
page.value = 1
|
||||
fetchData()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
defineExpose({ refresh })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 搜索栏 -->
|
||||
<div v-if="searchFields && searchFields.length > 0" class="mb-4 flex flex-wrap items-end gap-3">
|
||||
<template v-for="field in searchFields" :key="field.key">
|
||||
<div v-if="field.type === 'input'" class="w-full sm:w-auto">
|
||||
<Input
|
||||
v-model="searchParams[field.key]"
|
||||
:placeholder="field.placeholder || field.label"
|
||||
class="h-9 w-full sm:w-44"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="field.type === 'select'" class="w-full sm:w-auto">
|
||||
<Select v-model="searchParams[field.key]">
|
||||
<SelectTrigger class="h-9 w-full sm:w-36">
|
||||
<SelectValue :placeholder="field.placeholder || field.label" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="opt in field.options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" @click="handleSearch">
|
||||
<Search class="mr-1 h-4 w-4" />
|
||||
搜索
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" @click="handleReset">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="rounded-md border overflow-x-auto">
|
||||
<PageLoading v-if="loading && data.length === 0" text="加载中..." />
|
||||
<Table v-else class="min-w-[600px]">
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead v-for="header in headerGroup.headers" :key="header.id">
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows.length">
|
||||
<TableRow v-for="row in table.getRowModel().rows" :key="row.id">
|
||||
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
<template v-else>
|
||||
<TableRow>
|
||||
<TableCell :colspan="columns.length" class="text-center py-10 text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<!-- loading 遮罩(有数据时) -->
|
||||
<div v-if="loading && data.length > 0" class="flex justify-center py-2">
|
||||
<span class="text-sm text-muted-foreground">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="total > 0" class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||
<span class="text-sm text-muted-foreground">共 {{ total }} 条</span>
|
||||
<Pagination
|
||||
:total="total"
|
||||
:items-per-page="size"
|
||||
:page="page"
|
||||
:sibling-count="1"
|
||||
@update:page="handlePageChange"
|
||||
>
|
||||
<PaginationContent>
|
||||
<PaginationPrevious>上一页</PaginationPrevious>
|
||||
<template v-for="(item, index) in totalPages" :key="index">
|
||||
<PaginationItem v-if="shouldShowPage(item, page, totalPages)" :value="item" :is-active="item === page">
|
||||
{{ item }}
|
||||
</PaginationItem>
|
||||
<PaginationEllipsis v-else-if="shouldShowEllipsis(item, page, totalPages)" :index="index" />
|
||||
</template>
|
||||
<PaginationNext>下一页</PaginationNext>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// 辅助函数:判断是否显示页码
|
||||
function shouldShowPage(pageNum: number, current: number, total: number): boolean {
|
||||
if (pageNum === 1 || pageNum === total) return true
|
||||
if (Math.abs(pageNum - current) <= 1) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// 辅助函数:判断是否显示省略号
|
||||
function shouldShowEllipsis(pageNum: number, current: number, total: number): boolean {
|
||||
if (pageNum === 2 && current > 3) return true
|
||||
if (pageNum === total - 1 && current < total - 2) return true
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
icon: Component
|
||||
tooltip: string
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'ghost' | 'secondary' | 'link'
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon'
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ click: [e: MouseEvent] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
:variant="variant ?? 'ghost'"
|
||||
size="icon"
|
||||
:disabled="disabled"
|
||||
v-bind="$attrs"
|
||||
@click="$emit('click', $event)"
|
||||
>
|
||||
<component :is="icon" class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ tooltip }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { LoaderCircle } from 'lucide-vue-next'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
text?: string
|
||||
}>(),
|
||||
{
|
||||
text: '',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<LoaderCircle class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p v-if="text" class="text-sm text-muted-foreground">{{ text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
// 使用 import.meta.glob 加载所有 isme 目录下的 SVG 文件(raw 字符串)
|
||||
const svgModules = import.meta.glob('@/assets/icons/isme/*.svg', { eager: true, query: '?raw', import: 'default' }) as Record<string, string>
|
||||
|
||||
const svgContent = computed(() => {
|
||||
// import.meta.glob 返回的 key 是相对路径格式,需要匹配
|
||||
const suffix = `/${props.name}.svg`
|
||||
const matchedKey = Object.keys(svgModules).find(k => k.endsWith(suffix))
|
||||
const raw = matchedKey ? (svgModules[matchedKey] as string) : undefined
|
||||
if (!raw) return ''
|
||||
// 移除 XML 声明和 DOCTYPE,只保留 <svg> 标签
|
||||
let cleaned = raw.replace(/<\?xml[^?]*\?>/g, '').replace(/<!DOCTYPE[^>]*>/g, '').trim()
|
||||
// 仅移除 <svg> 根元素上的 width/height,不影响子元素的 width/height
|
||||
cleaned = cleaned.replace(/^(<svg\b)([^>]*?)>/, (_match, tag, attrs) => {
|
||||
const cleanedAttrs = attrs.replace(/\s(?:width|height)="[^"]*"/g, '')
|
||||
return `${tag}${cleanedAttrs}>`
|
||||
})
|
||||
return cleaned
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="svgContent" :class="props.class" style="display: inline-flex; align-items: center; justify-content: center;" v-html="svgContent" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
span :deep(svg) {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Menu, LogOut } from 'lucide-vue-next'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { resetRouteState } from '@/router/guards'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import BreadCrumb from './BreadCrumb.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout()
|
||||
userStore.clearProfile()
|
||||
resetRouteState()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex h-14 shrink-0 items-center border-b bg-background px-4 gap-3">
|
||||
<!-- 左侧:Logo + 折叠按钮 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-base font-semibold whitespace-nowrap">天神</span>
|
||||
<Button variant="ghost" size="icon-sm" class="hidden md:inline-flex" @click="appStore.toggleSidebar">
|
||||
<Menu class="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 中间:面包屑 -->
|
||||
<div class="flex-1 min-w-0 hidden md:block">
|
||||
<BreadCrumb />
|
||||
</div>
|
||||
|
||||
<!-- 右侧:用户头像+名称 下拉菜单 -->
|
||||
<div class="ml-auto">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="flex items-center gap-2 cursor-pointer rounded-md px-2 py-1 hover:bg-accent transition-colors outline-none">
|
||||
<Avatar class="size-8">
|
||||
<AvatarImage v-if="userStore.qqAvatar" :src="userStore.qqAvatar" />
|
||||
<AvatarFallback>{{ userStore.profile?.username?.charAt(0)?.toUpperCase() || 'U' }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="hidden sm:flex flex-col items-start text-left">
|
||||
<span class="text-sm leading-tight">{{ userStore.profile?.username || '用户' }}</span>
|
||||
<span v-if="userStore.currentRoleName" class="text-xs text-muted-foreground leading-tight">[{{ userStore.currentRoleName }}]</span>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-40">
|
||||
<DropdownMenuItem @click="handleLogout">
|
||||
<LogOut class="mr-2 size-4" />
|
||||
退出登录
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
import MobileNav from './MobileNav.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const keepAliveInclude = computed(() => {
|
||||
// 收集所有需要缓存的路由组件名
|
||||
const names: string[] = []
|
||||
if (route.matched) {
|
||||
route.matched.forEach(r => {
|
||||
if (r.meta?.keepAlive && r.name) {
|
||||
names.push(r.name as string)
|
||||
}
|
||||
})
|
||||
}
|
||||
return names
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen overflow-hidden bg-background">
|
||||
<!-- PC 端侧边栏 -->
|
||||
<AppSidebar v-if="!appStore.isMobile" />
|
||||
|
||||
<!-- 右侧主区域 -->
|
||||
<div class="flex flex-1 flex-col min-w-0 overflow-hidden">
|
||||
<!-- 顶部栏 -->
|
||||
<AppHeader />
|
||||
|
||||
<!-- 内容区 -->
|
||||
<main class="flex-1 overflow-y-auto p-4">
|
||||
<router-view v-slot="{ Component }">
|
||||
<KeepAlive :include="keepAliveInclude">
|
||||
<component :is="Component" />
|
||||
</KeepAlive>
|
||||
</router-view>
|
||||
</main>
|
||||
|
||||
<!-- 移动端底部导航(在 flex 列内,不用 fixed) -->
|
||||
<MobileNav v-if="appStore.isMobile" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { usePermissionStore } from '@/stores/permission'
|
||||
import SidebarMenuItem from './SidebarMenuItem.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
const visibleMenus = computed(() =>
|
||||
permissionStore.menus.filter(m => !m.hidden && m.type !== 'button')
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="hidden md:flex flex-col border-r bg-background transition-all duration-300 overflow-hidden"
|
||||
:class="appStore.sidebarCollapsed ? 'w-16' : 'w-60'"
|
||||
>
|
||||
<!-- Logo 区域 -->
|
||||
<div class="flex h-14 items-center border-b px-3 shrink-0">
|
||||
<span
|
||||
v-if="!appStore.sidebarCollapsed"
|
||||
class="text-base font-semibold truncate"
|
||||
>
|
||||
天神
|
||||
</span>
|
||||
<span v-else class="mx-auto text-base font-semibold">天神</span>
|
||||
</div>
|
||||
|
||||
<!-- 菜单列表 -->
|
||||
<nav class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
<SidebarMenuItem
|
||||
v-for="menu in visibleMenus"
|
||||
:key="menu.id"
|
||||
:menu="menu"
|
||||
:collapsed="appStore.sidebarCollapsed"
|
||||
/>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return route.matched
|
||||
.filter(r => r.meta?.title)
|
||||
.map((r, index, arr) => ({
|
||||
title: r.meta.title as string,
|
||||
path: r.path,
|
||||
isLast: index === arr.length - 1,
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<template v-for="(item, index) in breadcrumbs" :key="item.path">
|
||||
<BreadcrumbSeparator v-if="index > 0" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage v-if="item.isLast">
|
||||
{{ item.title }}
|
||||
</BreadcrumbPage>
|
||||
<BreadcrumbLink v-else :href="item.path">
|
||||
{{ item.title }}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</template>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</template>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import SvgIcon from '@/components/common/SvgIcon.vue'
|
||||
import { usePermissionStore } from '@/stores/permission'
|
||||
import type { UserMenu } from '@/types/menu'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
// 二级菜单弹窗状态
|
||||
const popoverMenuId = ref<number | null>(null)
|
||||
const popoverChildren = ref<UserMenu[]>([])
|
||||
|
||||
const visibleMenus = computed(() =>
|
||||
permissionStore.menus.filter(m => !m.hidden && m.type !== 'button')
|
||||
)
|
||||
|
||||
function ensureAbsolutePath(path: string) {
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
}
|
||||
|
||||
function isCustom(icon: string | undefined): boolean {
|
||||
if (!icon) return false
|
||||
return icon.replace(/^i-/, '').replace(/\?.*$/, '').startsWith('me:')
|
||||
}
|
||||
|
||||
function customName(icon: string | undefined): string {
|
||||
if (!icon) return ''
|
||||
return icon.replace(/^i-/, '').replace(/\?.*$/, '').replace(/^me:/, '')
|
||||
}
|
||||
|
||||
function toIconify(icon: string | undefined) {
|
||||
if (!icon) return ''
|
||||
const raw = icon.replace(/^i-/, '').replace(/\?.*$/, '')
|
||||
if (raw.startsWith('me:')) return ''
|
||||
if (raw.startsWith('fe:')) return 'feather:' + raw.slice(3)
|
||||
return raw
|
||||
}
|
||||
|
||||
function getVisibleChildren(menu: UserMenu): UserMenu[] {
|
||||
return (menu.children || []).filter(c => !c.hidden && c.type !== 'button')
|
||||
}
|
||||
|
||||
function isActive(menu: UserMenu): boolean {
|
||||
const children = getVisibleChildren(menu)
|
||||
if (children.length) {
|
||||
return children.some(c => isActive(c))
|
||||
}
|
||||
const menuPath = ensureAbsolutePath(menu.route)
|
||||
return route.path === menuPath || route.path.startsWith(menuPath + '/')
|
||||
}
|
||||
|
||||
function handleNavClick(menu: UserMenu) {
|
||||
const children = getVisibleChildren(menu)
|
||||
if (children.length) {
|
||||
// 切换弹窗:点击同一个关闭,点击不同的打开
|
||||
if (popoverMenuId.value === menu.id) {
|
||||
closePopover()
|
||||
} else {
|
||||
popoverMenuId.value = menu.id
|
||||
popoverChildren.value = children
|
||||
}
|
||||
} else {
|
||||
closePopover()
|
||||
router.push(ensureAbsolutePath(menu.route))
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildClick(child: UserMenu) {
|
||||
closePopover()
|
||||
router.push(ensureAbsolutePath(child.route))
|
||||
}
|
||||
|
||||
function closePopover() {
|
||||
popoverMenuId.value = null
|
||||
popoverChildren.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 遮罩层:点击关闭弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="popoverMenuId !== null"
|
||||
class="fixed inset-0 z-40 md:hidden"
|
||||
@click="closePopover"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- 二级菜单弹窗 -->
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="popoverMenuId !== null && popoverChildren.length"
|
||||
class="fixed left-2 right-2 z-50 mx-auto max-w-sm rounded-xl border bg-popover p-2 shadow-lg md:hidden"
|
||||
style="bottom: 3.75rem"
|
||||
>
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
<button
|
||||
v-for="child in popoverChildren"
|
||||
:key="child.id"
|
||||
class="flex flex-col items-center gap-1 rounded-lg px-2 py-2.5 text-xs transition-colors"
|
||||
:class="isActive(child) ? 'bg-primary/10 text-primary font-medium' : 'text-foreground hover:bg-accent'"
|
||||
@click="handleChildClick(child)"
|
||||
>
|
||||
<SvgIcon v-if="isCustom(child.icon)" :name="customName(child.icon)" class="size-5" />
|
||||
<Icon v-else-if="toIconify(child.icon)" :icon="toIconify(child.icon)" class="size-5" />
|
||||
<span class="truncate max-w-[72px] leading-tight">{{ child.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 底部导航栏 -->
|
||||
<nav class="md:hidden flex h-14 items-center border-t bg-background overflow-x-auto scrollbar-none shrink-0">
|
||||
<button
|
||||
v-for="item in visibleMenus"
|
||||
:key="item.id"
|
||||
class="flex min-w-[56px] flex-1 flex-col items-center justify-center gap-0.5 py-1 text-[11px] transition-colors relative"
|
||||
:class="[
|
||||
isActive(item) || popoverMenuId === item.id ? 'text-primary' : 'text-muted-foreground',
|
||||
]"
|
||||
@click="handleNavClick(item)"
|
||||
>
|
||||
<SvgIcon v-if="isCustom(item.icon)" :name="customName(item.icon)" class="size-5" />
|
||||
<Icon v-else-if="toIconify(item.icon)" :icon="toIconify(item.icon)" class="size-5" />
|
||||
<span class="truncate max-w-[56px] leading-tight">{{ item.name }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import SvgIcon from '@/components/common/SvgIcon.vue'
|
||||
import type { UserMenu } from '@/types/menu'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
menu: UserMenu
|
||||
collapsed: boolean
|
||||
depth?: number
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const depth = computed(() => props.depth ?? 0)
|
||||
|
||||
const visibleChildren = computed(() =>
|
||||
props.menu.children?.filter(c => !c.hidden && c.type !== 'button') ?? []
|
||||
)
|
||||
const hasChildren = computed(() => visibleChildren.value.length > 0)
|
||||
|
||||
/** 确保路径以 / 开头 */
|
||||
function ensureAbsolutePath(path: string) {
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
}
|
||||
|
||||
// 判断当前菜单或子菜单是否激活
|
||||
const isActive = computed(() => {
|
||||
if (hasChildren.value) {
|
||||
return visibleChildren.value.some(c => isMenuActive(c))
|
||||
}
|
||||
const menuPath = ensureAbsolutePath(props.menu.route)
|
||||
return route.path === menuPath || route.path.startsWith(menuPath + '/')
|
||||
})
|
||||
|
||||
function isMenuActive(menu: UserMenu): boolean {
|
||||
// 只考虑非按钮类型的子菜单,忽略按钮子节点
|
||||
const menuChildren = menu.children?.filter(c => !c.hidden && c.type !== 'button') ?? []
|
||||
if (menuChildren.length) {
|
||||
return menuChildren.some(c => isMenuActive(c))
|
||||
}
|
||||
const menuPath = ensureAbsolutePath(menu.route)
|
||||
return route.path === menuPath || route.path.startsWith(menuPath + '/')
|
||||
}
|
||||
|
||||
const isOpen = ref(isActive.value)
|
||||
|
||||
// 解析后端图标名(如 i-fe:home, i-me:awesome)
|
||||
const isCustomIcon = computed(() => {
|
||||
if (!props.menu.icon) return false
|
||||
const raw = props.menu.icon.replace(/^i-/, '').replace(/\?.*$/, '')
|
||||
return raw.startsWith('me:')
|
||||
})
|
||||
|
||||
const customIconName = computed(() => {
|
||||
if (!isCustomIcon.value) return ''
|
||||
return props.menu.icon.replace(/^i-/, '').replace(/\?.*$/, '').replace(/^me:/, '')
|
||||
})
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (!props.menu.icon || isCustomIcon.value) return ''
|
||||
const raw = props.menu.icon.replace(/^i-/, '').replace(/\?.*$/, '')
|
||||
if (raw.startsWith('fe:')) {
|
||||
return 'feather:' + raw.slice(3)
|
||||
}
|
||||
return raw
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (hasChildren.value) {
|
||||
isOpen.value = !isOpen.value
|
||||
} else {
|
||||
router.push(ensureAbsolutePath(props.menu.route))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 有子菜单:使用 Collapsible -->
|
||||
<Collapsible v-if="hasChildren && !collapsed" v-model:open="isOpen">
|
||||
<CollapsibleTrigger as-child>
|
||||
<button
|
||||
class="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
:class="[
|
||||
isActive ? 'bg-accent text-accent-foreground font-medium' : 'text-muted-foreground',
|
||||
]"
|
||||
:style="{ paddingLeft: `${depth * 12 + 12}px` }"
|
||||
>
|
||||
<SvgIcon v-if="isCustomIcon" :name="customIconName" class="size-4 shrink-0" />
|
||||
<Icon v-else-if="iconName" :icon="iconName" class="size-4 shrink-0" />
|
||||
<span class="flex-1 truncate text-left">{{ menu.name }}</span>
|
||||
<ChevronRight
|
||||
class="size-4 shrink-0 transition-transform duration-200"
|
||||
:class="{ 'rotate-90': isOpen }"
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuItem
|
||||
v-for="child in visibleChildren"
|
||||
:key="child.id"
|
||||
:menu="child"
|
||||
:collapsed="collapsed"
|
||||
:depth="depth + 1"
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<!-- 无子菜单 或 折叠状态 -->
|
||||
<template v-else>
|
||||
<Tooltip v-if="collapsed" :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="flex w-full items-center justify-center rounded-md p-2 transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
:class="[isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground']"
|
||||
@click="handleClick"
|
||||
>
|
||||
<SvgIcon v-if="isCustomIcon" :name="customIconName" class="size-5" />
|
||||
<Icon v-else-if="iconName" :icon="iconName" class="size-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{{ menu.name }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
v-else
|
||||
class="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
:class="[isActive ? 'bg-accent text-accent-foreground font-medium' : 'text-muted-foreground']"
|
||||
:style="{ paddingLeft: `${depth * 12 + 12}px` }"
|
||||
@click="handleClick"
|
||||
>
|
||||
<SvgIcon v-if="isCustomIcon" :name="customIconName" class="size-4 shrink-0" />
|
||||
<Icon v-else-if="iconName" :icon="iconName" class="size-4 shrink-0" />
|
||||
<span class="truncate">{{ menu.name }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RefreshCw, Loader2 } from 'lucide-vue-next'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { message as toast } from '@/composables/useMessage'
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [result: { captchaKey: string; dots: Array<{ x: number; y: number }> }]
|
||||
}>()
|
||||
|
||||
// 验证码数据
|
||||
const bgImage = ref('')
|
||||
const thumbImage = ref('')
|
||||
const thumbX = ref(0)
|
||||
const thumbY = ref(0)
|
||||
const thumbWidth = ref(0)
|
||||
const thumbHeight = ref(0)
|
||||
const captchaKey = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
// 滑块拖动状态
|
||||
const isDragging = ref(false)
|
||||
const dragStartX = ref(0)
|
||||
const sliderOffset = ref(0)
|
||||
const submitting = ref(false)
|
||||
|
||||
// 背景图实际渲染尺寸(固定宽度,按比例缩放)
|
||||
const bgNaturalWidth = ref(0)
|
||||
const bgNaturalHeight = ref(0)
|
||||
const DISPLAY_WIDTH = 320
|
||||
|
||||
const scale = computed(() => bgNaturalWidth.value ? DISPLAY_WIDTH / bgNaturalWidth.value : 1)
|
||||
const displayHeight = computed(() => Math.round(bgNaturalHeight.value * scale.value))
|
||||
|
||||
// 滑块在图片上的显示位置
|
||||
const thumbDisplayX = computed(() => Math.round(thumbX.value * scale.value) + sliderOffset.value)
|
||||
const thumbDisplayY = computed(() => Math.round(thumbY.value * scale.value))
|
||||
const thumbDisplayW = computed(() => Math.round(thumbWidth.value * scale.value))
|
||||
const thumbDisplayH = computed(() => Math.round(thumbHeight.value * scale.value))
|
||||
|
||||
function resetState() {
|
||||
bgImage.value = ''
|
||||
thumbImage.value = ''
|
||||
sliderOffset.value = 0
|
||||
submitting.value = false
|
||||
isDragging.value = false
|
||||
bgNaturalWidth.value = 0
|
||||
bgNaturalHeight.value = 0
|
||||
}
|
||||
|
||||
async function loadCaptcha() {
|
||||
loading.value = true
|
||||
sliderOffset.value = 0
|
||||
submitting.value = false
|
||||
try {
|
||||
const res = await authApi.getCaptcha()
|
||||
const d = res.data
|
||||
bgImage.value = d.image || ''
|
||||
thumbImage.value = d.thumb || ''
|
||||
thumbX.value = d.thumbX || 0
|
||||
thumbY.value = d.thumbY || 0
|
||||
thumbWidth.value = d.thumbWidth || 0
|
||||
thumbHeight.value = d.thumbHeight || 0
|
||||
captchaKey.value = d.captchaKey || ''
|
||||
} catch {
|
||||
toast.error('获取验证码失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onBgLoad(e: Event) {
|
||||
const img = e.target as HTMLImageElement
|
||||
bgNaturalWidth.value = img.naturalWidth
|
||||
bgNaturalHeight.value = img.naturalHeight
|
||||
}
|
||||
|
||||
watch(open, (val) => {
|
||||
if (val) {
|
||||
resetState()
|
||||
loadCaptcha()
|
||||
}
|
||||
})
|
||||
|
||||
// 滑块拖动逻辑
|
||||
function onDragStart(e: MouseEvent | TouchEvent) {
|
||||
if (submitting.value) return
|
||||
isDragging.value = true
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||
dragStartX.value = clientX - sliderOffset.value
|
||||
document.addEventListener('mousemove', onDragMove)
|
||||
document.addEventListener('mouseup', onDragEnd)
|
||||
document.addEventListener('touchmove', onDragMove, { passive: false })
|
||||
document.addEventListener('touchend', onDragEnd)
|
||||
}
|
||||
|
||||
function onDragMove(e: MouseEvent | TouchEvent) {
|
||||
if (!isDragging.value) return
|
||||
if ('touches' in e) e.preventDefault()
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||
const maxOffset = DISPLAY_WIDTH - Math.round(thumbX.value * scale.value) - thumbDisplayW.value
|
||||
const minOffset = -Math.round(thumbX.value * scale.value)
|
||||
let offset = clientX - dragStartX.value
|
||||
offset = Math.max(minOffset, Math.min(maxOffset, offset))
|
||||
sliderOffset.value = offset
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
if (!isDragging.value) return
|
||||
isDragging.value = false
|
||||
document.removeEventListener('mousemove', onDragMove)
|
||||
document.removeEventListener('mouseup', onDragEnd)
|
||||
document.removeEventListener('touchmove', onDragMove)
|
||||
document.removeEventListener('touchend', onDragEnd)
|
||||
handleConfirm()
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
submitting.value = true
|
||||
const actualX = Math.round(thumbX.value + sliderOffset.value / scale.value)
|
||||
const actualY = thumbY.value
|
||||
emit('confirm', {
|
||||
captchaKey: captchaKey.value,
|
||||
dots: [{ x: actualX, y: actualY }],
|
||||
})
|
||||
// 不在这里关闭弹窗,由父组件在请求完成后关闭
|
||||
}
|
||||
|
||||
// 滑动条轨道上的滑块位置
|
||||
const sliderTrackPercent = computed(() => {
|
||||
const maxTrack = DISPLAY_WIDTH - 40
|
||||
const maxOffset = DISPLAY_WIDTH - Math.round(thumbX.value * scale.value) - thumbDisplayW.value
|
||||
const minOffset = -Math.round(thumbX.value * scale.value)
|
||||
const range = maxOffset - minOffset
|
||||
if (range <= 0) return 0
|
||||
return ((sliderOffset.value - minOffset) / range) * maxTrack
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-[380px] p-0 overflow-hidden" @pointerDownOutside.prevent @escapeKeyDown.prevent="!submitting && (open = false)">
|
||||
<DialogHeader class="px-4 pt-4 pb-2">
|
||||
<DialogTitle>安全验证</DialogTitle>
|
||||
<DialogDescription>拖动滑块将拼图移到正确位置</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="px-4 pb-4 space-y-3">
|
||||
<!-- 验证码图片区域 -->
|
||||
<div
|
||||
class="relative select-none overflow-hidden rounded-md border bg-muted mx-auto"
|
||||
:style="{ width: DISPLAY_WIDTH + 'px', height: displayHeight ? displayHeight + 'px' : '200px' }"
|
||||
>
|
||||
<!-- loading 遮罩 -->
|
||||
<div
|
||||
v-if="loading || submitting"
|
||||
class="absolute inset-0 z-10 flex items-center justify-center bg-background/60"
|
||||
>
|
||||
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
||||
<span class="ml-2 text-sm text-muted-foreground">
|
||||
{{ submitting ? '验证中...' : '加载中...' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 背景图 -->
|
||||
<img
|
||||
v-if="bgImage"
|
||||
:src="bgImage"
|
||||
alt="验证码背景"
|
||||
class="block w-full h-full object-fill"
|
||||
draggable="false"
|
||||
@load="onBgLoad"
|
||||
/>
|
||||
<div
|
||||
v-else-if="!loading"
|
||||
class="w-full h-full flex items-center justify-center text-muted-foreground text-sm"
|
||||
>
|
||||
验证码加载失败
|
||||
</div>
|
||||
|
||||
<!-- 滑块拼图 -->
|
||||
<img
|
||||
v-if="thumbImage && !loading"
|
||||
:src="thumbImage"
|
||||
alt="滑块"
|
||||
class="absolute pointer-events-none drop-shadow-lg"
|
||||
:class="{ 'transition-none': isDragging }"
|
||||
:style="{
|
||||
left: thumbDisplayX + 'px',
|
||||
top: thumbDisplayY + 'px',
|
||||
width: thumbDisplayW + 'px',
|
||||
height: thumbDisplayH + 'px',
|
||||
}"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 滑动条 -->
|
||||
<div
|
||||
class="relative h-10 rounded-full border bg-muted/50 select-none mx-auto"
|
||||
:style="{ width: DISPLAY_WIDTH + 'px' }"
|
||||
>
|
||||
<!-- 已滑过的区域 -->
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 rounded-full bg-primary/20"
|
||||
:style="{ width: (sliderTrackPercent + 20) + 'px' }"
|
||||
/>
|
||||
|
||||
<!-- 提示文字 -->
|
||||
<span
|
||||
v-if="sliderOffset === 0 && !submitting"
|
||||
class="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground pointer-events-none"
|
||||
>
|
||||
向右拖动滑块完成验证
|
||||
</span>
|
||||
|
||||
<!-- 滑块按钮 -->
|
||||
<div
|
||||
class="absolute top-0 h-10 w-10 rounded-full border-2 bg-background shadow-md flex items-center justify-center cursor-grab active:cursor-grabbing transition-colors"
|
||||
:class="[
|
||||
isDragging ? 'border-primary ring-2 ring-primary/30' : 'border-muted-foreground/30',
|
||||
submitting ? 'opacity-50 cursor-not-allowed' : '',
|
||||
]"
|
||||
:style="{ left: sliderTrackPercent + 'px' }"
|
||||
@mousedown.prevent="onDragStart"
|
||||
@touchstart.prevent="onDragStart"
|
||||
>
|
||||
<Loader2 v-if="submitting" class="h-4 w-4 animate-spin text-primary" />
|
||||
<svg v-else width="14" height="14" viewBox="0 0 14 14" fill="none" class="text-muted-foreground">
|
||||
<path d="M9 3l4 4-4 4M5 3L1 7l4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<div class="flex justify-end">
|
||||
<Button variant="ghost" size="sm" @click="loadCaptcha" :disabled="loading || submitting">
|
||||
<RefreshCw class="h-4 w-4 mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
targetX: number
|
||||
targetY: number
|
||||
}>()
|
||||
|
||||
interface Character {
|
||||
id: string
|
||||
type: 'circle' | 'triangle' | 'square' | 'diamond'
|
||||
cx: number
|
||||
cy: number
|
||||
headColor: string
|
||||
bodyColor: string
|
||||
headSize: number
|
||||
bodyW: number
|
||||
bodyH: number
|
||||
}
|
||||
|
||||
// 彩色角色:头部几何形状 + 竖立长方形身体
|
||||
const characters: Character[] = [
|
||||
{ id: 'circle', type: 'circle', cx: 80, cy: 130, headColor: '#f97316', bodyColor: '#fb923c', headSize: 36, bodyW: 36, bodyH: 60 },
|
||||
{ id: 'triangle', type: 'triangle', cx: 190, cy: 120, headColor: '#8b5cf6', bodyColor: '#a78bfa', headSize: 34, bodyW: 34, bodyH: 56 },
|
||||
{ id: 'square', type: 'square', cx: 290, cy: 125, headColor: '#10b981', bodyColor: '#34d399', headSize: 32, bodyW: 32, bodyH: 54 },
|
||||
{ id: 'diamond', type: 'diamond', cx: 380, cy: 128, headColor: '#3b82f6', bodyColor: '#60a5fa', headSize: 30, bodyW: 30, bodyH: 52 },
|
||||
]
|
||||
|
||||
// 横线(桌面)的 Y 坐标,遮挡身体下半部分
|
||||
const tableY = 210
|
||||
|
||||
const maxEyeOffset = 4
|
||||
|
||||
function getEyeOffset(charCx: number, charCy: number) {
|
||||
const dx = props.targetX - charCx
|
||||
const dy = props.targetY - charCy
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
if (dist === 0) return { x: 0, y: 0 }
|
||||
const angle = Math.atan2(dy, dx)
|
||||
const offset = Math.min(maxEyeOffset, dist / 100)
|
||||
return {
|
||||
x: Math.cos(angle) * offset,
|
||||
y: Math.sin(angle) * offset,
|
||||
}
|
||||
}
|
||||
|
||||
const eyeOffsets = computed(() =>
|
||||
characters.map((c) => getEyeOffset(c.cx, c.cy))
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg viewBox="0 0 460 300" class="w-full h-full max-w-[460px] max-h-[300px]" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- 裁剪区域:只显示桌面以上的部分 -->
|
||||
<clipPath id="above-table">
|
||||
<rect x="0" y="0" width="460" :height="tableY" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<!-- 角色们(被桌面裁剪) -->
|
||||
<g clip-path="url(#above-table)">
|
||||
<!-- 圆形角色 -->
|
||||
<g>
|
||||
<!-- 身体(竖立长方形) -->
|
||||
<rect
|
||||
:x="characters[0].cx - characters[0].bodyW / 2"
|
||||
:y="characters[0].cy + characters[0].headSize - 8"
|
||||
:width="characters[0].bodyW"
|
||||
:height="characters[0].bodyH"
|
||||
rx="10"
|
||||
:fill="characters[0].bodyColor"
|
||||
/>
|
||||
<!-- 头部 -->
|
||||
<circle :cx="characters[0].cx" :cy="characters[0].cy" :r="characters[0].headSize" :fill="characters[0].headColor" />
|
||||
<!-- 左眼 -->
|
||||
<ellipse :cx="characters[0].cx - 11" :cy="characters[0].cy - 4" rx="7" ry="9" fill="white" />
|
||||
<circle :cx="characters[0].cx - 11 + eyeOffsets[0].x" :cy="characters[0].cy - 4 + eyeOffsets[0].y" r="3.5" fill="#1e293b" />
|
||||
<!-- 右眼 -->
|
||||
<ellipse :cx="characters[0].cx + 11" :cy="characters[0].cy - 4" rx="7" ry="9" fill="white" />
|
||||
<circle :cx="characters[0].cx + 11 + eyeOffsets[0].x" :cy="characters[0].cy - 4 + eyeOffsets[0].y" r="3.5" fill="#1e293b" />
|
||||
<!-- 嘴巴 -->
|
||||
<path :d="`M${characters[0].cx - 8} ${characters[0].cy + 12} Q${characters[0].cx} ${characters[0].cy + 20} ${characters[0].cx + 8} ${characters[0].cy + 12}`" fill="none" stroke="#1e293b" stroke-width="2" stroke-linecap="round" />
|
||||
</g>
|
||||
|
||||
<!-- 三角形角色 -->
|
||||
<g>
|
||||
<rect
|
||||
:x="characters[1].cx - characters[1].bodyW / 2"
|
||||
:y="characters[1].cy + characters[1].headSize - 4"
|
||||
:width="characters[1].bodyW"
|
||||
:height="characters[1].bodyH"
|
||||
rx="10"
|
||||
:fill="characters[1].bodyColor"
|
||||
/>
|
||||
<polygon
|
||||
:points="`${characters[1].cx},${characters[1].cy - characters[1].headSize} ${characters[1].cx - characters[1].headSize * 0.9},${characters[1].cy + characters[1].headSize * 0.5} ${characters[1].cx + characters[1].headSize * 0.9},${characters[1].cy + characters[1].headSize * 0.5}`"
|
||||
:fill="characters[1].headColor"
|
||||
/>
|
||||
<ellipse :cx="characters[1].cx - 10" :cy="characters[1].cy + 2" rx="6" ry="8" fill="white" />
|
||||
<circle :cx="characters[1].cx - 10 + eyeOffsets[1].x" :cy="characters[1].cy + 2 + eyeOffsets[1].y" r="3" fill="#1e293b" />
|
||||
<ellipse :cx="characters[1].cx + 10" :cy="characters[1].cy + 2" rx="6" ry="8" fill="white" />
|
||||
<circle :cx="characters[1].cx + 10 + eyeOffsets[1].x" :cy="characters[1].cy + 2 + eyeOffsets[1].y" r="3" fill="#1e293b" />
|
||||
<path :d="`M${characters[1].cx - 6} ${characters[1].cy + 14} Q${characters[1].cx} ${characters[1].cy + 20} ${characters[1].cx + 6} ${characters[1].cy + 14}`" fill="none" stroke="#1e293b" stroke-width="1.8" stroke-linecap="round" />
|
||||
</g>
|
||||
|
||||
<!-- 方形角色 -->
|
||||
<g>
|
||||
<rect
|
||||
:x="characters[2].cx - characters[2].bodyW / 2"
|
||||
:y="characters[2].cy + characters[2].headSize + 2"
|
||||
:width="characters[2].bodyW"
|
||||
:height="characters[2].bodyH"
|
||||
rx="10"
|
||||
:fill="characters[2].bodyColor"
|
||||
/>
|
||||
<rect
|
||||
:x="characters[2].cx - characters[2].headSize"
|
||||
:y="characters[2].cy - characters[2].headSize"
|
||||
:width="characters[2].headSize * 2"
|
||||
:height="characters[2].headSize * 2"
|
||||
rx="6"
|
||||
:fill="characters[2].headColor"
|
||||
/>
|
||||
<ellipse :cx="characters[2].cx - 10" :cy="characters[2].cy - 4" rx="6" ry="8" fill="white" />
|
||||
<circle :cx="characters[2].cx - 10 + eyeOffsets[2].x" :cy="characters[2].cy - 4 + eyeOffsets[2].y" r="3" fill="#1e293b" />
|
||||
<ellipse :cx="characters[2].cx + 10" :cy="characters[2].cy - 4" rx="6" ry="8" fill="white" />
|
||||
<circle :cx="characters[2].cx + 10 + eyeOffsets[2].x" :cy="characters[2].cy - 4 + eyeOffsets[2].y" r="3" fill="#1e293b" />
|
||||
<path :d="`M${characters[2].cx - 6} ${characters[2].cy + 10} Q${characters[2].cx} ${characters[2].cy + 16} ${characters[2].cx + 6} ${characters[2].cy + 10}`" fill="none" stroke="#1e293b" stroke-width="1.8" stroke-linecap="round" />
|
||||
</g>
|
||||
|
||||
<!-- 菱形角色 -->
|
||||
<g>
|
||||
<rect
|
||||
:x="characters[3].cx - characters[3].bodyW / 2"
|
||||
:y="characters[3].cy + characters[3].headSize + 2"
|
||||
:width="characters[3].bodyW"
|
||||
:height="characters[3].bodyH"
|
||||
rx="10"
|
||||
:fill="characters[3].bodyColor"
|
||||
/>
|
||||
<polygon
|
||||
:points="`${characters[3].cx},${characters[3].cy - characters[3].headSize} ${characters[3].cx + characters[3].headSize},${characters[3].cy} ${characters[3].cx},${characters[3].cy + characters[3].headSize} ${characters[3].cx - characters[3].headSize},${characters[3].cy}`"
|
||||
:fill="characters[3].headColor"
|
||||
/>
|
||||
<ellipse :cx="characters[3].cx - 9" :cy="characters[3].cy - 2" rx="5.5" ry="7" fill="white" />
|
||||
<circle :cx="characters[3].cx - 9 + eyeOffsets[3].x" :cy="characters[3].cy - 2 + eyeOffsets[3].y" r="2.8" fill="#1e293b" />
|
||||
<ellipse :cx="characters[3].cx + 9" :cy="characters[3].cy - 2" rx="5.5" ry="7" fill="white" />
|
||||
<circle :cx="characters[3].cx + 9 + eyeOffsets[3].x" :cy="characters[3].cy - 2 + eyeOffsets[3].y" r="2.8" fill="#1e293b" />
|
||||
<path :d="`M${characters[3].cx - 5} ${characters[3].cy + 10} Q${characters[3].cx} ${characters[3].cy + 15} ${characters[3].cx + 5} ${characters[3].cy + 10}`" fill="none" stroke="#1e293b" stroke-width="1.6" stroke-linecap="round" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- 桌面横线(遮挡身体下半部分) -->
|
||||
<rect x="20" :y="tableY" width="420" height="6" rx="3" fill="#d1d5db" />
|
||||
<!-- 桌面下方区域填充背景色,完全遮挡 -->
|
||||
<rect x="0" :y="tableY + 6" width="460" height="100" fill="currentColor" class="text-gray-200" />
|
||||
<!-- 桌腿 -->
|
||||
<rect x="60" :y="tableY + 6" width="4" height="80" rx="2" fill="#d1d5db" />
|
||||
<rect x="396" :y="tableY + 6" width="4" height="80" rx="2" fill="#d1d5db" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { z } from 'zod'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { message as toast } from '@/composables/useMessage'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import CaptchaDialog from './CaptchaDialog.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
focusTarget: [el: HTMLElement | null]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const schema = toTypedSchema(
|
||||
z.object({
|
||||
username: z.string().min(1, '请输入用户名'),
|
||||
password: z.string().min(1, '请输入密码'),
|
||||
})
|
||||
)
|
||||
|
||||
const { handleSubmit, errors, defineField } = useForm({ validationSchema: schema })
|
||||
|
||||
const [username, usernameAttrs] = defineField('username')
|
||||
const [password, passwordAttrs] = defineField('password')
|
||||
|
||||
const captchaOpen = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
// 缓存表单值,在验证码确认后使用
|
||||
let pendingValues: { username: string; password: string } | null = null
|
||||
|
||||
function onSubmitClick() {
|
||||
handleSubmit((values) => {
|
||||
pendingValues = values
|
||||
captchaOpen.value = true
|
||||
})()
|
||||
}
|
||||
|
||||
async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: number; y: number }> }) {
|
||||
if (!pendingValues) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await authApi.login({
|
||||
username: pendingValues.username,
|
||||
password: pendingValues.password,
|
||||
captchaKey: result.captchaKey,
|
||||
dots: result.dots,
|
||||
})
|
||||
// token 已由响应拦截器从 header 自动存入 localStorage
|
||||
// 同步到 authStore
|
||||
const token = localStorage.getItem('token') || ''
|
||||
authStore.setToken(token)
|
||||
toast.success(res.message || '登录成功')
|
||||
captchaOpen.value = false
|
||||
await router.push('/')
|
||||
} catch {
|
||||
// 业务错误已由拦截器统一 toast 提示
|
||||
// 重新加载验证码
|
||||
captchaOpen.value = false
|
||||
setTimeout(() => { captchaOpen.value = true }, 100)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFocus(e: FocusEvent) {
|
||||
emit('focusTarget', e.target as HTMLElement)
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
emit('focusTarget', null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="space-y-4 sm:space-y-5 w-full max-w-sm" @submit.prevent="onSubmitClick">
|
||||
<div class="space-y-2">
|
||||
<Label for="username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
v-model="username"
|
||||
v-bind="usernameAttrs"
|
||||
placeholder="请输入用户名"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<p v-if="errors.username" class="text-sm text-destructive">{{ errors.username }}</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
v-model="password"
|
||||
v-bind="passwordAttrs"
|
||||
placeholder="请输入密码"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<p v-if="errors.password" class="text-sm text-destructive">{{ errors.password }}</p>
|
||||
</div>
|
||||
|
||||
<Button type="submit" class="w-full" :disabled="submitting">
|
||||
{{ submitting ? '登录中...' : '登录' }}
|
||||
</Button>
|
||||
|
||||
<CaptchaDialog v-model:open="captchaOpen" @confirm="onCaptchaConfirm" />
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
|
||||
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<AlertDialogProps>()
|
||||
const emits = defineEmits<AlertDialogEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogActionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogAction } 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,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogCancelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogCancel } 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,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogContent,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
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="{ ...$attrs, ...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 { AlertDialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogDescription,
|
||||
} 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,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogTitle } 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,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogTriggerProps } from "reka-ui"
|
||||
import { AlertDialogTrigger } 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"
|
||||
@@ -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,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { AvatarFallbackProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AvatarFallback } 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>
|
||||
@@ -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"
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { BadgeVariants } from "."
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { 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>
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Badge } from "./Badge.vue"
|
||||
|
||||
export const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full 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,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { Primitive } 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,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from "."
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { 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"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } 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 hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white 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 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",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
@@ -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>
|
||||
@@ -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,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<CheckboxRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="checkbox"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class)"
|
||||
>
|
||||
<CheckboxIndicator
|
||||
data-slot="checkbox-indicator"
|
||||
class="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<slot v-bind="slotProps">
|
||||
<Check class="size-3.5" />
|
||||
</slot>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Checkbox } from "./Checkbox.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="slotProps"
|
||||
data-slot="collapsible"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { CollapsibleContentProps } from "reka-ui"
|
||||
import { CollapsibleContent } from "reka-ui"
|
||||
|
||||
const props = defineProps<CollapsibleContentProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</CollapsibleContent>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { CollapsibleTriggerProps } from "reka-ui"
|
||||
import { CollapsibleTrigger } 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,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
|
||||
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dialog"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from "reka-ui"
|
||||
import { DialogClose } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose
|
||||
data-slot="dialog-close"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import DialogOverlay from "./DialogOverlay.vue"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
||||
showCloseButton: true,
|
||||
})
|
||||
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="{ ...$attrs, ...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
|
||||
v-if="showCloseButton"
|
||||
data-slot="dialog-close"
|
||||
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,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogDescription, 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,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { DialogClose } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
showCloseButton?: boolean
|
||||
}>(), {
|
||||
showCloseButton: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
<DialogClose v-if="showCloseButton" as-child>
|
||||
<Button variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</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="dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogOverlay } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="dialog-overlay"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('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', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||