From aff58692bb5addf807cc1374ec3569346ad13ad5 Mon Sep 17 00:00:00 2001 From: owenchen Date: Sat, 17 Oct 2026 13:22:10 +0800 Subject: [PATCH] ow --- uniapp/youjiala/src/common/brand.ts | 30 ++ .../youjiala/src/common/http.interceptor.ts | 127 +++++-- uniapp/youjiala/src/common/pwd-crypto.ts | 12 + .../src/components/admin-form/admin-form.vue | 27 +- .../family-tree-home/family-tree-home.vue | 284 ++++++++++++++-- uniapp/youjiala/src/locale/lang/en-US.json | 72 +++- uniapp/youjiala/src/locale/lang/zh-CN.json | 72 +++- uniapp/youjiala/src/manifest.json | 2 +- uniapp/youjiala/src/pages.json | 14 + .../src/pages/login/change-local-name.vue | 90 +++++ .../src/pages/login/change-password.vue | 108 ++++++ .../youjiala/src/pages/login/login-agent.vue | 267 +++++++++++++++ uniapp/youjiala/src/pages/login/login.vue | 248 +++++++++++--- uniapp/youjiala/src/pages/login/register.vue | 309 ++++++++++++++++++ .../security/family-person-operation.vue | 131 ++++---- uniapp/youjiala/src/pages/tab/mine.vue | 274 ++++++++++++---- uniapp/youjiala/src/router/routes.ts | 24 +- 17 files changed, 1851 insertions(+), 240 deletions(-) create mode 100644 uniapp/youjiala/src/common/brand.ts create mode 100644 uniapp/youjiala/src/pages/login/change-local-name.vue create mode 100644 uniapp/youjiala/src/pages/login/change-password.vue create mode 100644 uniapp/youjiala/src/pages/login/login-agent.vue create mode 100644 uniapp/youjiala/src/pages/login/register.vue diff --git a/uniapp/youjiala/src/common/brand.ts b/uniapp/youjiala/src/common/brand.ts new file mode 100644 index 0000000..e2681fa --- /dev/null +++ b/uniapp/youjiala/src/common/brand.ts @@ -0,0 +1,30 @@ +/** + * 品牌字形:登录域各页(登录 / 注册)共用的叶脉树 Logo 与背景暗纹。 + * uview 图标集无树形,故用内联 SVG data URI(跨端安全,套路同 family-tree-home 的 treeGlyph)。 + */ + +/** 品牌标:叶脉树字形,白色描边,置于黛翠绿圆角方块内 */ +export const brandGlyph = `data:image/svg+xml,${encodeURIComponent( + [ + "", + "", + "", + "", + "", + "", + "", + ].join(""), +)}`; + +/** 背景右上淡叶脉暗纹:主脉分叉造型,绿色低透明,纯装饰 */ +export const veinGlyph = `data:image/svg+xml,${encodeURIComponent( + [ + "", + "", + "", + "", + "", + "", + "", + ].join(""), +)}`; diff --git a/uniapp/youjiala/src/common/http.interceptor.ts b/uniapp/youjiala/src/common/http.interceptor.ts index 5330810..1d22e02 100644 --- a/uniapp/youjiala/src/common/http.interceptor.ts +++ b/uniapp/youjiala/src/common/http.interceptor.ts @@ -141,14 +141,23 @@ function doRefreshToken(): Promise { data, timeout, success: (res) => { - if (res.statusCode >= 200 && res.statusCode < 300 && res.data) { - const jwt = res.data as JwtInfo; - setLoginJwt(jwt); - resolve(jwt); + // 后端「无效的令牌」以 HTTP 200 + 异常体返回(.NET ServiceErrorException), + // 只查状态码会把异常对象当新令牌存下去——刷新"成功"但令牌仍是坏的, + // 之后每个请求都 401→刷新→401 死循环,永远回不到登录页(异地登录踢人后实测根因)。 + // 故必须验体:非 2xx、异常体、缺 AccessToken 一律 reject(带 status 供 goLogin 清态跳转) + const jwt = res.data as JwtInfo | null; + const ok = res.statusCode >= 200 && res.statusCode < 300 + && !!jwt + && typeof jwt === "object" + && !(jwt as any).SourceCode + && !!jwt.AccessToken; + if (ok) { + setLoginJwt(jwt as JwtInfo); + resolve(jwt as JwtInfo); } else { // 带上 status:goLogin 据此区分「服务端拒绝」与「网络抖动」 - const err: any = new Error("刷新令牌失败"); + const err: any = new Error(extractErrorMessage(res.data) || "刷新令牌失败"); err.status = res.statusCode; reject(err); } @@ -343,6 +352,12 @@ const httpInterceptor: RequestInterceptor = { goForbidden(); handled = true; // 已跳 403 页并列出原因,边界不再重复提示 } + // 刷新并重试后仍 401:新令牌也不被接受(如服务端会话被吊销), + // 再留在原地只会让每个请求都 401 循环,清态回登录页是唯一出路 + if (statusCode === 401 && config._retry) { + goLogin(true); + handled = true; + } // 异常体优先:.NET 后端常以 500 + JSON 异常返回业务错误 const bizMsg = detectBusinessException(rawData); if (bizMsg) @@ -376,16 +391,95 @@ export interface UploadOptions { filePath: string; /** 文件对应的 form-key,后端按此名接收(默认 'file') */ name?: string; + /** 显式指定 multipart filename(后端物理落盘名):缺省自动生成单片三段式 uni-时间戳-1-1.ext */ + fileName?: string; formData?: Record; header?: Record; } -/** 上传文件(uni.uploadFile 封装,自动携带 token,失败 reject 可感知) */ +/** 上传响应统一裁决(两通道共用):非 2xx / 业务异常都走「已通知」reject */ +function settleUpload(statusCode: number, rawData: any, resolve: (v: any) => void, reject: (e: any) => void): void { + if (statusCode < 200 || statusCode >= 300) { + const message = `上传失败[${statusCode}]`; + notifyError("", message); + reject(markErrorNotified(new Error(message))); + return; + } + let data: any = rawData; + try { + data = typeof rawData === "string" ? JSON.parse(rawData) : rawData; + } + catch { + /* 非 JSON 响应保持原样 */ + } + const bizMsg = detectBusinessException(data); + if (bizMsg) + reject(markErrorNotified(new Error(bizMsg))); + else resolve(data); +} + +/** 后端分片协议的 multipart filename:单片固定 `-1-1`,保留原扩展名(无扩展给 .ext 兜底)。 + * 页面需要把物理名存库时(如头像 AvatarFilename)也用它生成,保证与后端落盘名一致 */ +export function buildChunkFileName(fileName: string): string { + const dot = fileName.lastIndexOf("."); + const ext = dot > 0 ? fileName.slice(dot) : ".ext"; + return `uni-${Date.now()}-1-1${ext}`; +} + +// #ifdef H5 +/** H5 上传通道:XHR + 手写 FormData(与文件页上传队列同一协议口径) */ +function xhrUploadFile( + fullUrl: string, + filePath: string, + name: string, + physicalName: string, + formData: Record | undefined, + header: Record, + auth: string, +): Promise { + return new Promise((resolve, reject) => { + const onFail = (): void => { + notifyError("", "上传失败"); + reject(markErrorNotified(new Error("上传失败"))); + }; + // chooseImage 的 tempFilePath 是 blob URL,先取回 File 再按协议名拼包 + fetch(filePath) + .then(r => r.blob()) + .then((blob) => { + const form = new FormData(); + form.append(name, blob, physicalName); + Object.entries(formData ?? {}).forEach(([k, v]) => form.append(k, String(v))); + const xhr = new XMLHttpRequest(); + xhr.open("POST", fullUrl); + xhr.timeout = timeout; + if (auth) { + xhr.setRequestHeader("Authorization", auth); + } + Object.entries(header).forEach(([k, v]) => xhr.setRequestHeader(k, String(v))); + xhr.onload = () => settleUpload(xhr.status, xhr.responseText, resolve, reject); + xhr.onerror = onFail; + xhr.ontimeout = onFail; + xhr.send(form); + }) + .catch(onFail); + }); +} +// #endif + +/** 上传文件(自动携带 token,失败 reject 可感知)。 + * 后端 PackUploadDmsFile 对 multipart filename 执行 Split('-') 取下标,要求 `uid-序号-总数.ext` + * 三段式(单片 -1-1),否则抛「Index was outside the bounds of the array」;而 uni.uploadFile + * 无法定制 filename,故 H5 改走 XHR + 手写 FormData(与文件页上传队列同款),App 端仍用 uni.uploadFile。 */ export function uploadFile(options: UploadOptions): Promise { - const { url, filePath, name = "file", formData, header = {} } = options; + const { url, filePath, name = "file", fileName, formData, header = {} } = options; const fullUrl = url.indexOf("http") === 0 ? url : baseUrl + url; const auth = bearerAuth(); + // #ifdef H5 + return xhrUploadFile(fullUrl, filePath, name, fileName || buildChunkFileName(name), formData, header, auth); + // #endif + + // #ifndef H5 return new Promise((resolve, reject) => { uni.uploadFile({ url: fullUrl, @@ -395,23 +489,7 @@ export function uploadFile(options: UploadOptions): Promise { timeout, header: auth ? { Authorization: auth, ...header } : header, success: (res) => { - if (res.statusCode < 200 || res.statusCode >= 300) { - const message = `上传失败[${res.statusCode}]`; - notifyError("", message); - reject(markErrorNotified(new Error(message))); - return; - } - let data: any = res.data; - try { - data = typeof res.data === "string" ? JSON.parse(res.data) : res.data; - } - catch { - /* 非 JSON 响应保持原样 */ - } - const bizMsg = detectBusinessException(data); - if (bizMsg) - reject(markErrorNotified(new Error(bizMsg))); - else resolve(data); + settleUpload(res.statusCode, res.data, resolve, reject); }, fail: (err: any) => { notifyError(err.errMsg ?? "", "上传失败"); @@ -419,6 +497,7 @@ export function uploadFile(options: UploadOptions): Promise { }, }); }); + // #endif } export interface DownloadOptions { diff --git a/uniapp/youjiala/src/common/pwd-crypto.ts b/uniapp/youjiala/src/common/pwd-crypto.ts index c915b4f..33c1e01 100644 --- a/uniapp/youjiala/src/common/pwd-crypto.ts +++ b/uniapp/youjiala/src/common/pwd-crypto.ts @@ -10,6 +10,18 @@ export function md5Hex(text: string): string { return CryptoJS.MD5(text).toString(); } +/** + * 生成 v4 风格 Guid(无需引入 uuid 包,与旧系统 uuidv4() 等效) + * 登录/注册/改密都要它作防重放前缀,统一放这里避免各页重复实现 + */ +export function makeGuid(): string { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + /** * RSA 公钥加密(PKCS#1 v1.5),返回 base64 密文 * @param plaintext 待加密明文 diff --git a/uniapp/youjiala/src/components/admin-form/admin-form.vue b/uniapp/youjiala/src/components/admin-form/admin-form.vue index 19d2572..e1c3201 100644 --- a/uniapp/youjiala/src/components/admin-form/admin-form.vue +++ b/uniapp/youjiala/src/components/admin-form/admin-form.vue @@ -127,14 +127,19 @@ function onTapAction(action: AdminFormAction): void { - - {{ heroTitle || EMPTY_CELL_TEXT }} - - - - {{ chip }} + + + + + {{ heroTitle || EMPTY_CELL_TEXT }} + + + {{ chip }} + + + @@ -211,6 +216,10 @@ function onTapAction(action: AdminFormAction): void { } .hero { + display: flex; + flex-direction: row; + align-items: center; + gap: 24rpx; padding: 32rpx 28rpx; margin-bottom: 28rpx; color: #ffffff; @@ -219,6 +228,12 @@ function onTapAction(action: AdminFormAction): void { box-shadow: 0 8rpx 24rpx rgba(5, 150, 105, 0.22); } +/* 标题+chips 主列:吃掉剩余宽度,两侧插槽各自贴边 */ +.hero-main { + flex: 1; + min-width: 0; +} + .hero-title { font-size: 40rpx; font-weight: 700; diff --git a/uniapp/youjiala/src/components/family-tree-home/family-tree-home.vue b/uniapp/youjiala/src/components/family-tree-home/family-tree-home.vue index 1606619..ceab2db 100644 --- a/uniapp/youjiala/src/components/family-tree-home/family-tree-home.vue +++ b/uniapp/youjiala/src/components/family-tree-home/family-tree-home.vue @@ -113,9 +113,53 @@ async function onRetry(): Promise { const data = ref(new FamilyTreeData()); const tree = computed(() => data.value.FamilyTree); const currentPerson = computed(() => data.value.CurrentPerson?.FamilyPerson); +/** 当前族员显示名(旧系统同款:姓+谱名连写) */ +const currentPersonName = computed(() => { + const p = currentPerson.value; + return p ? `${p.Surname ?? ""}${p.GenealogyName ?? ""}` : ""; +}); const spouseMaps = computed(() => data.value.CurrentPerson?.FamilyTreeSpouseMaps ?? []); const childMaps = computed(() => data.value.CurrentPerson?.FamilyTreeChildrenMaps ?? []); +/** 关系行头像查表:Spouse/Children 视图字段不带头像,回查全族人员档案拿 AvatarUrl(谱系图 getAvatar 同款口径) */ +function personAvatar(personId?: string): string { + if (!personId) { + return ""; + } + const persons = (data.value.CurrentPerson?.FamilyTreePersons ?? []) as any[]; + return persons.find(p => p?.PersonId === personId)?.AvatarUrl ?? ""; +} + +/** 谱系图同款性别判定(family-tree-chart isMale 字面一致):空性别按男兜底 */ +function genderIsMale(gender?: string): boolean { + if (!gender) { + return true; + } + const label = getPolicyText("GenderPolicy", gender).toLowerCase(); + return label.includes("男") || label.includes("male") || gender === "M" || gender === "1"; +} + +/** 关系对方性别回查:关系视图的 Gender 是记录拥有者(丈夫/父亲)的字段(谱系图配色踩过的坑),优先拿对方档案真值 */ +function personGender(personId?: string, fallback?: string): string { + if (!personId) { + return fallback ?? ""; + } + const persons = (data.value.CurrentPerson?.FamilyTreePersons ?? []) as any[]; + return persons.find(p => p?.PersonId === personId)?.Gender || fallback || ""; +} + +/** 谱系图同款性别配色:男蓝 / 女粉 */ +function genderTheme(gender?: string): string { + return genderIsMale(gender) ? "#1890ff" : "#eb2f96"; +} + +/** 无头像时的占位圆样式:白底 + 性别同色描边(谱系图同款);有头像返回空不覆盖 */ +function avatarFallbackStyle(hasAvatar: boolean, gender?: string): Record { + return hasAvatar + ? {} + : { backgroundColor: "#ffffff", border: `3rpx solid ${genderTheme(gender)}` }; +} + const treeId = ref(""); const loaded = ref(false); const isEdit = ref(false); @@ -704,10 +748,10 @@ defineExpose({ - + - + {{ tree.TreeName || t("FamilyTreePage.unnamed") }} @@ -768,11 +812,13 @@ defineExpose({