From ee9212ac705ceaaf295f41d019f119be8024cc6d Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Tue, 26 May 2026 14:36:37 +0800 Subject: [PATCH] feat: enhance user profile management with update functionality and toast notifications --- app/components/TopNav.vue | 3 +- app/composables/useAuthSession.ts | 18 +- app/pages/admin.vue | 9 +- app/pages/admin/profile/index.vue | 212 ++++++++++++++++----- app/pages/admin/scheduler/[id]/index.vue | 78 +++++++- app/plugins/vue3-toastify.client.ts | 2 +- app/utils/http/factory.ts | 4 +- package.json | 2 +- packages/drizzle-pkg/db.sqlite | Bin 282624 -> 282624 bytes server/api/auth/profile.put.ts | 85 +++++++++ server/api/scheduler/executions/[id].delete.ts | 13 ++ server/api/scheduler/executions/delete-all.post.ts | 13 ++ server/service/scheduler/index.ts | 15 ++ 13 files changed, 396 insertions(+), 58 deletions(-) create mode 100644 server/api/auth/profile.put.ts create mode 100644 server/api/scheduler/executions/[id].delete.ts create mode 100644 server/api/scheduler/executions/delete-all.post.ts 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 2950debdd9b7b77a3202205a85b01f82990670da..7a6d687f3327bd7f0775935f618ef88fb0c1f782 100644 GIT binary patch delta 1584 zcmb7^e@t6d6vyAY@AlCjw1qKs4q*jK2M@~NwWV}!py(ni+a!iAL`TBw1|e=zrZLfJ z7()a9n8+yENnny@e;A!4!!Tt*|1icS{Dqw{$Qp&YfHB5k(7705jo&JeiRATe`qdj zq*`C2Bh7}g{7&C)s#Em*AtPq~iEeGG4(d8=#IjQK>!xa@myZ}Rd7l2-R87?PtPwMP zNiEeGwT~7uihoMSY7Aq%`kt}RRwOq$RKu8bnYvY@2(dLNJOi7!mug?aGbDUQD%(LD zJ|k5ep?-fxWs@A>1AByXO#H7X7!CMCK27r;_xo$Te$7|wtqX*LUNx%L1bkX&Pz})e zcCpGDT_3$IPGJ9;etcIfIcxl2iwZ#RpvW`wFIqhnqOr zRwad-`UQm&9`0s5OfxHp$Q$ArnhbKc%)8|Gg}-UZrRxZEAMHe@%$uQGDXHjHjTAdA z?V-k3B!#ZMDwR{Os;BhI8)*E+NvVS3r=(JD4x`hXCHhfIxfEekAL4Fm_=w50ewlgn z^FAulm~x>(@7hM!HC9D~F;+qCF;>bgYjBm^tJim!=g|853e?9mmPL1Npuc5J_=egH zr$?a`dM|~mU8&5GLI+du=LqQaCyo1i4(s>4PR9zFf|pp{M)l=-Z|>Lgwb# zC&YPrCKn!-3*o`G9evHam_k(BIDd~sDlC*gzmwH36SMCE9y ze(xdOYdm|^6Q4^~#anNBbn|`jGbuwTBR9*+c1sb<7GC?%r9{qI6zd-!(5DoJl#mOy zf$iWS@G#f`9tHW}F;D<@0T(C&CBO|zfd@2zGEfc_pbyAmPzfFZc_08Hb=YdbZV&={ z!Q)^bCx_E+!Dy#9q;+9F)I|N7+NA}mgQ`uO7~Gh@g2AvB izA`g4&)P)pxb1A8$xgDA!@ZRjL)K+BV_lZ+_WlbhPLXB+ delta 1254 zcma))T}%{L6vy|@%r5&ib3hPKRtkEd3}v;ueC#3=kwR^3nowGhP(=$Yp{4~)jTMR& zSaz2fh~Pe4gJngeNQh7fiEEG=TVD)q3E8&PYPYl|Z8Vh!+J+aCwr2)-?k0ZtkmLqDkLds2|R-}ScRwX zJNyC<;5%4=d+;U9!3~&#aTtLBT!ahI172u{RyYZb&;T`X7%HF?4ni>$fD`PH0XT4` zvh<#G7N-_>pG3agU4bm>b|Uw8Z$}pT+K?~#s*(A=LS&wACvvY(K<0YukvZO1k=fpK zWQNy_O!qV*Wlt$G&0|NVdSZ|%U8j(=s}i}ZYcDdn%c8;kliVMpJm)@!+~F=lTHF+w z;MO6xbv7X5I}ajbJ2Q}5J0)aHM>BFuM;S7zBMTYX5sMU0Bg|C@Q$9kM$iWqjSsde0 z_CX0OLY(wOx+N}2cIl`%c2??@1o(iz&f6mX)kTvh5fkD7d=}BAzslW!`GDk;V$pwXE;Ag^ z?lAlTSwah!XS@j}QlfuDGjBYvZ89Vpnx(DUFT|(DnZdq1fraBZ3tKtC%dGlmUiOPC zvXI7?6aUH_B}MGYyS$avPI9up?KUO*SeO!-&BvqLf0LQzICeBWwE7#uDXi@=ztJjP zrNqI~?~@IWAEDZ8b?D#-C0T6G<;~>Ur&McKlZ;WSwW(jzqm;bJ=9P_KKTc$S;TR=W z|HX$gvyE~x+p~;EoyHTbAM?j?O43-M6&DSS81MgSbN0%0N+_#6gLB;1xRmwswn<8o z+4(TuqHYCy5<;YmMOm=%XaLvDi1kA0O-i=0mKLn}{M*fo4ZcN5EMp<8tYnCuGn>hD z1tnYj!SF`+twS^}Qn^iLoy(SMchp*I@E>?y=F2&jG|r{!(GpdA+`~~LcN5*AdPRaj zjlwSUok~CJ{hm)zsteS#?Jl7@GKc;GJNG`Ht|Z&lWNnzIMt%@IPW8nB#g+dwTJ_f3 z%08>;lInkpigV~r)wyDx>Ud?}-ORg0(hURn3G0zSsGynn@Y(QC { + 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),