mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 05:56:29 +00:00
feat: new frontend demo
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
pnpm-lock.yaml
|
||||||
|
components.json
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import prettierConfig from '@vue/eslint-config-prettier'
|
||||||
|
import tsParser from '@typescript-eslint/parser'
|
||||||
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
import vueParser from 'vue-eslint-parser'
|
||||||
|
|
||||||
|
const browserGlobals = {
|
||||||
|
AbortController: 'readonly',
|
||||||
|
DOMException: 'readonly',
|
||||||
|
Headers: 'readonly',
|
||||||
|
URL: 'readonly',
|
||||||
|
URLSearchParams: 'readonly',
|
||||||
|
console: 'readonly',
|
||||||
|
document: 'readonly',
|
||||||
|
fetch: 'readonly',
|
||||||
|
localStorage: 'readonly',
|
||||||
|
window: 'readonly',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', 'node_modules/**', 'coverage/**', '*.local', 'pnpm-lock.yaml'],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
parser: tsParser,
|
||||||
|
globals: browserGlobals,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-undef': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...pluginVue.configs['flat/recommended'],
|
||||||
|
{
|
||||||
|
files: ['**/*.vue'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
parser: vueParser,
|
||||||
|
parserOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
globals: browserGlobals,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-undef': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prettierConfig,
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,ts,vue}'],
|
||||||
|
rules: {
|
||||||
|
'no-console': 'warn',
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'vue/multi-word-component-names': 'off',
|
||||||
|
'vue/no-v-html': 'warn',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -6,7 +6,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"typecheck": "vue-tsc -b",
|
||||||
|
"lint": "eslint . --fix",
|
||||||
|
"lint:check": "eslint .",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
@@ -18,12 +23,19 @@
|
|||||||
"vue": "^3.5.33"
|
"vue": "^3.5.33"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.2",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
|
"@typescript-eslint/parser": "^8.59.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
|
"eslint-plugin-vue": "^10.7.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"vite": "^8.0.10",
|
"vite": "^8.0.10",
|
||||||
|
"vue-eslint-parser": "^10.4.0",
|
||||||
"vue-tsc": "^3.2.7"
|
"vue-tsc": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1012
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,72 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import HelloWorld from './components/HelloWorld.vue'
|
import { computed, onMounted } from 'vue'
|
||||||
|
import AppLayout from '@/components/AppLayout.vue'
|
||||||
|
import { useAuth } from '@/app/auth'
|
||||||
|
import { useRouter } from '@/app/router'
|
||||||
|
import LoginView from '@/views/LoginView.vue'
|
||||||
|
import PendingApprovalView from '@/views/PendingApprovalView.vue'
|
||||||
|
import DashboardView from '@/views/DashboardView.vue'
|
||||||
|
import TasksView from '@/views/TasksView.vue'
|
||||||
|
import TaskRecordsView from '@/views/TaskRecordsView.vue'
|
||||||
|
import RecordsView from '@/views/RecordsView.vue'
|
||||||
|
import SettingsView from '@/views/SettingsView.vue'
|
||||||
|
import NotFoundView from '@/views/NotFoundView.vue'
|
||||||
|
import AdminUsersView from '@/views/admin/AdminUsersView.vue'
|
||||||
|
import AdminTemplatesView from '@/views/admin/AdminTemplatesView.vue'
|
||||||
|
import AdminRecordsView from '@/views/admin/AdminRecordsView.vue'
|
||||||
|
import AdminLogsView from '@/views/admin/AdminLogsView.vue'
|
||||||
|
import AdminStatsView from '@/views/admin/AdminStatsView.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuth()
|
||||||
|
|
||||||
|
const view = computed(() => {
|
||||||
|
switch (router.current.value.key) {
|
||||||
|
case 'login':
|
||||||
|
return LoginView
|
||||||
|
case 'pending':
|
||||||
|
return PendingApprovalView
|
||||||
|
case 'dashboard':
|
||||||
|
return DashboardView
|
||||||
|
case 'tasks':
|
||||||
|
return TasksView
|
||||||
|
case 'task-records':
|
||||||
|
return TaskRecordsView
|
||||||
|
case 'records':
|
||||||
|
return RecordsView
|
||||||
|
case 'settings':
|
||||||
|
return SettingsView
|
||||||
|
case 'admin-users':
|
||||||
|
return AdminUsersView
|
||||||
|
case 'admin-templates':
|
||||||
|
return AdminTemplatesView
|
||||||
|
case 'admin-records':
|
||||||
|
return AdminRecordsView
|
||||||
|
case 'admin-logs':
|
||||||
|
return AdminLogsView
|
||||||
|
case 'admin-stats':
|
||||||
|
return AdminStatsView
|
||||||
|
default:
|
||||||
|
return NotFoundView
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrappedView = computed(() => {
|
||||||
|
if (['login', 'pending', 'not-found'].includes(router.current.value.key)) return view.value
|
||||||
|
return AppLayout
|
||||||
|
})
|
||||||
|
|
||||||
|
const usesLayout = computed(() => wrappedView.value === AppLayout)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void auth.refreshCurrentUser().catch(() => undefined)
|
||||||
|
void router.guardCurrent()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<HelloWorld />
|
<AppLayout v-if="usesLayout">
|
||||||
|
<component :is="view" />
|
||||||
|
</AppLayout>
|
||||||
|
<component :is="view" v-else />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import type { ApiErrorData } from './types'
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
status: number
|
||||||
|
message: string
|
||||||
|
data: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
|
||||||
|
const TOKEN_KEY = 'checkin.token'
|
||||||
|
const LEGACY_TOKEN_KEY = 'token'
|
||||||
|
const USER_KEY = 'checkin.user'
|
||||||
|
const LEGACY_USER_KEY = 'user'
|
||||||
|
|
||||||
|
export function getStoredToken() {
|
||||||
|
return localStorage.getItem(TOKEN_KEY) || localStorage.getItem(LEGACY_TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredToken(token: string) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
localStorage.setItem(LEGACY_TOKEN_KEY, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredAuth() {
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
localStorage.removeItem(LEGACY_TOKEN_KEY)
|
||||||
|
localStorage.removeItem(USER_KEY)
|
||||||
|
localStorage.removeItem(LEGACY_USER_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredUser(user: unknown) {
|
||||||
|
const value = JSON.stringify(user)
|
||||||
|
localStorage.setItem(USER_KEY, value)
|
||||||
|
localStorage.setItem(LEGACY_USER_KEY, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredUser<T>() {
|
||||||
|
const raw = localStorage.getItem(USER_KEY) || localStorage.getItem(LEGACY_USER_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(path: string, params?: Record<string, unknown>) {
|
||||||
|
const url = new URL(path, window.location.origin)
|
||||||
|
if (API_BASE_URL) {
|
||||||
|
const base = new URL(API_BASE_URL, window.location.origin)
|
||||||
|
url.protocol = base.protocol
|
||||||
|
url.host = base.host
|
||||||
|
url.pathname = `${base.pathname.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(params ?? {}).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null || value === '') return
|
||||||
|
url.searchParams.set(key, String(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
return API_BASE_URL ? url.toString() : `${url.pathname}${url.search}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(status: number, payload: ApiErrorData | null) {
|
||||||
|
if (payload?.error?.message) return payload.error.message
|
||||||
|
if (payload?.detail) return payload.detail
|
||||||
|
if (payload?.message) return payload.message
|
||||||
|
if (status === 401) return '登录已失效,请重新登录'
|
||||||
|
if (status === 403) return '当前账号没有权限执行该操作'
|
||||||
|
if (status === 404) return '请求的资源不存在'
|
||||||
|
if (status === 422) return '提交内容不符合要求'
|
||||||
|
if (status >= 500) return '服务器内部错误,请稍后重试'
|
||||||
|
return '请求失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit & { params?: Record<string, unknown>; timeout?: number } = {},
|
||||||
|
) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), options.timeout ?? 30000)
|
||||||
|
const token = getStoredToken()
|
||||||
|
const headers = new Headers(options.headers)
|
||||||
|
|
||||||
|
if (!headers.has('Content-Type') && options.body) {
|
||||||
|
headers.set('Content-Type', 'application/json')
|
||||||
|
}
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(buildUrl(path, options.params), {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 204) return undefined as T
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') ?? ''
|
||||||
|
const payload = contentType.includes('application/json')
|
||||||
|
? ((await response.json()) as unknown)
|
||||||
|
: await response.text()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) clearStoredAuth()
|
||||||
|
const data = typeof payload === 'object' && payload ? (payload as ApiErrorData) : null
|
||||||
|
throw {
|
||||||
|
status: response.status,
|
||||||
|
message: errorMessage(response.status, data),
|
||||||
|
data: payload,
|
||||||
|
} satisfies ApiError
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as ApiError).status !== undefined) throw error
|
||||||
|
throw {
|
||||||
|
status: 0,
|
||||||
|
message:
|
||||||
|
error instanceof DOMException && error.name === 'AbortError'
|
||||||
|
? '请求超时,请稍后重试'
|
||||||
|
: '网络错误,请检查后端服务是否可用',
|
||||||
|
data: null,
|
||||||
|
} satisfies ApiError
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = {
|
||||||
|
get: <T>(path: string, params?: Record<string, unknown>) => request<T>(path, { params }),
|
||||||
|
post: <T>(path: string, body?: unknown, timeout?: number) =>
|
||||||
|
request<T>(path, { method: 'POST', body: JSON.stringify(body ?? {}), timeout }),
|
||||||
|
put: <T>(path: string, body?: unknown) =>
|
||||||
|
request<T>(path, { method: 'PUT', body: JSON.stringify(body ?? {}) }),
|
||||||
|
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { apiClient } from './client'
|
||||||
|
import type {
|
||||||
|
AdminStats,
|
||||||
|
CheckInRecord,
|
||||||
|
CheckInRecordStatus,
|
||||||
|
CheckInStartResponse,
|
||||||
|
CreateTaskFromTemplatePayload,
|
||||||
|
CronValidation,
|
||||||
|
LoginResponse,
|
||||||
|
LogsResponse,
|
||||||
|
PaginatedResponse,
|
||||||
|
QRCodeRequestResponse,
|
||||||
|
QRCodeStatusResponse,
|
||||||
|
Task,
|
||||||
|
Template,
|
||||||
|
TemplatePreview,
|
||||||
|
TokenStatus,
|
||||||
|
User,
|
||||||
|
UserStatus,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
aliasLogin: (alias: string, password: string) =>
|
||||||
|
apiClient.post<LoginResponse>('/api/auth/alias_login', { alias, password }),
|
||||||
|
requestQRCode: (alias: string) =>
|
||||||
|
apiClient.post<QRCodeRequestResponse>('/api/auth/request_qrcode', { alias }),
|
||||||
|
getQRCodeStatus: (sessionId: string) =>
|
||||||
|
apiClient.get<QRCodeStatusResponse>(`/api/auth/qrcode_status/${sessionId}`),
|
||||||
|
cancelQRCodeSession: (sessionId: string) =>
|
||||||
|
apiClient.delete<{ success?: boolean; message?: string }>(
|
||||||
|
`/api/auth/qrcode_session/${sessionId}`,
|
||||||
|
),
|
||||||
|
verifyToken: (authorization: string) =>
|
||||||
|
apiClient.post<{ is_valid: boolean; message: string; user_id?: number }>(
|
||||||
|
'/api/auth/verify_token',
|
||||||
|
{
|
||||||
|
authorization,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userApi = {
|
||||||
|
me: () => apiClient.get<User>('/api/users/me'),
|
||||||
|
status: () => apiClient.get<UserStatus>('/api/users/me/status'),
|
||||||
|
tokenStatus: () => apiClient.get<TokenStatus>('/api/users/me/token_status'),
|
||||||
|
updateProfile: (payload: {
|
||||||
|
alias?: string
|
||||||
|
email?: string
|
||||||
|
current_password?: string
|
||||||
|
new_password?: string
|
||||||
|
}) => apiClient.put<User>('/api/users/me/profile', payload),
|
||||||
|
list: (params: Record<string, unknown> = {}) => apiClient.get<User[]>('/api/users', params),
|
||||||
|
create: (payload: Partial<User> & { password?: string }) =>
|
||||||
|
apiClient.post<User>('/api/users', payload),
|
||||||
|
update: (
|
||||||
|
userId: number,
|
||||||
|
payload: Partial<User> & { password?: string; reset_password?: boolean },
|
||||||
|
) => apiClient.put<User>(`/api/users/${userId}`, payload),
|
||||||
|
delete: (userId: number) => apiClient.delete<void>(`/api/users/${userId}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const taskApi = {
|
||||||
|
list: (params: Record<string, unknown> = {}) => apiClient.get<Task[]>('/api/tasks/', params),
|
||||||
|
detail: (taskId: number) => apiClient.get<Task>(`/api/tasks/${taskId}`),
|
||||||
|
update: (taskId: number, payload: Partial<Task>) =>
|
||||||
|
apiClient.put<Task>(`/api/tasks/${taskId}`, payload),
|
||||||
|
delete: (taskId: number) => apiClient.delete<void>(`/api/tasks/${taskId}`),
|
||||||
|
toggle: (taskId: number) => apiClient.post<Task>(`/api/tasks/${taskId}/toggle`),
|
||||||
|
validateCron: (cron_expression: string) =>
|
||||||
|
apiClient.post<CronValidation>('/api/tasks/validate-cron', { cron_expression }),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const checkInApi = {
|
||||||
|
manual: (taskId: number) =>
|
||||||
|
apiClient.post<CheckInStartResponse>(`/api/check_in/manual/${taskId}`, {}, 120000),
|
||||||
|
status: (recordId: number) =>
|
||||||
|
apiClient.get<CheckInRecordStatus>(`/api/check_in/record/${recordId}/status`),
|
||||||
|
taskRecords: (taskId: number, params: Record<string, unknown> = {}) =>
|
||||||
|
apiClient.get<PaginatedResponse<CheckInRecord>>(`/api/check_in/task/${taskId}/records`, params),
|
||||||
|
myRecords: (params: Record<string, unknown> = {}) =>
|
||||||
|
apiClient.get<PaginatedResponse<CheckInRecord>>('/api/check_in/my-records', params),
|
||||||
|
allRecords: (params: Record<string, unknown> = {}) =>
|
||||||
|
apiClient.get<PaginatedResponse<CheckInRecord>>('/api/check_in/records', params),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const templateApi = {
|
||||||
|
list: (params: Record<string, unknown> = {}) =>
|
||||||
|
apiClient.get<Template[]>('/api/templates/', params),
|
||||||
|
active: (params: Record<string, unknown> = {}) =>
|
||||||
|
apiClient.get<Template[]>('/api/templates/active', params),
|
||||||
|
detail: (templateId: number) => apiClient.get<Template>(`/api/templates/${templateId}`),
|
||||||
|
preview: (templateId: number) =>
|
||||||
|
apiClient.get<TemplatePreview>(`/api/templates/${templateId}/preview`),
|
||||||
|
create: (payload: Partial<Template>) => apiClient.post<Template>('/api/templates/', payload),
|
||||||
|
update: (templateId: number, payload: Partial<Template>) =>
|
||||||
|
apiClient.put<Template>(`/api/templates/${templateId}`, payload),
|
||||||
|
delete: (templateId: number) =>
|
||||||
|
apiClient.delete<{ message: string }>(`/api/templates/${templateId}`),
|
||||||
|
createTask: (payload: CreateTaskFromTemplatePayload) =>
|
||||||
|
apiClient.post<Task>('/api/templates/create-task', payload),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
pendingUsers: () => apiClient.get<User[]>('/api/admin/users/pending'),
|
||||||
|
approveUser: (userId: number) =>
|
||||||
|
apiClient.post<{ success: boolean; message: string }>(`/api/admin/users/${userId}/approve`),
|
||||||
|
rejectUser: (userId: number) =>
|
||||||
|
apiClient.delete<{ success: boolean; message: string }>(`/api/admin/users/${userId}/reject`),
|
||||||
|
stats: () => apiClient.get<AdminStats>('/api/admin/stats'),
|
||||||
|
logs: (lines: number) => apiClient.get<LogsResponse>('/api/admin/logs', { lines }),
|
||||||
|
batchToggleTasks: (task_ids: number[], is_active: boolean) =>
|
||||||
|
apiClient.post<{ success: boolean; message: string; count: number }>(
|
||||||
|
'/api/admin/batch_toggle_tasks',
|
||||||
|
{
|
||||||
|
task_ids,
|
||||||
|
is_active,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
batchCheckIn: (task_ids: number[]) =>
|
||||||
|
apiClient.post<unknown>('/api/admin/batch_check_in', { task_ids }),
|
||||||
|
}
|
||||||
|
|
||||||
|
export type * from './types'
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
export type Role = 'admin' | 'user'
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
alias: string
|
||||||
|
role: Role | string
|
||||||
|
is_approved: boolean
|
||||||
|
jwt_exp: string
|
||||||
|
email?: string | null
|
||||||
|
has_password?: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserStatus {
|
||||||
|
user_id: number
|
||||||
|
alias: string
|
||||||
|
is_approved: boolean
|
||||||
|
created_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenStatus {
|
||||||
|
is_valid: boolean
|
||||||
|
jwt_exp: string
|
||||||
|
expires_at?: number | null
|
||||||
|
days_until_expiry?: number | null
|
||||||
|
expiring_soon: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthUserPayload {
|
||||||
|
id?: number
|
||||||
|
user_id?: number
|
||||||
|
alias?: string
|
||||||
|
role?: Role | string
|
||||||
|
is_approved?: boolean
|
||||||
|
jwt_exp?: string
|
||||||
|
email?: string | null
|
||||||
|
has_password?: boolean
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
success?: boolean
|
||||||
|
message?: string
|
||||||
|
token?: string
|
||||||
|
authorization?: string
|
||||||
|
user?: AuthUserPayload
|
||||||
|
user_id?: number
|
||||||
|
alias?: string
|
||||||
|
role?: Role | string
|
||||||
|
is_approved?: boolean
|
||||||
|
warning?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QRCodeRequestResponse {
|
||||||
|
session_id: string
|
||||||
|
status?: string
|
||||||
|
qrcode_image?: string
|
||||||
|
qrcode_base64?: string
|
||||||
|
qr_code?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QRCodeStatusResponse extends LoginResponse {
|
||||||
|
status: 'pending' | 'waiting_scan' | 'success' | 'error' | string
|
||||||
|
qrcode_image?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
payload_config: string
|
||||||
|
name?: string | null
|
||||||
|
is_active?: boolean | null
|
||||||
|
created_at: string
|
||||||
|
updated_at?: string | null
|
||||||
|
cron_expression?: string | null
|
||||||
|
is_scheduled_enabled?: boolean | null
|
||||||
|
last_check_in_time?: string | null
|
||||||
|
last_check_in_status?: string | null
|
||||||
|
thread_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateFieldOption {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateFieldConfigItem {
|
||||||
|
display_name: string
|
||||||
|
field_type: 'text' | 'textarea' | 'number' | 'select' | string
|
||||||
|
default_value?: string
|
||||||
|
required?: boolean
|
||||||
|
hidden?: boolean
|
||||||
|
placeholder?: string | null
|
||||||
|
value_type?: 'string' | 'int' | 'double' | string
|
||||||
|
options?: TemplateFieldOption[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateFieldConfig {
|
||||||
|
signature?: TemplateFieldConfigItem
|
||||||
|
texts?: TemplateFieldConfigItem
|
||||||
|
values?: Record<string, TemplateFieldConfigItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Template {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
parent_id?: number | null
|
||||||
|
field_config: string
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplatePreview {
|
||||||
|
template_id: number
|
||||||
|
template_name: string
|
||||||
|
preview_payload: Record<string, unknown>
|
||||||
|
field_config: TemplateFieldConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaskFromTemplatePayload {
|
||||||
|
template_id: number
|
||||||
|
thread_id: string
|
||||||
|
field_values: Record<string, unknown>
|
||||||
|
task_name?: string | null
|
||||||
|
cron_expression?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckInRecord {
|
||||||
|
id: number
|
||||||
|
task_id: number
|
||||||
|
status: string
|
||||||
|
response_text?: string | null
|
||||||
|
error_message?: string | null
|
||||||
|
location?: string | null
|
||||||
|
trigger_type?: string | null
|
||||||
|
check_in_time?: string | null
|
||||||
|
user_id?: number | null
|
||||||
|
user_email?: string | null
|
||||||
|
user_alias?: string | null
|
||||||
|
task_name?: string | null
|
||||||
|
thread_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
records: T[]
|
||||||
|
total: number
|
||||||
|
skip: number
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckInStartResponse {
|
||||||
|
success?: boolean
|
||||||
|
message?: string
|
||||||
|
record_id?: number
|
||||||
|
id?: number
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckInRecordStatus {
|
||||||
|
record_id: number
|
||||||
|
task_id: number
|
||||||
|
status: string
|
||||||
|
response_text?: string | null
|
||||||
|
error_message?: string | null
|
||||||
|
trigger_type?: string | null
|
||||||
|
check_in_time?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminStats {
|
||||||
|
users: {
|
||||||
|
total: number
|
||||||
|
admin: number
|
||||||
|
regular: number
|
||||||
|
active: number
|
||||||
|
}
|
||||||
|
tasks: {
|
||||||
|
total: number
|
||||||
|
active: number
|
||||||
|
inactive: number
|
||||||
|
}
|
||||||
|
check_in_records: {
|
||||||
|
total: number
|
||||||
|
today: number
|
||||||
|
today_success: number
|
||||||
|
today_failure: number
|
||||||
|
today_out_of_time: number
|
||||||
|
today_unknown: number
|
||||||
|
}
|
||||||
|
tokens: {
|
||||||
|
expiring_soon: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogsResponse {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
logs: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CronValidation {
|
||||||
|
valid: boolean
|
||||||
|
message: string
|
||||||
|
next_times: string[]
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorData {
|
||||||
|
detail?: string
|
||||||
|
message?: string
|
||||||
|
error?: {
|
||||||
|
code?: string
|
||||||
|
message?: string
|
||||||
|
field?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { computed, reactive } from 'vue'
|
||||||
|
import {
|
||||||
|
clearStoredAuth,
|
||||||
|
getStoredToken,
|
||||||
|
getStoredUser,
|
||||||
|
setStoredToken,
|
||||||
|
setStoredUser,
|
||||||
|
} from '@/api/client'
|
||||||
|
import { userApi } from '@/api'
|
||||||
|
import type { LoginResponse, User } from '@/api'
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
token: string | null
|
||||||
|
user: User | null
|
||||||
|
initialized: boolean
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = reactive<AuthState>({
|
||||||
|
token: getStoredToken(),
|
||||||
|
user: getStoredUser<User>(),
|
||||||
|
initialized: false,
|
||||||
|
loading: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
function userFromLogin(payload: LoginResponse): User | null {
|
||||||
|
const raw = payload.user
|
||||||
|
if (raw?.id || raw?.user_id || payload.user_id) {
|
||||||
|
return {
|
||||||
|
id: raw?.id ?? raw?.user_id ?? payload.user_id ?? 0,
|
||||||
|
alias: raw?.alias ?? payload.alias ?? '未命名用户',
|
||||||
|
role: raw?.role ?? payload.role ?? 'user',
|
||||||
|
is_approved: raw?.is_approved ?? payload.is_approved ?? false,
|
||||||
|
jwt_exp: raw?.jwt_exp ?? '',
|
||||||
|
email: raw?.email ?? null,
|
||||||
|
has_password: raw?.has_password,
|
||||||
|
created_at: raw?.created_at ?? new Date().toISOString(),
|
||||||
|
updated_at: raw?.updated_at ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCurrentUser() {
|
||||||
|
if (!state.token) {
|
||||||
|
state.user = null
|
||||||
|
state.initialized = true
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
state.loading = true
|
||||||
|
try {
|
||||||
|
const user = await userApi.me()
|
||||||
|
state.user = user
|
||||||
|
setStoredUser(user)
|
||||||
|
return user
|
||||||
|
} catch (error) {
|
||||||
|
clearStoredAuth()
|
||||||
|
state.token = null
|
||||||
|
state.user = null
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
state.loading = false
|
||||||
|
state.initialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLogin(payload: LoginResponse) {
|
||||||
|
const token = payload.authorization ?? payload.token
|
||||||
|
if (!token) throw new Error(payload.message || '登录响应缺少 token')
|
||||||
|
|
||||||
|
state.token = token
|
||||||
|
setStoredToken(token)
|
||||||
|
|
||||||
|
const user = userFromLogin(payload)
|
||||||
|
if (user) {
|
||||||
|
state.user = user
|
||||||
|
setStoredUser(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
clearStoredAuth()
|
||||||
|
state.token = null
|
||||||
|
state.user = null
|
||||||
|
state.initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
isAuthenticated: computed(() => Boolean(state.token)),
|
||||||
|
isAdmin: computed(() => state.user?.role === 'admin'),
|
||||||
|
isApproved: computed(() => Boolean(state.user?.is_approved)),
|
||||||
|
applyLogin,
|
||||||
|
refreshCurrentUser,
|
||||||
|
logout,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { computed, reactive } from 'vue'
|
||||||
|
import { useAuth } from './auth'
|
||||||
|
|
||||||
|
export type RouteKey =
|
||||||
|
| 'login'
|
||||||
|
| 'pending'
|
||||||
|
| 'dashboard'
|
||||||
|
| 'tasks'
|
||||||
|
| 'task-records'
|
||||||
|
| 'records'
|
||||||
|
| 'settings'
|
||||||
|
| 'admin-users'
|
||||||
|
| 'admin-templates'
|
||||||
|
| 'admin-records'
|
||||||
|
| 'admin-logs'
|
||||||
|
| 'admin-stats'
|
||||||
|
| 'not-found'
|
||||||
|
|
||||||
|
export interface AppRoute {
|
||||||
|
key: RouteKey
|
||||||
|
path: string
|
||||||
|
title: string
|
||||||
|
requiresAuth?: boolean
|
||||||
|
requiresAdmin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routes: AppRoute[] = [
|
||||||
|
{ key: 'login', path: '/login', title: '登录' },
|
||||||
|
{ key: 'pending', path: '/pending-approval', title: '等待审批', requiresAuth: true },
|
||||||
|
{ key: 'dashboard', path: '/dashboard', title: '仪表盘', requiresAuth: true },
|
||||||
|
{ key: 'tasks', path: '/tasks', title: '任务管理', requiresAuth: true },
|
||||||
|
{ key: 'task-records', path: '/tasks/:taskId/records', title: '任务记录', requiresAuth: true },
|
||||||
|
{ key: 'records', path: '/records', title: '打卡记录', requiresAuth: true },
|
||||||
|
{ key: 'settings', path: '/settings', title: '个人设置', requiresAuth: true },
|
||||||
|
{
|
||||||
|
key: 'admin-users',
|
||||||
|
path: '/admin/users',
|
||||||
|
title: '用户管理',
|
||||||
|
requiresAuth: true,
|
||||||
|
requiresAdmin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'admin-templates',
|
||||||
|
path: '/admin/templates',
|
||||||
|
title: '模板管理',
|
||||||
|
requiresAuth: true,
|
||||||
|
requiresAdmin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'admin-records',
|
||||||
|
path: '/admin/records',
|
||||||
|
title: '全部记录',
|
||||||
|
requiresAuth: true,
|
||||||
|
requiresAdmin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'admin-logs',
|
||||||
|
path: '/admin/logs',
|
||||||
|
title: '系统日志',
|
||||||
|
requiresAuth: true,
|
||||||
|
requiresAdmin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'admin-stats',
|
||||||
|
path: '/admin/stats',
|
||||||
|
title: '统计信息',
|
||||||
|
requiresAuth: true,
|
||||||
|
requiresAdmin: true,
|
||||||
|
},
|
||||||
|
{ key: 'not-found', path: '/:pathMatch(.*)*', title: '页面未找到' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface RouterState {
|
||||||
|
path: string
|
||||||
|
params: Record<string, string>
|
||||||
|
query: URLSearchParams
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = reactive<RouterState>({
|
||||||
|
path: window.location.pathname,
|
||||||
|
params: {},
|
||||||
|
query: new URLSearchParams(window.location.search),
|
||||||
|
})
|
||||||
|
|
||||||
|
interface MatchedRoute {
|
||||||
|
route: AppRoute
|
||||||
|
params: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathnameOf(path: string) {
|
||||||
|
return path.split('?')[0] || '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchRoute(path: string): MatchedRoute {
|
||||||
|
const pathname = pathnameOf(path)
|
||||||
|
|
||||||
|
if (pathname === '/')
|
||||||
|
return { route: routes.find((route) => route.key === 'dashboard')!, params: {} }
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
if (!route.path.includes(':') && route.path === pathname) return { route, params: {} }
|
||||||
|
if (route.path === '/tasks/:taskId/records') {
|
||||||
|
const match = pathname.match(/^\/tasks\/(\d+)\/records$/)
|
||||||
|
if (match) return { route, params: { taskId: match[1] } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { route: routes.find((route) => route.key === 'not-found')!, params: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFromLocation() {
|
||||||
|
const matched = matchRoute(window.location.pathname)
|
||||||
|
state.path = window.location.pathname
|
||||||
|
state.params = matched.params
|
||||||
|
state.query = new URLSearchParams(window.location.search)
|
||||||
|
document.title = `${matched.route.title} - 接龙自动打卡系统`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPath(path: string, query?: Record<string, string | undefined>) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
Object.entries(query ?? {}).forEach(([key, value]) => {
|
||||||
|
if (value) params.set(key, value)
|
||||||
|
})
|
||||||
|
const suffix = params.toString()
|
||||||
|
return suffix ? `${path}?${suffix}` : path
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardPath(path: string) {
|
||||||
|
const { state: authState, isAdmin, refreshCurrentUser } = useAuth()
|
||||||
|
const matched = matchRoute(path)
|
||||||
|
const route = matched.route
|
||||||
|
const pathname = pathnameOf(path)
|
||||||
|
|
||||||
|
if (route.requiresAuth && !authState.token) {
|
||||||
|
return buildPath('/login', { redirect: pathname })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authState.token && !authState.initialized) {
|
||||||
|
try {
|
||||||
|
await refreshCurrentUser()
|
||||||
|
} catch {
|
||||||
|
return buildPath('/login', { redirect: path })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.requiresAuth && route.key !== 'pending' && !authState.user?.is_approved) {
|
||||||
|
return '/pending-approval'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.key === 'pending' && authState.user?.is_approved) {
|
||||||
|
return '/dashboard'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.requiresAdmin && !isAdmin.value) {
|
||||||
|
return '/dashboard'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.key === 'login' && authState.token && authState.user?.is_approved) {
|
||||||
|
return '/dashboard'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigate(path: string, replace = false) {
|
||||||
|
const target = await guardPath(path)
|
||||||
|
const next = target ?? path
|
||||||
|
if (replace) {
|
||||||
|
window.history.replaceState({}, '', next)
|
||||||
|
} else {
|
||||||
|
window.history.pushState({}, '', next)
|
||||||
|
}
|
||||||
|
syncFromLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
syncFromLocation()
|
||||||
|
void guardPath(window.location.pathname).then((target) => {
|
||||||
|
if (target) void navigate(target, true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
syncFromLocation()
|
||||||
|
|
||||||
|
export function useRouter() {
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
current: computed(() => matchRoute(state.path).route),
|
||||||
|
params: computed(() => state.params),
|
||||||
|
query: computed(() => state.query),
|
||||||
|
navigate,
|
||||||
|
replace: (path: string) => navigate(path, true),
|
||||||
|
guardCurrent: () => navigate(`${window.location.pathname}${window.location.search}`, true),
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 496 B |
@@ -0,0 +1,153 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
CheckSquare,
|
||||||
|
ClipboardList,
|
||||||
|
FileText,
|
||||||
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
ScrollText,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
UserRound,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useAuth } from '@/app/auth'
|
||||||
|
import { useRouter } from '@/app/router'
|
||||||
|
|
||||||
|
const { state: authState, isAdmin, logout } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
const mobileOpen = ref(false)
|
||||||
|
|
||||||
|
const userLinks = [
|
||||||
|
{ path: '/dashboard', label: '仪表盘', icon: LayoutDashboard },
|
||||||
|
{ path: '/tasks', label: '任务', icon: CheckSquare },
|
||||||
|
{ path: '/records', label: '记录', icon: ClipboardList },
|
||||||
|
{ path: '/settings', label: '设置', icon: Settings },
|
||||||
|
]
|
||||||
|
|
||||||
|
const adminLinks = [
|
||||||
|
{ path: '/admin/users', label: '用户', icon: Users },
|
||||||
|
{ path: '/admin/templates', label: '模板', icon: FileText },
|
||||||
|
{ path: '/admin/records', label: '全量记录', icon: ScrollText },
|
||||||
|
{ path: '/admin/logs', label: '日志', icon: Shield },
|
||||||
|
{ path: '/admin/stats', label: '统计', icon: BarChart3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const title = computed(() => router.current.value.title)
|
||||||
|
|
||||||
|
function go(path: string) {
|
||||||
|
mobileOpen.value = false
|
||||||
|
void router.navigate(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function signOut() {
|
||||||
|
logout()
|
||||||
|
void router.replace('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[100dvh] bg-zinc-50 text-zinc-950">
|
||||||
|
<header class="sticky top-0 z-20 border-b border-zinc-200 bg-white/95 backdrop-blur">
|
||||||
|
<div class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex size-9 items-center justify-center rounded-md border border-zinc-200 text-zinc-700 lg:hidden"
|
||||||
|
@click="mobileOpen = !mobileOpen"
|
||||||
|
>
|
||||||
|
<X v-if="mobileOpen" class="size-4" />
|
||||||
|
<Menu v-else class="size-4" />
|
||||||
|
</button>
|
||||||
|
<button class="text-left" type="button" @click="go('/dashboard')">
|
||||||
|
<div class="text-sm font-semibold leading-4">接龙自动打卡</div>
|
||||||
|
<div class="text-xs text-zinc-500">CheckIn workspace</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="hidden text-right sm:block">
|
||||||
|
<div class="text-sm font-medium">{{ authState.user?.alias ?? '未登录' }}</div>
|
||||||
|
<div class="text-xs text-zinc-500">{{ isAdmin ? '管理员' : '普通用户' }}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-50"
|
||||||
|
@click="signOut"
|
||||||
|
>
|
||||||
|
<LogOut class="size-4" />
|
||||||
|
<span class="hidden sm:inline">退出</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mx-auto grid max-w-7xl grid-cols-1 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||||
|
<aside
|
||||||
|
class="border-b border-zinc-200 bg-white px-4 py-3 lg:min-h-[calc(100dvh-3.5rem)] lg:border-b-0 lg:border-r"
|
||||||
|
:class="mobileOpen ? 'block' : 'hidden lg:block'"
|
||||||
|
>
|
||||||
|
<nav class="grid gap-1">
|
||||||
|
<button
|
||||||
|
v-for="link in userLinks"
|
||||||
|
:key="link.path"
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition hover:bg-zinc-100"
|
||||||
|
:class="
|
||||||
|
router.state.path === link.path
|
||||||
|
? 'bg-zinc-900 text-white hover:bg-zinc-900'
|
||||||
|
: 'text-zinc-700'
|
||||||
|
"
|
||||||
|
@click="go(link.path)"
|
||||||
|
>
|
||||||
|
<component :is="link.icon" class="size-4" />
|
||||||
|
{{ link.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div v-if="isAdmin" class="mt-5 border-t border-zinc-200 pt-4">
|
||||||
|
<div class="mb-2 px-3 text-xs font-semibold uppercase tracking-normal text-zinc-500">
|
||||||
|
管理员
|
||||||
|
</div>
|
||||||
|
<nav class="grid gap-1">
|
||||||
|
<button
|
||||||
|
v-for="link in adminLinks"
|
||||||
|
:key="link.path"
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition hover:bg-zinc-100"
|
||||||
|
:class="
|
||||||
|
router.state.path === link.path
|
||||||
|
? 'bg-zinc-900 text-white hover:bg-zinc-900'
|
||||||
|
: 'text-zinc-700'
|
||||||
|
"
|
||||||
|
@click="go(link.path)"
|
||||||
|
>
|
||||||
|
<component :is="link.icon" class="size-4" />
|
||||||
|
{{ link.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="min-w-0 px-4 py-5 sm:px-6 lg:px-8">
|
||||||
|
<div class="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold tracking-normal text-zinc-950">{{ title }}</h1>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">管理打卡任务、授权状态和系统记录。</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-3 py-1 text-xs text-zinc-600"
|
||||||
|
>
|
||||||
|
<UserRound class="size-3.5" />
|
||||||
|
{{ authState.user?.is_approved ? '已审批' : '待审批' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<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 type="button" class="counter" @click="count++">
|
|
||||||
Count is {{ count }}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="ticks"></div>
|
|
||||||
|
|
||||||
<section id="next-steps">
|
|
||||||
<div id="docs">
|
|
||||||
<svg class="icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#documentation-icon"></use>
|
|
||||||
</svg>
|
|
||||||
<h2>Documentation</h2>
|
|
||||||
<p>Your questions, answered</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="https://vite.dev/" target="_blank">
|
|
||||||
<img class="logo" :src="viteLogo" alt="" />
|
|
||||||
Explore Vite
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://vuejs.org/" target="_blank">
|
|
||||||
<img class="button-icon" :src="vueLogo" alt="" />
|
|
||||||
Learn more
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div id="social">
|
|
||||||
<svg class="icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#social-icon"></use>
|
|
||||||
</svg>
|
|
||||||
<h2>Connect with us</h2>
|
|
||||||
<p>Join the Vite community</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
|
||||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#github-icon"></use>
|
|
||||||
</svg>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://chat.vite.dev/" target="_blank">
|
|
||||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#discord-icon"></use>
|
|
||||||
</svg>
|
|
||||||
Discord
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://x.com/vite_js" target="_blank">
|
|
||||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#x-icon"></use>
|
|
||||||
</svg>
|
|
||||||
X.com
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
|
||||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#bluesky-icon"></use>
|
|
||||||
</svg>
|
|
||||||
Bluesky
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="ticks"></div>
|
|
||||||
<section id="spacer"></section>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { AlertCircle, Loader2, Search } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
type?: 'loading' | 'empty' | 'error'
|
||||||
|
actionLabel?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
action: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-lg border border-dashed border-zinc-200 bg-white p-6 text-center">
|
||||||
|
<div
|
||||||
|
class="mx-auto mb-3 flex size-10 items-center justify-center rounded-md bg-zinc-100 text-zinc-600"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="type === 'loading'" class="size-5 animate-spin" />
|
||||||
|
<AlertCircle v-else-if="type === 'error'" class="size-5" />
|
||||||
|
<Search v-else class="size-5" />
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-semibold text-zinc-900">{{ title }}</div>
|
||||||
|
<p v-if="description" class="mx-auto mt-1 max-w-md text-sm text-zinc-500">{{ description }}</p>
|
||||||
|
<button
|
||||||
|
v-if="actionLabel"
|
||||||
|
type="button"
|
||||||
|
class="mt-4 inline-flex items-center rounded-md border border-zinc-200 px-3 py-2 text-sm font-medium text-zinc-700 transition hover:bg-zinc-50 active:translate-y-px"
|
||||||
|
@click="$emit('action')"
|
||||||
|
>
|
||||||
|
{{ actionLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export type Tone = 'neutral' | 'success' | 'warning' | 'danger' | 'info'
|
||||||
|
|
||||||
|
export const buttonBase =
|
||||||
|
'inline-flex items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm font-medium transition active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50'
|
||||||
|
|
||||||
|
export const buttonTone = {
|
||||||
|
primary: 'border-zinc-900 bg-zinc-900 text-white hover:bg-zinc-800',
|
||||||
|
secondary: 'border-zinc-200 bg-white text-zinc-900 hover:bg-zinc-50',
|
||||||
|
ghost: 'border-transparent bg-transparent text-zinc-700 hover:bg-zinc-100',
|
||||||
|
danger: 'border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inputClass =
|
||||||
|
'w-full rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-zinc-900 focus:ring-2 focus:ring-zinc-900/10'
|
||||||
|
|
||||||
|
export const textareaClass = `${inputClass} min-h-24 resize-y font-mono text-xs leading-5`
|
||||||
|
|
||||||
|
export const cardClass = 'rounded-lg border border-zinc-200 bg-white shadow-sm'
|
||||||
|
|
||||||
|
export const labelClass = 'text-xs font-semibold uppercase tracking-normal text-zinc-500'
|
||||||
|
|
||||||
|
export const mutedText = 'text-sm text-zinc-500'
|
||||||
|
|
||||||
|
export function toneClass(tone: Tone) {
|
||||||
|
const tones: Record<Tone, string> = {
|
||||||
|
neutral: 'border-zinc-200 bg-zinc-50 text-zinc-700',
|
||||||
|
success: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||||
|
warning: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||||
|
danger: 'border-rose-200 bg-rose-50 text-rose-700',
|
||||||
|
info: 'border-sky-200 bg-sky-50 text-sky-700',
|
||||||
|
}
|
||||||
|
return `inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${tones[tone]}`
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ClassValue } from "clsx"
|
import type { ClassValue } from 'clsx'
|
||||||
import { clsx } from "clsx"
|
import { clsx } from 'clsx'
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import './style.css'
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import { useAuth } from './app/auth'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
const auth = useAuth()
|
||||||
|
|
||||||
|
if (auth.state.token && !auth.state.initialized) {
|
||||||
|
void auth.refreshCurrentUser().catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
createApp(App).mount('#app')
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
@import "tw-animate-css";
|
@import 'tw-animate-css';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { ApiError } from '@/api/client'
|
||||||
|
|
||||||
|
export function formatDateTime(value?: string | null) {
|
||||||
|
if (!value) return '未记录'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFullDateTime(value?: string | null) {
|
||||||
|
if (!value) return '未记录'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusLabel(status?: string | null) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
success: '成功',
|
||||||
|
failure: '失败',
|
||||||
|
failed: '失败',
|
||||||
|
pending: '进行中',
|
||||||
|
running: '进行中',
|
||||||
|
already_submitted: '已提交',
|
||||||
|
out_of_time: '超出时间',
|
||||||
|
unknown: '未知',
|
||||||
|
manual: '手动',
|
||||||
|
scheduler: '定时',
|
||||||
|
scheduled: '定时',
|
||||||
|
admin: '管理员',
|
||||||
|
}
|
||||||
|
return labels[status ?? ''] ?? status ?? '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusTone(status?: string | null) {
|
||||||
|
if (status === 'success' || status === 'already_submitted') return 'success'
|
||||||
|
if (status === 'pending' || status === 'running') return 'warning'
|
||||||
|
if (status === 'failure' || status === 'failed' || status === 'out_of_time') return 'danger'
|
||||||
|
return 'neutral'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cronLabel(value?: string | null) {
|
||||||
|
if (!value) return '未启用定时'
|
||||||
|
if (value === '0 20 * * *') return '每天 20:00'
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseJson<T>(value?: string | null, fallback: T = {} as T) {
|
||||||
|
if (!value) return fallback
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as T
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringifyJson(value: unknown) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2)
|
||||||
|
} catch {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractErrorMessage(error: unknown) {
|
||||||
|
if (typeof error === 'object' && error && 'message' in error) {
|
||||||
|
return String((error as ApiError).message)
|
||||||
|
}
|
||||||
|
if (error instanceof Error) return error.message
|
||||||
|
return '操作失败,请稍后重试'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function boolLabel(value?: boolean | null) {
|
||||||
|
return value ? '是' : '否'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampText(value?: string | null, fallback = '未命名') {
|
||||||
|
const trimmed = value?.trim()
|
||||||
|
return trimmed || fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Activity, CheckCircle2, Clock, KeyRound } from 'lucide-vue-next'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
checkInApi,
|
||||||
|
taskApi,
|
||||||
|
userApi,
|
||||||
|
type CheckInRecord,
|
||||||
|
type Task,
|
||||||
|
type TokenStatus,
|
||||||
|
} from '@/api'
|
||||||
|
import { useRouter } from '@/app/router'
|
||||||
|
import StateBlock from '@/components/StateBlock.vue'
|
||||||
|
import { cardClass, toneClass } from '@/components/ui'
|
||||||
|
import {
|
||||||
|
cronLabel,
|
||||||
|
extractErrorMessage,
|
||||||
|
formatDateTime,
|
||||||
|
statusLabel,
|
||||||
|
statusTone,
|
||||||
|
} from '@/utils/format'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const tasks = ref<Task[]>([])
|
||||||
|
const records = ref<CheckInRecord[]>([])
|
||||||
|
const tokenStatus = ref<TokenStatus | null>(null)
|
||||||
|
|
||||||
|
const activeTasks = computed(() => tasks.value.filter((task) => task.is_active).length)
|
||||||
|
const successToday = computed(
|
||||||
|
() => records.value.filter((record) => record.status === 'success').length,
|
||||||
|
)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const [taskList, token, recordPage] = await Promise.all([
|
||||||
|
taskApi.list(),
|
||||||
|
userApi.tokenStatus().catch(() => null),
|
||||||
|
checkInApi.myRecords({ limit: 6 }),
|
||||||
|
])
|
||||||
|
tasks.value = taskList
|
||||||
|
tokenStatus.value = token
|
||||||
|
records.value = recordPage.records
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<StateBlock v-if="loading" title="正在加载仪表盘" type="loading" />
|
||||||
|
<StateBlock
|
||||||
|
v-else-if="error"
|
||||||
|
title="仪表盘加载失败"
|
||||||
|
:description="error"
|
||||||
|
type="error"
|
||||||
|
action-label="重试"
|
||||||
|
@action="load"
|
||||||
|
/>
|
||||||
|
<div v-else class="grid gap-5">
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div :class="[cardClass, 'p-4']">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-zinc-500">任务总数</span>
|
||||||
|
<CheckCircle2 class="size-4 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-3xl font-semibold">{{ tasks.length }}</div>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">{{ activeTasks }} 个启用</p>
|
||||||
|
</div>
|
||||||
|
<div :class="[cardClass, 'p-4']">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-zinc-500">打卡 Token</span>
|
||||||
|
<KeyRound class="size-4 text-sky-600" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-lg font-semibold">
|
||||||
|
{{ tokenStatus?.is_valid ? '可用' : '需要更新' }}
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
|
{{
|
||||||
|
tokenStatus?.days_until_expiry == null
|
||||||
|
? '未获取到过期信息'
|
||||||
|
: `${tokenStatus.days_until_expiry} 天后过期`
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div :class="[cardClass, 'p-4']">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-zinc-500">最近成功</span>
|
||||||
|
<Activity class="size-4 text-zinc-700" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-3xl font-semibold">{{ successToday }}</div>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">最近记录中的成功数</p>
|
||||||
|
</div>
|
||||||
|
<div :class="[cardClass, 'p-4']">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-zinc-500">下次定时</span>
|
||||||
|
<Clock class="size-4 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-lg font-semibold">
|
||||||
|
{{ cronLabel(tasks.find((task) => task.is_active)?.cron_expression) }}
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">来自首个启用任务</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||||
|
<section :class="[cardClass, 'overflow-hidden']">
|
||||||
|
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
|
||||||
|
<h2 class="font-semibold">任务概览</h2>
|
||||||
|
<button
|
||||||
|
class="text-sm font-medium text-zinc-700 hover:text-zinc-950"
|
||||||
|
type="button"
|
||||||
|
@click="router.navigate('/tasks')"
|
||||||
|
>
|
||||||
|
管理任务
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<StateBlock
|
||||||
|
v-if="tasks.length === 0"
|
||||||
|
title="暂无任务"
|
||||||
|
description="从模板创建任务后,这里会显示任务状态。"
|
||||||
|
action-label="去创建"
|
||||||
|
@action="router.navigate('/tasks')"
|
||||||
|
/>
|
||||||
|
<div v-else class="divide-y divide-zinc-200">
|
||||||
|
<div
|
||||||
|
v-for="task in tasks.slice(0, 6)"
|
||||||
|
:key="task.id"
|
||||||
|
class="grid gap-2 px-4 py-3 sm:grid-cols-[1fr_auto] sm:items-center"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ task.name || `任务 #${task.id}` }}</div>
|
||||||
|
<div class="mt-1 text-sm text-zinc-500">
|
||||||
|
ThreadId: {{ task.thread_id || '未解析' }} · {{ cronLabel(task.cron_expression) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span :class="toneClass(task.is_active ? 'success' : 'neutral')">{{
|
||||||
|
task.is_active ? '启用' : '停用'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section :class="[cardClass, 'overflow-hidden']">
|
||||||
|
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
|
||||||
|
<h2 class="font-semibold">最近记录</h2>
|
||||||
|
<button
|
||||||
|
class="text-sm font-medium text-zinc-700 hover:text-zinc-950"
|
||||||
|
type="button"
|
||||||
|
@click="router.navigate('/records')"
|
||||||
|
>
|
||||||
|
查看全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<StateBlock
|
||||||
|
v-if="records.length === 0"
|
||||||
|
title="暂无记录"
|
||||||
|
description="手动或定时打卡后会生成记录。"
|
||||||
|
/>
|
||||||
|
<div v-else class="divide-y divide-zinc-200">
|
||||||
|
<div v-for="record in records" :key="record.id" class="px-4 py-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<span class="font-medium">{{ record.task_name || `任务 #${record.task_id}` }}</span>
|
||||||
|
<span :class="toneClass(statusTone(record.status))">{{
|
||||||
|
statusLabel(record.status)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-zinc-500">{{ formatDateTime(record.check_in_time) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { KeyRound, QrCode, RotateCw } from 'lucide-vue-next'
|
||||||
|
import { onBeforeUnmount, ref } from 'vue'
|
||||||
|
import { authApi } from '@/api'
|
||||||
|
import { useAuth } from '@/app/auth'
|
||||||
|
import { useRouter } from '@/app/router'
|
||||||
|
import { buttonBase, buttonTone, inputClass } from '@/components/ui'
|
||||||
|
import { extractErrorMessage } from '@/utils/format'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuth()
|
||||||
|
|
||||||
|
const alias = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const info = ref('')
|
||||||
|
const qrImage = ref('')
|
||||||
|
const qrSessionId = ref('')
|
||||||
|
let pollTimer: number | undefined
|
||||||
|
|
||||||
|
function loginRedirect() {
|
||||||
|
const redirect = router.query.value.get('redirect') || '/dashboard'
|
||||||
|
void auth.refreshCurrentUser().finally(() => router.replace(redirect))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWithPassword() {
|
||||||
|
error.value = ''
|
||||||
|
info.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await authApi.aliasLogin(alias.value.trim(), password.value)
|
||||||
|
auth.applyLogin(result)
|
||||||
|
loginRedirect()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestQrCode() {
|
||||||
|
error.value = ''
|
||||||
|
info.value = '正在创建扫码会话'
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (qrSessionId.value) await authApi.cancelQRCodeSession(qrSessionId.value)
|
||||||
|
const result = await authApi.requestQRCode(alias.value.trim())
|
||||||
|
if (result.status === 'error') throw new Error(result.message || '创建扫码会话失败')
|
||||||
|
qrSessionId.value = result.session_id
|
||||||
|
qrImage.value = result.qrcode_image ?? result.qrcode_base64 ?? result.qr_code ?? ''
|
||||||
|
info.value = '请使用 QQ 扫码完成授权'
|
||||||
|
startPolling()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
window.clearInterval(pollTimer)
|
||||||
|
pollTimer = window.setInterval(async () => {
|
||||||
|
if (!qrSessionId.value) return
|
||||||
|
try {
|
||||||
|
const status = await authApi.getQRCodeStatus(qrSessionId.value)
|
||||||
|
if (status.qrcode_image) qrImage.value = status.qrcode_image
|
||||||
|
if (status.status === 'success') {
|
||||||
|
window.clearInterval(pollTimer)
|
||||||
|
auth.applyLogin(status)
|
||||||
|
loginRedirect()
|
||||||
|
} else if (status.status === 'error') {
|
||||||
|
error.value = status.message || '扫码登录失败'
|
||||||
|
window.clearInterval(pollTimer)
|
||||||
|
} else {
|
||||||
|
info.value = status.message || '等待扫码确认'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
window.clearInterval(pollTimer)
|
||||||
|
}
|
||||||
|
}, 2200)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelQr() {
|
||||||
|
window.clearInterval(pollTimer)
|
||||||
|
if (qrSessionId.value) await authApi.cancelQRCodeSession(qrSessionId.value).catch(() => undefined)
|
||||||
|
qrSessionId.value = ''
|
||||||
|
qrImage.value = ''
|
||||||
|
info.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
void cancelQr()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main
|
||||||
|
class="grid min-h-[100dvh] bg-zinc-50 px-4 py-8 text-zinc-950 lg:grid-cols-[minmax(0,1fr)_440px] lg:p-0"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
class="hidden border-r border-zinc-200 bg-white p-10 lg:flex lg:flex-col lg:justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold text-zinc-500">CheckIn App</div>
|
||||||
|
<h1 class="mt-6 max-w-xl text-4xl font-semibold leading-tight tracking-normal">
|
||||||
|
接龙自动打卡系统的新前端工作台
|
||||||
|
</h1>
|
||||||
|
<p class="mt-4 max-w-lg text-base text-zinc-600">
|
||||||
|
使用账号密码或 QQ 扫码登录,管理任务、模板、记录和系统状态。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-3 text-sm">
|
||||||
|
<div class="rounded-lg border border-zinc-200 p-4">
|
||||||
|
<div class="text-2xl font-semibold">1</div>
|
||||||
|
<div class="mt-1 text-zinc-500">用户审批</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-zinc-200 p-4">
|
||||||
|
<div class="text-2xl font-semibold">N</div>
|
||||||
|
<div class="mt-1 text-zinc-500">多任务</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-zinc-200 p-4">
|
||||||
|
<div class="text-2xl font-semibold">24h</div>
|
||||||
|
<div class="mt-1 text-zinc-500">自动调度</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-auto flex w-full max-w-md flex-col justify-center lg:px-8">
|
||||||
|
<div class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm">
|
||||||
|
<h2 class="text-xl font-semibold">登录</h2>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">输入别名登录;没有或需要更新授权时使用 QQ 扫码。</p>
|
||||||
|
|
||||||
|
<form class="mt-6 grid gap-4" @submit.prevent="loginWithPassword">
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">别名</span>
|
||||||
|
<input v-model="alias" :class="inputClass" required placeholder="例如 zhangsan" />
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">密码</span>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
:class="inputClass"
|
||||||
|
type="password"
|
||||||
|
placeholder="已设置密码时可用"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="info"
|
||||||
|
class="rounded-md border border-sky-200 bg-sky-50 px-3 py-2 text-sm text-sky-700"
|
||||||
|
>
|
||||||
|
{{ info }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.primary]"
|
||||||
|
:disabled="loading || !alias || !password"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
<KeyRound class="size-4" />
|
||||||
|
{{ loading ? '处理中' : '密码登录' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-5 border-t border-zinc-200 pt-5">
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.secondary, 'w-full']"
|
||||||
|
:disabled="loading || !alias"
|
||||||
|
type="button"
|
||||||
|
@click="requestQrCode"
|
||||||
|
>
|
||||||
|
<QrCode class="size-4" />
|
||||||
|
请求 QQ 扫码
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="qrImage"
|
||||||
|
class="mt-4 rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="qrImage.startsWith('data:') ? qrImage : `data:image/png;base64,${qrImage}`"
|
||||||
|
alt="QQ 登录二维码"
|
||||||
|
class="mx-auto size-48 rounded-md bg-white object-contain"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="mt-3 inline-flex items-center gap-2 text-sm text-zinc-600 hover:text-zinc-900"
|
||||||
|
type="button"
|
||||||
|
@click="requestQrCode"
|
||||||
|
>
|
||||||
|
<RotateCw class="size-4" />
|
||||||
|
刷新会话
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from '@/app/router'
|
||||||
|
import { buttonBase, buttonTone } from '@/components/ui'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="rounded-lg border border-zinc-200 bg-white p-8 text-center shadow-sm">
|
||||||
|
<h2 class="text-xl font-semibold">页面不存在</h2>
|
||||||
|
<p class="mt-2 text-sm text-zinc-500">当前地址没有对应的新前端页面。</p>
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.primary, 'mt-5']"
|
||||||
|
type="button"
|
||||||
|
@click="router.navigate('/dashboard')"
|
||||||
|
>
|
||||||
|
返回仪表盘
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RefreshCw } from 'lucide-vue-next'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { userApi } from '@/api'
|
||||||
|
import { useAuth } from '@/app/auth'
|
||||||
|
import { useRouter } from '@/app/router'
|
||||||
|
import { buttonBase, buttonTone } from '@/components/ui'
|
||||||
|
import { extractErrorMessage, formatFullDateTime } from '@/utils/format'
|
||||||
|
|
||||||
|
const auth = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const status = await userApi.status()
|
||||||
|
if (status.is_approved) {
|
||||||
|
await auth.refreshCurrentUser()
|
||||||
|
await router.replace('/dashboard')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="mx-auto max-w-2xl rounded-lg border border-zinc-200 bg-white p-6 shadow-sm">
|
||||||
|
<h2 class="text-xl font-semibold">账号等待审批</h2>
|
||||||
|
<p class="mt-2 text-sm text-zinc-500">
|
||||||
|
当前账号
|
||||||
|
{{ auth.state.user?.alias ?? '未知用户' }} 已完成登录,但还需要管理员审批后才能访问工作台。
|
||||||
|
</p>
|
||||||
|
<dl class="mt-5 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div class="rounded-md border border-zinc-200 p-3">
|
||||||
|
<dt class="text-xs text-zinc-500">创建时间</dt>
|
||||||
|
<dd class="mt-1 text-sm font-medium">
|
||||||
|
{{ formatFullDateTime(auth.state.user?.created_at) }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-zinc-200 p-3">
|
||||||
|
<dt class="text-xs text-zinc-500">审批状态</dt>
|
||||||
|
<dd class="mt-1 text-sm font-medium">待审批</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="mt-4 rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.primary, 'mt-5']"
|
||||||
|
:disabled="loading"
|
||||||
|
type="button"
|
||||||
|
@click="refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw class="size-4" :class="{ 'animate-spin': loading }" />
|
||||||
|
刷新审批状态
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Search } from 'lucide-vue-next'
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { checkInApi, type CheckInRecord } from '@/api'
|
||||||
|
import StateBlock from '@/components/StateBlock.vue'
|
||||||
|
import { buttonBase, buttonTone, cardClass, inputClass, toneClass } from '@/components/ui'
|
||||||
|
import { extractErrorMessage, formatFullDateTime, statusLabel, statusTone } from '@/utils/format'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const records = ref<CheckInRecord[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const filters = reactive({ status: '', trigger_type: '', skip: 0, limit: 20 })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const page = await checkInApi.myRecords(filters)
|
||||||
|
records.value = page.records
|
||||||
|
total.value = page.total
|
||||||
|
filters.skip = page.skip
|
||||||
|
filters.limit = page.limit
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function page(delta: number) {
|
||||||
|
filters.skip = Math.max(0, filters.skip + delta * filters.limit)
|
||||||
|
void load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section :class="[cardClass, 'overflow-hidden']">
|
||||||
|
<div class="grid gap-3 border-b border-zinc-200 p-4 md:grid-cols-[1fr_180px_180px_auto]">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold">个人打卡记录</h2>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">按状态和触发方式查看最近的打卡结果。</p>
|
||||||
|
</div>
|
||||||
|
<select v-model="filters.status" :class="inputClass">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="success">成功</option>
|
||||||
|
<option value="failure">失败</option>
|
||||||
|
<option value="out_of_time">超出时间</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="filters.trigger_type" :class="inputClass">
|
||||||
|
<option value="">全部触发</option>
|
||||||
|
<option value="manual">手动</option>
|
||||||
|
<option value="scheduler">定时</option>
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
</select>
|
||||||
|
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">
|
||||||
|
<Search class="size-4" />
|
||||||
|
筛选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StateBlock v-if="loading" title="正在加载记录" type="loading" />
|
||||||
|
<StateBlock
|
||||||
|
v-else-if="error"
|
||||||
|
title="记录加载失败"
|
||||||
|
:description="error"
|
||||||
|
type="error"
|
||||||
|
action-label="重试"
|
||||||
|
@action="load"
|
||||||
|
/>
|
||||||
|
<StateBlock
|
||||||
|
v-else-if="records.length === 0"
|
||||||
|
title="暂无记录"
|
||||||
|
description="当前筛选条件下没有打卡记录。"
|
||||||
|
/>
|
||||||
|
<div v-else>
|
||||||
|
<div class="divide-y divide-zinc-200">
|
||||||
|
<article
|
||||||
|
v-for="record in records"
|
||||||
|
:key="record.id"
|
||||||
|
class="grid gap-3 p-4 lg:grid-cols-[180px_1fr_auto]"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold">
|
||||||
|
{{ record.task_name || `任务 #${record.task_id}` }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-500">
|
||||||
|
{{ formatFullDateTime(record.check_in_time) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm text-zinc-700">
|
||||||
|
{{ record.response_text || record.error_message || '无响应内容' }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-zinc-500">
|
||||||
|
触发方式:{{ statusLabel(record.trigger_type) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span :class="toneClass(statusTone(record.status))">{{
|
||||||
|
statusLabel(record.status)
|
||||||
|
}}</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between border-t border-zinc-200 px-4 py-3 text-sm text-zinc-500"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
>共 {{ total }} 条,当前 {{ filters.skip + 1 }} -
|
||||||
|
{{ Math.min(filters.skip + filters.limit, total) }}</span
|
||||||
|
>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.secondary]"
|
||||||
|
:disabled="filters.skip === 0"
|
||||||
|
type="button"
|
||||||
|
@click="page(-1)"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.secondary]"
|
||||||
|
:disabled="filters.skip + filters.limit >= total"
|
||||||
|
type="button"
|
||||||
|
@click="page(1)"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Save } from 'lucide-vue-next'
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { userApi, type TokenStatus } from '@/api'
|
||||||
|
import { useAuth } from '@/app/auth'
|
||||||
|
import StateBlock from '@/components/StateBlock.vue'
|
||||||
|
import { buttonBase, buttonTone, cardClass, inputClass, toneClass } from '@/components/ui'
|
||||||
|
import { extractErrorMessage } from '@/utils/format'
|
||||||
|
|
||||||
|
const auth = useAuth()
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
const token = ref<TokenStatus | null>(null)
|
||||||
|
const form = reactive({
|
||||||
|
alias: '',
|
||||||
|
email: '',
|
||||||
|
current_password: '',
|
||||||
|
new_password: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const [user, tokenStatus] = await Promise.all([
|
||||||
|
userApi.me(),
|
||||||
|
userApi.tokenStatus().catch(() => null),
|
||||||
|
])
|
||||||
|
auth.state.user = user
|
||||||
|
token.value = tokenStatus
|
||||||
|
form.alias = user.alias
|
||||||
|
form.email = user.email ?? ''
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
const user = await userApi.updateProfile({
|
||||||
|
alias: form.alias,
|
||||||
|
email: form.email || undefined,
|
||||||
|
current_password: form.current_password || undefined,
|
||||||
|
new_password: form.new_password || undefined,
|
||||||
|
})
|
||||||
|
auth.state.user = user
|
||||||
|
form.current_password = ''
|
||||||
|
form.new_password = ''
|
||||||
|
message.value = '个人信息已更新'
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<StateBlock v-if="loading" title="正在加载设置" type="loading" />
|
||||||
|
<StateBlock
|
||||||
|
v-else-if="error && !auth.state.user"
|
||||||
|
title="设置加载失败"
|
||||||
|
:description="error"
|
||||||
|
type="error"
|
||||||
|
action-label="重试"
|
||||||
|
@action="load"
|
||||||
|
/>
|
||||||
|
<div v-else class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||||
|
<form :class="[cardClass, 'grid gap-4 p-5']" @submit.prevent="save">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold">个人资料</h2>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">更新别名、邮箱和登录密码。</p>
|
||||||
|
</div>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">别名</span>
|
||||||
|
<input v-model="form.alias" :class="inputClass" required />
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">邮箱</span>
|
||||||
|
<input v-model="form.email" :class="inputClass" type="email" placeholder="用于打卡通知" />
|
||||||
|
</label>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">当前密码</span>
|
||||||
|
<input
|
||||||
|
v-model="form.current_password"
|
||||||
|
:class="inputClass"
|
||||||
|
type="password"
|
||||||
|
placeholder="修改密码时填写"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">新密码</span>
|
||||||
|
<input
|
||||||
|
v-model="form.new_password"
|
||||||
|
:class="inputClass"
|
||||||
|
type="password"
|
||||||
|
placeholder="至少 6 位"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="message"
|
||||||
|
class="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700"
|
||||||
|
>
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
<button :class="[buttonBase, buttonTone.primary, 'w-fit']" :disabled="saving" type="submit">
|
||||||
|
<Save class="size-4" />
|
||||||
|
{{ saving ? '保存中' : '保存设置' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<aside :class="[cardClass, 'h-fit p-5']">
|
||||||
|
<h2 class="font-semibold">授权状态</h2>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">这里检查的是打卡业务 token,不是网站登录状态。</p>
|
||||||
|
<div class="mt-4 grid gap-3 text-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-zinc-500">状态</span>
|
||||||
|
<span :class="toneClass(token?.is_valid ? 'success' : 'danger')">{{
|
||||||
|
token?.is_valid ? '可用' : '不可用'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-zinc-500">即将过期</span>
|
||||||
|
<span>{{ token?.expiring_soon ? '是' : '否' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-zinc-500">剩余天数</span>
|
||||||
|
<span>{{ token?.days_until_expiry ?? '未知' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ArrowLeft, Search } from 'lucide-vue-next'
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { checkInApi, taskApi, type CheckInRecord, type Task } from '@/api'
|
||||||
|
import { useRouter } from '@/app/router'
|
||||||
|
import StateBlock from '@/components/StateBlock.vue'
|
||||||
|
import { buttonBase, buttonTone, cardClass, inputClass, toneClass } from '@/components/ui'
|
||||||
|
import { extractErrorMessage, formatFullDateTime, statusLabel, statusTone } from '@/utils/format'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const taskId = Number(router.params.value.taskId)
|
||||||
|
const task = ref<Task | null>(null)
|
||||||
|
const records = ref<CheckInRecord[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const filters = reactive({ status: '', trigger_type: '', skip: 0, limit: 20 })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const [taskDetail, page] = await Promise.all([
|
||||||
|
taskApi.detail(taskId),
|
||||||
|
checkInApi.taskRecords(taskId, filters),
|
||||||
|
])
|
||||||
|
task.value = taskDetail
|
||||||
|
records.value = page.records
|
||||||
|
total.value = page.total
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section :class="[cardClass, 'overflow-hidden']">
|
||||||
|
<div class="grid gap-3 border-b border-zinc-200 p-4 lg:grid-cols-[1fr_180px_180px_auto]">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="mb-2 inline-flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-900"
|
||||||
|
type="button"
|
||||||
|
@click="router.navigate('/tasks')"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="size-4" />
|
||||||
|
返回任务
|
||||||
|
</button>
|
||||||
|
<h2 class="font-semibold">{{ task?.name || `任务 #${taskId}` }} 的打卡记录</h2>
|
||||||
|
</div>
|
||||||
|
<select v-model="filters.status" :class="inputClass">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="success">成功</option>
|
||||||
|
<option value="failure">失败</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="filters.trigger_type" :class="inputClass">
|
||||||
|
<option value="">全部触发</option>
|
||||||
|
<option value="manual">手动</option>
|
||||||
|
<option value="scheduler">定时</option>
|
||||||
|
</select>
|
||||||
|
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">
|
||||||
|
<Search class="size-4" />
|
||||||
|
筛选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StateBlock v-if="loading" title="正在加载任务记录" type="loading" />
|
||||||
|
<StateBlock
|
||||||
|
v-else-if="error"
|
||||||
|
title="任务记录加载失败"
|
||||||
|
:description="error"
|
||||||
|
type="error"
|
||||||
|
action-label="重试"
|
||||||
|
@action="load"
|
||||||
|
/>
|
||||||
|
<StateBlock
|
||||||
|
v-else-if="records.length === 0"
|
||||||
|
title="暂无记录"
|
||||||
|
description="当前任务还没有符合条件的记录。"
|
||||||
|
/>
|
||||||
|
<div v-else class="divide-y divide-zinc-200">
|
||||||
|
<article
|
||||||
|
v-for="record in records"
|
||||||
|
:key="record.id"
|
||||||
|
class="grid gap-3 p-4 md:grid-cols-[180px_1fr_auto]"
|
||||||
|
>
|
||||||
|
<div class="text-sm text-zinc-500">{{ formatFullDateTime(record.check_in_time) }}</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-zinc-700">
|
||||||
|
{{ record.response_text || record.error_message || '无响应内容' }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-500">触发:{{ statusLabel(record.trigger_type) }}</div>
|
||||||
|
</div>
|
||||||
|
<span :class="toneClass(statusTone(record.status))">{{ statusLabel(record.status) }}</span>
|
||||||
|
</article>
|
||||||
|
<div class="border-t border-zinc-200 px-4 py-3 text-sm text-zinc-500">
|
||||||
|
共 {{ total }} 条记录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Check, Edit3, Play, Plus, RefreshCw, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
checkInApi,
|
||||||
|
taskApi,
|
||||||
|
templateApi,
|
||||||
|
type CheckInRecordStatus,
|
||||||
|
type Task,
|
||||||
|
type Template,
|
||||||
|
type TemplateFieldConfigItem,
|
||||||
|
type TemplatePreview,
|
||||||
|
} from '@/api'
|
||||||
|
import { useRouter } from '@/app/router'
|
||||||
|
import StateBlock from '@/components/StateBlock.vue'
|
||||||
|
import {
|
||||||
|
buttonBase,
|
||||||
|
buttonTone,
|
||||||
|
cardClass,
|
||||||
|
inputClass,
|
||||||
|
textareaClass,
|
||||||
|
toneClass,
|
||||||
|
} from '@/components/ui'
|
||||||
|
import {
|
||||||
|
cronLabel,
|
||||||
|
extractErrorMessage,
|
||||||
|
parseJson,
|
||||||
|
statusLabel,
|
||||||
|
statusTone,
|
||||||
|
stringifyJson,
|
||||||
|
} from '@/utils/format'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
const tasks = ref<Task[]>([])
|
||||||
|
const templates = ref<Template[]>([])
|
||||||
|
const selectedTemplateId = ref<number | null>(null)
|
||||||
|
const preview = ref<TemplatePreview | null>(null)
|
||||||
|
const creating = ref(false)
|
||||||
|
const actionId = ref<number | null>(null)
|
||||||
|
const polling = ref<Record<number, CheckInRecordStatus>>({})
|
||||||
|
const editingTaskId = ref<number | null>(null)
|
||||||
|
let pollTimer: number | undefined
|
||||||
|
|
||||||
|
const createForm = reactive({
|
||||||
|
task_name: '',
|
||||||
|
thread_id: '',
|
||||||
|
cron_expression: '0 20 * * *',
|
||||||
|
field_values: {} as Record<string, string>,
|
||||||
|
})
|
||||||
|
|
||||||
|
const editForm = reactive({
|
||||||
|
name: '',
|
||||||
|
cron_expression: '',
|
||||||
|
payload_config: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const fieldEntries = computed(() => {
|
||||||
|
const config = preview.value?.field_config
|
||||||
|
if (!config) return []
|
||||||
|
const items: Array<[string, TemplateFieldConfigItem]> = []
|
||||||
|
if (config.signature) items.push(['signature', config.signature])
|
||||||
|
if (config.texts) items.push(['texts', config.texts])
|
||||||
|
Object.entries(config.values ?? {}).forEach(([key, value]) => items.push([key, value]))
|
||||||
|
return items.filter(([, field]) => field && !field.hidden)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const [taskList, templateList] = await Promise.all([taskApi.list(), templateApi.active()])
|
||||||
|
tasks.value = taskList
|
||||||
|
templates.value = templateList
|
||||||
|
if (!selectedTemplateId.value && templateList[0]) selectedTemplateId.value = templateList[0].id
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedTemplateId, async (id) => {
|
||||||
|
preview.value = null
|
||||||
|
createForm.field_values = {}
|
||||||
|
if (!id) return
|
||||||
|
try {
|
||||||
|
preview.value = await templateApi.preview(id)
|
||||||
|
fieldEntries.value.forEach(([key, field]) => {
|
||||||
|
createForm.field_values[key] = field?.default_value ?? ''
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function createTask() {
|
||||||
|
if (!selectedTemplateId.value) return
|
||||||
|
creating.value = true
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
await templateApi.createTask({
|
||||||
|
template_id: selectedTemplateId.value,
|
||||||
|
thread_id: createForm.thread_id,
|
||||||
|
task_name: createForm.task_name || undefined,
|
||||||
|
cron_expression: createForm.cron_expression || null,
|
||||||
|
field_values: createForm.field_values,
|
||||||
|
})
|
||||||
|
createForm.task_name = ''
|
||||||
|
createForm.thread_id = ''
|
||||||
|
message.value = '任务已创建'
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(task: Task) {
|
||||||
|
editingTaskId.value = task.id
|
||||||
|
editForm.name = task.name ?? ''
|
||||||
|
editForm.cron_expression = task.cron_expression ?? ''
|
||||||
|
editForm.payload_config = stringifyJson(parseJson(task.payload_config))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(taskId: number) {
|
||||||
|
actionId.value = taskId
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
JSON.parse(editForm.payload_config)
|
||||||
|
await taskApi.update(taskId, {
|
||||||
|
name: editForm.name,
|
||||||
|
cron_expression: editForm.cron_expression || null,
|
||||||
|
payload_config: editForm.payload_config,
|
||||||
|
})
|
||||||
|
editingTaskId.value = null
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
actionId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTask(task: Task) {
|
||||||
|
actionId.value = task.id
|
||||||
|
try {
|
||||||
|
await taskApi.toggle(task.id)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
actionId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTask(task: Task) {
|
||||||
|
if (!window.confirm(`确认删除任务「${task.name || task.id}」?关联记录也会删除。`)) return
|
||||||
|
actionId.value = task.id
|
||||||
|
try {
|
||||||
|
await taskApi.delete(task.id)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
actionId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function manualCheckIn(task: Task) {
|
||||||
|
actionId.value = task.id
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const result = await checkInApi.manual(task.id)
|
||||||
|
const recordId = result.record_id ?? result.id
|
||||||
|
if (recordId) startRecordPolling(recordId)
|
||||||
|
message.value = result.message || '已启动打卡任务'
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
actionId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRecordPolling(recordId: number) {
|
||||||
|
window.clearInterval(pollTimer)
|
||||||
|
pollTimer = window.setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const status = await checkInApi.status(recordId)
|
||||||
|
polling.value = { ...polling.value, [recordId]: status }
|
||||||
|
if (!['pending', 'running'].includes(status.status)) {
|
||||||
|
window.clearInterval(pollTimer)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
window.clearInterval(pollTimer)
|
||||||
|
}
|
||||||
|
}, 1800)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid gap-5">
|
||||||
|
<section :class="[cardClass, 'p-5']">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold">从模板创建任务</h2>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">选择启用模板,填写接龙 ID 和字段值后创建任务。</p>
|
||||||
|
</div>
|
||||||
|
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">
|
||||||
|
<RefreshCw class="size-4" />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="mt-4 grid gap-4" @submit.prevent="createTask">
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">模板</span>
|
||||||
|
<select v-model.number="selectedTemplateId" :class="inputClass">
|
||||||
|
<option v-for="template in templates" :key="template.id" :value="template.id">
|
||||||
|
{{ template.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">任务名称</span>
|
||||||
|
<input v-model="createForm.task_name" :class="inputClass" placeholder="可选" />
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">接龙 ThreadId</span>
|
||||||
|
<input v-model="createForm.thread_id" :class="inputClass" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="grid gap-2 md:max-w-xs">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">Cron 表达式</span>
|
||||||
|
<input
|
||||||
|
v-model="createForm.cron_expression"
|
||||||
|
:class="inputClass"
|
||||||
|
placeholder="0 20 * * *"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div v-if="fieldEntries.length" class="grid gap-4 md:grid-cols-2">
|
||||||
|
<label v-for="[key, field] in fieldEntries" :key="key" class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">{{
|
||||||
|
field?.display_name ?? key
|
||||||
|
}}</span>
|
||||||
|
<select
|
||||||
|
v-if="field?.field_type === 'select'"
|
||||||
|
v-model="createForm.field_values[key]"
|
||||||
|
:class="inputClass"
|
||||||
|
:required="field.required"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="option in field.options ?? []"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
v-else-if="field?.field_type === 'textarea'"
|
||||||
|
v-model="createForm.field_values[key]"
|
||||||
|
:class="textareaClass"
|
||||||
|
:placeholder="field.placeholder ?? ''"
|
||||||
|
:required="field.required"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="createForm.field_values[key]"
|
||||||
|
:class="inputClass"
|
||||||
|
:type="field?.field_type === 'number' ? 'number' : 'text'"
|
||||||
|
:placeholder="field?.placeholder ?? ''"
|
||||||
|
:required="field?.required"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="message"
|
||||||
|
class="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700"
|
||||||
|
>
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.primary, 'w-fit']"
|
||||||
|
:disabled="creating || !selectedTemplateId || !createForm.thread_id"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
<Plus class="size-4" />
|
||||||
|
{{ creating ? '创建中' : '创建任务' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<StateBlock v-if="loading" title="正在加载任务" type="loading" />
|
||||||
|
<StateBlock
|
||||||
|
v-else-if="error && tasks.length === 0"
|
||||||
|
title="任务加载失败"
|
||||||
|
:description="error"
|
||||||
|
type="error"
|
||||||
|
action-label="重试"
|
||||||
|
@action="load"
|
||||||
|
/>
|
||||||
|
<StateBlock
|
||||||
|
v-else-if="tasks.length === 0"
|
||||||
|
title="暂无任务"
|
||||||
|
description="先从模板创建一个任务。"
|
||||||
|
/>
|
||||||
|
<section v-else :class="[cardClass, 'overflow-hidden']">
|
||||||
|
<div class="border-b border-zinc-200 px-4 py-3">
|
||||||
|
<h2 class="font-semibold">任务列表</h2>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-zinc-200">
|
||||||
|
<article v-for="task in tasks" :key="task.id" class="p-4">
|
||||||
|
<div class="grid gap-3 lg:grid-cols-[1fr_auto]">
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 class="font-semibold">{{ task.name || `任务 #${task.id}` }}</h3>
|
||||||
|
<span :class="toneClass(task.is_active ? 'success' : 'neutral')">{{
|
||||||
|
task.is_active ? '启用' : '停用'
|
||||||
|
}}</span>
|
||||||
|
<span
|
||||||
|
v-if="task.last_check_in_status"
|
||||||
|
:class="toneClass(statusTone(task.last_check_in_status))"
|
||||||
|
>
|
||||||
|
{{ statusLabel(task.last_check_in_status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
|
ThreadId: {{ task.thread_id || '未解析' }} · {{ cronLabel(task.cron_expression) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.secondary]"
|
||||||
|
type="button"
|
||||||
|
@click="router.navigate(`/tasks/${task.id}/records`)"
|
||||||
|
>
|
||||||
|
记录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.secondary]"
|
||||||
|
:disabled="actionId === task.id"
|
||||||
|
type="button"
|
||||||
|
@click="manualCheckIn(task)"
|
||||||
|
>
|
||||||
|
<Play class="size-4" />
|
||||||
|
打卡
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.secondary]"
|
||||||
|
:disabled="actionId === task.id"
|
||||||
|
type="button"
|
||||||
|
@click="toggleTask(task)"
|
||||||
|
>
|
||||||
|
<Check class="size-4" />
|
||||||
|
{{ task.is_active ? '停用' : '启用' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.ghost]"
|
||||||
|
type="button"
|
||||||
|
@click="startEdit(task)"
|
||||||
|
>
|
||||||
|
<Edit3 class="size-4" />
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.danger]"
|
||||||
|
:disabled="actionId === task.id"
|
||||||
|
type="button"
|
||||||
|
@click="deleteTask(task)"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-if="editingTaskId === task.id"
|
||||||
|
class="mt-4 grid gap-3 rounded-lg border border-zinc-200 bg-zinc-50 p-4"
|
||||||
|
@submit.prevent="saveEdit(task.id)"
|
||||||
|
>
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">任务名称</span>
|
||||||
|
<input v-model="editForm.name" :class="inputClass" />
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">Cron</span>
|
||||||
|
<input v-model="editForm.cron_expression" :class="inputClass" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">Payload JSON</span>
|
||||||
|
<textarea v-model="editForm.payload_config" :class="textareaClass" />
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button :class="[buttonBase, buttonTone.primary]" type="submit">保存</button>
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.secondary]"
|
||||||
|
type="button"
|
||||||
|
@click="editingTaskId = null"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { adminApi } from '@/api'
|
||||||
|
import StateBlock from '@/components/StateBlock.vue'
|
||||||
|
import { buttonBase, buttonTone, cardClass, inputClass } from '@/components/ui'
|
||||||
|
import { extractErrorMessage } from '@/utils/format'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const lines = ref(200)
|
||||||
|
const logs = ref('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const result = await adminApi.logs(lines.value)
|
||||||
|
logs.value = result.logs
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section :class="[cardClass, 'overflow-hidden']">
|
||||||
|
<div class="flex flex-wrap items-center gap-3 border-b border-zinc-200 p-4">
|
||||||
|
<input
|
||||||
|
v-model.number="lines"
|
||||||
|
:class="inputClass"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="2000"
|
||||||
|
class="max-w-40"
|
||||||
|
/>
|
||||||
|
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">
|
||||||
|
刷新日志
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<StateBlock v-if="loading" title="正在加载日志" type="loading" />
|
||||||
|
<StateBlock
|
||||||
|
v-else-if="error"
|
||||||
|
title="日志加载失败"
|
||||||
|
:description="error"
|
||||||
|
type="error"
|
||||||
|
action-label="重试"
|
||||||
|
@action="load"
|
||||||
|
/>
|
||||||
|
<pre
|
||||||
|
v-else
|
||||||
|
class="max-h-[70vh] overflow-auto bg-zinc-950 p-4 text-xs leading-5 text-zinc-100"
|
||||||
|
>{{ logs || '无日志' }}</pre
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Search } from 'lucide-vue-next'
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { checkInApi, type CheckInRecord } from '@/api'
|
||||||
|
import StateBlock from '@/components/StateBlock.vue'
|
||||||
|
import { buttonBase, buttonTone, cardClass, inputClass, toneClass } from '@/components/ui'
|
||||||
|
import { extractErrorMessage, formatFullDateTime, statusLabel, statusTone } from '@/utils/format'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const records = ref<CheckInRecord[]>([])
|
||||||
|
const filters = reactive({ task_id: '', status: '', limit: 50 })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const page = await checkInApi.allRecords({
|
||||||
|
limit: filters.limit,
|
||||||
|
task_id: filters.task_id,
|
||||||
|
status: filters.status,
|
||||||
|
})
|
||||||
|
records.value = page.records
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section :class="[cardClass, 'overflow-hidden']">
|
||||||
|
<div class="grid gap-3 border-b border-zinc-200 p-4 md:grid-cols-[1fr_160px_160px_auto]">
|
||||||
|
<h2 class="font-semibold">全量记录</h2>
|
||||||
|
<input v-model="filters.task_id" :class="inputClass" placeholder="任务 ID" />
|
||||||
|
<select v-model="filters.status" :class="inputClass">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="success">成功</option>
|
||||||
|
<option value="failure">失败</option>
|
||||||
|
<option value="out_of_time">超出时间</option>
|
||||||
|
</select>
|
||||||
|
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">
|
||||||
|
<Search class="size-4" />
|
||||||
|
筛选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<StateBlock v-if="loading" title="正在加载记录" type="loading" />
|
||||||
|
<StateBlock
|
||||||
|
v-else-if="error"
|
||||||
|
title="记录加载失败"
|
||||||
|
:description="error"
|
||||||
|
type="error"
|
||||||
|
action-label="重试"
|
||||||
|
@action="load"
|
||||||
|
/>
|
||||||
|
<div v-else class="divide-y divide-zinc-200">
|
||||||
|
<article
|
||||||
|
v-for="record in records"
|
||||||
|
:key="record.id"
|
||||||
|
class="grid gap-2 p-4 md:grid-cols-[1fr_auto] md:items-center"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ record.user_alias || record.user_email || `任务 #${record.task_id}` }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-zinc-500">
|
||||||
|
{{ formatFullDateTime(record.check_in_time) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span :class="toneClass(statusTone(record.status))">{{
|
||||||
|
statusLabel(record.status)
|
||||||
|
}}</span>
|
||||||
|
<span class="text-sm text-zinc-500">{{
|
||||||
|
record.task_name || record.thread_id || '无任务名'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { adminApi, type AdminStats } from '@/api'
|
||||||
|
import StateBlock from '@/components/StateBlock.vue'
|
||||||
|
import { cardClass } from '@/components/ui'
|
||||||
|
import { extractErrorMessage } from '@/utils/format'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const stats = ref<AdminStats | null>(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
stats.value = await adminApi.stats()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<StateBlock v-if="loading" title="正在加载统计" type="loading" />
|
||||||
|
<StateBlock
|
||||||
|
v-else-if="error"
|
||||||
|
title="统计加载失败"
|
||||||
|
:description="error"
|
||||||
|
type="error"
|
||||||
|
action-label="重试"
|
||||||
|
@action="load"
|
||||||
|
/>
|
||||||
|
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div :class="[cardClass, 'p-4']">
|
||||||
|
<div class="text-sm text-zinc-500">用户</div>
|
||||||
|
<div class="mt-2 text-3xl font-semibold">{{ stats?.users.total }}</div>
|
||||||
|
<div class="mt-1 text-sm text-zinc-500">已审批 {{ stats?.users.active }}</div>
|
||||||
|
</div>
|
||||||
|
<div :class="[cardClass, 'p-4']">
|
||||||
|
<div class="text-sm text-zinc-500">任务</div>
|
||||||
|
<div class="mt-2 text-3xl font-semibold">{{ stats?.tasks.total }}</div>
|
||||||
|
<div class="mt-1 text-sm text-zinc-500">启用 {{ stats?.tasks.active }}</div>
|
||||||
|
</div>
|
||||||
|
<div :class="[cardClass, 'p-4']">
|
||||||
|
<div class="text-sm text-zinc-500">记录</div>
|
||||||
|
<div class="mt-2 text-3xl font-semibold">{{ stats?.check_in_records.total }}</div>
|
||||||
|
<div class="mt-1 text-sm text-zinc-500">今日 {{ stats?.check_in_records.today }}</div>
|
||||||
|
</div>
|
||||||
|
<div :class="[cardClass, 'p-4']">
|
||||||
|
<div class="text-sm text-zinc-500">Token 预警</div>
|
||||||
|
<div class="mt-2 text-3xl font-semibold">{{ stats?.tokens.expiring_soon }}</div>
|
||||||
|
<div class="mt-1 text-sm text-zinc-500">7 天内过期</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Eye, Plus, Save, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { templateApi, type Template, type TemplatePreview } from '@/api'
|
||||||
|
import StateBlock from '@/components/StateBlock.vue'
|
||||||
|
import {
|
||||||
|
buttonBase,
|
||||||
|
buttonTone,
|
||||||
|
cardClass,
|
||||||
|
inputClass,
|
||||||
|
textareaClass,
|
||||||
|
toneClass,
|
||||||
|
} from '@/components/ui'
|
||||||
|
import { extractErrorMessage, formatDateTime, stringifyJson } from '@/utils/format'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
const templates = ref<Template[]>([])
|
||||||
|
const editingId = ref<number | 'new' | null>(null)
|
||||||
|
const preview = ref<TemplatePreview | null>(null)
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
field_config:
|
||||||
|
'{\n "signature": {\n "display_name": "姓名",\n "field_type": "text",\n "default_value": "",\n "required": true\n }\n}',
|
||||||
|
is_active: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
templates.value = await templateApi.list()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCreate() {
|
||||||
|
editingId.value = 'new'
|
||||||
|
preview.value = null
|
||||||
|
form.name = ''
|
||||||
|
form.description = ''
|
||||||
|
form.field_config =
|
||||||
|
'{\n "signature": {\n "display_name": "姓名",\n "field_type": "text",\n "default_value": "",\n "required": true\n }\n}'
|
||||||
|
form.is_active = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(template: Template) {
|
||||||
|
editingId.value = template.id
|
||||||
|
preview.value = null
|
||||||
|
form.name = template.name
|
||||||
|
form.description = template.description ?? ''
|
||||||
|
try {
|
||||||
|
form.field_config = stringifyJson(JSON.parse(template.field_config))
|
||||||
|
} catch {
|
||||||
|
form.field_config = template.field_config
|
||||||
|
}
|
||||||
|
form.is_active = template.is_active
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
error.value = ''
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
JSON.parse(form.field_config)
|
||||||
|
const payload = {
|
||||||
|
name: form.name,
|
||||||
|
description: form.description || null,
|
||||||
|
field_config: form.field_config,
|
||||||
|
is_active: form.is_active,
|
||||||
|
}
|
||||||
|
if (editingId.value === 'new') {
|
||||||
|
await templateApi.create(payload)
|
||||||
|
} else if (typeof editingId.value === 'number') {
|
||||||
|
await templateApi.update(editingId.value, payload)
|
||||||
|
}
|
||||||
|
editingId.value = null
|
||||||
|
message.value = '模板已保存'
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showPreview(template: Template) {
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
preview.value = await templateApi.preview(template.id)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(template: Template) {
|
||||||
|
if (!window.confirm(`确认删除模板「${template.name}」?`)) return
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await templateApi.delete(template.id)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||||
|
<section :class="[cardClass, 'overflow-hidden']">
|
||||||
|
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
|
||||||
|
<h2 class="font-semibold">模板管理</h2>
|
||||||
|
<button :class="[buttonBase, buttonTone.primary]" type="button" @click="startCreate">
|
||||||
|
<Plus class="size-4" />
|
||||||
|
新建模板
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<StateBlock v-if="loading" title="正在加载模板" type="loading" />
|
||||||
|
<StateBlock
|
||||||
|
v-else-if="error && templates.length === 0"
|
||||||
|
title="模板加载失败"
|
||||||
|
:description="error"
|
||||||
|
type="error"
|
||||||
|
action-label="重试"
|
||||||
|
@action="load"
|
||||||
|
/>
|
||||||
|
<div v-else class="divide-y divide-zinc-200">
|
||||||
|
<article
|
||||||
|
v-for="template in templates"
|
||||||
|
:key="template.id"
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3 p-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="font-semibold">{{ template.name }}</h3>
|
||||||
|
<span :class="toneClass(template.is_active ? 'success' : 'neutral')">{{
|
||||||
|
template.is_active ? '启用' : '停用'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
|
{{ template.description || '无描述' }} · {{ formatDateTime(template.created_at) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.secondary]"
|
||||||
|
type="button"
|
||||||
|
@click="showPreview(template)"
|
||||||
|
>
|
||||||
|
<Eye class="size-4" />
|
||||||
|
预览
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.secondary]"
|
||||||
|
type="button"
|
||||||
|
@click="startEdit(template)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.danger]"
|
||||||
|
type="button"
|
||||||
|
@click="remove(template)"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="grid gap-5">
|
||||||
|
<form v-if="editingId" :class="[cardClass, 'grid gap-4 p-5']" @submit.prevent="save">
|
||||||
|
<h2 class="font-semibold">{{ editingId === 'new' ? '新建模板' : '编辑模板' }}</h2>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">名称</span>
|
||||||
|
<input v-model="form.name" :class="inputClass" required />
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">描述</span>
|
||||||
|
<input v-model="form.description" :class="inputClass" />
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="form.is_active" type="checkbox" />
|
||||||
|
启用模板
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">字段配置 JSON</span>
|
||||||
|
<textarea v-model="form.field_config" :class="textareaClass" class="min-h-64" />
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="message"
|
||||||
|
class="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700"
|
||||||
|
>
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button :class="[buttonBase, buttonTone.primary]" type="submit">
|
||||||
|
<Save class="size-4" />
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.secondary]"
|
||||||
|
type="button"
|
||||||
|
@click="editingId = null"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section v-if="preview" :class="[cardClass, 'p-5']">
|
||||||
|
<h2 class="font-semibold">{{ preview.template_name }} 预览</h2>
|
||||||
|
<pre
|
||||||
|
class="mt-3 max-h-96 overflow-auto rounded-md bg-zinc-950 p-3 text-xs leading-5 text-zinc-100"
|
||||||
|
>{{ stringifyJson(preview.preview_payload) }}</pre
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Check, Save, Search, Trash2, UserPlus } from 'lucide-vue-next'
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { adminApi, userApi, type User } from '@/api'
|
||||||
|
import StateBlock from '@/components/StateBlock.vue'
|
||||||
|
import { buttonBase, buttonTone, cardClass, inputClass, toneClass } from '@/components/ui'
|
||||||
|
import { extractErrorMessage, formatDateTime } from '@/utils/format'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const users = ref<User[]>([])
|
||||||
|
const search = ref('')
|
||||||
|
const editingId = ref<number | 'new' | null>(null)
|
||||||
|
const form = reactive({
|
||||||
|
alias: '',
|
||||||
|
email: '',
|
||||||
|
role: 'user',
|
||||||
|
password: '',
|
||||||
|
is_approved: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
users.value = await userApi.list(search.value ? { search: search.value } : {})
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approve(userId: number) {
|
||||||
|
await adminApi.approveUser(userId)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reject(userId: number) {
|
||||||
|
if (!window.confirm('确认拒绝并删除该用户?')) return
|
||||||
|
await adminApi.rejectUser(userId)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCreate() {
|
||||||
|
editingId.value = 'new'
|
||||||
|
form.alias = ''
|
||||||
|
form.email = ''
|
||||||
|
form.role = 'user'
|
||||||
|
form.password = ''
|
||||||
|
form.is_approved = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(user: User) {
|
||||||
|
editingId.value = user.id
|
||||||
|
form.alias = user.alias
|
||||||
|
form.email = user.email ?? ''
|
||||||
|
form.role = user.role
|
||||||
|
form.password = ''
|
||||||
|
form.is_approved = user.is_approved
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
alias: form.alias,
|
||||||
|
email: form.email || undefined,
|
||||||
|
role: form.role,
|
||||||
|
is_approved: form.is_approved,
|
||||||
|
password: form.password || undefined,
|
||||||
|
}
|
||||||
|
if (editingId.value === 'new') {
|
||||||
|
await userApi.create(payload)
|
||||||
|
} else if (typeof editingId.value === 'number') {
|
||||||
|
await userApi.update(editingId.value, payload)
|
||||||
|
}
|
||||||
|
editingId.value = null
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = extractErrorMessage(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
|
<section :class="[cardClass, 'overflow-hidden']">
|
||||||
|
<div class="flex flex-wrap items-center gap-3 border-b border-zinc-200 p-4">
|
||||||
|
<input v-model="search" :class="inputClass" class="max-w-sm" placeholder="搜索别名" />
|
||||||
|
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="load">
|
||||||
|
<Search class="size-4" />
|
||||||
|
搜索
|
||||||
|
</button>
|
||||||
|
<button :class="[buttonBase, buttonTone.primary]" type="button" @click="startCreate">
|
||||||
|
<UserPlus class="size-4" />
|
||||||
|
创建用户
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<StateBlock v-if="loading" title="正在加载用户" type="loading" />
|
||||||
|
<StateBlock
|
||||||
|
v-else-if="error && users.length === 0"
|
||||||
|
title="用户加载失败"
|
||||||
|
:description="error"
|
||||||
|
type="error"
|
||||||
|
action-label="重试"
|
||||||
|
@action="load"
|
||||||
|
/>
|
||||||
|
<div v-else class="divide-y divide-zinc-200">
|
||||||
|
<article
|
||||||
|
v-for="user in users"
|
||||||
|
:key="user.id"
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3 p-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="font-semibold">{{ user.alias }}</h3>
|
||||||
|
<span :class="toneClass(user.is_approved ? 'success' : 'warning')">{{
|
||||||
|
user.is_approved ? '已审批' : '待审批'
|
||||||
|
}}</span>
|
||||||
|
<span :class="toneClass(user.role === 'admin' ? 'info' : 'neutral')">{{
|
||||||
|
user.role
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
|
{{ user.email || '未设置邮箱' }} · {{ formatDateTime(user.created_at) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
v-if="!user.is_approved"
|
||||||
|
:class="[buttonBase, buttonTone.primary]"
|
||||||
|
type="button"
|
||||||
|
@click="approve(user.id)"
|
||||||
|
>
|
||||||
|
<Check class="size-4" />
|
||||||
|
审批
|
||||||
|
</button>
|
||||||
|
<button :class="[buttonBase, buttonTone.danger]" type="button" @click="reject(user.id)">
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[buttonBase, buttonTone.secondary]"
|
||||||
|
type="button"
|
||||||
|
@click="startEdit(user)"
|
||||||
|
>
|
||||||
|
<UserPlus class="size-4" />
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form v-if="editingId" :class="[cardClass, 'grid h-fit gap-4 p-5']" @submit.prevent="save">
|
||||||
|
<h2 class="font-semibold">{{ editingId === 'new' ? '创建用户' : '编辑用户' }}</h2>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">别名</span>
|
||||||
|
<input v-model="form.alias" :class="inputClass" required />
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">邮箱</span>
|
||||||
|
<input v-model="form.email" :class="inputClass" type="email" />
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">角色</span>
|
||||||
|
<select v-model="form.role" :class="inputClass">
|
||||||
|
<option value="user">user</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-xs font-semibold text-zinc-500">密码</span>
|
||||||
|
<input
|
||||||
|
v-model="form.password"
|
||||||
|
:class="inputClass"
|
||||||
|
type="password"
|
||||||
|
placeholder="留空不修改"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="form.is_approved" type="checkbox" />
|
||||||
|
已审批
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button :class="[buttonBase, buttonTone.primary]" type="submit">
|
||||||
|
<Save class="size-4" />
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button :class="[buttonBase, buttonTone.secondary]" type="button" @click="editingId = null">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -10,9 +10,7 @@
|
|||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": ["./src/*"]
|
||||||
"./src/*"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import path from 'node:path'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
|||||||
plugins: [vue(), tailwindcss()],
|
plugins: [vue(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
Reference in New Issue
Block a user