This commit is contained in:
owenchen committed 2026-10-17 22:31:52 +08:00
1 parent 28dd3ffebd
commit 1b7ee59d23
14 files changed
+538 -4

No files matched your search

@@ -3,5 +3,13 @@ import { SecurityUserEntity } from "./securityUserEntity";
export class SecurityUserDisplayData extends SecurityUserEntity
{
public OrgUserPassword: string = "";
public IsCanAmend?: Date= null!;
public Nation: string = "";
public Address: string = "";
public IdNumber: string = "";
public Issuer: string = "";
public Validity: string = "";
public IdCardFilename: string = "";
public IdCardUrl: string = "";
}
@@ -28,6 +28,10 @@
"loginAgent": "Agent Login",
"changePassword": "Change Password",
"changeLocalName": "Change Nickname",
"verifiedLocalName": "Verified Nickname",
"identityVerification": "Identity Verification",
"verified": "Verified",
"verifiedNoRename": "Verified users cannot change nickname",
"logout": "Log Out",
"logoutConfirmTitle": "Log Out",
"logoutConfirmContent": "Are you sure you want to log out?",
@@ -88,6 +92,19 @@
"nameTooShort": "User name must be at least 2 characters",
"success": "Updated, please log in again"
},
"IdentityVerificationPage": {
"title": "Identity Verification",
"intro": "After uploading your ID card, the system recognizes it and writes all information into the remark; the nickname becomes the name on the ID and the account is marked verified (applies to normal users only)",
"uploadBut": "Upload & Verify",
"uploadingTip": "Recognizing…",
"savingTip": "Submitting…",
"success": "Identity verified",
"failed": "Recognition or submission failed",
"noResult": "Nothing recognized, please try a clearer photo",
"previewTitle": "Recognized Info (will be saved to remark)",
"reloginTip": "Verification takes effect immediately; on another device or after clearing cache the server is the source of truth",
"verifiedDone": "You have already completed identity verification"
},
"ThemePage": {
"title": "My Theme",
"themeTitle": "Theme Color",
@@ -28,6 +28,10 @@
"loginAgent": "代理登录",
"changePassword": "修改密码",
"changeLocalName": "修改昵称",
"verifiedLocalName": "认证昵称",
"identityVerification": "身份验证",
"verified": "已认证",
"verifiedNoRename": "已认证用户不能修改昵称",
"logout": "退出登录",
"logoutConfirmTitle": "退出登录",
"logoutConfirmContent": "确定要退出当前账号吗?",
@@ -88,6 +92,19 @@
"nameTooShort": "用户名称不能少于两个字",
"success": "修改成功,请重新登录"
},
"IdentityVerificationPage": {
"title": "身份验证",
"intro": "上传身份证后,系统自动识别并将全部信息写入备注,昵称为身份证姓名,账号置为已认证(仅普通用户生效)",
"uploadBut": "上传并认证",
"uploadingTip": "识别中…",
"savingTip": "提交中…",
"success": "身份验证成功",
"failed": "识别或提交失败",
"noResult": "未识别到信息,请换一张更清晰的照片",
"previewTitle": "识别结果(将写入备注)",
"reloginTip": "认证结果已即时生效;换设备或清除缓存后以服务端为准",
"verifiedDone": "您已完成身份认证,无需重复认证"
},
"ThemePage": {
"title": "我的主题",
"themeTitle": "主题颜色",
+4
View File
@@ -24,6 +24,10 @@
{
"path": "pages/login/change-local-name"
},
// 身份验证(/identity-verification):从「我的」跳入,上传身份证→后端 OCR→识别信息写入 UserRemark 并置认证态
{
"path": "pages/login/identity-verification"
},
// 错误页:H5 短地址由 CLEAN_PATH_OVERRIDES 指定为 /error/page40x,与旧 WebVue 地址一致
{
"path": "pages/error/page401"
@@ -33,6 +33,11 @@ async function doLogout(): Promise<void> {
}
async function handleSubmit() {
// 已认证用户昵称锁定为身份证姓名,禁止再改(冷启动直达本页时的兜底)
if (loginProfileStore.userType === "N") {
uni.showToast({ title: t("MinePage.verifiedNoRename"), icon: "none" });
return;
}
const name = localName.value.trim();
if (!name) {
uni.showToast({ title: t("ChangeLocalNamePage.nameRequired"), icon: "none" });
@@ -0,0 +1,348 @@
<script setup lang="ts">
/**
* 身份验证页:从「我的」跳入(短地址 /identity-verification)。
* 流程:chooseImage 单图 → 复用同源后端 DmsFile/UploadIdCardFile(后端落盘 idcard 目录 +
* server-to-server 调同 docker 的 OCR,返回带识别结果的 FamilyPersonDisplayData,避免浏览器跨域)→
* 把「全部识别信息」格式化写入 UserRemark,昵称取身份证姓名,再调 SecurityUser/UpdateSecurityUserIdCard
* 落库并把 UserType 置为 NORMAL_USERS(已认证)。
* 与 family-person 的身份证不同:那边把无列信息并入「简介」;这里 UserRemark 是真实列,整体覆盖为识别信息。
* 注意:后端 UpdateSecurityUserIdCard 对「可查看全部用户」(管理员)会直接 return 不更新——普通用户自助认证场景。
*/
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { buildChunkFileName, uploadFile } from "@/common/http.interceptor";
import { useAuthGuard, useThemeGradient, useThemeTokens } from "@/composables";
import { SecurityUserDisplayData } from "@/data/auto/securityUserDisplayData";
import { pageUrl } from "@/router/routes";
import securityUserService from "@/service/security/securityUserService";
import { useLoginProfileStore } from "@/stores";
const { t } = useLocale();
useAuthGuard();
const loginProfileStore = useLoginProfileStore();
/** 后端 UpdateSecurityUserIdCard 会把 UserType 置为 NORMAL_USERS("N")代表已认证;此处与后端口径保持一致 */
const NORMAL_USERS = "N";
const imageUrl = ref("");
const remark = ref("");
const recognizedName = ref("");
const uploading = ref(false);
const saving = ref(false);
const busy = computed(() => uploading.value || saving.value);
const buttonText = computed(() => {
if (uploading.value) {
return t("IdentityVerificationPage.uploadingTip");
}
if (saving.value) {
return t("IdentityVerificationPage.savingTip");
}
return t("IdentityVerificationPage.uploadBut");
});
/** 已认证(UserType=NORMAL_USERS):禁止重复认证,仅展示认证状态 */
const isVerified = computed(() => loginProfileStore.userType === NORMAL_USERS);
/** 返回:有历史则回退,冷启动直达时兜底切回「我的」tab */
function onBack(): void {
if (getCurrentPages().length > 1) {
uni.navigateBack();
}
else {
uni.switchTab({ url: pageUrl("mineTab") });
}
}
/* 主题渐变底 + 主题色变量(与「我的」页同一接线口径) */
const { themeBackground } = useThemeGradient();
const { primary, accentGradient, accentSoft, accentShadow } = useThemeTokens();
const themeVars = computed(() => ({
"--fg-primary": primary.value,
"--fg-gradient": accentGradient.value,
"--fg-soft": accentSoft.value,
"--fg-shadow": accentShadow.value,
}));
/** 出生日期归一为 yyyy-MM-dd(兼容 yyyyMMdd 与带分隔符/时间戳);不足 8 位数字则原样返回 */
function formatBirth(raw: string): string {
const digits = String(raw ?? "").replace(/\D/g, "");
if (digits.length < 8) {
return String(raw ?? "").trim();
}
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`;
}
/** 取后端识别结果字段:兼容 PascalCase(服务端默认)与 camelCase(前端缓存转键) */
function pick(info: any, pascal: string, camel: string): string {
return String(info?.[pascal] ?? info?.[camel] ?? "").trim();
}
/** 把全部识别信息拼成 UserRemark 文本(有值才成行,逐行「标签: 值」);返回拼接文本与身份证姓名 */
function buildRemark(info: any): { text: string; name: string } {
const name = pick(info, "CommonName", "commonName");
const rows: string[] = [];
const push = (label: string, val: string): void => {
if (val) {
rows.push(`${label}: ${val}`);
}
};
push(t("FamilyPersonPage.commonName"), name);
push(t("FamilyPersonPage.gender"), pick(info, "Gender", "gender"));
push(t("FamilyPersonPage.birthDate"), formatBirth(pick(info, "BirthDate", "birthDate")));
push(t("FamilyPersonPage.nation"), pick(info, "Nation", "nation"));
push(t("FamilyPersonPage.address"), pick(info, "Address", "address"));
push(t("FamilyPersonPage.idNumber"), pick(info, "IdNumber", "idNumber"));
push(t("FamilyPersonPage.issuer"), pick(info, "Issuer", "issuer"));
push(t("FamilyPersonPage.validity"), pick(info, "Validity", "validity"));
return { text: rows.join("\n"), name };
}
/** 上传身份证:单图 → 后端 OCR → 拼 UserRemark(整体覆盖)→ UpdateSecurityUserIdCard 落库并置认证态 */
function onUploadIdCard(): void {
if (uploading.value || saving.value || isVerified.value) {
return;
}
uni.chooseImage({
count: 1,
success: async (res: any) => {
const filePath = String(res?.tempFilePaths?.[0] ?? "");
const fileName = String(res?.tempFiles?.[0]?.name || `idcard-${Date.now()}.jpg`);
if (!filePath) {
return;
}
try {
uploading.value = true;
const physicalName = buildChunkFileName(fileName);
imageUrl.value = filePath;
const result = await uploadFile({
url: "/DmsFile/UploadIdCardFile",
filePath,
name: fileName,
fileName: physicalName,
header: { FilePath: "idcard" },
formData: { side: "auto" },
});
const info = result?.Data ?? result?.data ?? result;
const { text, name } = buildRemark(info);
if (!text) {
uni.showToast({ title: t("IdentityVerificationPage.noResult"), icon: "none" });
return;
}
remark.value = text;
recognizedName.value = name;
await save();
}
catch (error) {
console.error(error);
uni.showToast({ title: t("IdentityVerificationPage.failed"), icon: "none" });
}
finally {
uploading.value = false;
}
},
});
}
/** 提交认证:昵称取身份证姓名(识别不到则沿用当前昵称),UserRemark 整体覆盖为识别信息 */
async function save(): Promise<void> {
try {
saving.value = true;
const payload = new SecurityUserDisplayData();
payload.UserLocalName = recognizedName.value || loginProfileStore.userLocalName || "";
payload.UserRemark = remark.value;
await securityUserService.UpdateSecurityUserIdCard(payload);
// 后端已将 UserType 置为 NORMAL_USERS、昵称改为身份证姓名;
// 登录快照是登录时的旧值,这里同步本地态,返回「我的」即可立即显示「已认证」
loginProfileStore.loginProfile.UserType = NORMAL_USERS;
if (payload.UserLocalName) {
loginProfileStore.loginProfile.UserLocalName = payload.UserLocalName;
}
uni.showToast({ title: t("IdentityVerificationPage.success"), icon: "none" });
// 成功后短延时返回「我的」,让 toast 露出
setTimeout(() => uni.navigateBack(), 800);
}
finally {
saving.value = false;
}
}
</script>
<template>
<app-page :hide-nav="true">
<view class="idv-page" :style="[{ background: themeBackground }, themeVars]">
<hero-navbar :title="t('IdentityVerificationPage.title')" @back="onBack" />
<view class="idv-body">
<!-- 已认证:仅展示状态,隐藏上传入口,禁止重复认证 -->
<view v-if="isVerified" class="idv-card idv-card--verified">
<u-icon name="checkmark-circle-fill" size="80" color="#52c41a" />
<text class="idv-verified-text">
{{ t("IdentityVerificationPage.verifiedDone") }}
</text>
</view>
<template v-else>
<!-- 说明卡 -->
<view class="idv-card">
<text class="idv-title">
{{ t("IdentityVerificationPage.title") }}
</text>
<text class="idv-intro">
{{ t("IdentityVerificationPage.intro") }}
</text>
</view>
<!-- 上传 + 预览卡 -->
<view class="idv-card">
<view class="idv-thumb">
<image v-if="imageUrl" class="idv-thumb__img" :src="imageUrl" mode="aspectFill" />
<view v-else class="idv-thumb__empty">
<u-icon name="camera" size="44" color="#c4c8cf" />
<text class="idv-thumb__empty-text">
{{ t("IdentityVerificationPage.uploadBut") }}
</text>
</view>
<view v-if="uploading" class="idv-thumb__mask">
<text class="idv-thumb__mask-text">
{{ t("IdentityVerificationPage.uploadingTip") }}
</text>
</view>
</view>
<u-button type="primary" shape="circle" :loading="busy" :disabled="busy" :text="buttonText"
@click="onUploadIdCard" />
</view>
<!-- 识别结果预览(保存后可见) -->
<view v-if="remark" class="idv-card">
<text class="idv-subtitle">
{{ t("IdentityVerificationPage.previewTitle") }}
</text>
<text class="idv-remark">
{{ remark }}
</text>
<text class="idv-tip">
{{ t("IdentityVerificationPage.reloginTip") }}
</text>
</view>
</template>
</view>
</view>
</app-page>
</template>
<style lang="scss" scoped>
.idv-page {
display: flex;
flex-direction: column;
box-sizing: border-box;
min-height: 100vh;
}
.idv-body {
display: flex;
flex: 1;
flex-direction: column;
gap: 24rpx;
padding: 0 24rpx 24rpx;
}
.idv-card {
display: flex;
flex-direction: column;
gap: 24rpx;
padding: 32rpx;
border-radius: 24rpx;
background-color: #ffffff;
box-shadow: 0 4rpx 16rpx rgba(31, 35, 41, 0.06);
}
.idv-title {
font-size: 32rpx;
font-weight: 600;
color: #1f2329;
}
.idv-subtitle {
font-size: 28rpx;
font-weight: 600;
color: #1f2329;
}
.idv-intro {
font-size: 24rpx;
line-height: 1.6;
color: #9aa0a6;
}
.idv-thumb {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 320rpx;
overflow: hidden;
border: 2rpx dashed #d6d9de;
border-radius: 16rpx;
background-color: #f5f6f8;
}
.idv-thumb__img {
width: 100%;
height: 100%;
}
.idv-thumb__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.idv-thumb__empty-text {
font-size: 26rpx;
color: #9aa0a6;
}
.idv-thumb__mask {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.35);
}
.idv-thumb__mask-text {
font-size: 24rpx;
color: #ffffff;
}
.idv-remark {
font-size: 26rpx;
line-height: 1.7;
color: #1f2329;
white-space: pre-wrap;
}
.idv-tip {
font-size: 22rpx;
color: #9aa0a6;
}
/* 已认证态卡:图标 + 文案居中,绿字与「我的」徒章同色系 */
.idv-card--verified {
align-items: center;
gap: 24rpx;
padding: 64rpx 32rpx;
}
.idv-verified-text {
font-size: 28rpx;
font-weight: 600;
color: #52c41a;
}
</style>
+55 -4
View File
@@ -28,6 +28,9 @@ const loginProfileStore = useLoginProfileStore();
const { can } = usePermission();
const canSeeLoginAgent = computed(() => can("none", "SecurityUser", "LoginAgent"));
/* 「已认证」判定:与后端 UpdateSecurityUserIdCard 口径一致——UserType=NORMAL_USERS("N") 即视为已认证(身份验证成功后本地快照同步) */
const isVerified = computed(() => loginProfileStore.userType === "N");
/* 统一风格:主题渐变底 + 主题色变量(与 family-person-enquiry / 族谱首页同一接线口径) */
const { themeBackground } = useThemeGradient();
const { primary, accentGradient, accentSoft, accentShadow } = useThemeTokens();
@@ -63,11 +66,22 @@ function goChangePassword(): void {
uni.navigateTo({ url: pageUrl("changePassword") });
}
/** 进入「修改昵称」页(/change-local-name):改用户名,成功后按旧系统约定退出重登刷新快照 */
/** 进入「修改昵称」页(/change-local-name):改用户名,成功后按旧系统约定退出重登刷新快照;已认证用户昵称锁定为身份证姓名,单击无效(改由右侧直接展示昵称) */
function goChangeLocalName(): void {
if (isVerified.value) {
return;
}
uni.navigateTo({ url: pageUrl("changeLocalName") });
}
/** 进入「身份验证」页(/identity-verification):上传身份证 OCR 后把信息写入备注并置认证态;已认证后不再重复认证,单击无效 */
function goIdentityVerification(): void {
if (isVerified.value) {
return;
}
uni.navigateTo({ url: pageUrl("identityVerification") });
}
/** 退出登录:确认后调后端 Logout、清本地档案,回登录页 */
async function handleLogout(): Promise<void> {
const confirmed = await new Promise<boolean>((resolve) => {
@@ -156,15 +170,32 @@ async function handleLogout(): Promise<void> {
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 修改昵称:改用户名,成功后退出重登刷新快照(/change-local-name) -->
<!-- 修改昵称:改用户名(/change-local-name);已认证则锁定、改名为「认证昵称」并直接展示昵称 -->
<view class="mine-cell" @tap="goChangeLocalName">
<view class="mine-cell__icon">
<u-icon name="edit-pen-fill" size="30" color="#ffffff" />
</view>
<text class="mine-cell__title">
{{ t("MinePage.changeLocalName") }}
{{ isVerified ? t("MinePage.verifiedLocalName") : t("MinePage.changeLocalName") }}
</text>
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
<text v-if="isVerified" class="mine-cell__value">
{{ loginProfileStore.userLocalName }}
</text>
<u-icon v-else name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 身份验证:上传身份证 OCR 后写入备注并置认证态(/identity-verification);已认证则右侧换为徽标 -->
<view class="mine-cell" @tap="goIdentityVerification">
<view class="mine-cell__icon">
<u-icon name="checkmark-circle-fill" size="30" color="#ffffff" />
</view>
<text class="mine-cell__title">
{{ t("MinePage.identityVerification") }}
</text>
<text v-if="isVerified" class="mine-cell__tag">
{{ t("MinePage.verified") }}
</text>
<u-icon v-else name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 退出登录:确认后清会话回登录页 -->
@@ -272,6 +303,26 @@ async function handleLogout(): Promise<void> {
color: #1f2329;
}
/* 已认证徽标:替换右箭头,绿色描边胶囊,与主题橙入口区分状态语义 */
.mine-cell__tag {
padding: 6rpx 20rpx;
border: 2rpx solid #52c41a;
border-radius: 999rpx;
font-size: 24rpx;
font-weight: 600;
color: #52c41a;
}
/* 已认证锁定的展示值(如昵称):右对齐灰字省略,替代不可点的箭头 */
.mine-cell__value {
max-width: 60%;
overflow: hidden;
font-size: 28rpx;
color: #8f959e;
white-space: nowrap;
text-overflow: ellipsis;
}
/* 退出登录:降权为描边行,标题居中、危险色,与普通入口区分 */
.mine-cell--logout {
justify-content: center;
+2
View File
@@ -19,6 +19,8 @@ export const PAGE_PATHS = {
changePassword: "pages/login/change-password",
// 修改昵称(短地址 /change-local-name):需登录,从「我的」页跳入,成功后按旧系统约定退出重登刷新快照
changeLocalName: "pages/login/change-local-name",
// 身份验证(短地址 /identity-verification):从「我的」跳入,上传身份证 OCR 后写入 UserRemark 并置认证态
identityVerification: "pages/login/identity-verification",
dashboard: "pages/dashboard/dashboard",
error401: "pages/error/page401",
error403: "pages/error/page403",
@@ -5,6 +5,7 @@ import { request,download } from "@/utils/request";
import type { SecurityUserSearchCriteria } from "@/data/auto/securityUserSearchCriteria";
import type { SecurityUserResultData } from "@/data/auto/securityUserResultData";
import type { SecurityFunctionResultData } from "@/data/auto/securityFunctionResultData";
import type { SecurityUserDisplayData } from "@/data/auto/securityUserDisplayData";
class SecurityUserService{
@@ -117,6 +118,16 @@ class SecurityUserService{
)
};
async UpdateSecurityUserIdCard(data: SecurityUserDisplayData) {
return request<any>(
{
url: "/SecurityUser/UpdateSecurityUserIdCard",
method: "post",
data,
}
)
};
}
const securityUserService= new SecurityUserService();