2 Commits

Author SHA1 Message Date
loveuer
050075d9c8 feat: add top navbar to /share page, move nav links out of upload zone
Some checks failed
Release Binaries / Build and Release (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Release Binaries / Build and Release (amd64, darwin, darwin-amd64) (push) Has been cancelled
Release Binaries / Build and Release (amd64, linux, linux-amd64) (push) Has been cancelled
Release Binaries / Build and Release (arm64, darwin, darwin-arm64) (push) Has been cancelled
Release Binaries / Build and Release (arm64, linux, linux-arm64) (push) Has been cancelled
- Add NavBar component with app branding and conditional nav links
- NavBar shows "个人中心" for token_manage permission, "管理控制台" for user_manage
- Restructure share.tsx with flex column layout (NavBar + 3-column grid)
- Clean up panel-left.tsx: remove auth check, nav links, and unused styles

🤖 Generated with [Qoder][https://qoder.com]
2026-03-01 23:38:15 -08:00
loveuer
62e8acf757 refactor: remove GORM FK associations, handle relations in business layer (v0.6.1)
Some checks failed
Release Binaries / Build and Release (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Release Binaries / Build and Release (amd64, darwin, darwin-amd64) (push) Has been cancelled
Release Binaries / Build and Release (amd64, linux, linux-amd64) (push) Has been cancelled
Release Binaries / Build and Release (arm64, darwin, darwin-arm64) (push) Has been cancelled
Release Binaries / Build and Release (arm64, linux, linux-arm64) (push) Has been cancelled
- Remove Role association field from User model
- Remove User association field from Token model
- controller/user.go: query Role separately after loading User
- controller/token.go: query User and Role with separate DB calls
- handler/admin.go: introduce userResp type, build role info manually;
  batch-load roles in AdminListUsers to avoid N+1

🤖 Generated with [Qoder][https://qoder.com]
2026-02-28 01:56:56 -08:00
8 changed files with 213 additions and 87 deletions

View File

@@ -0,0 +1,81 @@
import React, {useEffect, useState} from 'react';
import {createUseStyles} from 'react-jss';
const useStyle = createUseStyles({
nav: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 24px',
height: '48px',
backgroundColor: '#2c9678',
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
flexShrink: 0,
},
brand: {
color: 'white',
fontWeight: 700,
fontSize: '18px',
letterSpacing: '1px',
textDecoration: 'none',
},
links: {
display: 'flex',
gap: '8px',
alignItems: 'center',
},
link: {
color: 'rgba(255,255,255,0.9)',
fontSize: '13px',
textDecoration: 'none',
padding: '5px 12px',
borderRadius: '4px',
transition: 'background-color 0.2s',
'&:hover': {
backgroundColor: 'rgba(255,255,255,0.2)',
color: 'white',
},
},
divider: {
color: 'rgba(255,255,255,0.4)',
fontSize: '13px',
},
});
export const NavBar: React.FC = () => {
const style = useStyle();
const [isAdmin, setIsAdmin] = useState(false);
const [hasTokenPerm, setHasTokenPerm] = useState(false);
useEffect(() => {
fetch('/api/uauth/me').then(async res => {
if (res.ok) {
const json = await res.json();
const perms: string[] = json.data?.permissions ?? [];
setIsAdmin(perms.includes('user_manage'));
setHasTokenPerm(perms.includes('token_manage'));
}
}).catch(() => {});
}, []);
const showLinks = isAdmin || hasTokenPerm;
return (
<nav className={style.nav}>
<a href="/share" className={style.brand}>UShare</a>
{showLinks && (
<div className={style.links}>
{hasTokenPerm && (
<a href="/self" className={style.link}></a>
)}
{isAdmin && hasTokenPerm && (
<span className={style.divider}>|</span>
)}
{isAdmin && (
<a href="/admin" className={style.link}></a>
)}
</div>
)}
</nav>
);
};

View File

@@ -1,6 +1,6 @@
import {createUseStyles} from "react-jss"; import {createUseStyles} from "react-jss";
import {UButton} from "../../../component/button/u-button.tsx"; import {UButton} from "../../../component/button/u-button.tsx";
import React, {useEffect, useState} from "react"; import React, {useState} from "react";
import {useStore} from "../../../store/share.ts"; import {useStore} from "../../../store/share.ts";
import {message} from "../../../hook/message/u-message.tsx"; import {message} from "../../../hook/message/u-message.tsx";
import {useFileUpload} from "../../../api/upload.ts"; import {useFileUpload} from "../../../api/upload.ts";
@@ -60,22 +60,6 @@ const useUploadStyle = createUseStyles({
cursor: 'pointer', cursor: 'pointer',
'&:hover': {} '&:hover': {}
}, },
adminLink: {
display: 'block',
textAlign: 'center',
marginTop: '16px',
color: '#2c9678',
fontSize: '12px',
textDecoration: 'none',
opacity: 0.8,
'&:hover': {opacity: 1, textDecoration: 'underline'},
},
navLinks: {
display: 'flex',
justifyContent: 'center',
gap: '16px',
marginTop: '16px',
},
}) })
const useShowStyle = createUseStyles({ const useShowStyle = createUseStyles({
@@ -195,22 +179,9 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
const style = useUploadStyle() const style = useUploadStyle()
const {file, setFile} = useStore() const {file, setFile} = useStore()
const {uploadFile, progress, loading} = useFileUpload(); const {uploadFile, progress, loading} = useFileUpload();
const [isAdmin, setIsAdmin] = useState(false);
const [hasTokenPerm, setHasTokenPerm] = useState(false);
useEffect(() => {
fetch('/api/uauth/me').then(async res => {
if (res.ok) {
const json = await res.json();
const perms: string[] = json.data?.permissions ?? [];
setIsAdmin(perms.includes('user_manage'));
setHasTokenPerm(perms.includes('token_manage'));
}
}).catch(() => {});
}, []);
function onFileSelect() { function onFileSelect() {
// @ts-ignore // @ts-expect-error no types for direct DOM query
document.querySelector('#real-file-input').click(); document.querySelector('#real-file-input').click();
} }
@@ -254,12 +225,6 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
<div className={style.name}>{file.name}</div> <div className={style.name}>{file.name}</div>
</div> </div>
} }
{isAdmin && (
<a href="/admin" className={style.adminLink}></a>
)}
{hasTokenPerm && (
<a href="/self" className={style.adminLink}> / API Token</a>
)}
</div> </div>
</div> </div>
} }
@@ -278,7 +243,6 @@ const PanelLeftShow: React.FC<{ code: string; set_code: (code: string) => void }
return ( return (
<div className={classes.container}> <div className={classes.container}>
<div className={classes.form}> <div className={classes.form}>
<button <button
className={classes.closeButton} className={classes.closeButton}
@@ -302,4 +266,4 @@ const PanelLeftShow: React.FC<{ code: string; set_code: (code: string) => void }
</div> </div>
</div> </div>
); );
}; };

View File

@@ -2,17 +2,23 @@ import {createUseStyles} from 'react-jss'
import {PanelLeft} from "./component/panel-left.tsx"; import {PanelLeft} from "./component/panel-left.tsx";
import {PanelRight} from "./component/panel-right.tsx"; import {PanelRight} from "./component/panel-right.tsx";
import {PanelMid} from "./component/panel-mid.tsx"; import {PanelMid} from "./component/panel-mid.tsx";
import {NavBar} from "./component/nav-bar.tsx";
const useStyle = createUseStyles({ const useStyle = createUseStyles({
"@global": { "@global": {
margin: 0, margin: 0,
padding: 0, padding: 0,
}, },
wrapper: {
display: 'flex',
flexDirection: 'column',
height: '100vh',
},
container: { container: {
margin: 0, flex: 1,
height: "100vh",
display: "grid", display: "grid",
gridTemplateColumns: "40% 20% 40%", gridTemplateColumns: "40% 20% 40%",
overflow: 'hidden',
"@media (max-width: 768px)": { "@media (max-width: 768px)": {
gridTemplateColumns: "100%", gridTemplateColumns: "100%",
@@ -24,9 +30,14 @@ const useStyle = createUseStyles({
export const FileSharing = () => { export const FileSharing = () => {
const style = useStyle() const style = useStyle()
return <div className={style.container}> return (
<PanelLeft /> <div className={style.wrapper}>
<PanelMid /> <NavBar />
<PanelRight/> <div className={style.container}>
</div> <PanelLeft />
}; <PanelMid />
<PanelRight />
</div>
</div>
);
};

View File

@@ -65,13 +65,7 @@ func (tm *tokenManager) Delete(userID uint, tokenID uint) error {
// Verify looks up a DB API token and returns a Session if valid. // Verify looks up a DB API token and returns a Session if valid.
func (tm *tokenManager) Verify(rawToken string) (*model.Session, error) { func (tm *tokenManager) Verify(rawToken string) (*model.Session, error) {
var t model.Token var t model.Token
err := db.Default.Session(). if err := db.Default.Session().Where("token = ?", rawToken).First(&t).Error; err != nil {
Where("token = ?", rawToken).
Preload("User").
Preload("User.Role").
First(&t).Error
if err != nil {
return nil, errors.New("无效的 API Token") return nil, errors.New("无效的 API Token")
} }
@@ -79,16 +73,30 @@ func (tm *tokenManager) Verify(rawToken string) (*model.Session, error) {
return nil, errors.New("API Token 已过期") return nil, errors.New("API Token 已过期")
} }
var user model.User
if err := db.Default.Session().First(&user, t.UserID).Error; err != nil {
return nil, errors.New("Token 关联用户不存在")
}
if !user.Active {
return nil, errors.New("账号已被禁用")
}
var role model.Role
if err := db.Default.Session().First(&role, user.RoleID).Error; err != nil {
return nil, errors.New("账号角色异常")
}
// Update last_used_at asynchronously // Update last_used_at asynchronously
now := time.Now() now := time.Now()
go db.Default.Session().Model(&t).Update("last_used_at", now) //nolint:errcheck go db.Default.Session().Model(&t).Update("last_used_at", now) //nolint:errcheck
session := &model.Session{ session := &model.Session{
UserID: t.User.ID, UserID: user.ID,
Username: t.User.Username, Username: user.Username,
Role: t.User.Role.Name, Role: role.Name,
RoleLabel: t.User.Role.Label, RoleLabel: role.Label,
Permissions: t.User.Role.PermissionList(), Permissions: role.PermissionList(),
LoginAt: now.Unix(), LoginAt: now.Unix(),
Token: rawToken, Token: rawToken,
} }

View File

@@ -89,7 +89,6 @@ func (um *userManager) Login(username, password string) (*model.Session, error)
user := new(model.User) user := new(model.User)
if err := db.Default.Session(). if err := db.Default.Session().
Where("username = ? AND active = ?", username, true). Where("username = ? AND active = ?", username, true).
Preload("Role").
First(user).Error; err != nil { First(user).Error; err != nil {
return nil, errors.New("账号或密码错误") return nil, errors.New("账号或密码错误")
} }
@@ -98,12 +97,17 @@ func (um *userManager) Login(username, password string) (*model.Session, error)
return nil, errors.New("账号或密码错误") return nil, errors.New("账号或密码错误")
} }
var role model.Role
if err := db.Default.Session().First(&role, user.RoleID).Error; err != nil {
return nil, errors.New("账号角色异常,请联系管理员")
}
session := &model.Session{ session := &model.Session{
UserID: user.ID, UserID: user.ID,
Username: user.Username, Username: user.Username,
Role: user.Role.Name, Role: role.Name,
RoleLabel: user.Role.Label, RoleLabel: role.Label,
Permissions: user.Role.PermissionList(), Permissions: role.PermissionList(),
LoginAt: now.Unix(), LoginAt: now.Unix(),
Token: tool.RandomString(32), Token: tool.RandomString(32),
} }

View File

@@ -3,6 +3,7 @@ package handler
import ( import (
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/loveuer/nf" "github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log" "github.com/loveuer/nf/nft/log"
@@ -12,14 +13,65 @@ import (
"github.com/spf13/cast" "github.com/spf13/cast"
) )
// userResp is the JSON response shape for a user including role info,
// built manually at the business layer instead of relying on GORM associations.
type userResp struct {
ID uint `json:"id"`
Username string `json:"username"`
RoleID uint `json:"role_id"`
Role model.Role `json:"role"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toUserResp(u model.User, r model.Role) userResp {
return userResp{
ID: u.ID,
Username: u.Username,
RoleID: u.RoleID,
Role: r,
Active: u.Active,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
func AdminListUsers() nf.HandlerFunc { func AdminListUsers() nf.HandlerFunc {
return func(c *nf.Ctx) error { return func(c *nf.Ctx) error {
var users []model.User var users []model.User
if err := db.Default.Session().Preload("Role").Find(&users).Error; err != nil { if err := db.Default.Session().Find(&users).Error; err != nil {
log.Error("handler.AdminListUsers: %s", err.Error()) log.Error("handler.AdminListUsers: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"}) return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"})
} }
return c.Status(http.StatusOK).JSON(map[string]any{"data": users})
// Collect unique role IDs and query them in one shot
roleIDSet := make(map[uint]struct{})
for _, u := range users {
roleIDSet[u.RoleID] = struct{}{}
}
roleIDs := make([]uint, 0, len(roleIDSet))
for id := range roleIDSet {
roleIDs = append(roleIDs, id)
}
var roles []model.Role
if err := db.Default.Session().Where("id IN ?", roleIDs).Find(&roles).Error; err != nil {
log.Error("handler.AdminListUsers: query roles: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"})
}
roleMap := make(map[uint]model.Role, len(roles))
for _, r := range roles {
roleMap[r.ID] = r
}
resp := make([]userResp, 0, len(users))
for _, u := range users {
resp = append(resp, toUserResp(u, roleMap[u.RoleID]))
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": resp})
} }
} }
@@ -57,6 +109,11 @@ func AdminCreateUser() nf.HandlerFunc {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "用户名已存在"}) return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "用户名已存在"})
} }
var role model.Role
if err := db.Default.Session().First(&role, req.RoleID).Error; err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的角色"})
}
user := &model.User{ user := &model.User{
Username: req.Username, Username: req.Username,
Password: tool.NewPassword(req.Password), Password: tool.NewPassword(req.Password),
@@ -69,11 +126,7 @@ func AdminCreateUser() nf.HandlerFunc {
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "创建用户失败"}) return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "创建用户失败"})
} }
if err := db.Default.Session().Preload("Role").First(user, user.ID).Error; err != nil { return c.Status(http.StatusOK).JSON(map[string]any{"data": toUserResp(*user, role)})
log.Error("handler.AdminCreateUser: preload role: %s", err.Error())
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": user})
} }
} }
@@ -97,11 +150,16 @@ func AdminUpdateUser() nf.HandlerFunc {
session := c.Locals("user").(*model.Session) session := c.Locals("user").(*model.Session)
user := new(model.User) var user model.User
if err := db.Default.Session().Preload("Role").First(user, id).Error; err != nil { if err := db.Default.Session().First(&user, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"}) return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
} }
var currentRole model.Role
if err := db.Default.Session().First(&currentRole, user.RoleID).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询角色失败"})
}
updates := map[string]any{} updates := map[string]any{}
if req.RoleID != nil && *req.RoleID != user.RoleID { if req.RoleID != nil && *req.RoleID != user.RoleID {
@@ -110,7 +168,7 @@ func AdminUpdateUser() nf.HandlerFunc {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的角色"}) return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的角色"})
} }
// If demoting from admin, ensure at least one other active admin remains // If demoting from admin, ensure at least one other active admin remains
if user.Role.Name == model.RoleAdmin && newRole.Name != model.RoleAdmin { if currentRole.Name == model.RoleAdmin && newRole.Name != model.RoleAdmin {
var adminCount int64 var adminCount int64
db.Default.Session().Model(&model.User{}). db.Default.Session().Model(&model.User{}).
Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id). Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id).
@@ -120,13 +178,14 @@ func AdminUpdateUser() nf.HandlerFunc {
} }
} }
updates["role_id"] = *req.RoleID updates["role_id"] = *req.RoleID
currentRole = newRole
} }
if req.Active != nil && *req.Active != user.Active { if req.Active != nil && *req.Active != user.Active {
if user.ID == session.UserID && !*req.Active { if user.ID == session.UserID && !*req.Active {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能禁用自己的账号"}) return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能禁用自己的账号"})
} }
if user.Role.Name == model.RoleAdmin && !*req.Active { if currentRole.Name == model.RoleAdmin && !*req.Active {
var adminCount int64 var adminCount int64
db.Default.Session().Model(&model.User{}). db.Default.Session().Model(&model.User{}).
Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id). Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id).
@@ -149,16 +208,12 @@ func AdminUpdateUser() nf.HandlerFunc {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "没有需要更新的字段"}) return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "没有需要更新的字段"})
} }
if err := db.Default.Session().Model(user).Updates(updates).Error; err != nil { if err := db.Default.Session().Model(&user).Updates(updates).Error; err != nil {
log.Error("handler.AdminUpdateUser: %s", err.Error()) log.Error("handler.AdminUpdateUser: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "更新失败"}) return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "更新失败"})
} }
if err := db.Default.Session().Preload("Role").First(user, user.ID).Error; err != nil { return c.Status(http.StatusOK).JSON(map[string]any{"data": toUserResp(user, currentRole)})
log.Error("handler.AdminUpdateUser: preload: %s", err.Error())
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": user})
} }
} }
@@ -174,13 +229,18 @@ func AdminDeleteUser() nf.HandlerFunc {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能删除自己的账号"}) return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能删除自己的账号"})
} }
user := new(model.User) var user model.User
if err := db.Default.Session().Preload("Role").First(user, id).Error; err != nil { if err := db.Default.Session().First(&user, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"}) return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
} }
// Prevent deleting the last admin // Prevent deleting the last admin: check via role name
if user.Role.Name == model.RoleAdmin { var userRole model.Role
if err := db.Default.Session().First(&userRole, user.RoleID).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询角色失败"})
}
if userRole.Name == model.RoleAdmin {
var adminCount int64 var adminCount int64
db.Default.Session().Model(&model.User{}). db.Default.Session().Model(&model.User{}).
Where("role_id = ? AND id != ?", user.RoleID, id). Where("role_id = ? AND id != ?", user.RoleID, id).
@@ -190,7 +250,7 @@ func AdminDeleteUser() nf.HandlerFunc {
} }
} }
if err := db.Default.Session().Delete(user).Error; err != nil { if err := db.Default.Session().Delete(&user).Error; err != nil {
log.Error("handler.AdminDeleteUser: %s", err.Error()) log.Error("handler.AdminDeleteUser: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "删除失败"}) return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "删除失败"})
} }

View File

@@ -7,7 +7,6 @@ import "time"
type Token struct { type Token struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"` UserID uint `gorm:"not null;index" json:"user_id"`
User User `gorm:"foreignKey:UserID" json:"-"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Token string `gorm:"uniqueIndex;not null" json:"-"` Token string `gorm:"uniqueIndex;not null" json:"-"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`

View File

@@ -8,7 +8,6 @@ type User struct {
Username string `gorm:"uniqueIndex;not null" json:"username"` Username string `gorm:"uniqueIndex;not null" json:"username"`
Password string `gorm:"not null" json:"-"` Password string `gorm:"not null" json:"-"`
RoleID uint `gorm:"not null" json:"role_id"` RoleID uint `gorm:"not null" json:"role_id"`
Role Role `gorm:"foreignKey:RoleID" json:"role"`
Active bool `gorm:"default:true" json:"active"` Active bool `gorm:"default:true" json:"active"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`