feat: add admin web updates and verified username changes

Add isolated update jobs with database backups, progress logs and application rollback. Remove default account hints and prevent default admin recreation after renaming.

Enforce customer and order search scopes, secure session cookies, fix quotation rounding and amount wording, and update backend dependencies. Add API and deployment regression coverage.
This commit is contained in:
Drsakura
2026-10-22 17:24:19 +08:00
parent dc607e4ea3
commit 1ffcc1ea07
20 changed files with 905 additions and 585 deletions
+9 -5
View File
@@ -2,14 +2,14 @@
* 金额英文大写(外贸单证用)
*
* 目标格式与信用证 / 发票上的写法一致:
* 255.36 USD → SAY US DOLLARS TWO HUNDRED AND FIFTY-FIVE AND THIRTY-SIX CENTS ONLY
* 255.36 USD → SAY US DOLLARS TWO HUNDRED AND FIFTY-FIVE AND CENTS THIRTY-SIX ONLY
* 1000 CNY → SAY CHINESE YUAN ONE THOUSAND ONLY
* 0.5 EUR → SAY EUROS ZERO AND FIFTY CENTS ONLY
* 0.5 EUR → SAY EUROS ZERO AND CENTS FIFTY ONLY
*
* 几条实务约定(不是「随便翻译一下」):
* 1. 整句以 SAY 开头、以 ONLY 结尾,这是单证上的固定套路。
* 2. 百位与十位之间用 AND(英式写法),如 TWO HUNDRED AND FIFTY-FIVE。
* 「组」与「组」之间不重复加 AND,避免 ONE THOUSAND AND TWO HUNDRED AND ... 这种啰嗦写法。
* 最后的个位组小于 100 时,前面补 AND,如 ONE THOUSAND AND FIVE。
* 3. 货币名固定用复数形式(US DOLLARS / EUROS),因为这里是货币名称而非计量词,
* 单证上不写 ONE US DOLLAR。
* 4. 日元等无小数币种不带辅币段;小数位按币种配置取。
@@ -22,7 +22,7 @@ const ONES = [
'SEVENTEEN', 'EIGHTEEN', 'NINETEEN'
]
const TENS = ['', '', 'TWENTY', 'THIRTY', 'FORTY', 'FIFTY', 'SIXTY', 'SEVENTY', 'EIGHTY', 'NINETY']
const SCALES = ['', 'THOUSAND', 'MILLION', 'BILLION', 'TRILLION']
const SCALES = ['', 'THOUSAND', 'MILLION', 'BILLION', 'TRILLION', 'QUADRILLION']
/**
* 币种配置。
@@ -100,6 +100,9 @@ function integerToWords(value) {
rest = Math.floor(rest / 1000)
scale += 1
}
if (value >= 1000 && value % 1000 > 0 && value % 1000 < 100) {
groups[groups.length - 1] = `AND ${groups[groups.length - 1]}`
}
return groups.join(' ')
}
@@ -124,6 +127,7 @@ export function amountToWords(amount, currency = 'USD') {
/* 先四舍五入到「最小单位」,避免 255.36 这类浮点误差把小数位算成 35.999… */
const total = Math.round(Math.abs(num) * factor)
const major = Math.floor(total / factor)
if (!Number.isSafeInteger(major)) return ''
const minor = total % factor
const parts = [`SAY ${meta.head}`]
@@ -132,8 +136,8 @@ export function amountToWords(amount, currency = 'USD') {
// 有辅币时,主币为 0 也要读作 ZERO(单证上不会省略)
if (major === 0) parts.push('ZERO')
parts.push('AND')
parts.push(integerToWords(minor))
parts.push(meta.centsHead)
parts.push(integerToWords(minor))
}
parts.push('ONLY')
+69 -4
View File
@@ -148,6 +148,12 @@ function ImportExportSettings() {
版本号以顶层 package.json 为准;远端取 Release(优先)或 tag。
--------------------------------------------------------------------------- */
function SystemUpdateSettings() {
const { user } = useAuth()
const isAdmin = user?.role === 'admin'
const [job, setJob] = useState(null)
const [updatePassword, setUpdatePassword] = useState('')
const [starting, setStarting] = useState(false)
const [connectionLost, setConnectionLost] = useState(false)
const brand = useBranding()
const [info, setInfo] = useState(null)
const [repo, setRepo] = useState('')
@@ -193,6 +199,38 @@ function SystemUpdateSettings() {
}
}
const updating = starting || (job && !['idle', 'completed', 'failed'].includes(job.state))
useEffect(() => {
if (!isAdmin) return
let alive = true
let timer
const poll = async () => {
try {
const { data } = await axios.get('/api/system/update/status', { timeout: 8000 })
if (alive) { setJob(data); setConnectionLost(false) }
} catch {
if (alive) setConnectionLost(true)
} finally {
if (alive) timer = setTimeout(poll, 2500)
}
}
poll()
return () => { alive = false; clearTimeout(timer) }
}, [isAdmin])
const install = async event => {
event.preventDefault()
setStarting(true)
setError('')
try {
const { data } = await axios.post('/api/system/update', { current_password: updatePassword }, { timeout: 60000 })
setJob(current => ({ ...current, ...data }))
setUpdatePassword('')
} catch (e) {
setError(e?.response?.data?.error || '未能确认更新任务状态,请查看下方进度后再试')
} finally { setStarting(false) }
}
const fmtTime = (value) => {
if (!value) return ''
try {
@@ -205,7 +243,7 @@ function SystemUpdateSettings() {
return (
<div className="settings-section">
<h3>系统与更新</h3>
<p className="settings-description">查看当前版本与运行环境,并从官方仓库检查是否有新版本。</p>
<p className="settings-description">查看当前版本;系统管理员可在此安装更新。</p>
<div className="settings-group">
<h4>当前版本</h4>
@@ -229,16 +267,16 @@ function SystemUpdateSettings() {
value={repo}
placeholder="owner/repo"
onChange={(e) => setRepo(e.target.value)}
disabled={checking}
disabled
/>
</div>
<p className="help-text">默认抓取官方仓库,也支持填完整地址(https://github.com/owner/repo)。</p>
<p className="help-text">更新源由服务器配置,网页安装使用此仓库的最新正式发布版本或版本标签。</p>
</div>
<div className="settings-group">
<h4>检查更新</h4>
<div className="settings-actions">
<button className="btn btn-primary" onClick={check} disabled={checking || loadingInfo}>
<button className="btn btn-primary" onClick={check} disabled={checking || loadingInfo || updating}>
{checking ? '检查中…' : '检查更新'}
</button>
</div>
@@ -300,6 +338,33 @@ function SystemUpdateSettings() {
</>
)}
</div>
{isAdmin && (
<div className="settings-group">
<h4>安装更新</h4>
<p className="help-text">系统先构建新版本并备份数据库,再切换应用和重启。重启时页面可能短暂断开,进度会自动恢复。</p>
{job?.capability && !job.capability.supported && <p className="help-text">{job.capability.reason}</p>}
{result?.hasUpdate && job?.capability?.supported && !updating && !(job?.state === 'completed' && job?.version === result.latest) && (
<form onSubmit={install}>
<div className="form-group">
<label htmlFor="update-current-password">管理员当前密码</label>
<input id="update-current-password" className="form-input" type="password" autoComplete="current-password"
required value={updatePassword} onChange={event => setUpdatePassword(event.target.value)} />
</div>
<button type="submit" className="btn btn-primary" disabled={starting || !updatePassword}>
{starting ? '正在创建任务…' : `安装 v${result.latest}`}
</button>
</form>
)}
{job && job.state !== 'idle' && (
<div role="status" className={`update-status ${job.state === 'failed' ? 'is-error' : job.state === 'completed' ? 'is-ok' : 'is-checking'}`}>
{updating && <span className="spinner" />}<span>{job.message}</span>
</div>
)}
{connectionLost && <p role="status" className="help-text">暂时无法连接服务,正在自动重试…</p>}
{job?.state === 'completed' && <button className="btn btn-primary" onClick={() => window.location.reload()}>刷新使用新版本</button>}
{job?.log && <details><summary>更新日志</summary><pre className="update-notes">{job.log}</pre></details>}
</div>
)}
</div>
)
}
-1
View File
@@ -95,7 +95,6 @@ function LoginPage() {
{busy ? '登录中…' : '登 录'}
</button>
<p className="login-tip">首次使用默认账号 <code>admin</code>,登录后请在「个人资料」里修改密码。</p>
</form>
</div>
<p className="login-copyright">{brand.name} · 外贸供应链管理系统</p>
+47 -6
View File
@@ -5,7 +5,7 @@ import {
SaveIcon, KeyIcon, CameraIcon, LogoutIcon, BuildingIcon, ShieldIcon,
EyeIcon, EyeOffIcon, UserIcon
} from '../components/Icons'
import { useAuth, roleLabel } from '../auth'
import { useAuth, roleLabel, hasPermission } from '../auth'
import { setPageTitle } from '../branding'
import { setUnsavedGuard, clearUnsavedGuard } from '../unsavedGuard'
@@ -60,6 +60,11 @@ function ProfilePage() {
const [notice, setNotice] = useState(null)
const fileRef = useRef(null)
const [username, setUsername] = useState('')
const [usernamePassword, setUsernamePassword] = useState('')
const [usernameBusy, setUsernameBusy] = useState(false)
const [usernameNotice, setUsernameNotice] = useState(null)
const canManageUsers = hasPermission(user, 'user.manage')
const [password, setPassword] = useState({ current: '', next: '', confirm: '' })
const [passwordBusy, setPasswordBusy] = useState(false)
const [passwordNotice, setPasswordNotice] = useState(null)
@@ -108,14 +113,16 @@ function ProfilePage() {
phone: payload.phone,
address: payload.address,
avatar: payload.avatar,
role: payload.role,
org_id: payload.org_id === '' ? undefined : Number(payload.org_id)
...(hasPermission(user, 'user.manage') ? {
role: payload.role,
org_id: payload.org_id === '' ? undefined : Number(payload.org_id)
} : {})
})
const updated = response.data.user
setUser(updated)
setSaved(toForm(updated))
return updated
}, [setUser])
}, [setUser, user])
/* 让外壳在跳转前能问一句「要不要先保存」 */
useEffect(() => {
@@ -155,6 +162,21 @@ function ProfilePage() {
}
}
const submitUsername = async event => {
event.preventDefault()
setUsernameBusy(true)
setUsernameNotice(null)
try {
const { data } = await axios.patch('/api/me/username', { username: username.trim(), current_password: usernamePassword })
setUser(data.user)
setUsername('')
setUsernamePassword('')
setUsernameNotice({ type: 'ok', text: data.message })
} catch (error) {
setUsernameNotice({ type: 'error', text: error?.response?.data?.error || '修改用户名失败' })
} finally { setUsernameBusy(false) }
}
const submitPassword = async event => {
event.preventDefault()
if (!password.current) { setPasswordNotice({ type: 'error', text: '请输入当前密码' }); return }
@@ -258,7 +280,7 @@ function ProfilePage() {
</div>
<div className="form-group">
<label><BuildingIcon size={13} /> 所属组织</label>
<select value={form.org_id} onChange={event => setField('org_id', event.target.value)}>
<select disabled={!canManageUsers} value={form.org_id} onChange={event => setField('org_id', event.target.value)}>
<option value="">未归属</option>
{organizations.map(org => (
<option key={org.id} value={org.id}>{org.name}{org.member_count ? `(${org.member_count} 人)` : ''}</option>
@@ -267,7 +289,7 @@ function ProfilePage() {
</div>
<div className="form-group">
<label><ShieldIcon size={13} /> 权限角色</label>
<select value={form.role} onChange={event => setField('role', event.target.value)}>
<select disabled={!canManageUsers} value={form.role} onChange={event => setField('role', event.target.value)}>
{roles.map(role => (
<option key={role.value} value={role.value}>{role.label}</option>
))}
@@ -293,6 +315,25 @@ function ProfilePage() {
{/* ---------------- 修改密码 ---------------- */}
<section className="panel">
<div className="panel-title"><UserIcon size={15} /> 修改用户名</div>
<form className="profile-fields profile-fields-single" onSubmit={submitUsername}>
<p className="field-hint">当前用户名:{user?.username}。修改后请使用新用户名登录,业务数据归属保持不变。</p>
<div className="form-group">
<label htmlFor="new-username">新用户名</label>
<input id="new-username" autoComplete="username" value={username} minLength={3} maxLength={50} required
onChange={event => setUsername(event.target.value)} placeholder="3–50 位字母、数字或 _ . @ -" />
</div>
<div className="form-group">
<label htmlFor="username-password">当前密码</label>
<input id="username-password" type="password" autoComplete="current-password" required value={usernamePassword}
onChange={event => setUsernamePassword(event.target.value)} placeholder="输入当前密码以验证身份" />
</div>
{usernameNotice && <p role="status" className={`profile-notice ${usernameNotice.type}`}>{usernameNotice.text}</p>}
<button type="submit" className="btn btn-primary" disabled={usernameBusy}>
{usernameBusy ? '提交中…' : '确认修改用户名'}
</button>
</form>
<div className="panel-title"><KeyIcon size={15} /> 修改密码</div>
<form className="profile-fields profile-fields-single" onSubmit={submitPassword}>
{[