init: tianshen-web project

This commit is contained in:
sugarBecomer
2026-03-24 17:45:16 +08:00
commit 42729a1b63
235 changed files with 16409 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
VITE_API_BASE_URL=/api
VITE_APP_TITLE=天神
+2
View File
@@ -0,0 +1,2 @@
VITE_API_BASE_URL=/api
VITE_APP_TITLE=天神
+56
View File
@@ -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 }}
+24
View File
@@ -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?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+11
View File
@@ -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"]
+5
View File
@@ -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).
+21
View File
@@ -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": {}
}
+30
View File
@@ -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
+13
View File
@@ -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>
+3772
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -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"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -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

+11
View File
@@ -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>
+61
View File
@@ -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}`),
}
+65
View File
@@ -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}`),
}
+116
View File
@@ -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),
}
+88
View File
@@ -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
+134
View File
@@ -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),
}
+34
View File
@@ -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 }),
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+16
View File
@@ -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

+1
View File
@@ -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

+28
View File
@@ -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

+23
View File
@@ -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

+1
View File
@@ -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

+15
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+37
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+35
View File
@@ -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

+251
View File
@@ -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;
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

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

After

Width:  |  Height:  |  Size: 496 B

+93
View File
@@ -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>
+66
View File
@@ -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>
+249
View File
@@ -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>
+32
View File
@@ -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>
+19
View File
@@ -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>
+38
View File
@@ -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>
+70
View File
@@ -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>
+49
View File
@@ -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>
+41
View File
@@ -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>
+42
View File
@@ -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>
+151
View File
@@ -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>
+140
View File
@@ -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>
+251
View File
@@ -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>
+162
View File
@@ -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>
+118
View File
@@ -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>
+9
View File
@@ -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"
+18
View File
@@ -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>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { AvatarImageProps } from "reka-ui"
import { AvatarImage } from "reka-ui"
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage
data-slot="avatar-image"
v-bind="props"
class="aspect-square size-full"
>
<slot />
</AvatarImage>
</template>
+3
View File
@@ -0,0 +1,3 @@
export { default as Avatar } from "./Avatar.vue"
export { default as AvatarFallback } from "./AvatarFallback.vue"
export { default as AvatarImage } from "./AvatarImage.vue"
+26
View File
@@ -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>
+26
View File
@@ -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>
+7
View File
@@ -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"
+31
View File
@@ -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>
+38
View File
@@ -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>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>
+17
View File
@@ -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>
+17
View File
@@ -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>
+17
View File
@@ -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>
+17
View File
@@ -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>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<h3
data-slot="card-title"
:class="cn('leading-none font-semibold', props.class)"
>
<slot />
</h3>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as Card } from "./Card.vue"
export { default as CardAction } from "./CardAction.vue"
export { default as CardContent } from "./CardContent.vue"
export { default as CardDescription } from "./CardDescription.vue"
export { default as CardFooter } from "./CardFooter.vue"
export { default as CardHeader } from "./CardHeader.vue"
export { default as CardTitle } from "./CardTitle.vue"
+35
View File
@@ -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>
+1
View File
@@ -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>
+3
View File
@@ -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"
+19
View File
@@ -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>
+15
View File
@@ -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>
+27
View File
@@ -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>
+17
View File
@@ -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>

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