Browse Source

feat: enhance user profile management with update functionality and toast notifications

shadcn-as
npmrun 3 weeks ago
parent
commit
ee9212ac70
  1. 3
      app/components/TopNav.vue
  2. 18
      app/composables/useAuthSession.ts
  3. 9
      app/pages/admin.vue
  4. 212
      app/pages/admin/profile/index.vue
  5. 78
      app/pages/admin/scheduler/[id]/index.vue
  6. 2
      app/plugins/vue3-toastify.client.ts
  7. 4
      app/utils/http/factory.ts
  8. 2
      package.json
  9. BIN
      packages/drizzle-pkg/db.sqlite
  10. 85
      server/api/auth/profile.put.ts
  11. 13
      server/api/scheduler/executions/[id].delete.ts
  12. 13
      server/api/scheduler/executions/delete-all.post.ts
  13. 15
      server/service/scheduler/index.ts

3
app/components/TopNav.vue

@ -74,8 +74,8 @@ watch(() => route.path, () => {
<NuxtLink to="/auth/register" class="auth-btn">注册</NuxtLink> <NuxtLink to="/auth/register" class="auth-btn">注册</NuxtLink>
</div> </div>
<div class="nav-auth" v-else-if="loggedIn && initialized"> <div class="nav-auth" v-else-if="loggedIn && initialized">
<NuxtLink to="/profile" class="auth-link">欢迎您{{ user?.username }}</NuxtLink>
<NuxtLink to="/admin/dashboard" class="auth-btn">管理</NuxtLink> <NuxtLink to="/admin/dashboard" class="auth-btn">管理</NuxtLink>
<span @click="clear()" class="auth-link">登出</span>
</div> </div>
<button <button
@ -191,6 +191,7 @@ watch(() => route.path, () => {
text-decoration: none; text-decoration: none;
transition: color 0.2s ease; transition: color 0.2s ease;
padding: 6px 0; padding: 6px 0;
cursor: pointer;
} }
.auth-link:hover { .auth-link:hover {

18
app/composables/useAuthSession.ts

@ -3,6 +3,7 @@ import { request, unwrapApiBody, type ApiResponse } from "../utils/http/factory"
export type AuthUser = { export type AuthUser = {
id: number; id: number;
username: string; username: string;
email: string | null;
role: string; role: string;
publicSlug: string | null; publicSlug: string | null;
nickname: string | null; nickname: string | null;
@ -42,6 +43,7 @@ function isUnauthorized(error: unknown) {
} }
export function useAuthSession() { export function useAuthSession() {
const { $toast } = useNuxtApp()
const state = useState<AuthSessionState>(AUTH_SESSION_STATE_KEY, () => ({ const state = useState<AuthSessionState>(AUTH_SESSION_STATE_KEY, () => ({
...DEFAULT_AUTH_SESSION_STATE, ...DEFAULT_AUTH_SESSION_STATE,
})); }));
@ -53,9 +55,11 @@ export function useAuthSession() {
state.value.initialized = true; state.value.initialized = true;
}; };
const clear = () => { const clear = async () => {
await request("/api/auth/logout", { method: "post" })
clientMeSynced.value = false; clientMeSynced.value = false;
applyUser(null); applyUser(null);
$toast.success("登出成功")
}; };
const refresh = async (force = false) => { const refresh = async (force = false) => {
@ -83,6 +87,17 @@ export function useAuthSession() {
} }
}; };
const updateProfile = async (data: { username?: string; email?: string; nickname?: string }) => {
const payload = await request<ApiResponse<{ user: AuthUser }>>("/api/auth/profile", {
method: "put",
body: data,
});
const result = unwrapApiBody(payload);
applyUser(result.user);
$toast.success("个人资料已更新");
return result.user;
};
/** /**
* SPA `GET /api/auth/session` * SPA `GET /api/auth/session`
* Cookie Cookie `/api/auth/me` * Cookie Cookie `/api/auth/me`
@ -109,5 +124,6 @@ export function useAuthSession() {
refresh, refresh,
clear, clear,
ensureClientMeSynced, ensureClientMeSynced,
updateProfile,
}; };
} }

9
app/pages/admin.vue

@ -66,20 +66,20 @@ const logout = async () => {
</svg> </svg>
返回首页 返回首页
</NuxtLink> </NuxtLink>
<div class="user-section" v-if="user"> <NuxtLink is="div" class="user-section" to="/admin/profile" v-if="user">
<div class="user-avatar"> <div class="user-avatar">
{{ user.username?.charAt(0).toUpperCase() }} {{ user.username?.charAt(0).toUpperCase() }}
</div> </div>
<div class="user-info"> <div class="user-info">
<span class="user-name">{{ user.username }}</span> <span class="user-name">{{ user.username }}</span>
<span class="user-role">管理员</span> <span class="user-role">{{ user.role === "user" ? "普通用户" : "管理员" }}</span>
</div> </div>
<button class="logout-btn" @click="logout" title="退出登录"> <button class="logout-btn" @click.stop.prevent="logout" title="退出登录">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" stroke-linecap="round" stroke-linejoin="round"/> <path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
</button> </button>
</div> </NuxtLink>
</div> </div>
</aside> </aside>
@ -229,6 +229,7 @@ const logout = async () => {
padding: 8px; padding: 8px;
border-radius: 8px; border-radius: 8px;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
cursor: pointer;
} }
.user-avatar { .user-avatar {

212
app/pages/admin/profile/index.vue

@ -4,6 +4,7 @@ const { user, updateProfile } = useAuthSession()
const form = reactive({ const form = reactive({
username: user.value?.username || '', username: user.value?.username || '',
email: user.value?.email || '', email: user.value?.email || '',
nickname: user.value?.nickname || '',
}) })
const saving = ref(false) const saving = ref(false)
@ -19,6 +20,7 @@ async function handleSave() {
await updateProfile({ await updateProfile({
username: form.username, username: form.username,
email: form.email, email: form.email,
nickname: form.nickname,
}) })
success.value = true success.value = true
setTimeout(() => { success.value = false }, 3000) setTimeout(() => { success.value = false }, 3000)
@ -39,12 +41,12 @@ async function handleSave() {
<div class="profile-card"> <div class="profile-card">
<div class="avatar-section"> <div class="avatar-section">
<div class="avatar"> <div class="avatar" :style="user?.avatar ? `background-image: url(${user.avatar}); background-size: cover;` : ''">
{{ user?.username?.charAt(0).toUpperCase() || 'U' }} <template v-if="!user?.avatar">{{ user?.username?.charAt(0).toUpperCase() || 'U' }}</template>
</div> </div>
<div class="avatar-info"> <div class="avatar-info">
<div class="avatar-name">{{ user?.username }}</div> <div class="avatar-name">{{ user?.nickname || user?.username }}</div>
<div class="avatar-role">管理员</div> <div class="avatar-role badge-admin">{{ user?.role === 'admin' ? '管理员' : '用户' }}</div>
</div> </div>
</div> </div>
@ -52,14 +54,26 @@ async function handleSave() {
<div v-if="error" class="alert alert-error">{{ error }}</div> <div v-if="error" class="alert alert-error">{{ error }}</div>
<div v-if="success" class="alert alert-success">保存成功</div> <div v-if="success" class="alert alert-success">保存成功</div>
<div class="form-group"> <div class="form-row">
<label class="form-label">用户名</label> <div class="form-group">
<input <label class="form-label">用户名</label>
v-model="form.username" <input
type="text" v-model="form.username"
class="form-input" type="text"
placeholder="输入用户名" class="form-input"
/> placeholder="输入用户名"
/>
</div>
<div class="form-group">
<label class="form-label">昵称</label>
<input
v-model="form.nickname"
type="text"
class="form-input"
placeholder="输入昵称"
/>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -84,15 +98,42 @@ async function handleSave() {
</form> </form>
</div> </div>
<section class="danger-section"> <section class="section-row">
<h2 class="section-title">危险区域</h2> <section class="card-section">
<div class="danger-card"> <h2 class="section-title">安全设置</h2>
<div class="danger-info"> <div class="card-list">
<div class="danger-label">注销账户</div> <div class="list-item">
<div class="danger-desc">永久删除您的账户和所有相关数据</div> <div class="list-item-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="list-item-content">
<div class="list-item-label">修改密码</div>
<div class="list-item-desc">定期更换密码可以保护账户安全</div>
</div>
<button class="btn-outline">修改</button>
</div>
</div> </div>
<button class="btn-danger">注销账户</button> </section>
</div>
<section class="card-section">
<h2 class="section-title">危险区域</h2>
<div class="card-list">
<div class="list-item list-item-danger">
<div class="list-item-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="list-item-content">
<div class="list-item-label">注销账户</div>
<div class="list-item-desc">永久删除您的账户和所有相关数据</div>
</div>
<button class="btn-danger-outline">注销</button>
</div>
</div>
</section>
</section> </section>
</div> </div>
</template> </template>
@ -100,7 +141,7 @@ async function handleSave() {
<style scoped> <style scoped>
.profile-page { .profile-page {
padding: 40px; padding: 40px;
max-width: 640px; max-width: 800px;
} }
.page-header { .page-header {
@ -148,6 +189,8 @@ async function handleSave() {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-size: cover;
background-position: center;
} }
.avatar-info { .avatar-info {
@ -167,6 +210,17 @@ async function handleSave() {
color: var(--color-muted); color: var(--color-muted);
} }
.badge-admin {
display: inline-flex;
padding: 2px 10px;
background: rgba(204, 120, 92, 0.12);
color: var(--color-primary);
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
width: fit-content;
}
.profile-form { .profile-form {
padding: 24px; padding: 24px;
display: flex; display: flex;
@ -174,6 +228,12 @@ async function handleSave() {
gap: 20px; gap: 20px;
} }
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.alert { .alert {
padding: 12px 16px; padding: 12px 16px;
border-radius: 8px; border-radius: 8px;
@ -244,8 +304,17 @@ async function handleSave() {
cursor: not-allowed; cursor: not-allowed;
} }
.danger-section { .section-row {
margin-top: 32px; display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 24px;
}
.card-section {
background: var(--color-surface-card);
border-radius: 12px;
padding: 20px;
} }
.section-title { .section-title {
@ -254,39 +323,90 @@ async function handleSave() {
color: var(--color-muted); color: var(--color-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
margin: 0 0 12px 0; margin: 0 0 16px 0;
}
.card-list {
display: flex;
flex-direction: column;
gap: 12px;
} }
.danger-card { .list-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 14px;
padding: 20px 24px; padding: 14px;
background: var(--color-surface-card); background: var(--color-canvas);
border-radius: 12px; border-radius: 10px;
border: 1px solid rgba(198, 69, 69, 0.2); border: 1px solid var(--color-hairline);
}
.list-item-danger {
border-color: rgba(198, 69, 69, 0.2);
} }
.danger-info { .list-item-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--color-surface-soft);
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 4px; justify-content: center;
flex-shrink: 0;
}
.list-item-icon svg {
width: 20px;
height: 20px;
color: var(--color-muted);
}
.list-item-danger .list-item-icon {
background: rgba(198, 69, 69, 0.08);
}
.list-item-danger .list-item-icon svg {
color: var(--color-error);
} }
.danger-label { .list-item-content {
flex: 1;
min-width: 0;
}
.list-item-label {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: var(--color-error); color: var(--color-ink);
margin-bottom: 2px;
} }
.danger-desc { .list-item-desc {
font-size: 13px; font-size: 13px;
color: var(--color-muted); color: var(--color-muted);
} }
.btn-danger { .btn-outline {
padding: 10px 20px; padding: 8px 16px;
font-size: 14px; font-size: 13px;
font-weight: 500;
color: var(--color-ink);
background: transparent;
border: 1px solid var(--color-hairline);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-outline:hover {
background: var(--color-surface-soft);
}
.btn-danger-outline {
padding: 8px 16px;
font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--color-error); color: var(--color-error);
background: transparent; background: transparent;
@ -296,23 +416,21 @@ async function handleSave() {
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.btn-danger:hover { .btn-danger-outline:hover {
background: rgba(198, 69, 69, 0.08); background: rgba(198, 69, 69, 0.08);
} }
@media (max-width: 640px) { @media (max-width: 768px) {
.profile-page { .profile-page {
padding: 24px; padding: 24px;
} }
.danger-card { .form-row {
flex-direction: column; grid-template-columns: 1fr;
align-items: flex-start;
gap: 16px;
} }
.btn-danger { .section-row {
width: 100%; grid-template-columns: 1fr;
} }
} }
</style> </style>

78
app/pages/admin/scheduler/[id]/index.vue

@ -19,6 +19,18 @@ async function handleToggle() {
refresh() 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 { function statusClass(status: string): string {
switch (status) { switch (status) {
case "success": return "status-success" case "success": return "status-success"
@ -129,7 +141,12 @@ function timeAgo(ts: number | string): string {
<!-- Execution history --> <!-- Execution history -->
<section class="exec-section"> <section class="exec-section">
<h2 class="section-title">最近执行记录</h2> <div class="exec-header">
<h2 class="section-title">最近执行记录</h2>
<button v-if="recentExecutions.length > 0" class="btn-clear-all" @click="handleClearAll">
清除全部
</button>
</div>
<div class="exec-card"> <div class="exec-card">
<div v-if="recentExecutions.length === 0" class="empty-state"> <div v-if="recentExecutions.length === 0" class="empty-state">
暂无执行记录 暂无执行记录
@ -145,6 +162,11 @@ function timeAgo(ts: number | string): string {
<span v-if="log.resultSummary" class="exec-result"> <span v-if="log.resultSummary" class="exec-result">
{{ log.resultSummary }} {{ log.resultSummary }}
</span> </span>
<button class="btn-delete-log" @click="handleDeleteLog(log.id)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div> </div>
</div> </div>
</div> </div>
@ -330,6 +352,34 @@ function timeAgo(ts: number | string): string {
margin-bottom: 32px; 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 { .exec-card {
background: var(--color-surface-card); background: var(--color-surface-card);
border-radius: 12px; border-radius: 12px;
@ -401,6 +451,32 @@ function timeAgo(ts: number | string): string {
flex: 1; 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 { .not-found {
margin-top: 40px; margin-top: 40px;
font-size: 15px; font-size: 15px;

2
app/plugins/vue3-toastify.client.ts

@ -9,7 +9,7 @@ export default defineNuxtPlugin((nuxtApp) => {
clearOnUrlChange: false, clearOnUrlChange: false,
theme: "colored", theme: "colored",
transition: "slide", transition: "slide",
position: "top-right", position: "bottom-right",
}); });
return { return {

4
app/utils/http/factory.ts

@ -21,13 +21,13 @@ export function unwrapApiBody<T>(payload: ApiResponse<T>): T {
} }
export const request = import.meta.server ? undefined : $fetch.create({ export const request = $fetch.create({
credentials: 'include', credentials: 'include',
}) })
const httpFetchDefaults = { const httpFetchDefaults = {
retry: 0, retry: 0,
$fetch: request, $fetch: import.meta.server ? undefined :request,
transform: unwrapApiBody, transform: unwrapApiBody,
} }

2
package.json

@ -48,6 +48,6 @@
"tsconfig": "workspace:*", "tsconfig": "workspace:*",
"tsx": "4.21.0", "tsx": "4.21.0",
"typescript": "6.0.2", "typescript": "6.0.2",
"vue3-toastify": "^0.2.9" "vue3-toastify": "0.2.9"
} }
} }

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

85
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);
}
});

13
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 });
});

13
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 });
});

15
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() { export async function getStats() {
const [totalTasks, enabledTasks, recentExecutions] = await Promise.all([ const [totalTasks, enabledTasks, recentExecutions] = await Promise.all([
dbGlobal.select({ count: sql<number>`count(*)` }).from(scheduledTasks), dbGlobal.select({ count: sql<number>`count(*)` }).from(scheduledTasks),

Loading…
Cancel
Save