chore: prepare anxinyan release

This commit is contained in:
wushumin
2026-05-25 14:53:59 +08:00
parent 21360a6a2c
commit fa8c9015d9
26 changed files with 2124 additions and 120 deletions

View File

@@ -373,6 +373,7 @@ export interface ReportDetailData {
report_title: string;
report_status: string;
service_provider: string;
service_provider_text: string;
institution_name: string;
publish_time: string;
zhongjian_report_no: string;

View File

@@ -22,6 +22,30 @@ export interface LoginResult {
user_info: AuthUserInfo;
}
export interface WechatAuthConfig {
appid: string;
oauth_redirect_url: string;
enabled: boolean;
scope: string;
state: string;
}
export interface WechatExchangeResult {
status: "logged_in" | "need_bind";
token?: string;
user_info?: AuthUserInfo;
bind_ticket?: string;
expire_seconds?: number;
profile?: {
nickname: string;
avatar: string;
};
}
export interface WechatBindMobileResult extends LoginResult {
status: "logged_in";
}
export const authApi = {
sendLoginCode(mobile: string) {
return request<SendLoginCodeResult>("/api/app/auth/send-code", {
@@ -41,6 +65,25 @@ export const authApi = {
data: { mobile, password },
});
},
getWechatConfig() {
return request<WechatAuthConfig>("/api/app/auth/wechat/config");
},
exchangeWechatCode(code: string, state: string) {
return request<WechatExchangeResult>("/api/app/auth/wechat/exchange", {
method: "POST",
data: { code, state },
});
},
bindWechatMobile(payload: {
bind_ticket: string;
mobile: string;
code: string;
}) {
return request<WechatBindMobileResult>("/api/app/auth/wechat/bind-mobile", {
method: "POST",
data: payload,
});
},
getMe() {
return request<{ user_info: AuthUserInfo }>("/api/app/auth/me");
},

View File

@@ -387,6 +387,8 @@ export const reportDetailFallback: ReportDetailData = {
{ label: "检测结论", value: "正品", remark: "综合当前送检资料与商品特征判断,符合正品特征。" },
{ label: "品牌", value: "Rolex" },
{ label: "主体颜色", value: "银盘" },
{ label: "服务类型", value: "中检鉴定" },
{ label: "鉴定师", value: "张师傅" },
],
},
trace_info: {
@@ -422,6 +424,7 @@ export const reportDetailFallback: ReportDetailData = {
report_title: "中检鉴定报告",
report_status: "published",
service_provider: "zhongjian",
service_provider_text: "中检鉴定",
institution_name: "中检鉴定中心",
publish_time: "2026-04-18 18:26:00",
zhongjian_report_no: "ZJ-20260418-0001",

View File

@@ -6,6 +6,12 @@
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/auth/wechat-bind",
"style": {
"navigationBarTitleText": "绑定手机号"
}
},
{
"path": "pages/home/index",
"style": {

View File

@@ -4,7 +4,19 @@ import { onLoad } from "@dcloudio/uni-app";
import { authApi } from "../../api/auth";
import { useAppraisalStore } from "../../stores/appraisal";
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
import { isLoggedIn, isWechatBrowser, navigateAfterLogin, setUserToken } from "../../utils/auth";
import {
clearWechatBindSession,
clearWechatOAuthState,
consumeWechatOAuthSuppression,
getWechatOAuthState,
isLoggedIn,
isWechatBrowser,
navigateAfterLogin,
rememberLoginRedirect,
setUserToken,
setWechatBindSession,
setWechatOAuthState,
} from "../../utils/auth";
type LoginMode = "code" | "password";
const COUNTDOWN_STORAGE_KEY = "anxinyan_login_code_countdown_expire_at";
@@ -12,6 +24,8 @@ const COUNTDOWN_STORAGE_KEY = "anxinyan_login_code_countdown_expire_at";
const mode = ref<LoginMode>("code");
const sending = ref(false);
const submitting = ref(false);
const wechatProcessing = ref(false);
const wechatMessage = ref("");
const countdown = ref(0);
const redirect = ref("");
const sendCodeErrorMessage = ref("");
@@ -27,7 +41,7 @@ let countdownTimer: ReturnType<typeof setInterval> | null = null;
const browserHint = computed(() =>
isWechatBrowser()
? "微信内也支持手机号快捷登录,后续可继续补充微信授权。"
? "微信内将自动使用公众号授权登录;授权失败时也可继续用手机号登录。"
: "当前为非微信浏览器环境,可直接使用手机号验证码或密码登录。",
);
@@ -129,6 +143,138 @@ function goHome() {
uni.reLaunch({ url: "/pages/home/index" });
}
function currentQueryValue(key: string) {
// #ifdef H5
const url = new URL(window.location.href);
const directValue = url.searchParams.get(key);
if (directValue) {
return directValue;
}
const hashQuery = window.location.hash.includes("?") ? window.location.hash.split("?")[1] : "";
return new URLSearchParams(hashQuery).get(key) || "";
// #endif
return "";
}
function cleanWechatCallbackQuery() {
// #ifdef H5
const url = new URL(window.location.href);
const hashHasCallback = window.location.hash.includes("?")
&& (new URLSearchParams(window.location.hash.split("?")[1]).has("code")
|| new URLSearchParams(window.location.hash.split("?")[1]).has("state"));
if (!url.searchParams.has("code") && !url.searchParams.has("state") && !hashHasCallback) {
return;
}
url.searchParams.delete("code");
url.searchParams.delete("state");
if (window.location.hash.includes("?")) {
const [hashPath, hashQuery = ""] = window.location.hash.split("?");
const hashParams = new URLSearchParams(hashQuery);
hashParams.delete("code");
hashParams.delete("state");
const nextQuery = hashParams.toString();
url.hash = `${hashPath}${nextQuery ? `?${nextQuery}` : ""}`;
}
window.history.replaceState({}, document.title, url.toString());
// #endif
}
function buildWechatAuthorizeUrl(config: Awaited<ReturnType<typeof authApi.getWechatConfig>>) {
const params = [
`appid=${encodeURIComponent(config.appid)}`,
`redirect_uri=${encodeURIComponent(config.oauth_redirect_url)}`,
"response_type=code",
`scope=${encodeURIComponent(config.scope || "snsapi_userinfo")}`,
`state=${encodeURIComponent(config.state)}`,
].join("&");
return `https://open.weixin.qq.com/connect/oauth2/authorize?${params}#wechat_redirect`;
}
async function startWechatOAuth() {
if (wechatProcessing.value || !isWechatBrowser() || isLoggedIn()) {
return;
}
wechatProcessing.value = true;
wechatMessage.value = "正在准备微信授权";
try {
const config = await authApi.getWechatConfig();
if (!config.enabled) {
wechatMessage.value = "微信授权暂未启用,请使用手机号登录";
return;
}
setWechatOAuthState(config.state);
rememberLoginRedirect(redirect.value || "/pages/mine/index");
// #ifdef H5
window.location.href = buildWechatAuthorizeUrl(config);
// #endif
} catch (error) {
wechatMessage.value = "微信授权暂不可用,请使用手机号登录";
showErrorToast(error, "微信授权失败");
} finally {
setTimeout(() => {
wechatProcessing.value = false;
}, 800);
}
}
async function handleWechatCallback() {
const code = currentQueryValue("code");
const state = currentQueryValue("state");
if (!code) {
if (consumeWechatOAuthSuppression()) {
wechatMessage.value = "可继续使用手机号登录";
return;
}
await startWechatOAuth();
return;
}
const expectedState = getWechatOAuthState();
if (expectedState && state !== expectedState) {
clearWechatOAuthState();
cleanWechatCallbackQuery();
showErrorToast(new Error("微信授权状态不匹配,请重新登录"), "微信授权失败");
return;
}
wechatProcessing.value = true;
wechatMessage.value = "正在完成微信登录";
try {
const result = await authApi.exchangeWechatCode(code, state);
clearWechatOAuthState();
cleanWechatCallbackQuery();
if (result.status === "logged_in" && result.token) {
clearWechatBindSession();
setUserToken(result.token);
appraisalStore.resetForNewFlow();
showInfoToast("登录成功");
navigateAfterLogin(redirect.value || "/pages/mine/index");
return;
}
if (result.status === "need_bind" && result.bind_ticket) {
setWechatBindSession(result.bind_ticket, result.profile);
const bindUrl = `/pages/auth/wechat-bind${redirect.value ? `?redirect=${encodeURIComponent(redirect.value)}` : ""}`;
uni.redirectTo({ url: bindUrl });
return;
}
throw new Error("微信授权结果异常,请使用手机号登录");
} catch (error) {
clearWechatOAuthState();
cleanWechatCallbackQuery();
wechatMessage.value = "微信授权失败,可使用手机号登录";
showErrorToast(error, "微信授权失败");
} finally {
wechatProcessing.value = false;
}
}
async function handleSendCode() {
if (sending.value || countdown.value > 0) return;
if (!validateMobile()) return;
@@ -191,6 +337,11 @@ onLoad((options) => {
restoreCountdown();
if (isLoggedIn()) {
navigateAfterLogin(redirect.value || "/pages/mine/index");
return;
}
if (isWechatBrowser()) {
handleWechatCallback();
}
});
@@ -234,6 +385,14 @@ onUnmounted(clearCountdown);
</view>
<view class="auth-panel">
<view v-if="wechatProcessing || wechatMessage" class="auth-wechat-status">
<view class="auth-wechat-status__icon"></view>
<view>
<view class="auth-wechat-status__title">{{ wechatProcessing ? "微信授权登录" : "微信授权提示" }}</view>
<view class="auth-wechat-status__desc">{{ wechatMessage || "正在打开微信授权" }}</view>
</view>
</view>
<view class="auth-switch">
<view :class="['auth-switch__item', mode === 'code' ? 'auth-switch__item--active' : '']" @click="mode = 'code'">
验证码登录
@@ -414,6 +573,43 @@ onUnmounted(clearCountdown);
box-shadow: var(--shadow-sm);
}
.auth-wechat-status {
display: flex;
align-items: center;
gap: 18rpx;
margin-bottom: 22rpx;
padding: 20rpx 22rpx;
border-radius: 16rpx;
background: #edf7f0;
border: 1px solid rgba(47, 107, 79, 0.14);
}
.auth-wechat-status__icon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
background: #2f6b4f;
color: #ffffff;
font-size: 26rpx;
font-weight: 700;
line-height: 64rpx;
text-align: center;
flex-shrink: 0;
}
.auth-wechat-status__title {
color: #244f3b;
font-size: 26rpx;
font-weight: 700;
}
.auth-wechat-status__desc {
margin-top: 6rpx;
color: #4f7662;
font-size: 22rpx;
line-height: 1.6;
}
.auth-switch {
display: grid;
grid-template-columns: repeat(2, 1fr);

View File

@@ -0,0 +1,444 @@
<script setup lang="ts">
import { computed, onUnmounted, reactive, ref, watch } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { authApi } from "../../api/auth";
import { useAppraisalStore } from "../../stores/appraisal";
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
import {
clearWechatBindSession,
getWechatBindProfile,
getWechatBindTicket,
navigateAfterLogin,
setUserToken,
suppressNextWechatOAuth,
} from "../../utils/auth";
const COUNTDOWN_STORAGE_KEY = "anxinyan_wechat_bind_code_countdown_expire_at";
const redirect = ref("");
const sending = ref(false);
const submitting = ref(false);
const countdown = ref(0);
const sendCodeErrorMessage = ref("");
const bindTicket = ref("");
const profile = ref({ nickname: "", avatar: "" });
const appraisalStore = useAppraisalStore();
const form = reactive({
mobile: "",
code: "",
});
let countdownTimer: ReturnType<typeof setInterval> | null = null;
const sendButtonText = computed(() => (countdown.value > 0 ? `${countdown.value}s 后重发` : "发送验证码"));
const displayName = computed(() => profile.value.nickname || "微信用户");
const displayAvatar = computed(() => profile.value.avatar || "");
function resolveSendCodeError(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
if (message.includes("触发号码天级流控")) {
return "该手机号今日获取验证码次数已达上限,请明天再试或更换手机号。";
}
if (message.includes("请") && message.includes("秒后再试")) {
return message;
}
if (message.includes("短信配置未完成")) {
return "短信发送配置尚未完成,请联系管理员在后台补全短信参数。";
}
return message || "验证码发送失败,请稍后重试。";
}
function clearCountdown() {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
uni.removeStorageSync(COUNTDOWN_STORAGE_KEY);
}
function startCountdownByExpireAt(expireAt: number) {
if (!Number.isFinite(expireAt) || expireAt <= Date.now()) {
clearCountdown();
countdown.value = 0;
return;
}
if (countdownTimer) {
clearInterval(countdownTimer);
}
uni.setStorageSync(COUNTDOWN_STORAGE_KEY, String(expireAt));
const updateCountdown = () => {
const left = Math.max(0, Math.ceil((expireAt - Date.now()) / 1000));
countdown.value = left;
if (left <= 0) {
clearCountdown();
countdown.value = 0;
}
};
updateCountdown();
countdownTimer = setInterval(updateCountdown, 1000);
}
function startCountdown(seconds = 60) {
startCountdownByExpireAt(Date.now() + seconds * 1000);
}
function restoreCountdown() {
const expireAt = Number(uni.getStorageSync(COUNTDOWN_STORAGE_KEY) || 0);
if (expireAt) {
startCountdownByExpireAt(expireAt);
}
}
function validateMobile() {
if (!/^1\d{10}$/.test(form.mobile.trim())) {
showInfoToast("请输入正确的手机号");
return false;
}
return true;
}
async function handleSendCode() {
if (sending.value || countdown.value > 0) return;
if (!validateMobile()) return;
sendCodeErrorMessage.value = "";
sending.value = true;
try {
const data = await withLoading("正在发送验证码", async () => authApi.sendLoginCode(form.mobile.trim()));
startCountdown(data.retry_after_seconds || 60);
if (data.debug_code) {
showInfoToast(`调试验证码:${data.debug_code}`);
} else {
showInfoToast("验证码已发送");
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || "");
const retryMatch = message.match(/(\d+)\s*秒后再试/);
if (retryMatch) {
startCountdown(Number(retryMatch[1]));
}
sendCodeErrorMessage.value = resolveSendCodeError(error);
} finally {
sending.value = false;
}
}
async function handleSubmit() {
if (submitting.value) return;
if (!bindTicket.value) {
showInfoToast("微信绑定凭证已失效,请重新授权");
uni.redirectTo({ url: `/pages/auth/login${redirect.value ? `?redirect=${encodeURIComponent(redirect.value)}` : ""}` });
return;
}
if (!validateMobile()) return;
if (!/^\d{6}$/.test(form.code.trim())) {
showInfoToast("请输入 6 位验证码");
return;
}
submitting.value = true;
try {
const result = await withLoading("正在绑定", async () =>
authApi.bindWechatMobile({
bind_ticket: bindTicket.value,
mobile: form.mobile.trim(),
code: form.code.trim(),
}),
);
setUserToken(result.token);
clearWechatBindSession();
appraisalStore.resetForNewFlow();
showInfoToast("绑定成功");
navigateAfterLogin(redirect.value || "/pages/mine/index");
} catch (error) {
showErrorToast(error, "绑定失败");
} finally {
submitting.value = false;
}
}
function useMobileLogin() {
clearWechatBindSession();
suppressNextWechatOAuth();
uni.redirectTo({ url: `/pages/auth/login${redirect.value ? `?redirect=${encodeURIComponent(redirect.value)}` : ""}` });
}
onLoad((options) => {
redirect.value = String(options?.redirect || "");
bindTicket.value = getWechatBindTicket();
profile.value = getWechatBindProfile();
restoreCountdown();
if (!bindTicket.value) {
showInfoToast("微信绑定凭证已失效,请重新授权");
uni.redirectTo({ url: `/pages/auth/login${redirect.value ? `?redirect=${encodeURIComponent(redirect.value)}` : ""}` });
}
});
watch(
() => form.mobile,
() => {
sendCodeErrorMessage.value = "";
},
);
onUnmounted(clearCountdown);
</script>
<template>
<view class="bind-page">
<view class="bind-shell">
<view class="bind-hero">
<view class="bind-brand-row">
<view class="bind-brand-mark"></view>
<view>
<view class="bind-brand-title">安心验</view>
<view class="bind-brand-subtitle">绑定手机号后即可完成微信登录</view>
</view>
</view>
<view class="bind-profile">
<image v-if="displayAvatar" class="bind-profile__avatar" :src="displayAvatar" mode="aspectFill" />
<view v-else class="bind-profile__avatar bind-profile__avatar--text"></view>
<view>
<view class="bind-profile__name">{{ displayName }}</view>
<view class="bind-profile__desc">首次微信登录需验证手机号</view>
</view>
</view>
</view>
<view class="bind-panel">
<view class="bind-title">绑定手机号</view>
<view class="bind-desc">同一手机号已存在账号时将直接关联到原账号不会创建重复账号</view>
<view class="bind-form">
<view class="bind-field">
<view class="bind-field__label">手机号</view>
<view class="bind-input-wrap">
<input v-model="form.mobile" class="bind-input" maxlength="11" type="number" placeholder="请输入手机号" />
</view>
</view>
<view class="bind-field">
<view class="bind-field__label">验证码</view>
<view class="bind-code-row">
<view class="bind-input-wrap bind-code-row__input">
<input v-model="form.code" class="bind-input" maxlength="6" type="number" placeholder="请输入 6 位验证码" />
</view>
<view :class="['bind-code-btn', countdown > 0 ? 'bind-code-btn--disabled' : '']" @click="handleSendCode">
{{ sending ? "发送中..." : sendButtonText }}
</view>
</view>
<view v-if="sendCodeErrorMessage" class="bind-error-banner">{{ sendCodeErrorMessage }}</view>
</view>
</view>
<view class="bind-actions">
<view class="btn btn--secondary bind-actions__button" @click="useMobileLogin">手机号登录</view>
<view :class="['btn', 'btn--primary', 'bind-actions__button', submitting ? 'btn--disabled' : '']" @click="handleSubmit">
{{ submitting ? "绑定中..." : "完成绑定" }}
</view>
</view>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.bind-page {
min-height: 100vh;
padding: 36rpx 28rpx 48rpx;
background: #f2f2f4;
}
.bind-shell {
display: grid;
gap: 28rpx;
}
.bind-hero,
.bind-panel {
border: 1px solid var(--card-border);
border-radius: 16rpx;
background: #ffffff;
box-shadow: var(--shadow-sm);
}
.bind-hero {
padding: 36rpx 34rpx;
}
.bind-brand-row {
display: flex;
align-items: center;
gap: 18rpx;
}
.bind-brand-mark {
width: 78rpx;
height: 78rpx;
border-radius: 16rpx;
background: #edbd00;
color: #ffffff;
font-size: 36rpx;
font-weight: 700;
line-height: 78rpx;
text-align: center;
}
.bind-brand-title {
color: #252527;
font-size: 38rpx;
font-weight: 700;
line-height: 1.1;
}
.bind-brand-subtitle {
margin-top: 8rpx;
color: #777;
font-size: 22rpx;
line-height: 1.6;
}
.bind-profile {
display: flex;
align-items: center;
gap: 18rpx;
margin-top: 34rpx;
padding: 22rpx;
border-radius: 16rpx;
background: #edf7f0;
border: 1px solid rgba(47, 107, 79, 0.14);
}
.bind-profile__avatar {
width: 76rpx;
height: 76rpx;
border-radius: 16rpx;
flex-shrink: 0;
}
.bind-profile__avatar--text {
background: #2f6b4f;
color: #ffffff;
font-size: 30rpx;
font-weight: 700;
line-height: 76rpx;
text-align: center;
}
.bind-profile__name {
color: #244f3b;
font-size: 28rpx;
font-weight: 700;
}
.bind-profile__desc {
margin-top: 6rpx;
color: #4f7662;
font-size: 22rpx;
line-height: 1.6;
}
.bind-panel {
padding: 30rpx 28rpx;
}
.bind-title {
color: #252527;
font-size: 44rpx;
font-weight: 800;
line-height: 1.16;
}
.bind-desc {
margin-top: 14rpx;
color: #666;
font-size: 26rpx;
line-height: 1.7;
}
.bind-form {
display: grid;
gap: 24rpx;
margin-top: 30rpx;
}
.bind-field__label {
margin-bottom: 12rpx;
color: #252527;
font-size: 24rpx;
font-weight: 600;
}
.bind-input-wrap {
display: flex;
align-items: center;
min-height: 92rpx;
padding: 0 24rpx;
border: 1px solid #ededf0;
border-radius: 16rpx;
background: #fff;
}
.bind-input {
width: 100%;
color: #252527;
font-size: 28rpx;
}
.bind-code-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 212rpx;
gap: 14rpx;
align-items: center;
}
.bind-code-row__input {
min-width: 0;
}
.bind-code-btn {
display: flex;
align-items: center;
justify-content: center;
min-height: 92rpx;
border-radius: 16rpx;
background: rgba(237, 189, 0, 0.12);
color: #c89b00;
font-size: 24rpx;
font-weight: 600;
border: 1px solid rgba(237, 189, 0, 0.24);
}
.bind-code-btn--disabled {
opacity: 0.52;
}
.bind-error-banner {
margin-top: 14rpx;
padding: 18rpx 20rpx;
border-radius: 16rpx;
background: rgba(159, 59, 50, 0.08);
border: 1px solid rgba(159, 59, 50, 0.16);
color: #9f3b32;
font-size: 22rpx;
line-height: 1.7;
}
.bind-actions {
display: flex;
gap: 16rpx;
margin-top: 30rpx;
}
.bind-actions__button {
min-width: 0;
}
</style>

View File

@@ -6,6 +6,7 @@ import { reportDetailFallback } from "../../mocks/app";
import { resolveErrorMessage } from "../../utils/feedback";
type ReportTab = "product" | "trace";
type ProductDisplayItem = ReportDetailData["product_display"]["items"][number];
const detail = ref<ReportDetailData>(reportDetailFallback);
const downloading = ref(false);
@@ -37,12 +38,35 @@ const institutionName = computed(() =>
|| "-",
);
const productItems = computed(() => {
const items = detail.value.product_display?.items || [];
if (items.length) return items;
return [
{ label: "检测结论", value: detail.value.result_info.result_text || "-", remark: detail.value.result_info.result_desc || "" },
{ label: "品牌", value: detail.value.product_info.brand_name || "-" },
];
const items: ProductDisplayItem[] = [];
const displayItems = detail.value.product_display?.items || [];
const baseItems = displayItems.length
? displayItems
: [
{ label: "检测结论", value: detail.value.result_info.result_text || "-", remark: detail.value.result_info.result_desc || "" },
{ label: "品类", value: detail.value.product_info.category_name || "" },
{ label: "品牌", value: detail.value.product_info.brand_name || "" },
{ label: "颜色", value: detail.value.product_info.color || "" },
{ label: "规格/尺寸", value: detail.value.product_info.size_spec || "" },
{ label: "序列号/编码", value: detail.value.product_info.serial_no || "" },
];
for (const item of baseItems) {
appendProductItem(items, item.label, item.value, item.remark);
}
appendProductItem(
items,
"服务类型",
detail.value.report_header.service_provider_text || serviceProviderText(detail.value.report_header.service_provider),
);
const appraiserName = textValue(detail.value.appraisal_info?.appraiser_name)
|| textValue(detail.value.appraisal_info?.reviewer_name)
|| textValue(detail.value.report_header.report_entry_admin_name);
appendProductItem(items, "鉴定师", appraiserName);
return items;
});
const publishTime = computed(() => detail.value.report_header.publish_time || "-");
const resultItem = computed(() => {
@@ -74,6 +98,26 @@ const zhongjianImageFiles = computed(() => zhongjianReportFiles.value.filter((it
const zhongjianOtherFiles = computed(() => zhongjianReportFiles.value.filter((item) => item.file_type !== "image"));
const reportNo = computed(() => detail.value.report_header.report_no || "");
function appendProductItem(items: ProductDisplayItem[], label: unknown, value: unknown, remark: unknown = "") {
const labelText = textValue(label);
const valueText = textValue(value);
const remarkText = textValue(remark);
if (!labelText || (!valueText && !remarkText) || items.some((item) => item.label === labelText)) return;
items.push({
label: labelText,
value: valueText || "-",
remark: remarkText,
});
}
function textValue(value: unknown) {
return String(value ?? "").trim();
}
function serviceProviderText(serviceProvider: string) {
return serviceProvider === "zhongjian" ? "中检鉴定" : "实物鉴定";
}
function evidenceTypeText(fileType: string) {
if (fileType === "video") return "视频";
if (fileType === "pdf") return "PDF";
@@ -294,6 +338,7 @@ onLoad(async (options) => {
<template v-else>
<view class="report-shell">
<view class="report-shell__watermark" aria-hidden="true"></view>
<view class="report-cover">
<swiper v-if="reportImages.length" class="report-cover__swiper" indicator-dots circular>
<swiper-item v-for="item in reportImages" :key="item.file_url || item.file_id">
@@ -334,8 +379,6 @@ onLoad(async (options) => {
</view>
<view v-if="activeTab === 'product'" class="report-panel">
<view class="report-watermark" aria-hidden="true"></view>
<view class="report-result">
<view class="report-result__content">
<view class="report-result__label">{{ resultItem.label }}</view>
@@ -343,8 +386,8 @@ onLoad(async (options) => {
<view v-if="resultItem.remark" class="report-result__desc">{{ resultItem.remark }}</view>
</view>
<view class="report-seal">
<text class="report-seal__brand">ANXINYAN</text>
<text class="report-seal__main">可信</text>
<text class="report-seal__brand">安心验</text>
<text class="report-seal__main">鉴定</text>
</view>
</view>
@@ -490,6 +533,26 @@ onLoad(async (options) => {
box-shadow: 0 18rpx 48rpx rgba(31, 36, 48, 0.08);
}
.report-shell__watermark {
position: absolute;
z-index: 0;
top: 374rpx;
left: 28rpx;
right: 28rpx;
height: 560rpx;
background: url("../../static/report/report-watermark.svg") center / 100% 100% no-repeat;
opacity: 1;
pointer-events: none;
}
.report-cover,
.report-meta,
.report-tabs,
.report-panel {
position: relative;
z-index: 1;
}
.report-cover {
height: 356rpx;
margin: 28rpx 28rpx 0;
@@ -614,28 +677,14 @@ onLoad(async (options) => {
overflow: hidden;
}
.report-watermark {
position: absolute;
top: -6rpx;
left: 50%;
width: 520rpx;
height: 430rpx;
border-radius: 50%;
opacity: 0.46;
transform: translateX(-50%);
background:
repeating-radial-gradient(ellipse at center, rgba(230, 195, 79, 0.2) 0, rgba(230, 195, 79, 0.2) 2rpx, transparent 3rpx, transparent 17rpx),
repeating-conic-gradient(from 0deg, rgba(230, 195, 79, 0.12) 0deg 8deg, transparent 8deg 16deg);
pointer-events: none;
}
.report-result {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
gap: 24rpx;
padding: 10rpx 0 30rpx;
min-height: 132rpx;
padding: 10rpx 136rpx 30rpx 0;
border: 0;
border-bottom: 1px solid #e5e5e5;
border-radius: 0;
@@ -672,29 +721,57 @@ onLoad(async (options) => {
}
.report-seal {
flex: 0 0 auto;
position: absolute;
right: 2rpx;
bottom: 22rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 104rpx;
height: 104rpx;
margin-top: 2rpx;
border: 4rpx solid rgba(56, 164, 73, 0.8);
width: 106rpx;
height: 106rpx;
border: 4rpx solid rgba(40, 151, 73, 0.82);
border-radius: 999rpx;
color: #39a54b;
transform: rotate(-10deg);
background: rgba(255, 255, 255, 0.42);
color: #239245;
box-shadow: inset 0 0 0 4rpx rgba(40, 151, 73, 0.1);
transform: rotate(-9deg);
}
.report-seal::before {
position: absolute;
inset: 10rpx;
border: 2rpx solid rgba(40, 151, 73, 0.58);
border-radius: inherit;
content: "";
}
.report-seal::after {
position: absolute;
left: 50%;
bottom: 12rpx;
width: 34rpx;
height: 5rpx;
border-radius: 999rpx;
background: currentColor;
content: "";
opacity: 0.7;
transform: translateX(-50%);
}
.report-seal__brand {
font-size: 16rpx;
font-weight: 800;
position: relative;
z-index: 1;
font-size: 18rpx;
font-weight: 900;
line-height: 1;
}
.report-seal__main {
margin-top: 8rpx;
font-size: 28rpx;
position: relative;
z-index: 1;
margin-top: 9rpx;
font-size: 26rpx;
font-weight: 900;
line-height: 1;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 584">
<g fill="none" stroke="#E5BE39" stroke-linecap="round" stroke-linejoin="round">
<circle cx="320" cy="292" r="248" opacity=".16" stroke-width="2"/>
<circle cx="320" cy="292" r="210" opacity=".14" stroke-width="2"/>
<circle cx="320" cy="292" r="170" opacity=".12" stroke-width="2"/>
<circle cx="320" cy="292" r="118" opacity=".11" stroke-width="2"/>
<circle cx="320" cy="292" r="74" opacity=".12" stroke-width="2"/>
<g opacity=".11" stroke-width="1.6">
<ellipse cx="320" cy="292" rx="58" ry="234"/>
<ellipse cx="320" cy="292" rx="58" ry="234" transform="rotate(15 320 292)"/>
<ellipse cx="320" cy="292" rx="58" ry="234" transform="rotate(30 320 292)"/>
<ellipse cx="320" cy="292" rx="58" ry="234" transform="rotate(45 320 292)"/>
<ellipse cx="320" cy="292" rx="58" ry="234" transform="rotate(60 320 292)"/>
<ellipse cx="320" cy="292" rx="58" ry="234" transform="rotate(75 320 292)"/>
<ellipse cx="320" cy="292" rx="58" ry="234" transform="rotate(90 320 292)"/>
<ellipse cx="320" cy="292" rx="58" ry="234" transform="rotate(105 320 292)"/>
<ellipse cx="320" cy="292" rx="58" ry="234" transform="rotate(120 320 292)"/>
<ellipse cx="320" cy="292" rx="58" ry="234" transform="rotate(135 320 292)"/>
<ellipse cx="320" cy="292" rx="58" ry="234" transform="rotate(150 320 292)"/>
<ellipse cx="320" cy="292" rx="58" ry="234" transform="rotate(165 320 292)"/>
</g>
<g opacity=".18" stroke-width="2">
<path d="M320 88c34 54 69 86 128 106-59 20-94 52-128 106-34-54-69-86-128-106 59-20 94-52 128-106Z"/>
<path d="M320 284c28 45 58 73 107 90-49 17-79 45-107 90-28-45-58-73-107-90 49-17 79-45 107-90Z"/>
</g>
<g opacity=".1" stroke-width="1.4">
<path d="M96 292h448"/>
<path d="M320 68v448"/>
<path d="M160 132l320 320"/>
<path d="M480 132 160 452"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,5 +1,9 @@
const TOKEN_KEY = "anxinyan_user_token";
const LOGIN_REDIRECT_KEY = "anxinyan_user_login_redirect";
const WECHAT_BIND_TICKET_KEY = "anxinyan_wechat_bind_ticket";
const WECHAT_BIND_PROFILE_KEY = "anxinyan_wechat_bind_profile";
const WECHAT_OAUTH_STATE_KEY = "anxinyan_wechat_oauth_state";
const WECHAT_OAUTH_SUPPRESS_KEY = "anxinyan_wechat_oauth_suppress_once";
const TABBAR_PAGES = new Set([
"/pages/home/index",
@@ -16,6 +20,7 @@ const PUBLIC_PAGES = new Set([
"/pages/verify/result",
"/pages/material-tag/detail",
"/pages/auth/login",
"/pages/auth/wechat-bind",
]);
let redirecting = false;
@@ -56,6 +61,12 @@ export function isLoggedIn() {
return getUserToken() !== "";
}
export function rememberLoginRedirect(targetUrl: string) {
if (targetUrl) {
uni.setStorageSync(LOGIN_REDIRECT_KEY, targetUrl);
}
}
export function buildAuthHeaders(headers: Record<string, string> = {}) {
const token = getUserToken();
if (!token) {
@@ -75,6 +86,61 @@ export function isWechatBrowser() {
return false;
}
export function getWechatOAuthState() {
return String(uni.getStorageSync(WECHAT_OAUTH_STATE_KEY) || "");
}
export function setWechatOAuthState(state: string) {
uni.setStorageSync(WECHAT_OAUTH_STATE_KEY, state);
}
export function clearWechatOAuthState() {
uni.removeStorageSync(WECHAT_OAUTH_STATE_KEY);
}
export function suppressNextWechatOAuth() {
uni.setStorageSync(WECHAT_OAUTH_SUPPRESS_KEY, "1");
}
export function consumeWechatOAuthSuppression() {
const suppressed = String(uni.getStorageSync(WECHAT_OAUTH_SUPPRESS_KEY) || "") === "1";
if (suppressed) {
uni.removeStorageSync(WECHAT_OAUTH_SUPPRESS_KEY);
}
return suppressed;
}
export function setWechatBindSession(bindTicket: string, profile?: { nickname?: string; avatar?: string }) {
uni.setStorageSync(WECHAT_BIND_TICKET_KEY, bindTicket);
uni.setStorageSync(WECHAT_BIND_PROFILE_KEY, JSON.stringify(profile || {}));
}
export function getWechatBindTicket() {
return String(uni.getStorageSync(WECHAT_BIND_TICKET_KEY) || "");
}
export function getWechatBindProfile() {
const raw = String(uni.getStorageSync(WECHAT_BIND_PROFILE_KEY) || "");
if (!raw) {
return { nickname: "", avatar: "" };
}
try {
const parsed = JSON.parse(raw) as { nickname?: string; avatar?: string };
return {
nickname: String(parsed.nickname || ""),
avatar: String(parsed.avatar || ""),
};
} catch {
return { nickname: "", avatar: "" };
}
}
export function clearWechatBindSession() {
uni.removeStorageSync(WECHAT_BIND_TICKET_KEY);
uni.removeStorageSync(WECHAT_BIND_PROFILE_KEY);
}
export function isPublicPage(urlOrPath: string) {
const { path } = splitUrl(urlOrPath);
return PUBLIC_PAGES.has(path);
@@ -100,9 +166,7 @@ export function redirectToLogin(targetUrl?: string) {
return;
}
if (currentUrl) {
uni.setStorageSync(LOGIN_REDIRECT_KEY, currentUrl);
}
rememberLoginRedirect(currentUrl);
redirecting = true;
uni.navigateTo({