From 476da9b4593ea54953df0547ad5c6a48af75fe9e Mon Sep 17 00:00:00 2001 From: eafonyang Date: Thu, 18 Jun 2026 17:37:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=B4=A6=E5=8F=B7=EF=BC=8C?= =?UTF-8?q?=E8=B6=85=E7=AE=A1=E4=BD=93=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pnpm-store/v11/index.db | Bin 0 -> 8192 bytes .../projects/7abbab685605e7ae627cb6fc4a2315b1 | 1 + backend/src/app.js | 2 + backend/src/middleware/auth.js | 15 +- backend/src/models/DashboardUser.js | 57 +++ backend/src/models/index.js | 3 +- backend/src/routes/auth.js | 98 +++- backend/src/routes/index.js | 3 +- backend/src/routes/models.js | 4 +- backend/src/routes/users.js | 179 ++++++++ backend/src/services/userBootstrap.js | 27 ++ backend/src/utils/jwt.js | 15 +- backend/src/utils/password.js | 36 ++ frontend/index.html | 2 +- frontend/src/api/auth.js | 11 + frontend/src/api/user.js | 39 ++ .../src/components/ChangePasswordDialog.vue | 127 ++++++ frontend/src/components/TabsView.vue | 15 +- frontend/src/layout/index.vue | 72 ++- frontend/src/router/index.js | 28 +- frontend/src/styles/lux-theme.css | 26 +- frontend/src/utils/auth.js | 58 +++ frontend/src/utils/request.js | 9 +- frontend/src/utils/tabs.js | 8 + frontend/src/views/home/index.vue | 140 ++++++ frontend/src/views/login/index.vue | 123 +++-- frontend/src/views/system/users/index.vue | 425 ++++++++++++++++++ 27 files changed, 1439 insertions(+), 84 deletions(-) create mode 100644 .pnpm-store/v11/index.db create mode 120000 .pnpm-store/v11/projects/7abbab685605e7ae627cb6fc4a2315b1 create mode 100644 backend/src/models/DashboardUser.js create mode 100644 backend/src/routes/users.js create mode 100644 backend/src/services/userBootstrap.js create mode 100644 backend/src/utils/password.js create mode 100644 frontend/src/api/user.js create mode 100644 frontend/src/components/ChangePasswordDialog.vue create mode 100644 frontend/src/utils/tabs.js create mode 100644 frontend/src/views/home/index.vue create mode 100644 frontend/src/views/system/users/index.vue diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db new file mode 100644 index 0000000000000000000000000000000000000000..e6114c6ea8dcb445ae2d8b54ed3d0e04718717ff GIT binary patch literal 8192 zcmeIuzpBD86bA4#2p0s=&GDX11#$5OY&BppTCFMSqD0LV@%|C%po4=C;XCfB*y_009U<00Izz00bZafgB3lKCO>xt!CY>pipIV>wEYDQ#G?7q-|A44BRz*ko}y78W!h}e%vF6aP~>|v kx0l=(WA#c7>G359KmY;|fB*y_009U<00Izz00dHjf5$8{)c^nh literal 0 HcmV?d00001 diff --git a/.pnpm-store/v11/projects/7abbab685605e7ae627cb6fc4a2315b1 b/.pnpm-store/v11/projects/7abbab685605e7ae627cb6fc4a2315b1 new file mode 120000 index 0000000..1ba6d2e --- /dev/null +++ b/.pnpm-store/v11/projects/7abbab685605e7ae627cb6fc4a2315b1 @@ -0,0 +1 @@ +../../../backend \ No newline at end of file diff --git a/backend/src/app.js b/backend/src/app.js index 670ada4..c22f72e 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -9,6 +9,7 @@ const sequelize = require('./config/database'); const logger = require('./config/logger'); const routes = require('./routes'); const { bodyLimit } = require('./middleware/bodyLimit'); +const { ensureBootstrapSuperAdmin } = require('./services/userBootstrap'); const app = express(); @@ -42,6 +43,7 @@ async function start() { try { logger.info('Creating database tables...'); await sequelize.sync(); + await ensureBootstrapSuperAdmin(); logger.info('Database tables created successfully'); } catch (e) { logger.error(`Warning: Could not create tables: ${e.message}`); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 94c467d..2df9644 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -11,7 +11,11 @@ function authMiddleware(req, res, next) { if (!payload.sub) { return res.status(401).json({ detail: '无效凭证' }); } - req.user = payload.sub; + req.user = { + id: payload.sub, + username: payload.username || '', + is_super_admin: !!payload.is_super_admin, + }; next(); } catch (e) { if (e.name === 'TokenExpiredError') { @@ -21,4 +25,11 @@ function authMiddleware(req, res, next) { } } -module.exports = { authMiddleware }; +function requireSuperAdmin(req, res, next) { + if (!req.user?.is_super_admin) { + return res.status(403).json({ detail: '需要超级管理员权限' }); + } + next(); +} + +module.exports = { authMiddleware, requireSuperAdmin }; diff --git a/backend/src/models/DashboardUser.js b/backend/src/models/DashboardUser.js new file mode 100644 index 0000000..1381c3d --- /dev/null +++ b/backend/src/models/DashboardUser.js @@ -0,0 +1,57 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../config/database'); + +const DashboardUser = sequelize.define('DashboardUser', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + comment: '主键', + }, + username: { + type: DataTypes.STRING(64), + allowNull: false, + unique: true, + comment: '登录账号', + }, + password_hash: { + type: DataTypes.STRING(255), + allowNull: false, + comment: '密码哈希(bcrypt)', + }, + is_super_admin: { + type: DataTypes.TINYINT, + allowNull: false, + defaultValue: 0, + comment: '是否超级管理员:1=是', + }, + status: { + type: DataTypes.TINYINT, + allowNull: false, + defaultValue: 1, + comment: '状态:1=启用,0=禁用', + }, + last_login_at: { + type: DataTypes.DATE, + allowNull: true, + comment: '最近登录时间', + }, + create_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + comment: '创建时间', + }, + update_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + comment: '更新时间', + }, +}, { + tableName: 'dashboard_user', + comment: 'Dashboard 后台账号', +}); + +module.exports = DashboardUser; + diff --git a/backend/src/models/index.js b/backend/src/models/index.js index b4f4be3..4ee3589 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -2,5 +2,6 @@ const Brand = require('./Brand'); const Model = require('./Model'); const Ota = require('./Ota'); const ShareCodeLog = require('./ShareCodeLog'); +const DashboardUser = require('./DashboardUser'); -module.exports = { Brand, Model, Ota, ShareCodeLog }; +module.exports = { Brand, Model, Ota, ShareCodeLog, DashboardUser }; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 7ca7b85..1ab3ab8 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -1,28 +1,61 @@ /** - * 认证路由 — 对应 Python: routes/auth.py + * 认证路由 */ const router = require('express').Router(); +const DashboardUser = require('../models/DashboardUser'); const logger = require('../config/logger'); const { ApiResponse } = require('../utils/response'); -const { createAccessToken, verifyCredentials } = require('../utils/jwt'); +const { createAccessToken, getTokenTtlSeconds } = require('../utils/jwt'); +const { verifyPassword, hashPassword } = require('../utils/password'); +const { authMiddleware } = require('../middleware/auth'); -router.post('/api/auth/login', (req, res) => { +function userToDict(user) { + return { + id: user.id, + username: user.username, + is_super_admin: !!user.is_super_admin, + status: user.status, + last_login_at: user.last_login_at ? user.last_login_at.toISOString() : null, + create_at: user.create_at ? user.create_at.toISOString() : null, + update_at: user.update_at ? user.update_at.toISOString() : null, + }; +} + +router.post('/api/auth/login', async (req, res) => { try { const { username, password } = req.body || {}; - if (!username || !password) { + const trimmedUsername = (username || '').trim(); + + if (!trimmedUsername || !password) { return res.json(ApiResponse.error('用户名或密码不能为空')); } - if (!verifyCredentials(username.trim(), password)) { - logger.warn(`Login failed for username=${username}`); + + const user = await DashboardUser.findOne({ + where: { username: trimmedUsername }, + }); + + if (!user || user.status !== 1) { + logger.warn(`Login failed for username=${trimmedUsername}`); return res.json(ApiResponse.error('用户名或密码错误')); } - const token = createAccessToken(); - const ttlSeconds = 12 * 60 * 60; - logger.info(`User ${username.trim()} logged in`); + + const ok = await verifyPassword(password, user.password_hash); + if (!ok) { + logger.warn(`Login failed for username=${trimmedUsername}`); + return res.json(ApiResponse.error('用户名或密码错误')); + } + + user.last_login_at = new Date(); + await user.save(); + + const token = createAccessToken(user); + logger.info(`User ${trimmedUsername} logged in`); + return res.json(ApiResponse.success({ access_token: token, token_type: 'bearer', - expires_in: ttlSeconds, + expires_in: getTokenTtlSeconds(), + user: userToDict(user), })); } catch (e) { logger.error(`Login error: ${e.message}`); @@ -30,4 +63,49 @@ router.post('/api/auth/login', (req, res) => { } }); +router.get('/api/auth/me', authMiddleware, async (req, res) => { + try { + const user = await DashboardUser.findByPk(req.user.id); + if (!user || user.status !== 1) { + return res.status(401).json({ detail: '账号不存在或已禁用' }); + } + return res.json(ApiResponse.success(userToDict(user))); + } catch (e) { + logger.error(`Get current user error: ${e.message}`); + return res.json(ApiResponse.error('获取用户信息失败')); + } +}); + +router.put('/api/auth/password', authMiddleware, async (req, res) => { + try { + const { old_password: oldPassword, new_password: newPassword } = req.body || {}; + + if (!oldPassword || !newPassword) { + return res.json(ApiResponse.error('请填写原密码和新密码')); + } + if (String(newPassword).length < 6) { + return res.json(ApiResponse.error('新密码至少 6 位')); + } + + const user = await DashboardUser.findByPk(req.user.id); + if (!user || user.status !== 1) { + return res.status(401).json({ detail: '账号不存在或已禁用' }); + } + + const ok = await verifyPassword(oldPassword, user.password_hash); + if (!ok) { + return res.json(ApiResponse.error('原密码错误')); + } + + user.password_hash = await hashPassword(newPassword); + await user.save(); + + logger.info(`User ${user.username} changed password`); + return res.json(ApiResponse.success(null, '密码已修改')); + } catch (e) { + logger.error(`Change password error: ${e.message}`); + return res.json(ApiResponse.error('修改密码失败')); + } +}); + module.exports = router; diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index a0898ff..eaef321 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -6,5 +6,6 @@ const brandsRouter = require('./brands'); const modelsRouter = require('./models'); const otaRouter = require('./ota'); const shareCodeLogsRouter = require('./shareCodeLogs'); +const usersRouter = require('./users'); -module.exports = [authRouter, brandsRouter, modelsRouter, otaRouter, shareCodeLogsRouter]; +module.exports = [authRouter, brandsRouter, modelsRouter, otaRouter, shareCodeLogsRouter, usersRouter]; diff --git a/backend/src/routes/models.js b/backend/src/routes/models.js index 4d3dfa8..7c0fb9f 100644 --- a/backend/src/routes/models.js +++ b/backend/src/routes/models.js @@ -42,8 +42,8 @@ function convertTxtToCsv(buffer) { for (const line of dataLines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('*')) continue; - const parts = trimmed.split(/\s+/); - if (parts.length >= 2) { + const parts = trimmed.split(/[;\s]+/); + if (parts.length >= 2 && /^[-+]?\d/.test(parts[0])) { csvRows.push(`${parts[0]},${parts[1]}`); } } diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js new file mode 100644 index 0000000..25785a8 --- /dev/null +++ b/backend/src/routes/users.js @@ -0,0 +1,179 @@ +/** + * 账号管理路由(超级管理员) + */ +const router = require('express').Router(); +const { Op } = require('sequelize'); +const DashboardUser = require('../models/DashboardUser'); +const logger = require('../config/logger'); +const { ApiResponse } = require('../utils/response'); +const { authMiddleware, requireSuperAdmin } = require('../middleware/auth'); +const { hashPassword } = require('../utils/password'); + +router.use(authMiddleware); +router.use(requireSuperAdmin); + +function userToDict(user) { + return { + id: user.id, + username: user.username, + is_super_admin: !!user.is_super_admin, + status: user.status, + last_login_at: user.last_login_at ? user.last_login_at.toISOString() : null, + create_at: user.create_at ? user.create_at.toISOString() : null, + update_at: user.update_at ? user.update_at.toISOString() : null, + }; +} + +async function countSuperAdmins(excludeId = null) { + const where = { is_super_admin: 1, status: 1 }; + if (excludeId != null) { + where.id = { [Op.ne]: excludeId }; + } + return DashboardUser.count({ where }); +} + +// GET /api/users/ +router.get('/api/users/', async (req, res) => { + try { + const skip = parseInt(req.query.skip || '0', 10); + const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000); + const username = (req.query.username || '').trim(); + + const where = username ? { username: { [Op.like]: `%${username}%` } } : {}; + const total = await DashboardUser.count({ where }); + const rows = await DashboardUser.findAll({ + where, + offset: skip, + limit, + order: [['id', 'ASC']], + }); + + if (!rows.length) { + return res.json(ApiResponse.noData('empty')); + } + + const items = rows.map(userToDict); + return res.json(ApiResponse.success({ items, total, skip, limit })); + } catch (e) { + logger.error(`Error getting users: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// POST /api/users/ +router.post('/api/users/', async (req, res) => { + try { + const username = (req.body.username || '').trim(); + const password = req.body.password || ''; + const isSuperAdmin = !!req.body.is_super_admin; + + if (!username) { + return res.json(ApiResponse.error('用户名不能为空')); + } + if (username.length > 64) { + return res.json(ApiResponse.error('用户名不能超过 64 个字符')); + } + if (!password || password.length < 6) { + return res.json(ApiResponse.error('密码不能少于 6 位')); + } + + const existing = await DashboardUser.findOne({ where: { username } }); + if (existing) { + return res.json(ApiResponse.error('用户名已存在')); + } + + const user = await DashboardUser.create({ + username, + password_hash: await hashPassword(password), + is_super_admin: isSuperAdmin ? 1 : 0, + status: 1, + }); + + logger.info(`User created: id=${user.id}, username=${user.username}, by=${req.user.id}`); + return res.json(ApiResponse.success(userToDict(user), '账号创建成功')); + } catch (e) { + logger.error(`Error creating user: ${e.message}`); + return res.json(ApiResponse.error(`创建失败:${e.message}`)); + } +}); + +// PUT /api/users/:user_id +router.put('/api/users/:user_id', async (req, res) => { + try { + const userId = parseInt(req.params.user_id, 10); + const dbUser = await DashboardUser.findByPk(userId); + if (!dbUser) { + return res.json(ApiResponse.noData('账号不存在')); + } + + const { status, is_super_admin: isSuperAdmin, password } = req.body || {}; + const nextStatus = status !== undefined && status !== null ? parseInt(status, 10) : dbUser.status; + const nextIsSuperAdmin = isSuperAdmin !== undefined && isSuperAdmin !== null + ? (isSuperAdmin ? 1 : 0) + : dbUser.is_super_admin; + + if (![0, 1].includes(nextStatus)) { + return res.json(ApiResponse.error('状态值无效')); + } + + const wasSuperAdmin = !!dbUser.is_super_admin; + const willBeSuperAdmin = !!nextIsSuperAdmin; + const willBeEnabled = nextStatus === 1; + + if (wasSuperAdmin && (!willBeSuperAdmin || !willBeEnabled)) { + const otherSuperAdmins = await countSuperAdmins(userId); + if (otherSuperAdmins === 0) { + return res.json(ApiResponse.error('不能禁用或降级最后一个超级管理员')); + } + } + + if (password !== undefined && password !== null && String(password).length > 0) { + if (String(password).length < 6) { + return res.json(ApiResponse.error('密码不能少于 6 位')); + } + dbUser.password_hash = await hashPassword(password); + } + + dbUser.status = nextStatus; + dbUser.is_super_admin = nextIsSuperAdmin ? 1 : 0; + await dbUser.save(); + + logger.info(`User updated: id=${userId}, by=${req.user.id}`); + return res.json(ApiResponse.success(userToDict(dbUser), '账号更新成功')); + } catch (e) { + logger.error(`Error updating user ${req.params.user_id}: ${e.message}`); + return res.json(ApiResponse.error(`更新失败:${e.message}`)); + } +}); + +// DELETE /api/users/:user_id +router.delete('/api/users/:user_id', async (req, res) => { + try { + const userId = parseInt(req.params.user_id, 10); + + if (userId === req.user.id) { + return res.json(ApiResponse.error('不能删除当前登录账号')); + } + + const dbUser = await DashboardUser.findByPk(userId); + if (!dbUser) { + return res.json(ApiResponse.noData('账号不存在')); + } + + if (dbUser.is_super_admin) { + const otherSuperAdmins = await countSuperAdmins(userId); + if (otherSuperAdmins === 0) { + return res.json(ApiResponse.error('不能删除最后一个超级管理员')); + } + } + + await dbUser.destroy(); + logger.info(`User deleted: id=${userId}, by=${req.user.id}`); + return res.json(ApiResponse.success(null, '删除成功')); + } catch (e) { + logger.error(`Error deleting user ${req.params.user_id}: ${e.message}`); + return res.json(ApiResponse.error(`删除失败:${e.message}`)); + } +}); + +module.exports = router; diff --git a/backend/src/services/userBootstrap.js b/backend/src/services/userBootstrap.js new file mode 100644 index 0000000..60d735b --- /dev/null +++ b/backend/src/services/userBootstrap.js @@ -0,0 +1,27 @@ +const { DashboardUser } = require('../models'); +const logger = require('../config/logger'); +const { hashPassword } = require('../utils/password'); + +async function ensureBootstrapSuperAdmin() { + const count = await DashboardUser.count(); + if (count > 0) return; + + const username = (process.env.DASHBOARD_ADMIN_USERNAME || 'admin').trim(); + const password = process.env.DASHBOARD_ADMIN_PASSWORD || 'Eafon123'; + + if (!username) { + logger.warn('dashboard_user is empty and DASHBOARD_ADMIN_USERNAME is not set; skip bootstrap'); + return; + } + + await DashboardUser.create({ + username, + password_hash: await hashPassword(password), + is_super_admin: 1, + status: 1, + }); + + logger.info(`Bootstrap super admin created: username=${username}`); +} + +module.exports = { ensureBootstrapSuperAdmin }; diff --git a/backend/src/utils/jwt.js b/backend/src/utils/jwt.js index 34ff418..c15e491 100644 --- a/backend/src/utils/jwt.js +++ b/backend/src/utils/jwt.js @@ -4,10 +4,12 @@ const JWT_SECRET = process.env.JWT_SECRET || 'dev-only-change-me-for-production' const JWT_ALGORITHM = 'HS256'; const TOKEN_TTL_HOURS = 12; -function createAccessToken() { +function createAccessToken(user) { const now = Math.floor(Date.now() / 1000); const payload = { - sub: process.env.DASHBOARD_ADMIN_USERNAME || 'admin', + sub: user.id, + username: user.username, + is_super_admin: !!user.is_super_admin, iat: now, exp: now + TOKEN_TTL_HOURS * 3600, }; @@ -18,11 +20,8 @@ function decodeToken(token) { return jwt.verify(token, JWT_SECRET, { algorithms: [JWT_ALGORITHM] }); } -function verifyCredentials(username, password) { - const adminUsername = process.env.DASHBOARD_ADMIN_USERNAME || 'admin'; - const adminPassword = process.env.DASHBOARD_ADMIN_PASSWORD || 'Eafon123'; - if (username !== adminUsername) return false; - return password === adminPassword; +function getTokenTtlSeconds() { + return TOKEN_TTL_HOURS * 3600; } -module.exports = { createAccessToken, decodeToken, verifyCredentials }; +module.exports = { createAccessToken, decodeToken, getTokenTtlSeconds, TOKEN_TTL_HOURS }; diff --git a/backend/src/utils/password.js b/backend/src/utils/password.js new file mode 100644 index 0000000..4242f70 --- /dev/null +++ b/backend/src/utils/password.js @@ -0,0 +1,36 @@ +const crypto = require('crypto'); +const { promisify } = require('util'); + +const pbkdf2Async = promisify(crypto.pbkdf2); + +const ITERATIONS = 310000; +const KEYLEN = 32; +const DIGEST = 'sha256'; + +async function hashPassword(password) { + const salt = crypto.randomBytes(16).toString('hex'); + const hash = await pbkdf2Async(password, salt, ITERATIONS, KEYLEN, DIGEST); + return `pbkdf2:${DIGEST}:${ITERATIONS}:${salt}:${hash.toString('hex')}`; +} + +async function verifyPassword(password, stored) { + if (!password || !stored || typeof stored !== 'string') return false; + + const parts = stored.split(':'); + if (parts.length !== 5 || parts[0] !== 'pbkdf2') return false; + + const digest = parts[1]; + const iterations = parseInt(parts[2], 10); + const salt = parts[3]; + const expectedHex = parts[4]; + + if (!digest || !Number.isFinite(iterations) || !salt || !expectedHex) return false; + + const expected = Buffer.from(expectedHex, 'hex'); + const actual = await pbkdf2Async(password, salt, iterations, expected.length, digest); + + if (expected.length !== actual.length) return false; + return crypto.timingSafeEqual(expected, actual); +} + +module.exports = { hashPassword, verifyPassword }; diff --git a/frontend/index.html b/frontend/index.html index a172871..65d04cf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,7 +7,7 @@ - 耳机管理平台 + Luxsin CMS diff --git a/frontend/src/views/login/index.vue b/frontend/src/views/login/index.vue index b9f9021..2b45b29 100644 --- a/frontend/src/views/login/index.vue +++ b/frontend/src/views/login/index.vue @@ -33,10 +33,14 @@