新增 有家相册 uni-app 前端(uniapp/youjiala,H5 / 微信小程序 / App 三端)

从旧 WebVue 移植的地基部分:
- 登录页(RSA+MD5 密码加密、redirect 回跳、中英文切换)与登录后落地页
- 401 / 403 / 404 错误页 + error-panel 通用外壳(文案走 ErrorPage 语言包)
- 登录守卫双链路:uni.addInterceptor 拦站内跳转,页面级 onShow 复核冷启动深链
- 单一请求链路 common/http.interceptor:JWT 唯一来源、401 并发刷新单例、
  业务异常识别、HTTP 403 跳无权限页
- H5 干净短地址:initRouter 前向 __uniRoutes 追加克隆记录(router/h5-clean-url)
- 路由登记表 router/routes.ts(PAGE_PATHS / pageUrl / PAGE_PERMISSIONS)
  与新增页面四步登记约定(src/md/page-routing.md)
- i18n:uView Pro 语言包 + 后端词条合并,双取值(后端 zh-cn/en,包名 zh-CN/en-US)
- 后端接口层 service/ 与数据模型 data/auto/ 为生成器产物,不手改

不入库的内容:
- 摘除脚手架残留的 Harmony 签名密码(manifest.json signingConfigs 与
  harmony-configs/build-profile.json5 的 keyPassword / storePassword 已置空)
- 忽略 node_modules、dist、unpackage、.hbuilderx、.agents、pnpm-lock.yaml
  及根目录临时截图
This commit is contained in:
owenchen committed 2026-10-13 08:27:34 +08:00
1 parent 74757f9eb4
commit 6a8258ac4b
274 files changed
+30291

No files matched your search

@@ -0,0 +1,33 @@
<script setup lang="ts">
// 登录后的落地页:当前为空白占位,后续由旧 WebVue 项目的
// 菜单(/SecurityFunction/FindMenus)+ 权限守卫驱动真实内容
import { useAuthGuard } from "@/composables";
import { WebSitePolicy } from "@/policy/custom/webSitePolicy";
// 冷启动深链 / 分享链接直达本页时不经过任何导航 API,拦截器拦不到,故页面级再复核一次
useAuthGuard();
</script>
<template>
<app-page :nav-title="WebSitePolicy.Title">
<view class="dashboard">
<view class="dashboard__placeholder">
{{ WebSitePolicy.Title }}
</view>
</view>
</app-page>
</template>
<style lang="scss" scoped>
.dashboard {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
}
.dashboard__placeholder {
font-size: 32rpx;
color: $u-tips-color;
}
</style>
@@ -0,0 +1,57 @@
<script setup lang="ts">
// 401 未认证页,移植自旧 WebVue 的 views/error/page401.vue
// 谁来跳:router/nav-guard(未登录的站内跳转 + 冷启动深链复核)
// 请求层的 401 仍按原逻辑直接回登录页(自动续期失败即重登,不必先看一眼错误页)
import { onLoad } from "@dcloudio/uni-app";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { goLoginWithRedirect } from "@/router/nav-guard";
import { decodeQueryPath, pageUrl } from "@/router/routes";
const { t } = useLocale();
/** 出错的地址:守卫跳页时用 ?from= 带过来 */
const fromPath = ref("");
onLoad((options) => {
fromPath.value = decodeQueryPath(options?.from);
});
const reasons = computed(() => [
t("ErrorPage.r401_1"),
t("ErrorPage.r401_2"),
t("ErrorPage.r401_3"),
t("ErrorPage.r401_4"),
]);
/** 去登录:带上原地址,登录成功后能直接回到这里 */
function handleGoLogin() {
goLoginWithRedirect(fromPath.value);
}
/** 返回上一页:深链直达时页面栈只有本页,退回登录页 */
function handleGoBack() {
if (getCurrentPages().length > 1) {
uni.navigateBack();
return;
}
uni.reLaunch({ url: pageUrl("login") });
}
</script>
<template>
<error-panel
code="401" tone="blue" :badge="t('ErrorPage.badge401')" :title="t('ErrorPage.title401')"
:description="t('ErrorPage.desc401')" :reasons="reasons" :from="fromPath" :hint="t('ErrorPage.hint401')"
>
<template #icon>
<u-icon name="lock-fill" size="52" color="#ffffff" />
</template>
<template #actions>
<u-button
type="primary" size="large" shape="circle" :text="t('ErrorPage.goLoginBut')"
@click="handleGoLogin"
/>
<u-button size="large" shape="circle" :text="t('ErrorPage.goBackBut')" @click="handleGoBack" />
</template>
</error-panel>
</template>
@@ -0,0 +1,53 @@
<script setup lang="ts">
// 403 无权限页,移植自旧 WebVue 的 views/error/page403.vue
// 谁来跳:请求层拿到 HTTP 403(common/http.interceptor)时带原地址跳本页;
// 页面级权限校验(旧 setupPermissionGuard)等 PAGE_PERMISSIONS 接入后接在 nav-guard
import { onLoad } from "@dcloudio/uni-app";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { goLoginWithRedirect } from "@/router/nav-guard";
import { decodeQueryPath, pageUrl } from "@/router/routes";
const { t } = useLocale();
/** 出错的地址:跳页时用 ?from= 带过来 */
const fromPath = ref("");
onLoad((options) => {
fromPath.value = decodeQueryPath(options?.from);
});
const reasons = computed(() => [
t("ErrorPage.r403_1"),
t("ErrorPage.r403_2"),
t("ErrorPage.r403_3"),
t("ErrorPage.r403_4"),
]);
/** 重新登录:权限随令牌一起下发,变更后必须重登才会生效 */
function handleRelogin() {
goLoginWithRedirect(fromPath.value);
}
/** 回首页:首页本身只要求登录,不要求具体功能权限 */
function handleGoHome() {
uni.reLaunch({ url: pageUrl("dashboard") });
}
</script>
<template>
<error-panel
code="403" tone="amber" :badge="t('ErrorPage.badge403')" :title="t('ErrorPage.title403')"
:description="t('ErrorPage.desc403')" :reasons="reasons" :from="fromPath" :hint="t('ErrorPage.hint403')"
>
<template #icon>
<u-icon name="warning-fill" size="52" color="#ffffff" />
</template>
<template #actions>
<u-button
type="primary" size="large" shape="circle" :text="t('ErrorPage.reloginBut')"
@click="handleRelogin"
/>
<u-button size="large" shape="circle" :text="t('ErrorPage.goHomeBut')" @click="handleGoHome" />
</template>
</error-panel>
</template>
@@ -0,0 +1,56 @@
<script setup lang="ts">
// 404 找不到页面,移植自旧 WebVue 的 views/error/page404.vue
// 谁来跳:nav-guard 的 evaluate 认不出目标地址(站内跳错地址),
// 以及 H5 地址栏直接访问一个路由表里不存在的地址(setupUnknownPathFallback 兜底)
import { onLoad } from "@dcloudio/uni-app";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { decodeQueryPath, pageUrl } from "@/router/routes";
const { t } = useLocale();
/** 出错的地址:兜底跳页时用 ?from= 带过来 */
const fromPath = ref("");
onLoad((options) => {
fromPath.value = decodeQueryPath(options?.from);
});
const reasons = computed(() => [
t("ErrorPage.r404_1"),
t("ErrorPage.r404_2"),
t("ErrorPage.r404_3"),
t("ErrorPage.r404_4"),
]);
/** 回首页:dashboard 是登录后的落地页,与旧系统的 hasRoute("dashboard") 判断等价 */
function handleGoHome() {
uni.reLaunch({ url: pageUrl("dashboard") });
}
/** 返回上一页:深链直达时页面栈只有本页,退回首页 */
function handleGoBack() {
if (getCurrentPages().length > 1) {
uni.navigateBack();
return;
}
handleGoHome();
}
</script>
<template>
<error-panel
code="404" tone="violet" :badge="t('ErrorPage.badge404')" :title="t('ErrorPage.title404')"
:description="t('ErrorPage.desc404')" :reasons="reasons" :from="fromPath" :hint="t('ErrorPage.hint404')"
>
<template #icon>
<u-icon name="map" size="52" color="#ffffff" />
</template>
<template #actions>
<u-button
type="primary" size="large" shape="circle" :text="t('ErrorPage.goHomeBut')"
@click="handleGoHome"
/>
<u-button size="large" shape="circle" :text="t('ErrorPage.goBackBut')" @click="handleGoBack" />
</template>
</error-panel>
</template>
+183
View File
@@ -0,0 +1,183 @@
<script setup lang="ts">
// 最简登录页:复刻旧系统 WebVue 的登录流程,用于联调 CloudBuilder 后端接口
// 流程:GetRsaPublicKey -> RsaUserPassword = RSA(Guid + MD5(密码)) -> Login -> 存 JWT
// 加密已抽到 @/common/pwd-crypto(wxmp-rsa 纯 JS 实现,H5/小程序/App 全端可用)
import type { LoginData } from "@/data/auto/loginData";
import type { LoginProfile } from "@/data/auto/loginProfile";
import { onLoad } from "@dcloudio/uni-app";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { backendLocale, baseUrl } from "@/common/http.interceptor";
import { encryptLoginPassword } from "@/common/pwd-crypto";
import { LoginData as LoginDataClass } from "@/data/auto/loginData";
import { reloadLocales } from "@/locale";
import { decodeQueryPath, pageUrl, resolvePageName } from "@/router/routes";
import authService from "@/service/security/authService";
import { useLoginProfileStore } from "@/stores";
const userMobile = ref("");
const password = ref("");
const loading = ref(false);
const loginProfileStore = useLoginProfileStore();
/**
* 登录成功后的落地地址:被守卫送去登录页时由 ?redirect= 带过来(见 nav-guard 的 goLoginWithRedirect)。
* 只接受 PAGE_PATHS 里认得出的地址,避免 ?redirect= 把人送到站外或不存在的路径
*/
const redirectUrl = ref(pageUrl("dashboard"));
onLoad((options) => {
const target = decodeQueryPath(options?.redirect);
if (resolvePageName(target)) {
redirectUrl.value = target;
}
});
// 登录页中英文切换:走 uView Pro 语言包(t / setLocale),语言包 name 为 zh-CN / en-US
const { t, setLocale, currentLocale } = useLocale();
const isZh = computed(() => (currentLocale.value?.name ?? "zh-CN") !== "en-US");
function toggleLang() {
setLocale(isZh.value ? "en-US" : "zh-CN");
// 开发构建顺手重拉数据库翻译:改完 DB 词条点一下切换就能看到新值,无需整页刷新
// (生产构建 import.meta.env.DEV 为 false,该分支编译期即被剔除)
if (import.meta.env.DEV) {
reloadLocales();
}
}
/** 取消:清空输入 */
function handleCancel() {
userMobile.value = "";
password.value = "";
}
// 开发构建显示「登录测试」标记,发布构建只显示后端地址(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) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
async function handleLogin() {
if (!userMobile.value || !password.value) {
uni.showToast({ title: "请输入手机号或者账号名和密码", icon: "none" });
return;
}
loading.value = true;
try {
const guid = makeGuid();
// 1. 取后端 RSA 公钥(GET /Auth/GetRsaPublicKey,免鉴权)
const key = await authService.GetRsaPublicKey();
// 2. 密码先 MD5 再与 Guid 拼接后 RSA 加密(算法与旧系统一致,库换为跨端的 wxmp-rsa)
const rsaUserPassword = encryptLoginPassword(password.value, guid, String(key));
if (!rsaUserPassword) {
uni.showToast({ title: "密码加密失败,请重试", icon: "none" });
return;
}
// 3. 登录(失败时拦截器已统一弹窗提示,这里只处理成功路径)
const data: LoginData = new LoginDataClass();
data.UserMobile = userMobile.value;
data.Guid = guid;
data.RsaUserPassword = rsaUserPassword;
data.Culture = backendLocale();
const profile: LoginProfile = await authService.Login(data);
// 4. 存登录档案:档案字段进 store,Token 交由请求层保管(此后自动带 Bearer 头 + 401 自动续期)
loginProfileStore.updateLoginProfile(profile);
uni.showToast({ title: `欢迎,${profile.UserLocalName || profile.Username || profile.UserMobile}`, icon: "none" });
// 回到被拦前的页面(默认 dashboard):reLaunch 会清空页面栈,避免返回键退回登录页
setTimeout(() => uni.reLaunch({ url: redirectUrl.value }), 800);
}
catch {
// 错误提示已由 http.interceptor 统一处理
}
finally {
loading.value = false;
}
}
</script>
<template>
<view class="login-page">
<view class="login-title">
有家相册
</view>
<view class="login-subtitle">
{{ isDev ? `登录测试 · ${baseUrl}` : baseUrl }}
</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>
<view class="login-lang" @click="toggleLang">
<text class="login-lang__label">
{{ t('LoginForm.cultureLabel') }}
</text>
<text class="login-lang__switch">
{{ isZh ? 'English' : '中文' }}
</text>
</view>
</view>
</template>
<style lang="scss" scoped>
.login-page {
min-height: 100vh;
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));
}
.login-title {
font-size: 56rpx;
font-weight: 700;
color: var(--u-type-primary);
}
.login-subtitle {
margin: 16rpx 0 80rpx;
font-size: 24rpx;
color: $u-tips-color;
}
.login-form {
width: 100%;
display: flex;
flex-direction: column;
gap: 40rpx;
}
.login-lang {
margin-top: 48rpx;
display: flex;
align-items: center;
gap: 8rpx;
font-size: 26rpx;
}
.login-lang__label {
color: $u-tips-color;
}
.login-lang__switch {
color: var(--u-type-primary);
font-weight: 600;
}
</style>