v1.2.1 阶段性稳定版本

This commit is contained in:
甲辰生产 2026-03-20 15:03:26 +08:00
parent 4965bf68d3
commit d92559a3e3
7 changed files with 372 additions and 58 deletions

View File

@ -1,14 +1,5 @@
# 甲辰藏品管理系统 - 版本配置
# Version Configuration for Zodiac Collection Management System
# 当前版本号 (语义化版本:主版本。次版本.修订版)
VERSION=1.1.21
# 版本代号 (可选)
VERSION_CODENAME="新生"
# 发布日期
RELEASE_DATE=2026-03-18
# 版本说明
VERSION_NOTES="性能优化 - Nginx反向代理、图片OSS直连、自动压缩、上传限制20MB"
VERSION=1.2.1
VERSION_CODENAME="阶段性稳定版本"
RELEASE_DATE=2026-03-20
VERSION_NOTES="密码强度验证 - 至少8位需包含大写+小写+数字"

View File

@ -0,0 +1,50 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: jiachenlong-db-test
restart: unless-stopped
environment:
POSTGRES_DB: zodiac
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
backend:
image: python:3.11-slim
container_name: jiachenlong-backend-test
restart: unless-stopped
working_dir: /app
command: >
bash -c "pip install fastapi uvicorn sqlalchemy psycopg2-binary pydantic python-jose bcrypt python-multipart pillow dashscope alibabacloud-dysmsapi20170525 -q && uvicorn app.main:app --host 0.0.0.0 --port 3000"
ports:
- "3000:3000"
volumes:
- /root/jiachenlong/backend:/app
environment:
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/zodiac
- SECRET_KEY=test-secret-key-for-sms
- ACCESS_TOKEN_EXPIRE_MINUTES=60
- OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
- OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
- OSS_BUCKET=jiachenlong-oss
- OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
- SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
- SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
- SMS_SIGN_NAME=苏州算力
- SMS_TEMPLATE_CODE=SMS_501590956
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>甲辰收藏 v1.1.19</title>
<title>甲辰收藏 v1.2.0</title>
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/static/icons/favicon.ico" />

View File

@ -205,7 +205,7 @@ export default function Admin() {
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ color: '#94a3b8', fontSize: '12px' }}>藏品数</div>
<div style={{ color: '#60a5fa', fontSize: '20px', fontWeight: 'bold' }}>{user.collection_count || 0}</div>
<div style={{ color: '#60a5fa', fontSize: '20px', fontWeight: 'bold', cursor: 'pointer' }} onClick={() => window.location.hash = '#/list?userId=' + user.id} title='点击查看'>{user.collection_count || 0}</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>

View File

@ -6,6 +6,9 @@ export default function List() {
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState('')
const [filterType, setFilterType] = useState('')
const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || '')
const urlUserId = urlParams.get('userId') || ''
const [userIdFilter, setUserIdFilter] = useState(urlUserId)
const [sortField, setSortField] = useState('createdAt')
const [sortOrder, setSortOrder] = useState('desc')
const [viewMode, setViewMode] = useState('list')
@ -80,6 +83,11 @@ export default function List() {
}
//
// IDURL
if (userIdFilter) {
list = list.filter(item => item.userId === userIdFilter || item.userId === userIdFilter.replace(/-/g, ''))
}
if (filterType && filter) {
const fieldMap = {
status: 'status',

View File

@ -12,21 +12,83 @@ export default function Login() {
//
const [showRegister, setShowRegister] = useState(false)
const [registerData, setRegisterData] = useState({ username: '', password: '', confirmPassword: '', email: '' })
const [registerData, setRegisterData] = useState({
username: '',
password: '',
confirmPassword: '',
email: '',
phone: '',
verifyCode: ''
})
const [registerLoading, setRegisterLoading] = useState(false)
const [registerError, setRegisterError] = useState('')
//
//
const [sendingCode, setSendingCode] = useState(false)
const [codeCountdown, setCodeCountdown] = useState(0)
const [codeSent, setCodeSent] = useState(false)
//
useEffect(() => {
generateCaptcha()
}, [])
//
useEffect(() => {
if (codeCountdown > 0) {
const timer = setTimeout(() => setCodeCountdown(codeCountdown - 1), 1000)
return () => clearTimeout(timer)
}
}, [codeCountdown])
const generateCaptcha = () => {
const num1 = Math.floor(Math.random() * 10)
const num2 = Math.floor(Math.random() * 10)
setCaptcha({ num1, num2, answer: '' })
}
//
const handleSendCode = async () => {
if (!registerData.phone) {
setRegisterError('请先输入手机号')
return
}
//
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(registerData.phone)) {
setRegisterError('请输入正确的手机号')
return
}
setSendingCode(true)
setRegisterError('')
try {
const res = await fetch('/api/auth/send-verification-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ phone: registerData.phone })
})
const data = await res.json()
if (data.success) {
setCodeSent(true)
setCodeCountdown(60)
setRegisterError('')
} else {
setRegisterError(data.message || '发送失败')
}
} catch (err) {
setRegisterError('发送失败,请稍后重试')
} finally {
setSendingCode(false)
}
}
//
const handleRegister = async () => {
//
@ -35,13 +97,13 @@ export default function Login() {
return
}
if (registerData.username.length < 3) {
setRegisterError('用户名至少 3 个字符')
if (registerData.username.length < 2) {
setRegisterError('用户名至少 2 个字符')
return
}
if (registerData.password.length < 6) {
setRegisterError('密码至少 6 个字符')
if (registerData.password.length < 8 || !/[A-Z]/.test(registerData.password) || !/[a-z]/.test(registerData.password) || !/[0-9]/.test(registerData.password)) {
setRegisterError('密码至少8位需包含大写、小写、数字')
return
}
@ -50,13 +112,33 @@ export default function Login() {
return
}
//
if (!registerData.phone) {
setRegisterError('手机号为必填项')
return
}
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(registerData.phone)) {
setRegisterError('请输入正确的手机号')
return
}
//
if (!registerData.verifyCode) {
setRegisterError('请输入短信获取')
return
}
setRegisterLoading(true)
setRegisterError('')
try {
const payload = {
username: registerData.username,
password: registerData.password
password: registerData.password,
phone: registerData.phone,
verifyCode: registerData.verifyCode
}
if (registerData.email) {
payload.email = registerData.email
@ -73,12 +155,14 @@ export default function Login() {
const data = await res.json()
if (!res.ok) {
throw new Error(data.error?.message || '注册失败')
throw new Error(data.detail || data.error?.message || '注册失败')
}
alert('注册成功!请登录')
setShowRegister(false)
setRegisterData({ username: '', password: '', confirmPassword: '', email: '' })
setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '' })
setCodeSent(false)
setCodeCountdown(0)
generateCaptcha()
} catch (err) {
console.error('注册错误:', err)
@ -95,17 +179,13 @@ export default function Login() {
return
}
if (username.length < 3) {
if (username.length < 2) {
setError(ErrorCodes.E00021.message)
return
}
if (password.length < 6) {
setError(ErrorCodes.E00022.message)
return
}
//
//
const expectedAnswer = captcha.num1 + captcha.num2
if (!captcha.answer || Number(captcha.answer) !== expectedAnswer) {
setError(ErrorCodes.E00012.message)
@ -241,11 +321,11 @@ export default function Login() {
/>
</div>
{/* 验证码 */}
{/* 获取 */}
<div style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', gap: '12px' }}>
<div onClick={generateCaptcha} style={{
flex: 1,
flex: 1, minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
@ -355,7 +435,9 @@ export default function Login() {
background: 'rgba(30, 41, 59, 0.95)',
borderRadius: '16px',
padding: '24px',
border: '1px solid rgba(255,255,255,0.1)'
border: '1px solid rgba(255,255,255,0.1)',
maxHeight: '90vh',
overflowY: 'auto'
}}>
<h2 style={{ color: '#fbbf24', fontSize: '20px', fontWeight: '600', marginBottom: '20px', textAlign: 'center' }}>
注册新账号 🎉
@ -381,7 +463,7 @@ export default function Login() {
type="text"
value={registerData.username}
onChange={(e) => setRegisterData(prev => ({ ...prev, username: e.target.value }))}
placeholder="用户名(至少 3 个字符)"
placeholder="用户名(至少 2 个字符)"
style={{
width: '100%',
padding: '14px',
@ -395,6 +477,68 @@ export default function Login() {
/>
</div>
{/* 手机号 */}
<div style={{ marginBottom: '16px' }}>
<input
type="tel"
value={registerData.phone}
onChange={(e) => setRegisterData(prev => ({ ...prev, phone: e.target.value }))}
placeholder="手机号11位"
maxLength={11}
style={{
width: '100%',
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '16px',
outline: 'none'
}}
/>
</div>
{/* 短信获取 */}
<div style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', gap: '12px' }}>
<input
type="text"
value={registerData.verifyCode}
onChange={(e) => setRegisterData(prev => ({ ...prev, verifyCode: e.target.value }))}
placeholder="短信验证码"
maxLength={6}
style={{
flex: 1, minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '16px',
outline: 'none'
}}
/>
<button
onClick={handleSendCode}
disabled={sendingCode || codeCountdown > 0}
style={{
minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: 'none',
background: codeCountdown > 0 ? 'rgba(148, 163, 184, 0.3)' : 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
color: '#fff',
fontSize: '14px',
fontWeight: '600',
cursor: codeCountdown > 0 ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap'
}}
>
{codeCountdown > 0 ? `${codeCountdown}` : sendingCode ? '发送中...' : '获取验证码'}
</button>
</div>
</div>
{/* 邮箱(可选) */}
<div style={{ marginBottom: '16px' }}>
<input
@ -421,7 +565,7 @@ export default function Login() {
type="password"
value={registerData.password}
onChange={(e) => setRegisterData(prev => ({ ...prev, password: e.target.value }))}
placeholder="密码(至少 6 个字符"
placeholder="密码(至少8位需大写+小写+数字"
style={{
width: '100%',
padding: '14px',
@ -460,11 +604,13 @@ export default function Login() {
<button
onClick={() => {
setShowRegister(false)
setRegisterData({ username: '', password: '', confirmPassword: '', email: '' })
setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '' })
setRegisterError('')
setCodeSent(false)
setCodeCountdown(0)
}}
style={{
flex: 1,
flex: 1, minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: 'none',
@ -481,7 +627,7 @@ export default function Login() {
onClick={handleRegister}
disabled={registerLoading}
style={{
flex: 1,
flex: 1, minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: 'none',

View File

@ -16,6 +16,13 @@ export default function Settings() {
bio: ''
})
//
const [phoneChanged, setPhoneChanged] = useState(false)
const [newPhone, setNewPhone] = useState('')
const [verifyCode, setVerifyCode] = useState('')
const [sendingCode, setSendingCode] = useState(false)
const [codeCountdown, setCodeCountdown] = useState(0)
const [passwordForm, setPasswordForm] = useState({
oldPassword: '',
newPassword: '',
@ -29,6 +36,14 @@ export default function Settings() {
fetchUserInfo()
}, [])
//
useEffect(() => {
if (codeCountdown > 0) {
const timer = setTimeout(() => setCodeCountdown(codeCountdown - 1), 1000)
return () => clearTimeout(timer)
}
}, [codeCountdown])
const fetchUserInfo = async () => {
try {
const res = await fetch('/api/users/me', {
@ -45,12 +60,10 @@ export default function Settings() {
address: data.address || '',
bio: data.bio || ''
})
} else {
console.error('获取用户信息失败:', res.status)
setNewPhone(data.phone || '')
}
} catch (e) {
console.error('获取用户信息异常:', e)
//
const userStr = localStorage.getItem('user')
if (userStr) {
try {
@ -63,6 +76,7 @@ export default function Settings() {
address: user.address || '',
bio: user.bio || ''
})
setNewPhone(user.phone || '')
} catch (e2) {
setError('获取用户信息失败')
}
@ -72,26 +86,92 @@ export default function Settings() {
}
}
//
const handleSendCode = async () => {
if (!newPhone) {
setError('请输入手机号')
return
}
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(newPhone)) {
setError('请输入正确的手机号')
return
}
setSendingCode(true)
setError('')
try {
const res = await fetch('/api/auth/send-verification-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ phone: newPhone })
})
const data = await res.json()
if (data.success) {
setPhoneChanged(true)
setCodeCountdown(60)
setSuccess('验证码已发送到 ' + newPhone.substring(0,3) + '****' + newPhone.substring(7))
} else {
setError(data.message || '发送失败')
}
} catch (err) {
setError('发送失败,请稍后重试')
} finally {
setSendingCode(false)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setSuccess('')
//
if (newPhone !== form.phone) {
if (!verifyCode) {
setError('请输入手机验证码')
return
}
}
setSaving(true)
try {
const updateData = {
username: form.username,
email: form.email || null,
phone: newPhone,
address: form.address,
bio: form.bio
}
//
if (newPhone !== form.phone) {
updateData.verifyCode = verifyCode
}
const res = await fetch('/api/users/me', {
method: 'PUT',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify(form)
body: JSON.stringify(updateData)
})
if (res.ok) {
const data = await res.json()
setSuccess('保存成功!')
//
setForm({...form, phone: newPhone})
setVerifyCode('')
setPhoneChanged(false)
//
const userStr = localStorage.getItem('user')
if (userStr) {
const user = JSON.parse(userStr)
@ -102,7 +182,7 @@ export default function Settings() {
}
} else {
const data = await res.json()
setError(data.error?.message || '保存失败')
setError(data.error?.message || data.detail || '保存失败')
}
} catch (e) {
setError('保存失败,请重试')
@ -126,16 +206,14 @@ export default function Settings() {
return
}
if (passwordForm.newPassword.length < 6) {
setError('密码长度至少6位')
if (passwordForm.newPassword.length < 8) {
setError('密码至少8位需包含大写+小写+数字')
return
}
setSaving(true)
console.log('开始修改密码...')
try {
console.log('发送请求到 /api/auth/change-password')
const res = await fetch('/api/auth/change-password', {
method: 'POST',
headers: {
@ -148,9 +226,7 @@ export default function Settings() {
})
})
console.log('响应状态:', res.status)
const data = await res.json()
console.log('响应数据:', data)
if (res.ok) {
setSuccess('密码修改成功!')
@ -200,11 +276,12 @@ export default function Settings() {
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>邮箱</label>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>邮箱选填</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="选填"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
@ -213,27 +290,69 @@ export default function Settings() {
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>手机号</label>
<input
type="tel"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
value={newPhone}
onChange={(e) => {
setNewPhone(e.target.value)
setPhoneChanged(true)
setVerifyCode('')
}}
placeholder="11位手机号"
maxLength={11}
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
{/* 手机验证码 - 仅在手机号变更时显示 */}
{phoneChanged && (
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>手机验证码</label>
<div style={{ display: 'flex', gap: '12px' }}>
<input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)}
placeholder="6位验证码"
maxLength={6}
style={{ flex: 1, padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
<button
type="button"
onClick={handleSendCode}
disabled={sendingCode || codeCountdown > 0}
style={{
width: '120px',
padding: '12px',
borderRadius: '8px',
border: 'none',
background: codeCountdown > 0 ? '#4b5563' : '#22c55e',
color: '#fff',
fontSize: '14px',
cursor: codeCountdown > 0 ? 'not-allowed' : 'pointer'
}}
>
{codeCountdown > 0 ? codeCountdown + '秒' : sendingCode ? '发送中...' : '获取验证码'}
</button>
</div>
</div>
)}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>地址</label>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>地址选填</label>
<input
type="text"
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
placeholder="选填"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>简介</label>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>简介选填</label>
<textarea
value={form.bio}
onChange={(e) => setForm({ ...form, bio: e.target.value })}
placeholder="选填"
rows={3}
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff', resize: 'none' }}
/>
@ -260,8 +379,8 @@ export default function Settings() {
type="password"
value={passwordForm.oldPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, oldPassword: e.target.value })}
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
placeholder="请输入当前密码"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
@ -271,8 +390,8 @@ export default function Settings() {
type="password"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
placeholder="至少8位需包含大写+小写+数字"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
placeholder="至少6位"
/>
</div>
@ -282,8 +401,8 @@ export default function Settings() {
type="password"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
placeholder="再次输入新密码"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>