This commit is contained in:
owenchen committed 2026-10-17 13:22:10 +08:00
1 parent b5d2a2ede0
commit aff58692bb
17 files changed
+1851 -240

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
/**
* 品牌字形:登录域各页(登录 / 注册)共用的叶脉树 Logo 与背景暗纹。
* uview 图标集无树形,故用内联 SVG data URI(跨端安全,套路同 family-tree-home 的 treeGlyph)。
*/
/** 品牌标:叶脉树字形,白色描边,置于黛翠绿圆角方块内 */
export const brandGlyph = `data:image/svg+xml,${encodeURIComponent(
[
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='none' stroke='#ffffff' stroke-width='2.4' stroke-linecap='round'>",
"<path d='M24 42V9'/>",
"<path d='M24 16 16 10'/><path d='M24 16 32 10'/>",
"<path d='M24 24 15 18'/><path d='M24 24 33 18'/>",
"<path d='M24 32 16 26'/><path d='M24 32 32 26'/>",
"<circle cx='24' cy='7' r='2.4' fill='#ffffff' stroke='none'/>",
"</svg>",
].join(""),
)}`;
/** 背景右上淡叶脉暗纹:主脉分叉造型,绿色低透明,纯装饰 */
export const veinGlyph = `data:image/svg+xml,${encodeURIComponent(
[
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200' fill='none' stroke='#2f7d5c' stroke-width='2' stroke-linecap='round'>",
"<path d='M150 10C120 60 110 120 120 190'/>",
"<path d='M138 45 100 30'/><path d='M132 75 88 62'/>",
"<path d='M127 105 82 95'/><path d='M124 135 84 128'/>",
"<path d='M138 45 168 60'/><path d='M132 75 165 92'/>",
"<path d='M127 105 160 124'/><path d='M124 135 154 156'/>",
"</svg>",
].join(""),
)}`;
+103 -24
View File
@@ -141,14 +141,23 @@ function doRefreshToken(): Promise<JwtInfo> {
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<string, any>;
header?: Record<string, any>;
}
/** 上传文件(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<string, any> | undefined,
header: Record<string, any>,
auth: string,
): Promise<any> {
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<any> {
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<any> {
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<any> {
},
});
});
// #endif
}
export interface DownloadOptions {
+12
View File
@@ -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 待加密明文
@@ -127,14 +127,19 @@ function onTapAction(action: AdminFormAction): void {
<scroll-view class="form-body" scroll-y :show-scrollbar="false">
<view class="hero">
<text class="hero-title">
{{ heroTitle || EMPTY_CELL_TEXT }}
</text>
<view v-if="heroChips.length" class="hero-chips">
<text v-for="chip in heroChips" :key="chip" class="hero-chip">
{{ chip }}
<!-- 头部左右侧插槽:如成员详情的头像(hero-side)与清除头像(hero-end),不传则不占位 -->
<slot name="hero-side" :readonly="readonly" />
<view class="hero-main">
<text class="hero-title">
{{ heroTitle || EMPTY_CELL_TEXT }}
</text>
<view v-if="heroChips.length" class="hero-chips">
<text v-for="chip in heroChips" :key="chip" class="hero-chip">
{{ chip }}
</text>
</view>
</view>
<slot name="hero-end" :readonly="readonly" />
</view>
<view v-for="section in sections" :key="section.title" class="group">
@@ -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;
@@ -113,9 +113,53 @@ async function onRetry(): Promise<void> {
const data = ref<FamilyTreeData>(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<string, string> {
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({
</view>
<view class="tree-page__scroll-wrap">
<scroll-view scroll-y class="tree-page__scroll">
<!-- 头卡(与媒体/共享页同套):白底 + 主题浅底图标 + 编号灰字独立行 -->
<!-- 头卡(与媒体/共享页同套):白底 + 族谱树图标(与列表卡同款渐变底白树形) + 编号灰字独立行 -->
<view class="detail-hero">
<view class="detail-hero__avatar">
<u-icon name="account-fill" size="64" :color="primary" />
<image class="detail-hero__glyph" :src="treeGlyph" mode="aspectFit" />
</view>
<view class="detail-hero__main">
<text class="detail-hero__name">{{ tree.TreeName || t("FamilyTreePage.unnamed") }}</text>
@@ -768,11 +812,13 @@ defineExpose({
<textarea v-model="tree.Brief" class="detail-field__textarea" :disabled="!isEdit" :maxlength="1000"
:placeholder="t('FamilyTreePage.noBrief')" />
</view>
<!-- 封面文件暂时不用,先隐藏(保留代码便于日后恢复)
<view class="detail-field">
<text class="detail-field__label">{{ t("FamilyTreePage.treeCover") }}</text>
<input v-model="tree.TreeCoverFilename" class="detail-field__input" :disabled="!isEdit"
:maxlength="255">
</view>
-->
<view class="detail-field detail-field--row">
<text class="detail-field__label">{{ t("FamilyTreePage.publicIndc") }}</text>
<u-switch :model-value="tree.PublicIndc === 'Y'" :disabled="!isEdit" :active-color="primary"
@@ -795,17 +841,28 @@ defineExpose({
<u-icon :name="folded.person ? 'arrow-right' : 'arrow-down'" size="26" color="#8f959e" />
</view>
<view v-show="!folded.person">
<view class="detail-field detail-field--row">
<text class="detail-field__label">{{ t("FamilyTreePage.gender") }}</text>
<text class="detail-field__value">{{ genderText(currentPerson?.Gender) || "-" }}</text>
</view>
<view class="detail-field detail-field--row">
<text class="detail-field__label">{{ t("FamilyTreePage.genealogyName") }}</text>
<text class="detail-field__value">{{ currentPerson?.GenealogyName || "-" }}</text>
</view>
<view class="detail-field detail-field--row">
<text class="detail-field__label">{{ t("FamilyTreePage.commonName") }}</text>
<text class="detail-field__value">{{ currentPerson?.CommonName || "-" }}</text>
<!-- 头像行(旧系统同款):圆头像 + 名字带性别 + 编号芯片与常用名,无头像时灰底人形占位 -->
<view class="person-hero">
<view class="person-hero__avatar"
:style="avatarFallbackStyle(!!currentPerson?.AvatarUrl, currentPerson?.Gender)">
<image v-if="currentPerson?.AvatarUrl" class="person-hero__img" :src="currentPerson.AvatarUrl"
mode="aspectFill" />
<u-icon v-else name="account" size="56" :color="genderTheme(currentPerson?.Gender)" />
</view>
<view class="person-hero__main">
<view class="person-hero__line">
<text class="person-hero__name">{{ currentPersonName || "-" }}</text>
<text class="person-hero__gender">{{ genderText(currentPerson?.Gender) || "-" }}</text>
</view>
<view class="person-hero__chips">
<text v-if="currentPerson?.PersonId" class="person-hero__chip">
<text v-if="currentPerson?.CommonName" class="person-hero__chip-name">
{{ currentPerson.CommonName }}
</text>
{{ `${t("FamilyPersonPage.personId")}: ${currentPerson.PersonId}` }}
</text>
</view>
</view>
</view>
<view class="op-btns">
<view class="op-btn" @tap="pickCurrentPerson">
@@ -853,9 +910,28 @@ defineExpose({
</view>
<view v-for="(record, index) in spouseMaps" :key="record.SpouseId" class="rel-item">
<view class="rel-head" @tap="expandedSpouses[record.SpouseId] = !expandedSpouses[record.SpouseId]">
<!-- 当前族员同款:圆头像 + 名字带性别 + 芯片(常用名值+成员编号) -->
<view class="rel-avatar"
:style="avatarFallbackStyle(!!personAvatar(record.SpouseId), personGender(record.SpouseId, record.Gender))">
<image v-if="personAvatar(record.SpouseId)" class="rel-avatar__img"
:src="personAvatar(record.SpouseId)" mode="aspectFill" />
<u-icon v-else name="account" size="40"
:color="genderTheme(personGender(record.SpouseId, record.Gender))" />
</view>
<view class="rel-text">
<text class="rel-name">{{ record.SpouseSurname }}{{ record.SpouseGenealogyName }}</text>
<text class="rel-sub">{{ genderText(record.Gender) }}</text>
<view class="person-hero__line">
<text class="rel-name">{{ record.SpouseSurname }}{{ record.SpouseGenealogyName }}</text>
<text class="person-hero__gender">{{ genderText(personGender(record.SpouseId, record.Gender))
}}</text>
</view>
<view class="person-hero__chips">
<text class="person-hero__chip">
<text v-if="record.SpouseCommonName" class="person-hero__chip-name">
{{ record.SpouseCommonName }}
</text>
{{ `${t("FamilyPersonPage.personId")}: ${record.SpouseId}` }}
</text>
</view>
</view>
<view class="rel-ops">
<template v-if="isEdit">
@@ -971,9 +1047,28 @@ defineExpose({
<view v-for="(record, index) in childMaps" :key="record.ChildrenId" class="rel-item">
<view class="rel-head"
@tap="expandedChildren[record.ChildrenId] = !expandedChildren[record.ChildrenId]">
<!-- 当前族员同款:圆头像 + 名字带性别 + 芯片(常用名值+成员编号) -->
<view class="rel-avatar"
:style="avatarFallbackStyle(!!personAvatar(record.ChildrenId), personGender(record.ChildrenId, record.Gender))">
<image v-if="personAvatar(record.ChildrenId)" class="rel-avatar__img"
:src="personAvatar(record.ChildrenId)" mode="aspectFill" />
<u-icon v-else name="account" size="40"
:color="genderTheme(personGender(record.ChildrenId, record.Gender))" />
</view>
<view class="rel-text">
<text class="rel-name">{{ record.ChildrenSurname }}{{ record.ChildrenGenealogyName }}</text>
<text class="rel-sub">{{ genderText(record.Gender) }}</text>
<view class="person-hero__line">
<text class="rel-name">{{ record.ChildrenSurname }}{{ record.ChildrenGenealogyName }}</text>
<text class="person-hero__gender">{{ genderText(personGender(record.ChildrenId, record.Gender))
}}</text>
</view>
<view class="person-hero__chips">
<text class="person-hero__chip">
<text v-if="record.ChildrenCommonName" class="person-hero__chip-name">
{{ record.ChildrenCommonName }}
</text>
{{ `${t("FamilyPersonPage.personId")}: ${record.ChildrenId}` }}
</text>
</view>
</view>
<view class="rel-ops">
<template v-if="isEdit">
@@ -1035,7 +1130,10 @@ defineExpose({
<u-icon :name="folded.perms ? 'arrow-right' : 'arrow-down'" size="26" color="#8f959e" />
</view>
<view v-show="!folded.perms">
<text class="perm-section-label">{{ t("FamilyTreePage.permPending") }}</text>
<view class="perm-section-head">
<text class="perm-section-label">{{ t("FamilyTreePage.permPending") }}</text>
<text class="perm-section-count">{{ pendingPermissions.length }}</text>
</view>
<view v-if="!pendingPermissions.length" class="detail-empty">
<text>{{ t("FamilyTreePage.permEmpty") }}</text>
</view>
@@ -1057,7 +1155,11 @@ defineExpose({
</view>
</view>
<text class="perm-section-label">{{ t("FamilyTreePage.permManager") }}</text>
<view class="perm-divider" />
<view class="perm-section-head">
<text class="perm-section-label">{{ t("FamilyTreePage.permManager") }}</text>
<text class="perm-section-count">{{ managerPermissions.length }}</text>
</view>
<view v-if="!managerPermissions.length" class="detail-empty">
<text>{{ t("FamilyTreePage.permEmpty") }}</text>
</view>
@@ -1358,10 +1460,95 @@ defineExpose({
width: 140rpx;
height: 140rpx;
border-radius: 32rpx;
background-color: var(--fg-soft, #eaf4ff);
background: var(--fg-gradient, linear-gradient(135deg, #4a9dff, #2b8fff));
flex-shrink: 0;
}
/* 树形字形(与列表卡同款内联 SVG data-URI) */
.detail-hero__glyph {
width: 84rpx;
height: 84rpx;
}
/* 当前族员头像行(旧系统 hero 同款):圆头像 + 名字 + 编号芯片 */
.person-hero {
display: flex;
flex-direction: row;
align-items: center;
gap: 24rpx;
/* 上距标题 24rpx;下距分隔线由 .op-btns 的 margin-top(24rpx) 提供,两侧视觉等距 */
padding: 24rpx 0 0;
}
.person-hero__avatar {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 112rpx;
height: 112rpx;
overflow: hidden;
background-color: var(--fg-soft, #eaf4ff);
border: 4rpx solid #ffffff;
border-radius: 50%;
box-shadow: 0 4rpx 14rpx rgba(31, 35, 41, 0.12);
}
.person-hero__img {
width: 100%;
height: 100%;
}
.person-hero__main {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 10rpx;
}
.person-hero__line {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 16rpx;
}
.person-hero__name {
font-size: 32rpx;
font-weight: 700;
color: #1f2329;
}
.person-hero__gender {
font-size: 26rpx;
color: #8f959e;
}
.person-hero__chips {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 12rpx;
}
.person-hero__chip {
padding: 4rpx 18rpx;
font-size: 22rpx;
color: var(--fg-primary, #2b8fff);
background-color: var(--fg-soft, #eaf4ff);
border-radius: 10rpx;
}
/* 芯片内的常用名值:排在编号前,同芯片浅底、深色字(旧系统同款合并位) */
.person-hero__chip-name {
margin-right: 24rpx;
font-size: 24rpx;
color: #1f2329;
}
.detail-hero__main {
display: flex;
flex: 1;
@@ -1594,12 +1781,33 @@ defineExpose({
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
/* 关系行圆头像(当前族员同款缩小一号):档案无头像时灰底人形占位 */
.rel-avatar {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 80rpx;
height: 80rpx;
overflow: hidden;
background-color: var(--fg-soft, #eaf4ff);
border-radius: 50%;
}
.rel-avatar__img {
width: 100%;
height: 100%;
}
.rel-text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
gap: 8rpx;
}
.rel-name {
@@ -1680,11 +1888,39 @@ defineExpose({
}
/* ===== 权限维护行 ===== */
/* 子标题分组头:加深字色 + 加粗 + 计数徽章,让分区标题清晰可辨(对齐参照图) */
.perm-section-head {
display: flex;
flex-direction: row;
align-items: center;
gap: 12rpx;
padding: 28rpx 0 8rpx;
}
/* 第二分区(管理名单)上方的通栏分隔带:负外边距 bleed 到卡片左右边,色带用主题软底把白卡横向"切开";
加 border-radius 让色带两端收圆角,圆角处透出白卡底,两分区过渡更柔和 */
.perm-divider {
height: 16rpx;
margin: 28rpx -28rpx 0;
background-color: var(--fg-soft, #f6efe8);
border-radius: 16rpx;
}
.perm-section-label {
display: block;
padding: 24rpx 0 4rpx;
font-size: 24rpx;
color: #8f959e;
font-size: 28rpx;
font-weight: 600;
color: #1f2329;
}
.perm-section-count {
min-width: 34rpx;
padding: 0 10rpx;
font-size: 22rpx;
line-height: 34rpx;
color: var(--fg-primary, #2b8fff);
text-align: center;
background-color: var(--fg-soft, #eaf4ff);
border-radius: 17rpx;
}
.perm-row {
+66 -6
View File
@@ -1,11 +1,16 @@
{
"name": "en-US",
"LoginForm": {
"userLabel": "User ID:",
"passwordLabel": "Password:",
"userLabel": "Username",
"passwordLabel": "Password",
"loginBut": "Login",
"cancelBut": "Cancel",
"cultureLabel": "Language:"
"cultureLabel": "Language: ",
"registerTip": "No account yet? ",
"registerBut": "Sign up now",
"brandName": "Family Tree",
"slogan": "One tree, generations of us",
"langZh": "简体中文",
"langEn": "English"
},
"TabBar": {
"home": "Home",
@@ -20,12 +25,69 @@
"mySecurity": "Security",
"familyTree": "Family Trees",
"familyPerson": "Family Members",
"loginAgent": "Agent Login",
"changePassword": "Change Password",
"changeLocalName": "Change Nickname",
"logout": "Log Out",
"logoutConfirmTitle": "Log Out",
"logoutConfirmContent": "Are you sure you want to log out?",
"logoutSuccess": "Logged out",
"currentTheme": "Current Theme",
"aboutTitle": "About",
"appName": "App",
"versionLabel": "Version",
"platformLabel": "Platform"
},
"RegisterPage": {
"title": "Register",
"mobileLabel": "Mobile",
"passwordLabel": "Password",
"confirmLabel": "Confirm password",
"registerBut": "Register",
"loginBut": "Back to login",
"mobileRequired": "Please enter your mobile",
"passwordRequired": "Please enter your password",
"confirmRequired": "Please confirm your password",
"passwordMismatch": "The two passwords do not match",
"success": "Registered, please sign in",
"agreementPrefix": "By registering you agree to the ",
"agreementAnd": " and ",
"userAgreement": "User Agreement",
"privacyPolicy": "Privacy Policy",
"policyTodo": "Agreement content coming soon"
},
"LoginAgentPage": {
"title": "Agent Login",
"agentLabel": "Agent user ID",
"mobileLabel": "Mobile",
"passwordLabel": "Password",
"submitBut": "Agent login",
"backBut": "Back",
"agentRequired": "Please enter the agent user ID",
"mobileRequired": "Please enter your mobile",
"passwordRequired": "Please enter your password",
"success": "Signed in as agent"
},
"ChangePasswordPage": {
"title": "Change Password",
"oldLabel": "Current password",
"newLabel": "New password",
"confirmLabel": "Confirm new password",
"submitBut": "Confirm",
"oldRequired": "Please enter your current password",
"newRequired": "Please enter a new password",
"confirmRequired": "Please confirm the new password",
"passwordMismatch": "The two passwords do not match",
"success": "Password changed"
},
"ChangeLocalNamePage": {
"title": "Change Nickname",
"nameLabel": "User name",
"submitBut": "Confirm",
"nameRequired": "Please enter a user name",
"nameTooShort": "User name must be at least 2 characters",
"success": "Updated, please log in again"
},
"ThemePage": {
"title": "My Theme",
"themeTitle": "Theme Color",
@@ -164,8 +226,6 @@
"introduction": "Biography",
"yes": "Yes",
"no": "No",
"avatarTitle": "Avatar",
"uploadAvatar": "Upload Avatar",
"clearAvatar": "Clear Avatar",
"uploadingTip": "Uploading…",
"avatarUploadedTip": "Avatar uploaded",
+66 -6
View File
@@ -1,11 +1,16 @@
{
"name": "zh-CN",
"LoginForm": {
"userLabel": "用户名:",
"passwordLabel": "密码:",
"userLabel": "用户名",
"passwordLabel": "密码",
"loginBut": "登录",
"cancelBut": "取消",
"cultureLabel": "语言:"
"cultureLabel": "语言:",
"registerTip": "还没有账号?",
"registerBut": "立即注册",
"brandName": "有家族谱",
"slogan": "一棵树,几代人",
"langZh": "简体中文",
"langEn": "English"
},
"TabBar": {
"home": "首页",
@@ -20,12 +25,69 @@
"mySecurity": "安全管理",
"familyTree": "族谱管理",
"familyPerson": "家族成员",
"loginAgent": "代理登录",
"changePassword": "修改密码",
"changeLocalName": "修改昵称",
"logout": "退出登录",
"logoutConfirmTitle": "退出登录",
"logoutConfirmContent": "确定要退出当前账号吗?",
"logoutSuccess": "已退出登录",
"currentTheme": "当前主题",
"aboutTitle": "关于",
"appName": "应用名称",
"versionLabel": "当前版本",
"platformLabel": "运行平台"
},
"RegisterPage": {
"title": "注册",
"mobileLabel": "手机号",
"passwordLabel": "密码",
"confirmLabel": "确认密码",
"registerBut": "注册",
"loginBut": "返回登录",
"mobileRequired": "请输入手机号",
"passwordRequired": "请输入密码",
"confirmRequired": "请再次输入密码",
"passwordMismatch": "两次输入的密码不一致",
"success": "注册成功,请登录",
"agreementPrefix": "注册即代表同意",
"agreementAnd": "和",
"userAgreement": "《用户协议》",
"privacyPolicy": "《隐私政策》",
"policyTodo": "协议内容整理中,敬请期待"
},
"LoginAgentPage": {
"title": "代理登录",
"agentLabel": "被代理号",
"mobileLabel": "手机号",
"passwordLabel": "密码",
"submitBut": "代理登录",
"backBut": "返回",
"agentRequired": "请输入被代理号",
"mobileRequired": "请输入手机号",
"passwordRequired": "请输入密码",
"success": "已以代理身份登录"
},
"ChangePasswordPage": {
"title": "修改密码",
"oldLabel": "当前密码",
"newLabel": "新密码",
"confirmLabel": "确认新密码",
"submitBut": "确认修改",
"oldRequired": "请输入当前密码",
"newRequired": "请输入新密码",
"confirmRequired": "请再次输入新密码",
"passwordMismatch": "两次输入的密码不一致",
"success": "密码已修改"
},
"ChangeLocalNamePage": {
"title": "修改昵称",
"nameLabel": "用户名称",
"submitBut": "确认修改",
"nameRequired": "请输入用户名称",
"nameTooShort": "用户名称不能少于两个字",
"success": "修改成功,请重新登录"
},
"ThemePage": {
"title": "我的主题",
"themeTitle": "主题颜色",
@@ -164,8 +226,6 @@
"introduction": "生平简介",
"yes": "是",
"no": "否",
"avatarTitle": "头像",
"uploadAvatar": "上传头像",
"clearAvatar": "清除头像",
"uploadingTip": "上传中…",
"avatarUploadedTip": "头像上传成功",
+1 -1
View File
@@ -56,7 +56,7 @@
"base": "/"
},
"devServer": {
"host": "0.0.0.0",
"host": "::",
"port": 5173
},
"darkmode": true,
+14
View File
@@ -10,6 +10,20 @@
{
"path": "pages/login/login"
},
// 注册(/register,免登录)/ 代理登录(/login-agent)/ 修改密码(/change-password)/ 修改昵称(/change-local-name):
// 后三者从「我的」页跳入,均需登录
{
"path": "pages/login/register"
},
{
"path": "pages/login/login-agent"
},
{
"path": "pages/login/change-password"
},
{
"path": "pages/login/change-local-name"
},
// 错误页:H5 短地址由 CLEAN_PATH_OVERRIDES 指定为 /error/page40x,与旧 WebVue 地址一致
{
"path": "pages/error/page401"
@@ -0,0 +1,90 @@
<script setup lang="ts">
// 修改昵称页:复刻旧系统 WebVue views/login/changeLocalName.vue(旧为弹窗,本页改作「我的」跳入的独立页)
// 旧系统约定:昵称写在登录快照(LoginProfile.UserLocalName)里,改后无法即时刷新,
// 故 UpdateLocalName 成功后照旧退出登录并清档案,回登录页重登以拉取最新昵称
import { useLocale } from "uview-pro";
import { ref } from "vue";
import { useAuthGuard } from "@/composables";
import { pageUrl } from "@/router/routes";
import authService from "@/service/security/authService";
import securityUserService from "@/service/security/securityUserService";
import { useLoginProfileStore } from "@/stores";
const { t } = useLocale();
useAuthGuard();
const loginProfileStore = useLoginProfileStore();
// 预填当前昵称,便于在原值上微调
const localName = ref(loginProfileStore.userLocalName || "");
const loading = ref(false);
/** 退出登录并回登录页:改昵称 / 主动退出共用 */
async function doLogout(): Promise<void> {
try {
await authService.Logout();
}
catch {
// 后端退出失败也要清本地态,避免卡在已登录的假象
}
loginProfileStore.clearStorage();
uni.reLaunch({ url: pageUrl("login") });
}
async function handleSubmit() {
const name = localName.value.trim();
if (!name) {
uni.showToast({ title: t("ChangeLocalNamePage.nameRequired"), icon: "none" });
return;
}
if (name.length < 2) {
uni.showToast({ title: t("ChangeLocalNamePage.nameTooShort"), icon: "none" });
return;
}
loading.value = true;
try {
await securityUserService.UpdateLocalName(name);
uni.showToast({ title: t("ChangeLocalNamePage.success"), icon: "none" });
// 旧系统约定:改昵称后退出重登,刷新登录快照里的昵称
setTimeout(doLogout, 800);
}
catch {
// 错误提示已由 http.interceptor 统一处理
}
finally {
loading.value = false;
}
}
</script>
<template>
<app-page :nav-title="t('ChangeLocalNamePage.title')">
<view class="name-page">
<view class="name-card">
<u-input v-model="localName" :placeholder="t('ChangeLocalNamePage.nameLabel')" border clearable
maxlength="20" @confirm="handleSubmit" />
<u-button type="primary" shape="circle" :loading="loading" :text="t('ChangeLocalNamePage.submitBut')"
@click="handleSubmit" />
</view>
</view>
</app-page>
</template>
<style lang="scss" scoped>
.name-page {
box-sizing: border-box;
min-height: 100%;
padding: 40rpx 24rpx;
}
.name-card {
display: flex;
flex-direction: column;
gap: 40rpx;
padding: 48rpx 32rpx;
border-radius: 24rpx;
background-color: #ffffff;
box-shadow: 0 4rpx 16rpx rgba(31, 35, 41, 0.06);
}
</style>
@@ -0,0 +1,108 @@
<script setup lang="ts">
// 修改密码页:复刻旧系统 WebVue views/login/changePassword.vue(旧为弹窗,本页改作「我的」跳入的独立页)
// 加密沿用旧系统约定:UserName 字段放 guid 作前缀,旧密文与新密文都用同一前缀
// RsaUserPassword = RSA( guid + MD5(旧密码) ),UserPassword = RSA( guid + MD5(新密码) )
// AmendPassword 成功后返回最新 LoginProfile,直接覆盖档案,保持登录态无需重登
import { useLocale } from "uview-pro";
import { ref } from "vue";
import { backendLocale } from "@/common/http.interceptor";
import { encryptLoginPassword, makeGuid } from "@/common/pwd-crypto";
import { useAuthGuard } from "@/composables";
import { LoginData as LoginDataClass } from "@/data/auto/loginData";
import authService from "@/service/security/authService";
import { useLoginProfileStore } from "@/stores";
const { t } = useLocale();
useAuthGuard();
const loginProfileStore = useLoginProfileStore();
const oldPassword = ref("");
const newPassword = ref("");
const confirmNew = ref("");
const loading = ref(false);
async function handleSubmit() {
if (!oldPassword.value) {
uni.showToast({ title: t("ChangePasswordPage.oldRequired"), icon: "none" });
return;
}
if (!newPassword.value) {
uni.showToast({ title: t("ChangePasswordPage.newRequired"), icon: "none" });
return;
}
if (confirmNew.value !== newPassword.value) {
uni.showToast({ title: t("ChangePasswordPage.passwordMismatch"), icon: "none" });
return;
}
loading.value = true;
try {
const guid = makeGuid();
// 1. 取后端 RSA 公钥
const key = await authService.GetRsaPublicKey();
// 2. 旧、新密码分别用同一 guid 前缀加密
const rsaOld = encryptLoginPassword(oldPassword.value, guid, String(key));
const rsaNew = encryptLoginPassword(newPassword.value, guid, String(key));
if (!rsaOld || !rsaNew) {
uni.showToast({ title: "密码加密失败,请重试", icon: "none" });
return;
}
// 3. 修改密码(UserName 字段放 guid 作前缀,与旧系统一致)
const data = new LoginDataClass();
data.UserName = guid;
data.RsaUserPassword = rsaOld;
data.UserPassword = rsaNew;
data.Culture = backendLocale();
const profile = await authService.AmendPassword(data);
loginProfileStore.updateLoginProfile(profile);
uni.showToast({ title: t("ChangePasswordPage.success"), icon: "none" });
setTimeout(() => uni.navigateBack(), 800);
}
catch {
// 错误提示已由 http.interceptor 统一处理
}
finally {
loading.value = false;
}
}
</script>
<template>
<app-page :nav-title="t('ChangePasswordPage.title')">
<view class="pwd-page">
<view class="pwd-card">
<u-input v-model="oldPassword" type="password" :placeholder="t('ChangePasswordPage.oldLabel')" border
:password-icon="true" />
<u-input v-model="newPassword" type="password" :placeholder="t('ChangePasswordPage.newLabel')" border
:password-icon="true" />
<u-input v-model="confirmNew" type="password" :placeholder="t('ChangePasswordPage.confirmLabel')" border
:password-icon="true" @confirm="handleSubmit" />
<u-button type="primary" shape="circle" :loading="loading" :text="t('ChangePasswordPage.submitBut')"
@click="handleSubmit" />
</view>
</view>
</app-page>
</template>
<style lang="scss" scoped>
.pwd-page {
box-sizing: border-box;
min-height: 100%;
padding: 40rpx 24rpx;
}
.pwd-card {
display: flex;
flex-direction: column;
gap: 40rpx;
padding: 48rpx 32rpx;
border-radius: 24rpx;
background-color: #ffffff;
box-shadow: 0 4rpx 16rpx rgba(31, 35, 41, 0.06);
}
</style>
@@ -0,0 +1,267 @@
<script setup lang="ts">
// 代理登录页:复刻旧系统 WebVue views/login/loginAgent.vue
// 已登录的管理员在「我的」页输入被代理号 + 对方手机号 + 密码,LoginAgent 成功后覆盖当前会话
// 加密与登录同算法:RsaUserPassword = RSA( guid + MD5(密码) )
// 视觉与登录页共用一套品牌语言:暖米白底 + 叶脉树 Logo + 黛翠绿渐变按钮 + 图标内嵌白底胶囊输入框
import { useLocale } from "uview-pro";
import { ref } from "vue";
import { brandGlyph, veinGlyph } from "@/common/brand";
import { backendLocale } from "@/common/http.interceptor";
import { encryptLoginPassword, makeGuid } from "@/common/pwd-crypto";
import { useAuthGuard } from "@/composables";
import { LoginData as LoginDataClass } from "@/data/auto/loginData";
import { pageUrl } from "@/router/routes";
import authService from "@/service/security/authService";
import { useLoginProfileStore } from "@/stores";
const { t } = useLocale();
useAuthGuard();
const loginProfileStore = useLoginProfileStore();
const agentUserName = ref("");
const mobile = ref("");
const password = ref("");
const showPwd = ref(false);
const loading = ref(false);
/** 返回「我的」:代理登录从「我的」跳入,优先回退;深链直达无栈时重进「我的」 */
function goBack(): void {
if (getCurrentPages().length > 1) {
uni.navigateBack();
}
else {
uni.reLaunch({ url: pageUrl("mineTab") });
}
}
async function handleSubmit() {
if (!agentUserName.value) {
uni.showToast({ title: t("LoginAgentPage.agentRequired"), icon: "none" });
return;
}
if (!mobile.value) {
uni.showToast({ title: t("LoginAgentPage.mobileRequired"), icon: "none" });
return;
}
if (!password.value) {
uni.showToast({ title: t("LoginAgentPage.passwordRequired"), icon: "none" });
return;
}
loading.value = true;
try {
const guid = makeGuid();
// 1. 取后端 RSA 公钥
const key = await authService.GetRsaPublicKey();
// 2. RSA( guid + MD5(密码) )
const rsaUserPassword = encryptLoginPassword(password.value, guid, String(key));
if (!rsaUserPassword) {
uni.showToast({ title: "密码加密失败,请重试", icon: "none" });
return;
}
// 3. 代理登录(成功后覆盖当前会话档案与令牌)
const data = new LoginDataClass();
data.AgentUserName = agentUserName.value;
data.UserMobile = mobile.value;
data.Guid = guid;
data.RsaUserPassword = rsaUserPassword;
data.Culture = backendLocale();
const profile = await authService.LoginAgent(data);
loginProfileStore.updateLoginProfile(profile);
uni.showToast({ title: t("LoginAgentPage.success"), icon: "none" });
// reLaunch 清空页面栈,避免返回键退回代理登录页
setTimeout(() => uni.reLaunch({ url: pageUrl("dashboard") }), 800);
}
catch {
// 错误提示已由 http.interceptor 统一处理
}
finally {
loading.value = false;
}
}
</script>
<template>
<view class="login-page">
<!-- 背景右上淡叶脉暗纹(纯装饰,低透明) -->
<image class="login-vein" :src="veinGlyph" mode="aspectFit" />
<!-- 品牌位:叶脉树 Logo + 名称 + 「代理登录」副标题 -->
<view class="login-brand">
<view class="login-brand__logo">
<image class="login-brand__glyph" :src="brandGlyph" mode="aspectFit" />
</view>
<view class="login-brand__name">
{{ t("LoginForm.brandName") }}
</view>
<view class="login-brand__sub">
{{ t("LoginAgentPage.title") }}
</view>
</view>
<!-- 表单:图标内嵌白底圆角胶囊 -->
<view class="login-form">
<view class="login-field">
<u-icon name="account" size="34" color="#9aa0a6" />
<input v-model="agentUserName" class="login-field__input" :placeholder="t('LoginAgentPage.agentLabel')"
placeholder-class="login-field__ph">
</view>
<view class="login-field">
<u-icon name="phone" size="34" color="#9aa0a6" />
<input v-model="mobile" class="login-field__input" type="number"
:placeholder="t('LoginAgentPage.mobileLabel')" placeholder-class="login-field__ph">
</view>
<view class="login-field">
<u-icon name="lock" size="34" color="#9aa0a6" />
<input v-model="password" class="login-field__input" :password="!showPwd"
:placeholder="t('LoginAgentPage.passwordLabel')" placeholder-class="login-field__ph"
@confirm="handleSubmit">
<u-icon :name="showPwd ? 'eye-fill' : 'eye-off'" size="34" color="#9aa0a6"
@click="showPwd = !showPwd" />
</view>
</view>
<!-- 按钮:代理登录黛翠绿渐变实心,返回白底绿描边 -->
<view class="login-btn login-btn--submit" @click="handleSubmit">
<u-loading v-if="loading" mode="circle" color="#ffffff" :size="34" />
<text v-else>{{ t("LoginAgentPage.submitBut") }}</text>
</view>
<view class="login-btn login-btn--ghost" @click="goBack">
<text>{{ t("LoginAgentPage.backBut") }}</text>
</view>
</view>
</template>
<style lang="scss" scoped>
/* 品牌配色:黛翠绿主色 + 琥珀金点缀 + 暖米白底,与登录页同一套变量 */
$brand-green: #2f7d5c;
$brand-green-dark: #1f5f47;
$brand-green-light: #47a678;
$brand-amber: #c8912f;
$brand-ink: #1f3d2f;
.login-page {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
min-height: 100vh;
padding: 120rpx 60rpx 60rpx;
overflow: hidden;
background: linear-gradient(165deg, #f7f0e3 0%, #f1e7d6 55%, #eaddc7 100%);
}
/* 右上淡叶脉暗纹:半隐若现,不拦截点击 */
.login-vein {
position: absolute;
top: -40rpx;
right: -60rpx;
width: 460rpx;
height: 460rpx;
opacity: 0.08;
pointer-events: none;
}
/* ===== 品牌位 ===== */
.login-brand {
display: flex;
z-index: 1;
flex-direction: column;
align-items: center;
margin-bottom: 70rpx;
}
.login-brand__logo {
display: flex;
align-items: center;
justify-content: center;
width: 120rpx;
height: 120rpx;
margin-bottom: 24rpx;
border-radius: 34rpx;
background: linear-gradient(150deg, $brand-green-light, $brand-green-dark);
box-shadow: 0 16rpx 36rpx rgba(31, 95, 71, 0.32);
}
.login-brand__glyph {
width: 70rpx;
height: 70rpx;
}
.login-brand__name {
font-size: 48rpx;
font-weight: 700;
letter-spacing: 4rpx;
color: $brand-ink;
}
.login-brand__sub {
margin-top: 10rpx;
font-size: 26rpx;
letter-spacing: 6rpx;
color: $brand-amber;
}
/* ===== 表单 ===== */
.login-form {
display: flex;
z-index: 1;
flex-direction: column;
gap: 28rpx;
width: 100%;
}
.login-field {
display: flex;
align-items: center;
gap: 16rpx;
padding: 0 32rpx;
border-radius: 20rpx;
background-color: #ffffff;
box-shadow: 0 6rpx 20rpx rgba(31, 95, 71, 0.06);
}
.login-field__input {
flex: 1;
height: 92rpx;
font-size: 30rpx;
color: $brand-ink;
}
.login-field__ph {
color: #b3b8bd;
}
/* ===== 按钮 ===== */
.login-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 92rpx;
margin-top: 32rpx;
border-radius: 46rpx;
font-size: 32rpx;
font-weight: 600;
letter-spacing: 8rpx;
}
.login-btn--submit {
color: #ffffff;
background: linear-gradient(135deg, $brand-green-light, $brand-green-dark);
box-shadow: 0 16rpx 32rpx rgba(31, 95, 71, 0.3);
}
.login-btn--ghost {
margin-top: 24rpx;
color: $brand-green;
border: 2rpx solid $brand-green;
background-color: #ffffff;
}
</style>
+207 -41
View File
@@ -7,6 +7,7 @@ import type { LoginProfile } from "@/data/auto/loginProfile";
import { onLoad } from "@dcloudio/uni-app";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { brandGlyph, veinGlyph } from "@/common/brand";
import { backendLocale, baseUrl } from "@/common/http.interceptor";
import { encryptLoginPassword } from "@/common/pwd-crypto";
import { LoginData as LoginDataClass } from "@/data/auto/loginData";
@@ -17,6 +18,7 @@ import { useLoginProfileStore } from "@/stores";
const userMobile = ref("");
const password = ref("");
const showPwd = ref(false);
const loading = ref(false);
const loginProfileStore = useLoginProfileStore();
@@ -44,6 +46,10 @@ onLoad((options) => {
// 登录页中英文切换:走 uView Pro 语言包(t / setLocale),语言包 name 为 zh-CN / en-US
const { t, setLocale, currentLocale } = useLocale();
const isZh = computed(() => (currentLocale.value?.name ?? "zh-CN") !== "en-US");
/** 非发布构建才显示「当前环境 + 实际调用的后端地址」:发布版 import.meta.env.PROD 为 true,整行编译期即被剔除 */
const isProd = import.meta.env.PROD;
const envMode = import.meta.env.MODE;
function toggleLang() {
setLocale(isZh.value ? "en-US" : "zh-CN");
// 开发构建顺手重拉数据库翻译:改完 DB 词条点一下切换就能看到新值,无需整页刷新
@@ -53,15 +59,11 @@ function toggleLang() {
}
}
/** 取消:清空输入 */
function handleCancel() {
userMobile.value = "";
password.value = "";
/** 进入注册页(/register,免登录):navigateTo 保留历史以便返回登录 */
function goRegister() {
uni.navigateTo({ url: pageUrl("register") });
}
// 开发构建显示「登录测试」标记,发布构建只显示后端地址(import.meta.env.DEV 编译期确定)
const isDev = import.meta.env.DEV;
/** 生成 v4 风格 Guid(无需引入 uuid 包,与旧系统 uuidv4() 等效) */
function makeGuid(): string {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
@@ -116,77 +118,241 @@ async function handleLogin() {
<template>
<view class="login-page">
<view class="login-title">
有家相册
</view>
<view class="login-subtitle">
{{ isDev ? `登录测试 · ${baseUrl}` : baseUrl }}
<!-- 背景右上淡叶脉暗纹(纯装饰,低透明) -->
<image class="login-vein" :src="veinGlyph" mode="aspectFit" />
<!-- 品牌位:叶脉树 Logo + 名称 + 标语 -->
<view class="login-brand">
<view class="login-brand__logo">
<image class="login-brand__glyph" :src="brandGlyph" mode="aspectFit" />
</view>
<view class="login-brand__name">
{{ t("LoginForm.brandName") }}
</view>
<view class="login-brand__slogan">
{{ t("LoginForm.slogan") }}
</view>
</view>
<!-- 表单:图标内嵌白底圆角胶囊 -->
<view class="login-form">
<u-input v-model="userMobile" :placeholder="t('LoginForm.userLabel')" border clearable />
<u-input
v-model="password" type="password" :placeholder="t('LoginForm.passwordLabel')" border :password-icon="true"
@confirm="handleLogin"
/>
<u-button type="primary" shape="circle" :loading="loading" :text="t('LoginForm.loginBut')" @click="handleLogin" />
<u-button shape="circle" :text="t('LoginForm.cancelBut')" @click="handleCancel" />
<view class="login-field">
<u-icon name="account" size="34" color="#9aa0a6" />
<input
v-model="userMobile" class="login-field__input" :placeholder="t('LoginForm.userLabel')"
placeholder-class="login-field__ph" @confirm="handleLogin"
>
</view>
<view class="login-field">
<u-icon name="lock" size="34" color="#9aa0a6" />
<input
v-model="password" class="login-field__input" :password="!showPwd" :placeholder="t('LoginForm.passwordLabel')"
placeholder-class="login-field__ph" @confirm="handleLogin"
>
<u-icon :name="showPwd ? 'eye-fill' : 'eye-off'" size="34" color="#9aa0a6" @click="showPwd = !showPwd" />
</view>
</view>
<view class="login-lang" @click="toggleLang">
<text class="login-lang__label">
{{ t('LoginForm.cultureLabel') }}
</text>
<text class="login-lang__switch">
{{ isZh ? 'English' : '中文' }}
</text>
<!-- 按钮:登录黛翠绿渐变实心 -->
<view class="login-btn login-btn--submit" @click="handleLogin">
<u-loading v-if="loading" mode="circle" color="#ffffff" :size="34" />
<text v-else>{{ t("LoginForm.loginBut") }}</text>
</view>
<!-- 底部:语言切换 + 注册入口(非发布版另显示当前环境与后端地址) -->
<view class="login-footer">
<view class="login-lang" @click="toggleLang">
<text class="login-lang__label">
{{ t("LoginForm.cultureLabel") }}
</text>
<text class="login-lang__switch">
{{ isZh ? t("LoginForm.langZh") : t("LoginForm.langEn") }}
</text>
</view>
<view class="login-register" @click="goRegister">
<text class="login-register__tip">
{{ t("LoginForm.registerTip") }}
</text>
<text class="login-register__link">
{{ t("LoginForm.registerBut") }}
</text>
</view>
<view v-if="!isProd" class="login-env">
{{ envMode }} · {{ baseUrl }}
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
/* 品牌配色:黛翠绿主色 + 琥珀金点缀 + 暖米白底,登录页品牌身份独立于应用内主题 */
$brand-green: #2f7d5c;
$brand-green-dark: #1f5f47;
$brand-green-light: #47a678;
$brand-amber: #c8912f;
$brand-ink: #1f3d2f;
.login-page {
min-height: 100vh;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 200rpx 60rpx 0;
background: linear-gradient(160deg, rgba(41, 121, 255, 0.08), rgba(25, 190, 107, 0.06));
box-sizing: border-box;
min-height: 100vh;
padding: 150rpx 60rpx 60rpx;
overflow: hidden;
background: linear-gradient(165deg, #f7f0e3 0%, #f1e7d6 55%, #eaddc7 100%);
}
.login-title {
font-size: 56rpx;
/* 右上淡叶脉暗纹:半隐若现,不拦截点击 */
.login-vein {
position: absolute;
top: -40rpx;
right: -60rpx;
width: 460rpx;
height: 460rpx;
opacity: 0.08;
pointer-events: none;
}
/* ===== 品牌位 ===== */
.login-brand {
display: flex;
z-index: 1;
flex-direction: column;
align-items: center;
margin-bottom: 80rpx;
}
.login-brand__logo {
display: flex;
align-items: center;
justify-content: center;
width: 132rpx;
height: 132rpx;
margin-bottom: 28rpx;
border-radius: 36rpx;
background: linear-gradient(150deg, $brand-green-light, $brand-green-dark);
box-shadow: 0 16rpx 36rpx rgba(31, 95, 71, 0.32);
}
.login-brand__glyph {
width: 76rpx;
height: 76rpx;
}
.login-brand__name {
font-size: 52rpx;
font-weight: 700;
color: var(--u-type-primary);
letter-spacing: 4rpx;
color: $brand-ink;
}
.login-subtitle {
margin: 16rpx 0 80rpx;
font-size: 24rpx;
color: $u-tips-color;
.login-brand__slogan {
margin-top: 14rpx;
font-size: 26rpx;
letter-spacing: 6rpx;
color: $brand-amber;
}
/* ===== 表单 ===== */
.login-form {
display: flex;
z-index: 1;
flex-direction: column;
gap: 32rpx;
width: 100%;
}
.login-field {
display: flex;
align-items: center;
gap: 16rpx;
padding: 0 32rpx;
border-radius: 20rpx;
background-color: #ffffff;
box-shadow: 0 6rpx 20rpx rgba(31, 95, 71, 0.06);
}
.login-field__input {
flex: 1;
height: 96rpx;
font-size: 30rpx;
color: $brand-ink;
}
.login-field__ph {
color: #b3b8bd;
}
/* ===== 按钮 ===== */
.login-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 96rpx;
margin-top: 40rpx;
border-radius: 48rpx;
font-size: 32rpx;
font-weight: 600;
letter-spacing: 8rpx;
}
.login-btn--submit {
color: #ffffff;
background: linear-gradient(135deg, $brand-green-light, $brand-green-dark);
box-shadow: 0 16rpx 32rpx rgba(31, 95, 71, 0.3);
}
/* ===== 底部 ===== */
.login-footer {
display: flex;
flex-direction: column;
gap: 40rpx;
align-items: center;
width: 100%;
margin-top: auto;
padding-top: 60rpx;
}
.login-lang {
margin-top: 48rpx;
display: flex;
align-items: center;
gap: 8rpx;
gap: 6rpx;
font-size: 26rpx;
}
.login-lang__label {
color: $u-tips-color;
color: #9aa0a6;
}
.login-lang__switch {
color: var(--u-type-primary);
font-weight: 600;
color: $brand-green;
}
.login-register {
display: flex;
align-items: center;
gap: 6rpx;
margin-top: 24rpx;
font-size: 26rpx;
}
.login-register__tip {
color: #9aa0a6;
}
.login-register__link {
font-weight: 600;
color: $brand-amber;
}
/* 非发布版的环境 / 后端地址角标:低调灰字,仅开发或 UAT 构建出现 */
.login-env {
margin-top: 22rpx;
font-size: 22rpx;
letter-spacing: 0.5rpx;
color: #b0a894;
}
</style>
@@ -0,0 +1,309 @@
<script setup lang="ts">
// 注册页:复刻旧系统 WebVue views/login/register.vue 的注册流程
// 流程:GetRsaPublicKey -> RsaUserPassword = RSA( guid + MD5(密码) ) -> Register -> 回登录页
// 与旧系统一致:RegisterData.UserPassword 字段放 guid 作防重放前缀,密文放 RsaUserPassword
// 视觉与登录页共用一套品牌语言:暖米白底 + 叶脉树 Logo + 黛翠绿渐变按钮 + 图标内嵌白底胶囊输入框
import { useLocale } from "uview-pro";
import { ref } from "vue";
import { brandGlyph, veinGlyph } from "@/common/brand";
import { encryptLoginPassword, makeGuid } from "@/common/pwd-crypto";
import { RegisterData as RegisterDataClass } from "@/data/auto/registerData";
import { pageUrl } from "@/router/routes";
import authService from "@/service/security/authService";
const { t } = useLocale();
const mobile = ref("");
const password = ref("");
const confirm = ref("");
const showPwd = ref(false);
const showConfirmPwd = ref(false);
const loading = ref(false);
/** 返回登录:注册从登录页跳入,优先回退;深链直达无栈时直接重进登录页 */
function goLogin() {
if (getCurrentPages().length > 1) {
uni.navigateBack();
}
else {
uni.reLaunch({ url: pageUrl("login") });
}
}
/** 用户协议 / 隐私政策:暂无独立页面,先以提示占位 */
function openPolicy(): void {
uni.showToast({ title: t("RegisterPage.policyTodo"), icon: "none" });
}
async function handleRegister() {
if (!mobile.value) {
uni.showToast({ title: t("RegisterPage.mobileRequired"), icon: "none" });
return;
}
if (!password.value) {
uni.showToast({ title: t("RegisterPage.passwordRequired"), icon: "none" });
return;
}
if (confirm.value !== password.value) {
uni.showToast({ title: t("RegisterPage.passwordMismatch"), icon: "none" });
return;
}
loading.value = true;
try {
const guid = makeGuid();
// 1. 取后端 RSA 公钥(免鉴权)
const key = await authService.GetRsaPublicKey();
// 2. 密码先 MD5 再与 Guid 拼接后 RSA 加密(与登录同一算法)
const rsaUserPassword = encryptLoginPassword(password.value, guid, String(key));
if (!rsaUserPassword) {
uni.showToast({ title: "密码加密失败,请重试", icon: "none" });
return;
}
// 3. 注册(失败时拦截器已统一弹窗提示)
const data = new RegisterDataClass();
data.UserMobile = mobile.value;
data.UserPassword = guid; // 旧系统约定:此字段放 guid 作前缀
data.RsaUserPassword = rsaUserPassword;
data.ConfirmUserPassword = "";
await authService.Register(data);
uni.showToast({ title: t("RegisterPage.success"), icon: "none" });
setTimeout(goLogin, 800);
}
catch {
// 错误提示已由 http.interceptor 统一处理
}
finally {
loading.value = false;
}
}
</script>
<template>
<view class="login-page">
<!-- 背景右上淡叶脉暗纹(纯装饰,低透明) -->
<image class="login-vein" :src="veinGlyph" mode="aspectFit" />
<!-- 品牌位:叶脉树 Logo + 名称 + 「注册」副标题 -->
<view class="login-brand">
<view class="login-brand__logo">
<image class="login-brand__glyph" :src="brandGlyph" mode="aspectFit" />
</view>
<view class="login-brand__name">
{{ t("LoginForm.brandName") }}
</view>
<view class="login-brand__sub">
{{ t("RegisterPage.title") }}
</view>
</view>
<!-- 表单:图标内嵌白底圆角胶囊 -->
<view class="login-form">
<view class="login-field">
<u-icon name="phone" size="34" color="#9aa0a6" />
<input v-model="mobile" class="login-field__input" type="number"
:placeholder="t('RegisterPage.mobileLabel')" placeholder-class="login-field__ph">
</view>
<view class="login-field">
<u-icon name="lock" size="34" color="#9aa0a6" />
<input v-model="password" class="login-field__input" :password="!showPwd"
:placeholder="t('RegisterPage.passwordLabel')" placeholder-class="login-field__ph">
<u-icon :name="showPwd ? 'eye-fill' : 'eye-off'" size="34" color="#9aa0a6"
@click="showPwd = !showPwd" />
</view>
<view class="login-field">
<u-icon name="checkmark-circle" size="34" color="#9aa0a6" />
<input v-model="confirm" class="login-field__input" :password="!showConfirmPwd"
:placeholder="t('RegisterPage.confirmLabel')" placeholder-class="login-field__ph"
@confirm="handleRegister">
<u-icon :name="showConfirmPwd ? 'eye-fill' : 'eye-off'" size="34" color="#9aa0a6"
@click="showConfirmPwd = !showConfirmPwd" />
</view>
</view>
<!-- 按钮:注册黛翠绿渐变实心,返回登录白底绿描边 -->
<view class="login-btn login-btn--submit" @click="handleRegister">
<u-loading v-if="loading" mode="circle" color="#ffffff" :size="34" />
<text v-else>{{ t("RegisterPage.registerBut") }}</text>
</view>
<view class="login-btn login-btn--ghost" @click="goLogin">
<text>{{ t("RegisterPage.loginBut") }}</text>
</view>
<!-- 底部:协议声明 -->
<view class="login-footer">
<view class="login-agreement">
<text class="login-agreement__tip">
{{ t("RegisterPage.agreementPrefix") }}
</text>
<text class="login-agreement__link" @click="openPolicy">
{{ t("RegisterPage.userAgreement") }}
</text>
<text class="login-agreement__tip">
{{ t("RegisterPage.agreementAnd") }}
</text>
<text class="login-agreement__link" @click="openPolicy">
{{ t("RegisterPage.privacyPolicy") }}
</text>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
/* 品牌配色:黛翠绿主色 + 琥珀金点缀 + 暖米白底,与登录页同一套变量 */
$brand-green: #2f7d5c;
$brand-green-dark: #1f5f47;
$brand-green-light: #47a678;
$brand-amber: #c8912f;
$brand-ink: #1f3d2f;
.login-page {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
min-height: 100vh;
padding: 120rpx 60rpx 60rpx;
overflow: hidden;
background: linear-gradient(165deg, #f7f0e3 0%, #f1e7d6 55%, #eaddc7 100%);
}
/* 右上淡叶脉暗纹:半隐若现,不拦截点击 */
.login-vein {
position: absolute;
top: -40rpx;
right: -60rpx;
width: 460rpx;
height: 460rpx;
opacity: 0.08;
pointer-events: none;
}
/* ===== 品牌位 ===== */
.login-brand {
display: flex;
z-index: 1;
flex-direction: column;
align-items: center;
margin-bottom: 70rpx;
}
.login-brand__logo {
display: flex;
align-items: center;
justify-content: center;
width: 120rpx;
height: 120rpx;
margin-bottom: 24rpx;
border-radius: 34rpx;
background: linear-gradient(150deg, $brand-green-light, $brand-green-dark);
box-shadow: 0 16rpx 36rpx rgba(31, 95, 71, 0.32);
}
.login-brand__glyph {
width: 70rpx;
height: 70rpx;
}
.login-brand__name {
font-size: 48rpx;
font-weight: 700;
letter-spacing: 4rpx;
color: $brand-ink;
}
.login-brand__sub {
margin-top: 10rpx;
font-size: 26rpx;
letter-spacing: 6rpx;
color: $brand-amber;
}
/* ===== 表单 ===== */
.login-form {
display: flex;
z-index: 1;
flex-direction: column;
gap: 28rpx;
width: 100%;
}
.login-field {
display: flex;
align-items: center;
gap: 16rpx;
padding: 0 32rpx;
border-radius: 20rpx;
background-color: #ffffff;
box-shadow: 0 6rpx 20rpx rgba(31, 95, 71, 0.06);
}
.login-field__input {
flex: 1;
height: 92rpx;
font-size: 30rpx;
color: $brand-ink;
}
.login-field__ph {
color: #b3b8bd;
}
/* ===== 按钮 ===== */
.login-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 92rpx;
margin-top: 32rpx;
border-radius: 46rpx;
font-size: 32rpx;
font-weight: 600;
letter-spacing: 8rpx;
}
.login-btn--submit {
color: #ffffff;
background: linear-gradient(135deg, $brand-green-light, $brand-green-dark);
box-shadow: 0 16rpx 32rpx rgba(31, 95, 71, 0.3);
}
.login-btn--ghost {
margin-top: 24rpx;
color: $brand-green;
border: 2rpx solid $brand-green;
background-color: #ffffff;
}
/* ===== 底部 ===== */
.login-footer {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
margin-top: auto;
padding-top: 60rpx;
}
.login-agreement {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
font-size: 24rpx;
}
.login-agreement__tip {
color: #9aa0a6;
}
.login-agreement__link {
color: $brand-green;
}
</style>
@@ -5,7 +5,8 @@
* 对齐旧逻辑:
* - FindByKey(PersonId) 取 FamilyPersonData,主对象 FamilyPerson;
* - 头像上传 file-path=FamilyPersonAvatar(旧 FileUpload 的移动端等价:chooseImage 单图 +
* uploadFile 直传 DmsFile/UploadDmsFile,FilePath 走请求头、form 字段名=原始文件名);
* uploadFile 直传 DmsFile/UploadFile,FilePath 走请求头、form 字段名=原始文件名、
* filename=分片三段式物理名并同存 AvatarFilename);
* - 性别是 GenderPolicy 枚举,admin-form 无 select,用 #extra 行点开 option-picker 回写;
* - 只读门控:!IsCanAmend 且 Privacy!=='Y' 时只显「基本资料」卡、隐藏其余卡与修改/删除;
* - 保存按 CreatedBy 有无决定 Update / Insert。
@@ -15,7 +16,7 @@ import type { AdminFormAction, AdminFormField, AdminFormSection } from "@/admin/
import { onLoad } from "@dcloudio/uni-app";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { uploadFile } from "@/common/http.interceptor";
import { buildChunkFileName, uploadFile } from "@/common/http.interceptor";
import { useAuthGuard } from "@/composables";
import { usePolicyOptions } from "@/composables/usePolicyOptions";
import { FamilyPersonData } from "@/data/auto/familyPersonData";
@@ -166,7 +167,9 @@ function onGenderConfirm(key: string): void {
genderPickerVisible.value = false;
}
/** 头像上传:单图 -> DmsFile/UploadDmsFile(FilePath=FamilyPersonAvatar),成功后本地预览 */
/** 头像上传:单图 -> DmsFile/UploadFile(旧 FileUpload 默认通道,FilePath 头指定 FamilyPersonAvatar)。
* 后端按 {Backup}/{FilePath}/{ext}/{分片名} 落盘,FindByKey 的 AvatarUrl 也按此拼 URL,
* 故 AvatarFilename 必须存分片物理名(buildChunkFileName),不能存原始文件名 */
async function onUploadAvatar(): Promise<void> {
if (uploading.value) {
return;
@@ -181,13 +184,15 @@ async function onUploadAvatar(): Promise<void> {
}
try {
uploading.value = true;
const physicalName = buildChunkFileName(fileName);
await uploadFile({
url: "/DmsFile/UploadDmsFile",
url: "/DmsFile/UploadFile",
filePath,
name: fileName,
fileName: physicalName,
header: { FilePath: "FamilyPersonAvatar" },
});
person.value.AvatarFilename = fileName;
person.value.AvatarFilename = physicalName;
person.value.AvatarUrl = filePath;
uni.showToast({ title: t("FamilyPersonPage.avatarUploadedTip"), icon: "none" });
}
@@ -288,6 +293,26 @@ useAuthGuard(() => {
<admin-form :nav-title="t('FamilyPersonPage.detailTitle')" :show-nav-back="true" :hero-title="heroTitle"
:hero-chips="heroChips" :sections="sections" :model="person" :readonly="readonly" :actions="actions"
@back="onBack" @action="onAction" @field-change="onFieldChange">
<!-- 旧系统同款:头像就在 hero 卡左侧,显示与上传同一处;编辑态右侧出「清除头像」 -->
<template #hero-side="{ readonly: ro }">
<view class="hero-avatar" @tap="!ro && onUploadAvatar()">
<image v-if="person.AvatarUrl" class="hero-avatar__img" :src="person.AvatarUrl" mode="aspectFill" />
<u-icon v-else name="account" size="56" color="#ffffff" />
<view v-if="!ro" class="hero-avatar__mask">
<text v-if="uploading" class="hero-avatar__mask-text">
{{ t("FamilyPersonPage.uploadingTip") }}
</text>
<u-icon v-else name="camera" size="28" color="#ffffff" />
</view>
</view>
</template>
<template #hero-end="{ readonly: ro }">
<view v-if="!ro && (person.AvatarUrl || person.AvatarFilename)" class="hero-clear" @tap="onClearAvatar">
<text class="hero-clear-text">
{{ t("FamilyPersonPage.clearAvatar") }}
</text>
</view>
</template>
<template #extra="{ readonly: ro }">
<!-- 只读门控提示:无修改权限且对方未公开详细资料 -->
<view v-if="readonlyBasicOnly" class="ro-tip">
@@ -297,38 +322,7 @@ useAuthGuard(() => {
</view>
<template v-else>
<!-- 头像 + 性别(admin-form 无 select,枚举行在这里点开的 option-picker 里回写) -->
<view class="group">
<view class="group-head">
<text class="group-title">
{{ t("FamilyPersonPage.avatarTitle") }}
</text>
</view>
<view class="group-card avatar-card">
<view class="avatar-box" :class="{ 'avatar-box--tap': !ro }" @tap="!ro && onUploadAvatar()">
<image v-if="person.AvatarUrl" class="avatar-img" :src="person.AvatarUrl"
mode="aspectFill" />
<u-icon v-else name="account-fill" size="72" color="#c4c8cf" />
</view>
<view v-if="!ro" class="avatar-ops">
<view class="avatar-op" @tap="onUploadAvatar">
<u-icon name="camera" size="30" color="#059669" />
<text class="avatar-op-text">
{{ uploading ? t("FamilyPersonPage.uploadingTip") :
t("FamilyPersonPage.uploadAvatar") }}
</text>
</view>
<view v-if="person.AvatarUrl || person.AvatarFilename" class="avatar-op"
@tap="onClearAvatar">
<u-icon name="close-circle" size="30" color="#c4c8cf" />
<text class="avatar-op-text">
{{ t("FamilyPersonPage.clearAvatar") }}
</text>
</view>
</view>
</view>
</view>
<!-- 性别(admin-form 无 select,枚举行在这里点开的 option-picker 里回写) -->
<view class="group">
<view class="group-card">
<view class="enum-row" :class="{ 'enum-row--tap': !ro }"
@@ -388,51 +382,54 @@ useAuthGuard(() => {
border-radius: 20rpx;
}
.avatar-card {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 28rpx;
}
.avatar-box {
/* hero 左侧头像(旧系统 ft-hero-avatar 同款):显示与上传同一处,编辑态覆相机遮罩 */
.hero-avatar {
position: relative;
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 160rpx;
height: 160rpx;
width: 120rpx;
height: 120rpx;
overflow: hidden;
background-color: $u-bg-color;
border: 2rpx solid $u-border-color;
background-color: rgba(255, 255, 255, 0.18);
border: 4rpx solid rgba(255, 255, 255, 0.55);
border-radius: 50%;
}
.avatar-box--tap {
border-style: dashed;
}
.avatar-img {
.hero-avatar__img {
width: 100%;
height: 100%;
}
.avatar-ops {
.hero-avatar__mask {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.avatar-op {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.35);
}
.avatar-op-text {
margin-left: 8rpx;
font-size: 26rpx;
color: $u-content-color;
.hero-avatar__mask-text {
font-size: 20rpx;
color: #ffffff;
}
/* hero 右侧「清除头像」白胶囊(旧系统同款位) */
.hero-clear {
flex: none;
padding: 10rpx 24rpx;
background-color: rgba(255, 255, 255, 0.22);
border-radius: 999rpx;
}
.hero-clear-text {
font-size: 24rpx;
color: #ffffff;
}
.enum-row {
+217 -57
View File
@@ -6,14 +6,38 @@
* 版本号取 common/constant 的 APP_INFO(与 manifest/package 对齐);H5 无 plus.runtime.version。
*/
import { useLocale } from "uview-pro";
import { computed } from "vue";
import { APP_INFO } from "@/common/constant";
import { useAuthGuard } from "@/composables";
import { useAuthGuard, useThemeGradient, useThemeTokens } from "@/composables";
import { usePermission } from "@/permission/permission-context";
import { pageUrl } from "@/router/routes";
import authService from "@/service/security/authService";
import { useLoginProfileStore } from "@/stores";
const { t } = useLocale();
useAuthGuard();
const loginProfileStore = useLoginProfileStore();
/* 代理登录入口显隐,口径必须与后端 /Auth/LoginAgent 的 [PermissionOperate(name:"SecurityUser", operate:"LoginAgent")] 完全一致:
后端 PermissionHandler 对非标准 operate 走 default 分支 → CanNone("SecurityUser","LoginAgent"),
即查「数据级授权」里有没有一条 PermissionName=SecurityUser 且 DependentDetailName=LoginAgent 的行(只看挂没挂,不看 Y/N)。
前端镜像就是 can("none","SecurityUser","LoginAgent")。之前误用 canNone("LoginAgent") 查的是功能级同名行,口径错位——
会出现「入口看得见、一点就 403」。改成同口径后:见得到 == 用得了。 */
const { can } = usePermission();
const canSeeLoginAgent = computed(() => can("none", "SecurityUser", "LoginAgent"));
/* 统一风格:主题渐变底 + 主题色变量(与 family-person-enquiry / 族谱首页同一接线口径) */
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,
}));
/** 进入「我的主题」页:theme 非 tab 页,用 navigateTo 保留历史以便返回 */
function goTheme(): void {
uni.navigateTo({ url: pageUrl("themePage") });
@@ -28,63 +52,160 @@ function goSecurity(): void {
function goFamilyPerson(): void {
uni.navigateTo({ url: pageUrl("familyPersonEnquiry") });
}
/** 进入「代理登录」页(/login-agent):管理员以被代理号身份切换会话 */
function goLoginAgent(): void {
uni.navigateTo({ url: pageUrl("loginAgent") });
}
/** 进入「修改密码」页(/change-password):当前登录用户改密,成功后更新档案保持登录 */
function goChangePassword(): void {
uni.navigateTo({ url: pageUrl("changePassword") });
}
/** 进入「修改昵称」页(/change-local-name):改用户名,成功后按旧系统约定退出重登刷新快照 */
function goChangeLocalName(): void {
uni.navigateTo({ url: pageUrl("changeLocalName") });
}
/** 退出登录:确认后调后端 Logout、清本地档案,回登录页 */
async function handleLogout(): Promise<void> {
const confirmed = await new Promise<boolean>((resolve) => {
uni.showModal({
title: t("MinePage.logoutConfirmTitle"),
content: t("MinePage.logoutConfirmContent"),
success: res => resolve(!!res.confirm),
fail: () => resolve(false),
});
});
if (!confirmed) {
return;
}
try {
await authService.Logout();
}
catch {
// 后端退出失败也要清本地态,避免卡在已登录的假象
}
loginProfileStore.clearStorage();
uni.showToast({ title: t("MinePage.logoutSuccess"), icon: "none" });
// mine 是 tab 页,用 reLaunch 清栈回登录页,避免返回键又回到「我的」
setTimeout(() => uni.reLaunch({ url: pageUrl("login") }), 800);
}
</script>
<template>
<app-page :nav-title="t('MinePage.title')" :show-nav-back="false" :show-tabbar="true">
<view class="mine-page">
<!-- 我的主题:进入主题配色 / 深色模式设置页(/theme) -->
<view class="mine-cell" @tap="goTheme">
<text class="mine-cell__title">
{{ t("MinePage.myTheme") }}
</text>
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 安全管理:进入权限体系基础设定聚合页(/security) -->
<view class="mine-cell" @tap="goSecurity">
<text class="mine-cell__title">
{{ t("MinePage.mySecurity") }}
</text>
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 家族成员:族人档案检索(/family-person-enquiry) -->
<view class="mine-cell" @tap="goFamilyPerson">
<text class="mine-cell__title">
{{ t("MinePage.familyPerson") }}
</text>
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 关于:应用名 + 当前版本 + 运行平台 -->
<view class="mine-card">
<text class="mine-card__title">
{{ t("MinePage.aboutTitle") }}
</text>
<view class="mine-about__row">
<text class="mine-about__label">
{{ t("MinePage.appName") }}
<!-- 统一风格:隐藏旧 u-navbar,改用与其他 tab 页同款透明 page-navbar 坐在主题渐变底上 -->
<app-page :hide-nav="true" :show-tabbar="true">
<view class="mine-page" :style="[{ background: themeBackground }, themeVars]">
<page-navbar :title="t('MinePage.title')" :show-back="false" :show-transfer="false" :show-quick-toggle="false"
:show-search-toggle="false" />
<view class="mine-page__body">
<!-- 我的主题:进入主题配色 / 深色模式设置页(/theme) -->
<view class="mine-cell" @tap="goTheme">
<view class="mine-cell__icon">
<u-icon name="setting-fill" size="30" color="#ffffff" />
</view>
<text class="mine-cell__title">
{{ t("MinePage.myTheme") }}
</text>
<text class="mine-about__value">
{{ APP_INFO.name }}
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 安全管理:进入权限体系基础设定聚合页(/security) -->
<view class="mine-cell" @tap="goSecurity">
<view class="mine-cell__icon">
<u-icon name="lock-fill" size="30" color="#ffffff" />
</view>
<text class="mine-cell__title">
{{ t("MinePage.mySecurity") }}
</text>
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 家族成员:族人档案检索(/family-person-enquiry) -->
<view class="mine-cell" @tap="goFamilyPerson">
<view class="mine-cell__icon">
<u-icon name="account-fill" size="30" color="#ffffff" />
</view>
<text class="mine-cell__title">
{{ t("MinePage.familyPerson") }}
</text>
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 代理登录:管理员以被代理号身份切换会话(/login-agent);仅授予 LoginAgent 功能者可见 -->
<view v-if="canSeeLoginAgent" class="mine-cell" @tap="goLoginAgent">
<view class="mine-cell__icon">
<u-icon name="account-fill" size="30" color="#ffffff" />
</view>
<text class="mine-cell__title">
{{ t("MinePage.loginAgent") }}
</text>
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 修改密码:当前登录用户改密(/change-password) -->
<view class="mine-cell" @tap="goChangePassword">
<view class="mine-cell__icon">
<u-icon name="lock-fill" size="30" color="#ffffff" />
</view>
<text class="mine-cell__title">
{{ t("MinePage.changePassword") }}
</text>
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 修改昵称:改用户名,成功后退出重登刷新快照(/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") }}
</text>
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 退出登录:确认后清会话回登录页 -->
<view class="mine-cell mine-cell--logout" @tap="handleLogout">
<view class="mine-cell__icon mine-cell__icon--logout">
<u-icon name="close-circle-fill" size="30" color="#ffffff" />
</view>
<text class="mine-cell__title">
{{ t("MinePage.logout") }}
</text>
</view>
<view class="mine-about__row">
<text class="mine-about__label">
{{ t("MinePage.versionLabel") }}
</text>
<text class="mine-about__value">
v{{ APP_INFO.version }}
</text>
</view>
<view class="mine-about__row">
<text class="mine-about__label">
{{ t("MinePage.platformLabel") }}
</text>
<text class="mine-about__value">
{{ APP_INFO.platform }}
<!-- 关于:应用名 + 当前版本 + 运行平台 -->
<view class="mine-card">
<text class="mine-card__title">
{{ t("MinePage.aboutTitle") }}
</text>
<view class="mine-about__row">
<text class="mine-about__label">
{{ t("MinePage.appName") }}
</text>
<text class="mine-about__value">
{{ APP_INFO.name }}
</text>
</view>
<view class="mine-about__row">
<text class="mine-about__label">
{{ t("MinePage.versionLabel") }}
</text>
<text class="mine-about__value">
v{{ APP_INFO.version }}
</text>
</view>
<view class="mine-about__row">
<text class="mine-about__label">
{{ t("MinePage.platformLabel") }}
</text>
<text class="mine-about__value">
{{ APP_INFO.platform }}
</text>
</view>
</view>
</view>
</view>
@@ -95,15 +216,25 @@ function goFamilyPerson(): void {
.mine-page {
display: flex;
flex-direction: column;
gap: 24rpx;
padding: 24rpx;
box-sizing: border-box;
min-height: 100vh;
}
/* 白底圆角卡片:与文件页分区卡片同一风格语言 */
/* 内容区:page-navbar 之下,卡片间距与页边距统一 */
.mine-page__body {
display: flex;
flex: 1;
flex-direction: column;
gap: 24rpx;
padding: 0 24rpx 24rpx;
}
/* 白底圆角卡片:与文件页分区卡片同一风格语言(渐变底上轻阴影浮起) */
.mine-card {
padding: 32rpx;
border-radius: 24rpx;
background-color: #ffffff;
box-shadow: 0 4rpx 16rpx rgba(31, 35, 41, 0.06);
}
.mine-card__title {
@@ -112,22 +243,51 @@ function goFamilyPerson(): void {
color: #1f2329;
}
/* 可点的列表行:标题左、箭头右,整行白底卡片 */
/* 可点的列表行:主题渐变图标块 + 标题左、箭头右,整行白底卡片 */
.mine-cell {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
padding: 28rpx 32rpx;
border-radius: 24rpx;
background-color: #ffffff;
box-shadow: 0 4rpx 16rpx rgba(31, 35, 41, 0.06);
}
.mine-cell__icon {
display: flex;
align-items: center;
justify-content: center;
width: 60rpx;
height: 60rpx;
border-radius: 18rpx;
background: var(--fg-gradient, linear-gradient(135deg, #ff9a3d, #ff7d1f));
box-shadow: var(--fg-shadow, 0 6rpx 16rpx rgba(255, 125, 31, 0.35));
}
.mine-cell__title {
flex: 1;
margin-left: 24rpx;
font-size: 30rpx;
font-weight: 600;
color: #1f2329;
}
/* 退出登录:降权为描边行,标题居中、危险色,与普通入口区分 */
.mine-cell--logout {
justify-content: center;
}
.mine-cell__icon--logout {
background: linear-gradient(135deg, #ff7a7a, #fa3534);
box-shadow: 0 6rpx 16rpx rgba(250, 53, 52, 0.35);
}
.mine-cell--logout .mine-cell__title {
flex: none;
margin-left: 24rpx;
color: #fa3534;
}
/* 关于页行:标签左灰、值右深,横向两端对齐 */
.mine-about__row {
display: flex;
+16 -8
View File
@@ -11,6 +11,14 @@ import type { PermissionNames } from "@/permission/permission-kernel";
export const PAGE_PATHS = {
login: "pages/login/login",
// 注册(短地址 /register):免登录页,从登录页「注册」入口跳入
register: "pages/login/register",
// 代理登录(短地址 /login-agent):需登录,从「我的」页跳入,成功后覆盖当前会话
loginAgent: "pages/login/login-agent",
// 修改密码(短地址 /change-password):需登录,从「我的」页跳入,AmendPassword 成功后更新档案
changePassword: "pages/login/change-password",
// 修改昵称(短地址 /change-local-name):需登录,从「我的」页跳入,成功后按旧系统约定退出重登刷新快照
changeLocalName: "pages/login/change-local-name",
dashboard: "pages/dashboard/dashboard",
error401: "pages/error/page401",
error403: "pages/error/page403",
@@ -93,6 +101,7 @@ const CLEAN_PATH_OVERRIDES: Partial<Record<PageName, string>> = {
*/
export const AUTH_FREE_PAGES: PageName[] = [
"login",
"register",
"error401",
"error403",
"error404",
@@ -114,14 +123,13 @@ export const PAGE_PERMISSIONS: Partial<Record<PageName, PermissionNames>> = {
userEnquiry: "UserEnquiry",
userOperation: "UserOperation",
actorEnquiry: "SecurityActor",
// 底部导航 5 个 tab:真实权限尚未定义,按当前要求统一用 UserOperation 占位。
// 注意 dashboard 也被登记后,登录落地页(首页)会开始按 UserOperation 做路由级校验;
// 路由级是 fail-open(档案缺失/为空时放行),真正的边界仍在后端。
dashboard: "DmsFile",
fileTab: "DmsFile",
mediaTab: "DmsFile",
shareTab: "DmsFile",
mineTab: "DmsFile",
// 首页与底部 5 个 tab(dashboard/fileTab/mediaTab/shareTab/mineTab)刻意不绑权限:
// 落地页一旦被权限门卡住,等于整个 App 进不去且无路可退(403 页「返回首页」会再被弹回一次)。
// 而登录档案是本地快照、可能滞后于后端(clearLoginState 只清 token 不清档案,又没有按 token
// 重拉权限的接口),一份「非空但过期」的旧快照就能把首页判成 403;路由级 fail-open 只在档案
// 完全为空时救场,救不了非空旧档案。故按 permission.md §3.1 原设计:首页/tab 不做路由级校验,
// 真正的边界仍在后端逐接口 [PermissionOperate]。下面是 tab 内的深层子页,仍绑 DmsFile 占位——
// 它们 403 只是「点了才回登录」,不锁入口,可接受。
transport: "DmsFile",
mediaPlay: "DmsFile",
photoShow: "DmsFile",