diff --git a/app/components/TopNav.vue b/app/components/TopNav.vue index a859bcd..4a20ef7 100644 --- a/app/components/TopNav.vue +++ b/app/components/TopNav.vue @@ -74,8 +74,8 @@ watch(() => route.path, () => { 注册 - + @@ -229,6 +229,7 @@ const logout = async () => { padding: 8px; border-radius: 8px; background: rgba(255, 255, 255, 0.03); + cursor: pointer; } .user-avatar { diff --git a/app/pages/admin/profile/index.vue b/app/pages/admin/profile/index.vue index 99bdba7..9490f3e 100644 --- a/app/pages/admin/profile/index.vue +++ b/app/pages/admin/profile/index.vue @@ -4,6 +4,7 @@ const { user, updateProfile } = useAuthSession() const form = reactive({ username: user.value?.username || '', email: user.value?.email || '', + nickname: user.value?.nickname || '', }) const saving = ref(false) @@ -19,6 +20,7 @@ async function handleSave() { await updateProfile({ username: form.username, email: form.email, + nickname: form.nickname, }) success.value = true setTimeout(() => { success.value = false }, 3000) @@ -39,12 +41,12 @@ async function handleSave() {
-
- {{ user?.username?.charAt(0).toUpperCase() || 'U' }} +
+
-
{{ user?.username }}
-
管理员
+
{{ user?.nickname || user?.username }}
+
{{ user?.role === 'admin' ? '管理员' : '用户' }}
@@ -52,14 +54,26 @@ async function handleSave() {
{{ error }}
保存成功
-
- - +
+
+ + +
+ +
+ + +
@@ -84,15 +98,42 @@ async function handleSave() {
-
-

危险区域

-
-
-
注销账户
-
永久删除您的账户和所有相关数据
+
+
+

安全设置

+
+
+
+ + + +
+
+
修改密码
+
定期更换密码可以保护账户安全
+
+ +
- -
+
+ +
+

危险区域

+
+
+
+ + + +
+
+
注销账户
+
永久删除您的账户和所有相关数据
+
+ +
+
+
@@ -100,7 +141,7 @@ async function handleSave() { diff --git a/app/pages/admin/scheduler/[id]/index.vue b/app/pages/admin/scheduler/[id]/index.vue index 9461e42..14a9ed9 100644 --- a/app/pages/admin/scheduler/[id]/index.vue +++ b/app/pages/admin/scheduler/[id]/index.vue @@ -19,6 +19,18 @@ async function handleToggle() { refresh() } +async function handleDeleteLog(logId: string) { + if (!confirm('确定删除这条执行记录?')) return + await $fetch(`/api/scheduler/executions/${logId}`, { method: "DELETE" }) + refresh() +} + +async function handleClearAll() { + if (!confirm('确定清除该任务的所有执行记录?')) return + await $fetch(`/api/scheduler/executions/delete-all?taskId=${id}`, { method: "POST" }) + refresh() +} + function statusClass(status: string): string { switch (status) { case "success": return "status-success" @@ -129,7 +141,12 @@ function timeAgo(ts: number | string): string {
-

最近执行记录

+
+

最近执行记录

+ +
暂无执行记录 @@ -145,6 +162,11 @@ function timeAgo(ts: number | string): string { {{ log.resultSummary }} +
@@ -330,6 +352,34 @@ function timeAgo(ts: number | string): string { margin-bottom: 32px; } +.exec-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.exec-header .section-title { + margin: 0; +} + +.btn-clear-all { + padding: 6px 14px; + background: transparent; + color: var(--color-muted); + border: 1px solid var(--color-hairline); + border-radius: 6px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.btn-clear-all:hover { + color: var(--color-error); + border-color: var(--color-error); +} + .exec-card { background: var(--color-surface-card); border-radius: 12px; @@ -401,6 +451,32 @@ function timeAgo(ts: number | string): string { flex: 1; } +.btn-delete-log { + padding: 4px; + background: transparent; + color: var(--color-muted-soft); + border: none; + border-radius: 4px; + cursor: pointer; + opacity: 0; + transition: all 0.15s ease; +} + +.exec-item:hover .btn-delete-log { + opacity: 1; +} + +.btn-delete-log:hover { + color: var(--color-error); + background: rgba(198, 69, 69, 0.1); +} + +.btn-delete-log svg { + width: 14px; + height: 14px; + display: block; +} + .not-found { margin-top: 40px; font-size: 15px; diff --git a/app/plugins/vue3-toastify.client.ts b/app/plugins/vue3-toastify.client.ts index ebc2070..ee230bc 100644 --- a/app/plugins/vue3-toastify.client.ts +++ b/app/plugins/vue3-toastify.client.ts @@ -9,7 +9,7 @@ export default defineNuxtPlugin((nuxtApp) => { clearOnUrlChange: false, theme: "colored", transition: "slide", - position: "top-right", + position: "bottom-right", }); return { diff --git a/app/utils/http/factory.ts b/app/utils/http/factory.ts index 36d9ba9..57f99c7 100644 --- a/app/utils/http/factory.ts +++ b/app/utils/http/factory.ts @@ -21,13 +21,13 @@ export function unwrapApiBody(payload: ApiResponse): T { } -export const request = import.meta.server ? undefined : $fetch.create({ +export const request = $fetch.create({ credentials: 'include', }) const httpFetchDefaults = { retry: 0, - $fetch: request, + $fetch: import.meta.server ? undefined :request, transform: unwrapApiBody, } diff --git a/package.json b/package.json index 169a1b0..28164ca 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,6 @@ "tsconfig": "workspace:*", "tsx": "4.21.0", "typescript": "6.0.2", - "vue3-toastify": "^0.2.9" + "vue3-toastify": "0.2.9" } } \ No newline at end of file diff --git a/packages/drizzle-pkg/db.sqlite b/packages/drizzle-pkg/db.sqlite index 2950deb..7a6d687 100644 Binary files a/packages/drizzle-pkg/db.sqlite and b/packages/drizzle-pkg/db.sqlite differ diff --git a/server/api/auth/profile.put.ts b/server/api/auth/profile.put.ts new file mode 100644 index 0000000..f00464b --- /dev/null +++ b/server/api/auth/profile.put.ts @@ -0,0 +1,85 @@ +import { dbGlobal } from "drizzle-pkg/lib/db"; +import { users } from "drizzle-pkg/lib/schema/auth"; +import { eq } from "drizzle-orm"; +import { UNAUTHORIZED_MESSAGE } from "#server/constants/auth"; +import { toPublicAuthError } from "#server/service/auth/errors"; + +export default defineWrappedResponseHandler(async (event) => { + try { + const user = await event.context.auth.requireUser(); + if (!user) { + throw createError({ + statusCode: 401, + statusMessage: UNAUTHORIZED_MESSAGE, + }); + } + + const body = await readBody<{ + username?: string; + email?: string; + nickname?: string; + }>(event); + + if (!body || Object.keys(body).length === 0) { + throw createError({ + statusCode: 400, + statusMessage: "请提供要更新的字段", + }); + } + + const updateData: Partial<{ + username: string; + email: string | null; + nickname: string | null; + }> = {}; + + if (body.username !== undefined) { + if (typeof body.username !== "string" || body.username.trim().length === 0) { + throw createError({ statusCode: 400, statusMessage: "用户名不能为空" }); + } + if (body.username.length < 2 || body.username.length > 50) { + throw createError({ statusCode: 400, statusMessage: "用户名长度需在2-50字符之间" }); + } + updateData.username = body.username.trim(); + } + + if (body.email !== undefined) { + if (body.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) { + throw createError({ statusCode: 400, statusMessage: "邮箱格式不正确" }); + } + updateData.email = body.email?.trim() || null; + } + + if (body.nickname !== undefined) { + updateData.nickname = body.nickname?.trim() || null; + } + + if (Object.keys(updateData).length > 0) { + await dbGlobal + .update(users) + .set(updateData) + .where(eq(users.id, user.id)); + + // Invalidate me cache + await event.context.cache.del(`auth:me:${user.id}`); + } + + // Fetch updated user data + const [row] = await dbGlobal + .select({ + id: users.id, + username: users.username, + email: users.email, + role: users.role, + nickname: users.nickname, + avatar: users.avatar, + }) + .from(users) + .where(eq(users.id, user.id)) + .limit(1); + + return R.success({ user: row }); + } catch (err) { + throw toPublicAuthError(err); + } +}); \ No newline at end of file diff --git a/server/api/scheduler/executions/[id].delete.ts b/server/api/scheduler/executions/[id].delete.ts new file mode 100644 index 0000000..0e5e108 --- /dev/null +++ b/server/api/scheduler/executions/[id].delete.ts @@ -0,0 +1,13 @@ +import { deleteExecution } from "#server/service/scheduler"; + +export default defineWrappedResponseHandler(async (event) => { + const id = getRouterParam(event, "id"); + if (!id) return R.throwError(400, "Missing id", null); + + const deleted = await deleteExecution(id); + + await event.context.cache.del('scheduler:executions:1:20:all:all') + await event.context.cache.del('scheduler:stats') + + return R.success({ deleted }); +}); \ No newline at end of file diff --git a/server/api/scheduler/executions/delete-all.post.ts b/server/api/scheduler/executions/delete-all.post.ts new file mode 100644 index 0000000..c9d3c65 --- /dev/null +++ b/server/api/scheduler/executions/delete-all.post.ts @@ -0,0 +1,13 @@ +import { deleteAllExecutions } from "#server/service/scheduler"; + +export default defineWrappedResponseHandler(async (event) => { + const query = getQuery(event); + const taskId = query.taskId as string | undefined; + + const deleted = await deleteAllExecutions(taskId); + + await event.context.cache.del('scheduler:executions:1:20:all:all') + await event.context.cache.del('scheduler:stats') + + return R.success({ deleted }); +}); \ No newline at end of file diff --git a/server/service/scheduler/index.ts b/server/service/scheduler/index.ts index 83dccc5..5424eec 100644 --- a/server/service/scheduler/index.ts +++ b/server/service/scheduler/index.ts @@ -216,6 +216,21 @@ export async function cleanupOldLogs(retentionDays: number) { } } +export async function deleteExecution(id: string) { + const result = await dbGlobal + .delete(taskExecutionLogs) + .where(eq(taskExecutionLogs.id, id)); + return result.rowsAffected > 0; +} + +export async function deleteAllExecutions(taskId?: string) { + const condition = taskId ? eq(taskExecutionLogs.taskId, taskId) : undefined; + const result = await dbGlobal + .delete(taskExecutionLogs) + .where(condition); + return result.rowsAffected; +} + export async function getStats() { const [totalTasks, enabledTasks, recentExecutions] = await Promise.all([ dbGlobal.select({ count: sql`count(*)` }).from(scheduledTasks),