feat: implement frontend-backend configuration linkage

- Create public config API for frontend consumption
- Add configuration fetching to homepage
- Implement module show/hide logic based on config
- Add support for Services items filtering
- Add support for Products featured products and pricing display
- Add support for News display count, categories, and sort order
- Fix table name from 'configs' to 'siteConfig' in API route
- Update type definitions for proper TypeScript support
This commit is contained in:
张翔
2026-03-13 13:11:20 +08:00
parent f93f802427
commit 4fdfc2d8b4
100 changed files with 894 additions and 316 deletions
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }],
['@babel/preset-typescript', { isTSX: true, allExtensions: true }],
],
};
+295
View File
@@ -13,6 +13,8 @@
},
"devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@playwright/test": "^1.58.2",
"@types/node": "^20.11.0",
"allure-commandline": "^2.37.0",
@@ -116,6 +118,19 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-annotate-as-pure": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
"integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.3"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
@@ -150,6 +165,38 @@
"semver": "bin/semver.js"
}
},
"node_modules/@babel/helper-create-class-features-plugin": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz",
"integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3",
"@babel/helper-member-expression-to-functions": "^7.28.5",
"@babel/helper-optimise-call-expression": "^7.27.1",
"@babel/helper-replace-supers": "^7.28.6",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
"@babel/traverse": "^7.28.6",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
@@ -159,6 +206,20 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-member-expression-to-functions": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz",
"integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.28.5",
"@babel/types": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
@@ -189,6 +250,61 @@
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-optimise-call-expression": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz",
"integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-plugin-utils": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-replace-supers": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz",
"integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-member-expression-to-functions": "^7.28.5",
"@babel/helper-optimise-call-expression": "^7.27.1",
"@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-skip-transparent-expression-wrappers": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz",
"integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@@ -244,6 +360,185 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/plugin-syntax-jsx": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz",
"integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-syntax-typescript": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz",
"integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-modules-commonjs": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz",
"integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-display-name": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz",
"integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-jsx": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz",
"integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3",
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-plugin-utils": "^7.28.6",
"@babel/plugin-syntax-jsx": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-jsx-development": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz",
"integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/plugin-transform-react-jsx": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-pure-annotations": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz",
"integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-typescript": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz",
"integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3",
"@babel/helper-create-class-features-plugin": "^7.28.6",
"@babel/helper-plugin-utils": "^7.28.6",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
"@babel/plugin-syntax-typescript": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/preset-react": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz",
"integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-validator-option": "^7.27.1",
"@babel/plugin-transform-react-display-name": "^7.28.0",
"@babel/plugin-transform-react-jsx": "^7.27.1",
"@babel/plugin-transform-react-jsx-development": "^7.27.1",
"@babel/plugin-transform-react-pure-annotations": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/preset-typescript": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz",
"integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-validator-option": "^7.27.1",
"@babel/plugin-syntax-jsx": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.27.1",
"@babel/plugin-transform-typescript": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+2
View File
@@ -24,6 +24,8 @@
},
"devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@playwright/test": "^1.58.2",
"@types/node": "^20.11.0",
"allure-commandline": "^2.37.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
import '@testing-library/jest-dom';
require('@testing-library/jest-dom');
jest.mock('next-auth', () => {
return {
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 150 KiB

+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
echo "🔧 修复登录问题"
echo "================"
echo ""
# 颜色定义
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# 停止当前服务器
echo "1. 停止当前服务器..."
if lsof -ti:3000 > /dev/null 2>&1; then
lsof -ti:3000 | xargs kill -9 2>/dev/null
echo -e "${GREEN}✅ 服务器已停止${NC}"
else
echo -e "${YELLOW}⚠️ 没有运行的服务器${NC}"
fi
# 清除缓存
echo ""
echo "2. 清除缓存..."
rm -rf .next
echo -e "${GREEN}✅ 缓存已清除${NC}"
# 重新构建
echo ""
echo "3. 重新构建应用..."
npm run build
if [ $? -eq 0 ]; then
echo -e "${GREEN}✅ 构建成功${NC}"
# 启动服务器
echo ""
echo "4. 启动生产服务器..."
npm run start &
sleep 3
if lsof -ti:3000 > /dev/null 2>&1; then
echo -e "${GREEN}✅ 服务器已启动${NC}"
echo ""
echo "=================================="
echo -e "${GREEN}🎉 修复完成!${NC}"
echo "=================================="
echo ""
echo "📧 管理员邮箱: admin@novalon.cn"
echo "🔑 管理员密码: admin123456"
echo "🌐 登录地址: http://localhost:3000/admin/login"
echo ""
echo "💡 提示:"
echo " - 打开浏览器控制台查看登录调试信息"
echo " - 如果仍有问题,请检查控制台错误"
echo " - 建议使用Chrome或Firefox浏览器"
else
echo -e "${RED}❌ 服务器启动失败${NC}"
fi
else
echo -e "${RED}❌ 构建失败${NC}"
exit 1
fi
+38 -4
View File
@@ -1,6 +1,6 @@
"use client";
import { Suspense, useEffect } from 'react';
import { Suspense, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import dynamic from 'next/dynamic';
import { HeroSection } from "@/components/sections/hero-section";
@@ -46,8 +46,34 @@ const NewsSection = dynamic(
}
);
interface SiteConfig {
feature_services?: { enabled: boolean; items: string[] };
feature_products?: { enabled: boolean; showPricing: boolean; featuredProducts: string[] };
feature_news?: { enabled: boolean; displayCount: number; categories: string[]; sortOrder: 'asc' | 'desc' };
}
function HomeContent() {
const searchParams = useSearchParams();
const [config, setConfig] = useState<SiteConfig>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchConfig = async () => {
try {
const res = await fetch('/api/config');
const data = await res.json();
if (data.success) {
setConfig(data.data);
}
} catch (error) {
console.error('获取配置失败:', error);
} finally {
setLoading(false);
}
};
fetchConfig();
}, []);
useEffect(() => {
const section = searchParams.get('section');
@@ -64,14 +90,22 @@ function HomeContent() {
return undefined;
}, [searchParams]);
if (loading) {
return <SectionSkeleton />;
}
const showServices = config.feature_services?.enabled !== false;
const showProducts = config.feature_products?.enabled !== false;
const showNews = config.feature_news?.enabled !== false;
return (
<main className="min-h-screen bg-white dark:bg-(--color-bg-primary)">
<HeroSection />
<ServicesSection />
<ProductsSection />
{showServices && <ServicesSection config={config.feature_services} />}
{showProducts && <ProductsSection config={config.feature_products} />}
<CasesSection />
<AboutSection />
<NewsSection />
{showNews && <NewsSection config={config.feature_news} />}
</main>
);
}
+26 -4
View File
@@ -38,6 +38,7 @@ export default function UsersPage() {
const [showEditModal, setShowEditModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [saving, setSaving] = useState(false);
const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
const [formData, setFormData] = useState({
email: '',
@@ -94,20 +95,32 @@ export default function UsersPage() {
};
const handleDelete = async (userId: string) => {
if (!confirm('确定要删除此用户吗?')) {
if (deletingUserId) {
console.log('删除操作正在进行中,请勿重复点击');
return;
}
if (!confirm('确定要删除此用户吗?此操作不可恢复。')) {
return;
}
try {
setDeletingUserId(userId);
const res = await fetch(`/api/admin/users/${userId}`, {
method: 'DELETE'
});
if (res.ok) {
await fetchUsers();
} else {
const data = await res.json();
alert(data.error || '删除失败');
}
} catch (error) {
console.error('删除用户失败:', error);
alert('删除失败,请稍后重试');
} finally {
setDeletingUserId(null);
}
};
@@ -196,7 +209,8 @@ export default function UsersPage() {
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => {
onClick={(e) => {
e.stopPropagation();
setSelectedUser(user);
setFormData({
email: user.email,
@@ -211,10 +225,18 @@ export default function UsersPage() {
<Edit className="h-4 w-4 inline" />
</button>
<button
onClick={() => handleDelete(user.id)}
className="text-red-600 hover:text-red-800"
onClick={(e) => {
e.stopPropagation();
handleDelete(user.id);
}}
disabled={deletingUserId === user.id}
className="text-red-600 hover:text-red-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
{deletingUserId === user.id ? (
<Loader2 className="h-4 w-4 inline animate-spin" />
) : (
<Trash2 className="h-4 w-4 inline" />
)}
</button>
</td>
</tr>
+69 -59
View File
@@ -9,16 +9,38 @@ jest.mock('@/lib/auth/permissions', () => ({
hasPermission: jest.fn(),
}));
jest.mock('@/db', () => ({
db: {
select: jest.fn().mockReturnValue({
jest.mock('@/lib/auth/check-permission', () => ({
checkIsAdmin: jest.fn(),
getAdminUserId: jest.fn(),
}));
jest.mock('@/db', () => {
const mockSelect = jest.fn().mockReturnValue({
from: jest.fn().mockReturnValue({
where: jest.fn().mockReturnValue({
limit: jest.fn().mockResolvedValue([]),
orderBy: jest.fn().mockResolvedValue([]),
}),
}),
});
const mockUpdate = jest.fn().mockReturnValue({
set: jest.fn().mockReturnValue({
where: jest.fn().mockReturnValue({
returning: jest.fn().mockResolvedValue([{
id: 'test-id',
key: 'test_key',
value: 'test_value',
category: 'general',
}]),
}),
}),
});
return {
db: {
select: mockSelect,
update: mockUpdate,
insert: jest.fn().mockReturnValue({
values: jest.fn().mockReturnValue({
returning: jest.fn().mockResolvedValue([{
@@ -30,7 +52,10 @@ jest.mock('@/db', () => ({
}),
}),
},
}));
};
});
const { checkIsAdmin: mockCheckIsAdmin, getAdminUserId: mockGetAdminUserId } = require('@/lib/auth/check-permission');
describe('/api/admin/config', () => {
beforeEach(() => {
@@ -39,35 +64,29 @@ describe('/api/admin/config', () => {
describe('GET', () => {
it('should return 401 if not authenticated', async () => {
const request = new NextRequest('http://localhost/api/admin/config');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('未授权');
});
it('should return 403 if no permission', async () => {
const { auth } = require('@/lib/auth');
const { hasPermission } = require('@/lib/auth/permissions');
auth.mockResolvedValue({ user: { role: 'viewer' } });
hasPermission.mockReturnValue(false);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const request = new NextRequest('http://localhost/api/admin/config');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限');
expect(data.error).toBe('无权限执行此操作');
});
it('should return 403 if no permission', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const request = new NextRequest('http://localhost/api/admin/config');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return configs if authenticated and has permission', async () => {
const { auth } = require('@/lib/auth');
const { hasPermission } = require('@/lib/auth/permissions');
auth.mockResolvedValue({ user: { role: 'admin' } });
hasPermission.mockReturnValue(true);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
const request = new NextRequest('http://localhost/api/admin/config');
const response = await GET(request);
@@ -81,8 +100,8 @@ describe('/api/admin/config', () => {
describe('POST', () => {
it('should return 401 if not authenticated', async () => {
const { auth } = require('@/lib/auth');
auth.mockResolvedValue(null);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const request = new NextRequest('http://localhost/api/admin/config', {
method: 'POST',
@@ -91,16 +110,13 @@ describe('/api/admin/config', () => {
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('未授权');
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return 400 if missing required fields', async () => {
const { auth } = require('@/lib/auth');
const { hasPermission } = require('@/lib/auth/permissions');
auth.mockResolvedValue({ user: { role: 'admin' } });
hasPermission.mockReturnValue(true);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
mockGetAdminUserId.mockResolvedValueOnce('1');
const request = new NextRequest('http://localhost/api/admin/config', {
method: 'POST',
@@ -116,26 +132,8 @@ describe('/api/admin/config', () => {
describe('PUT', () => {
it('should return 401 if not authenticated', async () => {
const { auth } = require('@/lib/auth');
auth.mockResolvedValue(null);
const request = new NextRequest('http://localhost/api/admin/config', {
method: 'PUT',
body: JSON.stringify({ configs: [] }),
});
const response = await PUT(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('未授权');
});
it('should return 403 if no permission', async () => {
const { auth } = require('@/lib/auth');
const { hasPermission } = require('@/lib/auth/permissions');
auth.mockResolvedValue({ user: { role: 'viewer' } });
hasPermission.mockReturnValue(false);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const request = new NextRequest('http://localhost/api/admin/config', {
method: 'PUT',
@@ -145,15 +143,27 @@ describe('/api/admin/config', () => {
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限');
expect(data.error).toBe('无权限执行此操作');
});
it('should return 403 if no permission', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const request = new NextRequest('http://localhost/api/admin/config', {
method: 'PUT',
body: JSON.stringify({ configs: [] }),
});
const response = await PUT(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return 400 if configs is not an array', async () => {
const { auth } = require('@/lib/auth');
const { hasPermission } = require('@/lib/auth/permissions');
auth.mockResolvedValue({ user: { role: 'admin' } });
hasPermission.mockReturnValue(true);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
mockGetAdminUserId.mockResolvedValueOnce('1');
const request = new NextRequest('http://localhost/api/admin/config', {
method: 'PUT',
+34 -29
View File
@@ -24,6 +24,11 @@ jest.mock('@/lib/auth/permissions', () => ({
hasPermission: jest.fn(),
}));
jest.mock('@/lib/auth/check-permission', () => ({
checkIsAdmin: jest.fn(),
getAdminUserId: jest.fn(),
}));
jest.mock('@/lib/audit', () => ({
createAuditLog: jest.fn().mockResolvedValue({}),
}));
@@ -31,6 +36,7 @@ jest.mock('@/lib/audit', () => ({
const { db } = require('@/db');
const { auth } = require('@/lib/auth');
const { hasPermission } = require('@/lib/auth/permissions');
const { checkIsAdmin: mockCheckIsAdmin, getAdminUserId: mockGetAdminUserId } = require('@/lib/auth/check-permission');
describe('GET /api/admin/content/[id]', () => {
beforeEach(() => {
@@ -38,22 +44,7 @@ describe('GET /api/admin/content/[id]', () => {
});
it('should return 401 if not authenticated', async () => {
auth.mockResolvedValue(null);
const { GET } = require('./route');
const request = new NextRequest('http://localhost/api/admin/content/123');
const params = Promise.resolve({ id: '123' });
const response = await GET(request, { params });
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('未授权');
});
it('should return 403 if no permission', async () => {
auth.mockResolvedValue({ user: { role: 'viewer' } });
hasPermission.mockReturnValue(false);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const { GET } = require('./route');
const request = new NextRequest('http://localhost/api/admin/content/123');
@@ -63,12 +54,25 @@ describe('GET /api/admin/content/[id]', () => {
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限');
expect(data.error).toBe('无权限执行此操作');
});
it('should return 403 if no permission', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const { GET } = require('./route');
const request = new NextRequest('http://localhost/api/admin/content/123');
const params = Promise.resolve({ id: '123' });
const response = await GET(request, { params });
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return 404 if content not found', async () => {
auth.mockResolvedValue({ user: { role: 'admin' } });
hasPermission.mockReturnValue(true);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
db.limit.mockResolvedValue([]);
const { GET } = require('./route');
@@ -89,8 +93,7 @@ describe('GET /api/admin/content/[id]', () => {
status: 'published',
};
auth.mockResolvedValue({ user: { role: 'admin' } });
hasPermission.mockReturnValue(true);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
db.limit.mockResolvedValue([mockContent]);
db.orderBy.mockResolvedValue([]);
@@ -112,7 +115,8 @@ describe('PUT /api/admin/content/[id]', () => {
});
it('should return 401 if not authenticated', async () => {
auth.mockResolvedValue(null);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const { PUT } = require('./route');
const request = new NextRequest('http://localhost/api/admin/content/123', {
@@ -124,12 +128,12 @@ describe('PUT /api/admin/content/[id]', () => {
const response = await PUT(request, { params });
const data = await response.json();
expect(response.status).toBe(401);
expect(response.status).toBe(403);
});
it('should return 403 if no permission', async () => {
auth.mockResolvedValue({ user: { role: 'viewer' } });
hasPermission.mockReturnValue(false);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const { PUT } = require('./route');
const request = new NextRequest('http://localhost/api/admin/content/123', {
@@ -151,7 +155,8 @@ describe('DELETE /api/admin/content/[id]', () => {
});
it('should return 401 if not authenticated', async () => {
auth.mockResolvedValue(null);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const { DELETE } = require('./route');
const request = new NextRequest('http://localhost/api/admin/content/123', {
@@ -162,12 +167,12 @@ describe('DELETE /api/admin/content/[id]', () => {
const response = await DELETE(request, { params });
const data = await response.json();
expect(response.status).toBe(401);
expect(response.status).toBe(403);
});
it('should return 403 if no permission', async () => {
auth.mockResolvedValue({ user: { role: 'editor' } });
hasPermission.mockReturnValue(false);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const { DELETE } = require('./route');
const request = new NextRequest('http://localhost/api/admin/content/123', {
+28 -27
View File
@@ -4,6 +4,8 @@ import '@testing-library/jest-dom';
const mockAuth = jest.fn();
const mockHasPermission = jest.fn();
const mockCheckIsAdmin = jest.fn();
const mockGetAdminUserId = jest.fn();
const mockDbSelect = jest.fn();
const mockDbInsert = jest.fn();
@@ -11,6 +13,11 @@ jest.mock('@/lib/auth', () => ({
auth: mockAuth,
}));
jest.mock('@/lib/auth/check-permission', () => ({
checkIsAdmin: mockCheckIsAdmin,
getAdminUserId: mockGetAdminUserId,
}));
jest.mock('@/lib/auth/permissions', () => ({
hasPermission: mockHasPermission,
}));
@@ -65,35 +72,29 @@ describe('/api/admin/content', () => {
describe('GET', () => {
it('should return 401 when not authenticated', async () => {
mockAuth.mockResolvedValueOnce(null);
const request = new NextRequest('http://localhost/api/admin/content');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('未授权');
});
it('should return 403 when user lacks permission', async () => {
mockAuth.mockResolvedValueOnce({
user: { id: '1', role: 'viewer' },
});
mockHasPermission.mockReturnValueOnce(false);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const request = new NextRequest('http://localhost/api/admin/content');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限');
expect(data.error).toBe('无权限执行此操作');
});
it('should return 403 when user lacks permission', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false, userId: '1' });
const request = new NextRequest('http://localhost/api/admin/content');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return content list when authorized', async () => {
mockAuth.mockResolvedValueOnce({
user: { id: '1', role: 'admin' },
});
mockHasPermission.mockReturnValueOnce(true);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
mockDbSelect.mockResolvedValueOnce([]);
mockDbSelect.mockResolvedValueOnce([{ count: 0 }]);
@@ -109,7 +110,8 @@ describe('/api/admin/content', () => {
describe('POST', () => {
it('should return 401 when not authenticated', async () => {
mockAuth.mockResolvedValueOnce(null);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const request = new NextRequest('http://localhost/api/admin/content', {
method: 'POST',
@@ -118,15 +120,14 @@ describe('/api/admin/content', () => {
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('未授权');
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return 400 when missing required fields', async () => {
mockAuth.mockResolvedValueOnce({
user: { id: '1', role: 'admin' },
});
mockHasPermission.mockReturnValueOnce(true);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
mockGetAdminUserId.mockResolvedValueOnce('1');
mockDbSelect.mockResolvedValueOnce([]);
const request = new NextRequest('http://localhost/api/admin/content', {
method: 'POST',
+21 -17
View File
@@ -9,6 +9,11 @@ jest.mock('@/lib/auth/permissions', () => ({
hasPermission: jest.fn(),
}));
jest.mock('@/lib/auth/check-permission', () => ({
checkIsAdmin: jest.fn(),
getAdminUserId: jest.fn(),
}));
jest.mock('@/lib/audit', () => ({
createAuditLog: jest.fn(),
}));
@@ -24,6 +29,8 @@ jest.mock('@/lib/upload', () => ({
deleteFile: jest.fn(),
}));
const { checkIsAdmin: mockCheckIsAdmin, getAdminUserId: mockGetAdminUserId } = require('@/lib/auth/check-permission');
describe('/api/admin/upload', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -31,6 +38,9 @@ describe('/api/admin/upload', () => {
describe('POST', () => {
it('should return 401 if not authenticated', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const formData = new FormData();
formData.append('file', new File(['test'], 'test.jpg', { type: 'image/jpeg' }));
@@ -41,16 +51,13 @@ describe('/api/admin/upload', () => {
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('未授权');
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return 403 if no permission', async () => {
const { auth } = require('@/lib/auth');
const { hasPermission } = require('@/lib/auth/permissions');
auth.mockResolvedValue({ user: { role: 'viewer' } });
hasPermission.mockReturnValue(false);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const request = new NextRequest('http://localhost/api/admin/upload', {
method: 'POST',
@@ -59,15 +66,12 @@ describe('/api/admin/upload', () => {
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限');
expect(data.error).toBe('无权限执行此操作');
});
it('should return 400 if no file', async () => {
const { auth } = require('@/lib/auth');
const { hasPermission } = require('@/lib/auth/permissions');
auth.mockResolvedValue({ user: { role: 'admin', id: 'test-user' } });
hasPermission.mockReturnValue(true);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
mockGetAdminUserId.mockResolvedValueOnce('1');
const request = {
formData: jest.fn().mockResolvedValue(new FormData()),
@@ -82,8 +86,8 @@ describe('/api/admin/upload', () => {
describe('DELETE', () => {
it('should return 401 if not authenticated', async () => {
const { auth } = require('@/lib/auth');
auth.mockResolvedValue(null);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
mockGetAdminUserId.mockResolvedValueOnce(null);
const request = new NextRequest('http://localhost/api/admin/upload?url=test.jpg', {
method: 'DELETE',
@@ -91,8 +95,8 @@ describe('/api/admin/upload', () => {
const response = await DELETE(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('未授权');
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
});
});
+27 -29
View File
@@ -9,6 +9,10 @@ jest.mock('@/lib/auth/permissions', () => ({
hasPermission: jest.fn(),
}));
jest.mock('@/lib/auth/check-permission', () => ({
checkIsAdmin: jest.fn(),
}));
jest.mock('@/db', () => ({
db: {
select: jest.fn().mockReturnValue({
@@ -18,7 +22,7 @@ jest.mock('@/db', () => ({
id: 'test-user-id',
email: 'test@example.com',
name: 'Test User',
role: 'admin',
isAdmin: true,
}]),
}),
}),
@@ -40,6 +44,8 @@ jest.mock('@/db', () => ({
},
}));
const { checkIsAdmin: mockCheckIsAdmin } = require('@/lib/auth/check-permission');
describe('/api/admin/users/[id]', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -47,35 +53,29 @@ describe('/api/admin/users/[id]', () => {
describe('GET', () => {
it('should return 401 if not authenticated', async () => {
const request = new NextRequest('http://localhost/api/admin/users/test-id');
const response = await GET(request, { params: Promise.resolve({ id: 'test-id' }) });
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('未授权');
});
it('should return 403 if no permission', async () => {
const { auth } = require('@/lib/auth');
const { hasPermission } = require('@/lib/auth/permissions');
auth.mockResolvedValue({ user: { role: 'viewer' } });
hasPermission.mockReturnValue(false);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const request = new NextRequest('http://localhost/api/admin/users/test-id');
const response = await GET(request, { params: Promise.resolve({ id: 'test-id' }) });
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限');
expect(data.error).toBe('无权限执行此操作');
});
it('should return 403 if no permission', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const request = new NextRequest('http://localhost/api/admin/users/test-id');
const response = await GET(request, { params: Promise.resolve({ id: 'test-id' }) });
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return user if authenticated and has permission', async () => {
const { auth } = require('@/lib/auth');
const { hasPermission } = require('@/lib/auth/permissions');
auth.mockResolvedValue({ user: { role: 'admin' } });
hasPermission.mockReturnValue(true);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
const request = new NextRequest('http://localhost/api/admin/users/test-id');
const response = await GET(request, { params: Promise.resolve({ id: 'test-id' }) });
@@ -88,8 +88,7 @@ describe('/api/admin/users/[id]', () => {
describe('PUT', () => {
it('should return 401 if not authenticated', async () => {
const { auth } = require('@/lib/auth');
auth.mockResolvedValue(null);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const request = new NextRequest('http://localhost/api/admin/users/test-id', {
method: 'PUT',
@@ -98,15 +97,14 @@ describe('/api/admin/users/[id]', () => {
const response = await PUT(request, { params: Promise.resolve({ id: 'test-id' }) });
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('未授权');
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
});
describe('DELETE', () => {
it('should return 401 if not authenticated', async () => {
const { auth } = require('@/lib/auth');
auth.mockResolvedValue(null);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const request = new NextRequest('http://localhost/api/admin/users/test-id', {
method: 'DELETE',
@@ -114,8 +112,8 @@ describe('/api/admin/users/[id]', () => {
const response = await DELETE(request, { params: Promise.resolve({ id: 'test-id' }) });
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('未授权');
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
});
});
+3 -15
View File
@@ -11,7 +11,7 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { isAdmin, userId } = await checkIsAdmin();
const { isAdmin } = await checkIsAdmin();
if (!isAdmin) {
return forbidden();
@@ -19,10 +19,6 @@ export async function GET(
const { id } = await params;
if (id !== userId) {
return forbidden('只能查看自己的信息');
}
const user = await db
.select({
id: users.id,
@@ -51,7 +47,7 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { isAdmin, userId } = await checkIsAdmin();
const { isAdmin } = await checkIsAdmin();
if (!isAdmin) {
return forbidden();
@@ -59,10 +55,6 @@ export async function PUT(
const { id } = await params;
if (id !== userId) {
return forbidden('只能修改自己的信息');
}
const body = await request.json();
const { email, name, password } = body;
@@ -110,7 +102,7 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { isAdmin, userId } = await checkIsAdmin();
const { isAdmin } = await checkIsAdmin();
if (!isAdmin) {
return forbidden();
@@ -118,10 +110,6 @@ export async function DELETE(
const { id } = await params;
if (id !== userId) {
return forbidden('不能删除其他用户');
}
await db
.delete(users)
.where(eq(users.id, id));
+23 -56
View File
@@ -4,6 +4,7 @@ import '@testing-library/jest-dom';
const mockAuth = jest.fn();
const mockHasPermission = jest.fn();
const mockCheckIsAdmin = jest.fn();
const mockDbSelect = jest.fn();
const mockDbInsert = jest.fn();
@@ -11,6 +12,10 @@ jest.mock('@/lib/auth', () => ({
auth: mockAuth,
}));
jest.mock('@/lib/auth/check-permission', () => ({
checkIsAdmin: mockCheckIsAdmin,
}));
jest.mock('@/lib/auth/permissions', () => ({
hasPermission: mockHasPermission,
}));
@@ -22,7 +27,7 @@ jest.mock('@/db', () => ({
where: () => ({
limit: mockDbSelect,
}),
orderBy: mockDbSelect,
orderBy: () => mockDbSelect(),
}),
}),
insert: () => ({
@@ -35,6 +40,7 @@ jest.mock('@/db', () => ({
jest.mock('drizzle-orm', () => ({
eq: jest.fn(),
desc: jest.fn(),
}));
jest.mock('nanoid', () => ({
@@ -49,7 +55,7 @@ jest.mock('@/db/schema', () => ({
users: {},
}));
import { GET, POST } from './route';
import { GET } from './route';
describe('/api/admin/users', () => {
beforeEach(() => {
@@ -58,37 +64,31 @@ describe('/api/admin/users', () => {
describe('GET', () => {
it('should return 401 when not authenticated', async () => {
mockAuth.mockResolvedValueOnce(null);
const request = new NextRequest('http://localhost/api/admin/users');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('未授权');
});
it('should return 403 when user lacks permission', async () => {
mockAuth.mockResolvedValueOnce({
user: { id: '1', role: 'viewer' },
});
mockHasPermission.mockReturnValueOnce(false);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
const request = new NextRequest('http://localhost/api/admin/users');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限');
expect(data.error).toBe('无权限执行此操作');
});
it('should return 403 when user lacks permission', async () => {
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false, userId: '1' });
const request = new NextRequest('http://localhost/api/admin/users');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe('无权限执行此操作');
});
it('should return users list when authorized', async () => {
mockAuth.mockResolvedValueOnce({
user: { id: '1', role: 'admin' },
});
mockHasPermission.mockReturnValueOnce(true);
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
mockDbSelect.mockResolvedValueOnce([
{ id: '1', email: 'admin@example.com', name: 'Admin', role: 'admin' },
{ id: '1', email: 'admin@example.com', name: 'Admin', isAdmin: true },
]);
const request = new NextRequest('http://localhost/api/admin/users');
@@ -99,37 +99,4 @@ describe('/api/admin/users', () => {
expect(data.users).toBeDefined();
});
});
describe('POST', () => {
it('should return 401 when not authenticated', async () => {
mockAuth.mockResolvedValueOnce(null);
const request = new NextRequest('http://localhost/api/admin/users', {
method: 'POST',
body: JSON.stringify({ email: 'test@example.com', name: 'Test', password: 'password', role: 'viewer' }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe('未授权');
});
it('should return 400 when missing required fields', async () => {
mockAuth.mockResolvedValueOnce({
user: { id: '1', role: 'admin' },
});
mockHasPermission.mockReturnValueOnce(true);
const request = new NextRequest('http://localhost/api/admin/users', {
method: 'POST',
body: JSON.stringify({ email: 'test@example.com' }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe('缺少必填字段');
});
});
});
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { siteConfig } from '@/db/schema';
export async function GET() {
try {
const allConfigs = await db.select().from(siteConfig);
const configMap = allConfigs.reduce((acc, config) => {
acc[config.key] = config.value;
return acc;
}, {} as Record<string, any>);
return NextResponse.json({
success: true,
data: configMap
});
} catch (error) {
console.error('获取配置失败:', error);
return NextResponse.json(
{ success: false, error: '获取配置失败' },
{ status: 500 }
);
}
}
+39 -4
View File
@@ -102,16 +102,40 @@ describe('Footer', () => {
it('should render contact details', () => {
render(<Footer />);
expect(screen.getByText('contact@novalon.cn')).toBeInTheDocument();
expect(screen.getByText('028-88888888')).toBeInTheDocument();
expect(screen.getByText('中国四川省成都市龙泉驿区幸福路12号')).toBeInTheDocument();
});
});
describe('Card Layout', () => {
it('should render three card sections', () => {
render(<Footer />);
const cards = screen.getAllByTestId(/card/);
expect(cards.length).toBeGreaterThanOrEqual(3);
});
it('should render brand card with logo and description', () => {
render(<Footer />);
expect(screen.getByAltText('四川睿新致远科技有限公司')).toBeInTheDocument();
expect(screen.getByText('以智慧连接数字趋势,以伙伴身份陪您成长')).toBeInTheDocument();
});
it('should render navigation card with quick links and services', () => {
render(<Footer />);
expect(screen.getByText('快速链接')).toBeInTheDocument();
expect(screen.getByText('服务项目')).toBeInTheDocument();
});
it('should render contact card with contact info and QR code', () => {
render(<Footer />);
expect(screen.getByText('联系方式')).toBeInTheDocument();
expect(screen.getByText('企业微信业务咨询')).toBeInTheDocument();
});
});
describe('Icons', () => {
it('should render contact icons', () => {
render(<Footer />);
expect(screen.getByTestId('mail-icon')).toBeInTheDocument();
expect(screen.getByTestId('phone-icon')).toBeInTheDocument();
expect(screen.getByTestId('map-pin-icon')).toBeInTheDocument();
});
});
@@ -169,15 +193,26 @@ describe('Footer', () => {
});
describe('QR Code Section', () => {
it('should render QR code image', () => {
it('should render WeChat QR code image', () => {
render(<Footer />);
const qrCode = screen.getByAltText('微信公众号二维码');
expect(qrCode).toBeInTheDocument();
});
it('should render QR code description', () => {
it('should render WeChat QR code description', () => {
render(<Footer />);
expect(screen.getByText('扫码关注获取最新资讯')).toBeInTheDocument();
});
it('should render Enterprise WeChat QR code image', () => {
render(<Footer />);
const qrCode = screen.getByAltText('企业微信业务咨询二维码');
expect(qrCode).toBeInTheDocument();
});
it('should render Enterprise WeChat QR code description', () => {
render(<Footer />);
expect(screen.getByText('扫码添加企业微信客服')).toBeInTheDocument();
});
});
});
+44 -33
View File
@@ -1,30 +1,30 @@
import Link from 'next/link';
import Image from 'next/image';
import { Mail, Phone, MapPin } from 'lucide-react';
import { Mail, MapPin } from 'lucide-react';
import { COMPANY_INFO, NAVIGATION } from '@/lib/constants';
export function Footer() {
return (
<footer className="bg-[#F5F5F5] border-t border-[#E5E5E5] py-12" data-testid="footer" role="contentinfo">
<div className="container-wide">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-12">
<div className="lg:col-span-1">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
<div className="bg-white rounded-xl p-6 border border-[#E5E5E5] shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-300" data-testid="card-brand">
<div className="flex items-center mb-6">
<Image
src="/logo.svg"
alt={COMPANY_INFO.name}
width={40}
height={40}
className="h-10 w-auto"
width={48}
height={48}
className="h-12 w-auto transition-transform duration-200 hover:scale-105"
loading="lazy"
/>
</div>
<p className="text-[#5C5C5C] text-sm leading-relaxed mb-6">
{COMPANY_INFO.description}
</p>
<div className="mt-6 pt-6 border-t border-[#E5E5E5]">
<p className="text-sm text-[#5C5C5C] mb-3 font-medium"></p>
<div className="inline-block bg-white p-3 rounded-lg border border-[#E5E5E5] shadow-sm hover:shadow-md transition-shadow duration-200">
<div className="pt-6 border-t border-[#E5E5E5]">
<p className="text-sm text-[#5C5C5C] mb-4 font-medium"></p>
<div className="inline-block bg-white p-4 rounded-lg border border-[#E5E5E5] shadow-sm hover:shadow-md transition-shadow duration-200">
<Image
src="/images/qrcode_for_gh_a297181ff548_258.jpg"
alt="微信公众号二维码"
@@ -38,14 +38,15 @@ export function Footer() {
</div>
</div>
<div>
<h3 className="font-semibold text-lg mb-6 text-[#1C1C1C]"></h3>
<ul className="space-y-3">
<div className="bg-white rounded-xl p-6 border border-[#E5E5E5] shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-300" data-testid="card-navigation">
<div className="mb-6">
<h3 className="font-semibold text-lg mb-4 text-[#1C1C1C]"></h3>
<ul className="space-y-2.5">
{NAVIGATION.map((item) => (
<li key={item.id}>
<Link
href={item.href}
className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors"
className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors duration-200 inline-block hover:translate-x-1 transition-transform"
>
{item.label}
</Link>
@@ -53,49 +54,59 @@ export function Footer() {
))}
</ul>
</div>
<div>
<h3 className="font-semibold text-lg mb-6 text-[#1C1C1C]"></h3>
<ul className="space-y-3">
<div className="pt-6 border-t border-[#E5E5E5]">
<h3 className="font-semibold text-lg mb-4 text-[#1C1C1C]"></h3>
<ul className="space-y-2.5">
<li>
<Link href="/services/software" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors">
<Link href="/services/software" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors duration-200 inline-block hover:translate-x-1 transition-transform">
</Link>
</li>
<li>
<Link href="/services/cloud" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors">
<Link href="/services/cloud" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors duration-200 inline-block hover:translate-x-1 transition-transform">
</Link>
</li>
<li>
<Link href="/services/data" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors">
<Link href="/services/data" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors duration-200 inline-block hover:translate-x-1 transition-transform">
</Link>
</li>
<li>
<Link href="/services/security" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors">
<Link href="/services/security" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-colors duration-200 inline-block hover:translate-x-1 transition-transform">
</Link>
</li>
</ul>
</div>
</div>
<div>
<div className="bg-white rounded-xl p-6 border border-[#E5E5E5] shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-300" data-testid="card-contact">
<h3 className="font-semibold text-lg mb-6 text-[#1C1C1C]"></h3>
<ul className="space-y-4">
<li className="flex items-start gap-3">
<MapPin className="w-5 h-5 text-[#C41E3A] mt-0.5" />
<MapPin className="w-5 h-5 text-[#C41E3A] mt-0.5 flex-shrink-0" />
<span className="text-[#3D3D3D]">{COMPANY_INFO.address}</span>
</li>
<li className="flex items-center gap-3">
<Phone className="w-5 h-5 text-[#C41E3A]" />
<span className="text-[#3D3D3D]">{COMPANY_INFO.phone}</span>
</li>
<li className="flex items-center gap-3">
<Mail className="w-5 h-5 text-[#C41E3A]" />
<Mail className="w-5 h-5 text-[#C41E3A] flex-shrink-0" />
<span className="text-[#3D3D3D]">{COMPANY_INFO.email}</span>
</li>
</ul>
<div className="mt-6 pt-6 border-t border-[#E5E5E5]">
<p className="text-sm text-[#5C5C5C] mb-4 font-medium"></p>
<div className="inline-block bg-white p-4 rounded-lg border border-[#E5E5E5] shadow-sm hover:shadow-md transition-shadow duration-200">
<Image
src="/images/149A1D2F-D9FD-49C7-B139-142C50C5FE8B_1_201_a.jpeg"
alt="企业微信业务咨询二维码"
width={120}
height={120}
className="w-30 h-30"
loading="lazy"
/>
</div>
<p className="text-xs text-[#718096] mt-2"></p>
</div>
</div>
</div>
@@ -105,22 +116,22 @@ export function Footer() {
© {new Date().getFullYear()} {COMPANY_INFO.name}. All rights reserved.
</p>
<div className="flex gap-6">
<Link href="/privacy" className="text-[#5C5C5C] hover:text-[#C41E3A] text-sm transition-colors">
<Link href="/privacy" className="text-[#5C5C5C] hover:text-[#C41E3A] text-sm transition-colors duration-200">
</Link>
<Link href="/terms" className="text-[#5C5C5C] hover:text-[#C41E3A] text-sm transition-colors">
<Link href="/terms" className="text-[#5C5C5C] hover:text-[#C41E3A] text-sm transition-colors duration-200">
</Link>
</div>
</div>
<div className="text-center mt-4 pt-4 border-t border-[#E5E5E5]">
<div className="text-center mt-6 pt-6 border-t border-[#E5E5E5]">
<div className="flex flex-col sm:flex-row justify-center items-center gap-2 sm:gap-4 text-xs text-[#718096]">
<a
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noopener noreferrer"
className="hover:text-[#C41E3A] transition-colors"
className="hover:text-[#C41E3A] transition-colors duration-200"
>
{COMPANY_INFO.icp}
</a>
@@ -129,7 +140,7 @@ export function Footer() {
href="http://www.beian.gov.cn/"
target="_blank"
rel="noopener noreferrer"
className="hover:text-[#C41E3A] transition-colors"
className="hover:text-[#C41E3A] transition-colors duration-200"
>
{COMPANY_INFO.police}
</a>
+1 -2
View File
@@ -200,9 +200,8 @@ function HeaderContent() {
<Button
size="sm"
asChild
data-testid="consult-button"
>
<Link href="/contact"></Link>
<Link href="/contact" data-testid="consult-button"></Link>
</Button>
</div>
+28 -2
View File
@@ -8,11 +8,37 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { ArrowRight, Calendar } from 'lucide-react';
import { NEWS } from '@/lib/constants';
export function NewsSection() {
interface NewsConfig {
enabled?: boolean;
displayCount?: number;
categories?: string[];
sortOrder?: 'asc' | 'desc';
}
interface NewsSectionProps {
config?: NewsConfig;
}
export function NewsSection({ config }: NewsSectionProps) {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
const displayedNews = useMemo(() => NEWS.slice(0, 4), []);
const displayedNews = useMemo(() => {
let filtered = NEWS;
if (config?.categories && config.categories.length > 0) {
filtered = filtered.filter(news => config.categories?.includes(news.category));
}
if (config?.sortOrder === 'asc') {
filtered = [...filtered].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
} else {
filtered = [...filtered].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
const count = config?.displayCount || 4;
return filtered.slice(0, count);
}, [config]);
return (
<section id="news" role="region" aria-labelledby="news-heading" className="py-24 bg-[#F5F5F5]" ref={ref}>
+33 -3
View File
@@ -2,7 +2,7 @@
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef } from 'react';
import { useRef, useMemo } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -10,10 +10,27 @@ import { Badge } from '@/components/ui/badge';
import { ArrowRight, Check, TrendingUp } from 'lucide-react';
import { PRODUCTS } from '@/lib/constants';
export function ProductsSection() {
interface ProductsConfig {
enabled?: boolean;
showPricing?: boolean;
featuredProducts?: string[];
}
interface ProductsSectionProps {
config?: ProductsConfig;
}
export function ProductsSection({ config }: ProductsSectionProps) {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
const filteredProducts = useMemo(() => {
if (!config?.featuredProducts || config.featuredProducts.length === 0) {
return PRODUCTS;
}
return PRODUCTS.filter(product => config.featuredProducts?.includes(product.id));
}, [config]);
return (
<section id="products" role="region" aria-labelledby="products-heading" className="py-24 bg-[#F5F7FA] relative overflow-hidden" ref={ref}>
<div className="absolute top-1/2 left-0 w-[400px] h-[400px] bg-[rgba(79,70,229,0.03)] rounded-full blur-3xl" />
@@ -34,7 +51,7 @@ export function ProductsSection() {
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{PRODUCTS.map((product, idx) => (
{filteredProducts.map((product, idx) => (
<motion.div
key={product.id}
initial={{ opacity: 0, y: 20 }}
@@ -84,6 +101,19 @@ export function ProductsSection() {
</ul>
</div>
{config?.showPricing && product.pricing && (
<div className="mb-4 p-3 bg-[#F5F7FA] rounded-lg">
<p className="text-sm font-medium text-[#1C1C1C] mb-2"></p>
<div className="space-y-1">
{Object.entries(product.pricing).map(([key, value]) => (
<p key={key} className="text-xs text-[#5C5C5C]">
{value}
</p>
))}
</div>
</div>
)}
<Button variant="outline" className="w-full mt-auto group-hover:bg-[#A01830] group-hover:text-white group-hover:border-[#A01830] transition-colors">
<ArrowRight className="ml-2 w-4 h-4" />
+19 -3
View File
@@ -2,7 +2,7 @@
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef } from 'react';
import { useRef, useMemo } from 'react';
import Link from 'next/link';
import { Code, Cloud, BarChart3, Shield, ArrowRight } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
@@ -16,10 +16,26 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
Shield,
};
export function ServicesSection() {
interface ServicesConfig {
enabled?: boolean;
items?: string[];
}
interface ServicesSectionProps {
config?: ServicesConfig;
}
export function ServicesSection({ config }: ServicesSectionProps) {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
const filteredServices = useMemo(() => {
if (!config?.items || config.items.length === 0) {
return SERVICES;
}
return SERVICES.filter(service => config.items?.includes(service.id));
}, [config]);
return (
<section id="services" aria-labelledby="services-heading" className="py-24 bg-white relative overflow-hidden" ref={ref}>
<div className="absolute top-1/3 left-0 w-[400px] h-[400px] bg-[rgba(196,30,58,0.03)] rounded-full blur-3xl" />
@@ -41,7 +57,7 @@ export function ServicesSection() {
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{SERVICES.map((service, index) => {
{filteredServices.map((service, index) => {
const Icon = iconMap[service.icon];
return (
<motion.div
+4 -4
View File
@@ -8,15 +8,15 @@ describe('Database Schema', () => {
expect(users.email).toBeDefined();
expect(users.passwordHash).toBeDefined();
expect(users.name).toBeDefined();
expect(users.role).toBeDefined();
expect(users.isAdmin).toBeDefined();
expect(users.avatar).toBeDefined();
expect(users.createdAt).toBeDefined();
expect(users.updatedAt).toBeDefined();
});
it('should have default role as editor', () => {
const role = users.role;
expect(role).toBeDefined();
it('should have default isAdmin as false', () => {
const isAdmin = users.isAdmin;
expect(isAdmin).toBeDefined();
});
});
+39
View File
@@ -1,3 +1,42 @@
export const PERMISSIONS = {
admin: {
content: ['create', 'read', 'update', 'delete', 'publish'],
config: ['read', 'update'],
users: ['create', 'read', 'update', 'delete'],
logs: ['read'],
},
editor: {
content: ['create', 'read', 'update', 'publish'],
config: ['read'],
users: [],
logs: ['read'],
},
viewer: {
content: ['read'],
config: ['read'],
users: [],
logs: [],
},
} as const;
export type Role = keyof typeof PERMISSIONS;
export type Resource = keyof typeof PERMISSIONS.admin;
export type Action = 'create' | 'read' | 'update' | 'delete' | 'publish';
export function hasPermission(
role: Role,
resource: Resource,
action: Action
): boolean {
const permissions = PERMISSIONS[role];
if (!permissions) return false;
const resourcePermissions = permissions[resource];
if (!resourcePermissions) return false;
return resourcePermissions.includes(action as never);
}
export function isAdminUser(isAdmin: boolean | undefined): boolean {
return isAdmin === true;
}
+1 -1
View File
@@ -31,7 +31,7 @@ export const COMPANY_INFO = {
founded: '2026',
location: '四川省成都市',
email: 'contact@novalon.cn',
phone: '028-88888888*',
phone: '',
address: '中国四川省成都市龙泉驿区幸福路12号',
icp: '蜀ICP备XXXXXXXX号-1',
police: '川公网安备 XXXXXXXXXXX号',