feat: new frontend demo

This commit is contained in:
2026-05-04 00:58:19 +08:00
parent 903bed57c0
commit 44f89c4f54
37 changed files with 4200 additions and 117 deletions
+4
View File
@@ -0,0 +1,4 @@
dist
node_modules
pnpm-lock.yaml
components.json
+6
View File
@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}
+65
View File
@@ -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',
},
},
]
+13 -1
View File
@@ -6,7 +6,12 @@
"scripts": {
"dev": "vite",
"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": {
"@tailwindcss/vite": "^4.2.4",
@@ -18,12 +23,19 @@
"vue": "^3.5.33"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^25.6.0",
"@typescript-eslint/parser": "^8.59.1",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/eslint-config-prettier": "^10.2.0",
"@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",
"typescript": "~6.0.3",
"vite": "^8.0.10",
"vue-eslint-parser": "^10.4.0",
"vue-tsc": "^3.2.7"
}
}
+1012
View File
File diff suppressed because it is too large Load Diff
+67 -2
View File
@@ -1,7 +1,72 @@
<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>
<template>
<HelloWorld />
<AppLayout v-if="usesLayout">
<component :is="view" />
</AppLayout>
<component :is="view" v-else />
</template>
+138
View File
@@ -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' }),
}
+123
View File
@@ -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'
+220
View File
@@ -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
}
}
+99
View File
@@ -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,
}
}
+195
View File
@@ -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
View File
@@ -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>
+33
View File
@@ -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]}`
}
+3 -3
View File
@@ -1,6 +1,6 @@
import type { ClassValue } from "clsx"
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import type { ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
+8 -1
View File
@@ -1,5 +1,12 @@
import { createApp } from 'vue'
import './style.css'
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')
+2 -3
View File
@@ -1,8 +1,7 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap');
@import "tailwindcss";
@import "tw-animate-css";
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
+92
View File
@@ -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>
+205
View File
@@ -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>
+134
View File
@@ -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>
+426
View File
@@ -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>
+1 -3
View File
@@ -10,9 +10,7 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": [
"./src/*"
]
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
+1 -4
View File
@@ -1,9 +1,6 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
+3 -3
View File
@@ -1,5 +1,5 @@
import { defineConfig } from 'vite'
import path from 'node:path'
import { fileURLToPath, URL } from 'node:url'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
@@ -8,7 +8,7 @@ export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})
})