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

@@ -1346,6 +1346,7 @@ export interface AdminSystemConfigGroupItem {
placeholder: string; placeholder: string;
remark: string; remark: string;
is_secret: boolean; is_secret: boolean;
read_only?: boolean;
value: string; value: string;
options?: Array<{ options?: Array<{
label: string; label: string;

View File

@@ -36,6 +36,7 @@ async function fetchConfigs() {
try { try {
const response = await adminApi.getSystemConfigs(); const response = await adminApi.getSystemConfigs();
groups.value = sortGroups(response.data.groups); groups.value = sortGroups(response.data.groups);
groups.value.forEach(applyDerivedConfigValues);
groupSnapshots.value = cloneSnapshot(groups.value); groupSnapshots.value = cloneSnapshot(groups.value);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -63,9 +64,52 @@ function markFieldSnapshot(groupCode: string, configKey: string, value: string)
}; };
} }
function normalizeH5PageBaseUrl(value: string) {
let baseUrl = value.trim();
if (!baseUrl) return "";
const hashIndex = baseUrl.indexOf("#");
if (hashIndex !== -1) {
baseUrl = baseUrl.slice(0, hashIndex);
}
if (!/^https?:\/\//i.test(baseUrl)) {
baseUrl = `https://${baseUrl.replace(/^\/+/, "")}`;
}
return baseUrl.replace(/\/+$/, "");
}
function buildH5OAuthRedirectUrl(pageBaseUrl: string) {
const baseUrl = normalizeH5PageBaseUrl(pageBaseUrl);
return baseUrl ? `${baseUrl}/#/pages/auth/login` : "";
}
function applyDerivedConfigValues(group: AdminSystemConfigGroupItem) {
if (group.group_code !== "h5") return;
const pageBaseUrl = group.items.find((item) => item.config_key === "page_base_url");
const oauthRedirectUrl = group.items.find((item) => item.config_key === "oauth_redirect_url");
if (!oauthRedirectUrl) return;
oauthRedirectUrl.value = buildH5OAuthRedirectUrl(pageBaseUrl?.value || "");
}
function handleFieldValueChange(
group: AdminSystemConfigGroupItem,
item: AdminSystemConfigGroupItem["items"][number],
value: string | number,
) {
if (item.read_only) return;
item.value = String(value ?? "");
applyDerivedConfigValues(group);
}
async function saveGroup(group: AdminSystemConfigGroupItem) { async function saveGroup(group: AdminSystemConfigGroupItem) {
savingGroupCode.value = group.group_code; savingGroupCode.value = group.group_code;
try { try {
applyDerivedConfigValues(group);
const items = group.items.map((item) => ({ const items = group.items.map((item) => ({
config_group: group.group_code, config_group: group.group_code,
config_key: item.config_key, config_key: item.config_key,
@@ -216,17 +260,21 @@ onMounted(fetchConfigs);
</el-select> </el-select>
<el-input <el-input
v-else-if="item.field_type !== 'textarea'" v-else-if="item.field_type !== 'textarea'"
v-model="item.value" :model-value="item.value"
:type="item.field_type === 'password' ? 'password' : 'text'" :type="item.field_type === 'password' ? 'password' : 'text'"
show-password show-password
:disabled="item.read_only"
:placeholder="item.placeholder" :placeholder="item.placeholder"
@update:model-value="handleFieldValueChange(group, item, $event)"
/> />
<el-input <el-input
v-else v-else
v-model="item.value" :model-value="item.value"
type="textarea" type="textarea"
:rows="5" :rows="5"
:disabled="item.read_only"
:placeholder="item.placeholder" :placeholder="item.placeholder"
@update:model-value="handleFieldValueChange(group, item, $event)"
/> />
<div style="margin-top: 6px; color: var(--admin-text-subtle); font-size: 12px;"> <div style="margin-top: 6px; color: var(--admin-text-subtle); font-size: 12px;">
{{ item.remark }} {{ item.remark }}

View File

@@ -10,7 +10,7 @@
## 2. 后台系统配置 ## 2. 后台系统配置
- 在后台 `系统配置` 中填写并保存: - 在后台 `系统配置` 中填写并保存:
- 小程序 `AppID / AppSecret / 原始ID` - 小程序 `AppID / AppSecret / 原始ID`
- H5 `AppID / AppSecret / OAuth 回调地址 / H5 页面根地址` - H5 `AppID / AppSecret / H5 页面根地址``OAuth 回调地址` 会由 H5 页面根地址自动生成
- 短信 `阿里云 AccessKey ID / AccessKey Secret / 短信签名 / 登录模板 Code / Region ID` - 短信 `阿里云 AccessKey ID / AccessKey Secret / 短信签名 / 登录模板 Code / Region ID`
- 支付 `MchID / APIv3 Key / 商户证书序列号 / 商户私钥 / 平台证书序列号 / 支付回调地址` - 支付 `MchID / APIv3 Key / 商户证书序列号 / 商户私钥 / 平台证书序列号 / 支付回调地址`
- 严禁保留演示值: - 严禁保留演示值:
@@ -50,6 +50,7 @@
- `cd user-app && npm run build:mp-weixin` - `cd user-app && npm run build:mp-weixin`
- 构建前确认 [user-app/src/manifest.json](/Users/wushumin/www/biyou/anxinyan/user-app/src/manifest.json) 中 `mp-weixin.appid` 已同步为正式值 - 构建前确认 [user-app/src/manifest.json](/Users/wushumin/www/biyou/anxinyan/user-app/src/manifest.json) 中 `mp-weixin.appid` 已同步为正式值
- 确认后台 `H5 页面根地址` 指向正式 H5 域名,例如 `https://m.example.com`,用于生成扫码查看报告和验真页链接 - 确认后台 `H5 页面根地址` 指向正式 H5 域名,例如 `https://m.example.com`,用于生成扫码查看报告和验真页链接
- 后台 `H5 授权回调地址` 应自动显示为 `H5 页面根地址 + /#/pages/auth/login`
- H5 授权域名、支付域名、回调域名已在微信平台完成配置 - H5 授权域名、支付域名、回调域名已在微信平台完成配置
- 微信支付商户平台证书与 APIv3 Key 已完成正式部署 - 微信支付商户平台证书与 APIv3 Key 已完成正式部署

View File

@@ -8,6 +8,8 @@ use support\think\Db;
class SystemConfigsController class SystemConfigsController
{ {
private const H5_OAUTH_REDIRECT_HASH_PATH = '/#/pages/auth/login';
public function index(Request $request) public function index(Request $request)
{ {
$this->bootstrapDefaults(); $this->bootstrapDefaults();
@@ -23,6 +25,7 @@ class SystemConfigsController
foreach ($configs as $item) { foreach ($configs as $item) {
$configMap[$item['config_group'] . '.' . $item['config_key']] = $item['config_value'] ?? ''; $configMap[$item['config_group'] . '.' . $item['config_key']] = $item['config_value'] ?? '';
} }
$this->applyDerivedConfigValues($configMap);
$groups = []; $groups = [];
foreach ($this->definitions() as $groupCode => $group) { foreach ($this->definitions() as $groupCode => $group) {
@@ -38,6 +41,7 @@ class SystemConfigsController
'placeholder' => $item['placeholder'], 'placeholder' => $item['placeholder'],
'remark' => $item['remark'], 'remark' => $item['remark'],
'is_secret' => (bool)$item['is_secret'], 'is_secret' => (bool)$item['is_secret'],
'read_only' => (bool)($item['read_only'] ?? false),
'options' => $item['options'] ?? [], 'options' => $item['options'] ?? [],
'visible_when' => $item['visible_when'] ?? null, 'visible_when' => $item['visible_when'] ?? null,
'value' => $configMap[$groupCode . '.' . $item['config_key']] ?? '', 'value' => $configMap[$groupCode . '.' . $item['config_key']] ?? '',
@@ -74,6 +78,7 @@ class SystemConfigsController
} }
} }
$submittedConfigKeys = [];
foreach ($items as $item) { foreach ($items as $item) {
if (!is_array($item)) { if (!is_array($item)) {
continue; continue;
@@ -87,6 +92,18 @@ class SystemConfigsController
} }
$configValueMap[$mapKey] = (string)($item['config_value'] ?? ''); $configValueMap[$mapKey] = (string)($item['config_value'] ?? '');
$submittedConfigKeys[$mapKey] = [
'config_group' => $groupCode,
'config_key' => $configKey,
];
}
$this->applyDerivedConfigValues($configValueMap);
if (isset($submittedConfigKeys['h5.page_base_url']) || isset($submittedConfigKeys['h5.oauth_redirect_url'])) {
$submittedConfigKeys['h5.oauth_redirect_url'] = [
'config_group' => 'h5',
'config_key' => 'oauth_redirect_url',
];
} }
try { try {
@@ -99,14 +116,10 @@ class SystemConfigsController
Db::startTrans(); Db::startTrans();
try { try {
foreach ($items as $item) { foreach ($submittedConfigKeys as $mapKey => $configMeta) {
if (!is_array($item)) { $groupCode = $configMeta['config_group'];
continue; $configKey = $configMeta['config_key'];
} $configValue = (string)($configValueMap[$mapKey] ?? '');
$groupCode = trim((string)($item['config_group'] ?? ''));
$configKey = trim((string)($item['config_key'] ?? ''));
$configValue = (string)($item['config_value'] ?? '');
$mapKey = $groupCode . '.' . $configKey; $mapKey = $groupCode . '.' . $configKey;
if ($groupCode === '' || $configKey === '' || !isset($allowedMap[$mapKey])) { if ($groupCode === '' || $configKey === '' || !isset($allowedMap[$mapKey])) {
@@ -405,8 +418,8 @@ class SystemConfigsController
'items' => [ 'items' => [
['config_key' => 'app_id', 'title' => 'H5 AppID', 'field_type' => 'text', 'placeholder' => '请输入 H5 AppID', 'remark' => '用于 H5 登录与开放平台接入', 'is_secret' => false], ['config_key' => 'app_id', 'title' => 'H5 AppID', 'field_type' => 'text', 'placeholder' => '请输入 H5 AppID', 'remark' => '用于 H5 登录与开放平台接入', 'is_secret' => false],
['config_key' => 'app_secret', 'title' => 'H5 AppSecret', 'field_type' => 'password', 'placeholder' => '请输入 H5 AppSecret', 'remark' => '请妥善保管,仅后台可见', 'is_secret' => true], ['config_key' => 'app_secret', 'title' => 'H5 AppSecret', 'field_type' => 'password', 'placeholder' => '请输入 H5 AppSecret', 'remark' => '请妥善保管,仅后台可见', 'is_secret' => true],
['config_key' => 'oauth_redirect_url', 'title' => '授权回调地址', 'field_type' => 'text', 'placeholder' => '请输入 H5 授权回调地址', 'remark' => '用于 H5 登录或支付回调', 'is_secret' => false], ['config_key' => 'oauth_redirect_url', 'title' => '授权回调地址', 'field_type' => 'text', 'placeholder' => '保存 H5 页面根地址后自动生成', 'remark' => ' H5 页面根地址自动拼接,无需手动填写。', 'is_secret' => false, 'read_only' => true],
['config_key' => 'page_base_url', 'title' => 'H5 页面根地址', 'field_type' => 'text', 'placeholder' => '例如 https://m.anxinyan.com', 'remark' => '用于生成扫码查看报告和验真页的完整 H5 链接', 'is_secret' => false], ['config_key' => 'page_base_url', 'title' => 'H5 页面根地址', 'field_type' => 'text', 'placeholder' => '例如 https://m.anxinjianyan.com', 'remark' => '用于生成扫码查看报告和验真页的完整 H5 链接', 'is_secret' => false],
], ],
], ],
'payment' => [ 'payment' => [
@@ -501,4 +514,38 @@ class SystemConfigsController
throw new \RuntimeException('当前已切换为七牛云存储,请至少填写公开访问域名或七牛公网访问域名'); throw new \RuntimeException('当前已切换为七牛云存储,请至少填写公开访问域名或七牛公网访问域名');
} }
} }
private function applyDerivedConfigValues(array &$configValueMap): void
{
$configValueMap['h5.oauth_redirect_url'] = $this->buildH5OAuthRedirectUrl((string)($configValueMap['h5.page_base_url'] ?? ''));
}
private function buildH5OAuthRedirectUrl(string $pageBaseUrl): string
{
$baseUrl = $this->normalizeH5PageBaseUrl($pageBaseUrl);
if ($baseUrl === '') {
return '';
}
return $baseUrl . self::H5_OAUTH_REDIRECT_HASH_PATH;
}
private function normalizeH5PageBaseUrl(string $value): string
{
$baseUrl = trim($value);
if ($baseUrl === '') {
return '';
}
$hashPos = strpos($baseUrl, '#');
if ($hashPos !== false) {
$baseUrl = substr($baseUrl, 0, $hashPos);
}
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
$baseUrl = 'https://' . ltrim($baseUrl, '/');
}
return rtrim($baseUrl, '/');
}
} }

View File

@@ -66,6 +66,59 @@ class AuthController
} }
} }
public function wechatConfig(Request $request)
{
try {
$payload = (new AppAuthService())->wechatConfig();
return api_success($payload);
} catch (\Throwable $e) {
return api_error('微信授权配置获取失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
public function wechatExchange(Request $request)
{
$code = trim((string)$request->input('code', ''));
$state = trim((string)$request->input('state', ''));
if ($code === '') {
return api_error('微信授权 code 不能为空', 422);
}
try {
$payload = (new AppAuthService())->exchangeWechatCode($code, $state, $request);
return api_success($payload, ($payload['status'] ?? '') === 'need_bind' ? '请绑定手机号' : '登录成功');
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('微信授权登录失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
public function wechatBindMobile(Request $request)
{
$bindTicket = trim((string)$request->input('bind_ticket', ''));
$mobile = trim((string)$request->input('mobile', ''));
$code = trim((string)$request->input('code', ''));
if ($bindTicket === '' || $mobile === '' || $code === '') {
return api_error('微信绑定凭证、手机号和验证码不能为空', 422);
}
try {
$payload = (new AppAuthService())->bindWechatMobile($bindTicket, $mobile, $code, $request);
return api_success($payload, '绑定成功');
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('微信绑定手机号失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
public function me(Request $request) public function me(Request $request)
{ {
$userInfo = (new AppAuthService())->current($request); $userInfo = (new AppAuthService())->current($request);

View File

@@ -104,7 +104,13 @@ class ReportsController
'valuation_snapshot' => $this->decodeJsonField($content['valuation_snapshot_json'] ?? null), 'valuation_snapshot' => $this->decodeJsonField($content['valuation_snapshot_json'] ?? null),
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice, 'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice,
]; ];
$productDisplay = $this->buildProductDisplay($reportData, $payload['product_snapshot'], $payload['result_snapshot'], $payload['valuation_snapshot']); $productDisplay = $this->buildProductDisplay(
$reportData,
$payload['product_snapshot'],
$payload['result_snapshot'],
$payload['valuation_snapshot'],
$payload['appraisal_snapshot']
);
$reportMedia = [ $reportMedia = [
'images' => $this->filterAssetsByType($evidenceAttachments, 'image'), 'images' => $this->filterAssetsByType($evidenceAttachments, 'image'),
]; ];
@@ -118,7 +124,12 @@ class ReportsController
) )
: ['visible' => false, 'nodes' => []]; : ['visible' => false, 'nodes' => []];
$traceInfo['visible'] = $traceInfoVisible; $traceInfo['visible'] = $traceInfoVisible;
$pdfUrl = $this->ensurePdfFile($request, $reportData, $content ?: [], $verify ?: [], $productDisplay, $reportMedia); $pdfProductDisplay = $productDisplay;
$pdfProductDisplay['items'] = array_values(array_filter(
$productDisplay['items'] ?? [],
fn (array $item) => ($item['label'] ?? '') !== '服务类型'
));
$pdfUrl = $this->ensurePdfFile($request, $reportData, $content ?: [], $verify ?: [], $pdfProductDisplay, $reportMedia);
return api_success([ return api_success([
'report_header' => [ 'report_header' => [
@@ -128,6 +139,7 @@ class ReportsController
'report_title' => $reportData['report_title'], 'report_title' => $reportData['report_title'],
'report_status' => $reportData['report_status'], 'report_status' => $reportData['report_status'],
'service_provider' => $reportData['service_provider'], 'service_provider' => $reportData['service_provider'],
'service_provider_text' => $this->serviceProviderText((string)$reportData['service_provider']),
'institution_name' => $this->displayInstitutionName((string)$reportData['service_provider']), 'institution_name' => $this->displayInstitutionName((string)$reportData['service_provider']),
'publish_time' => $reportData['publish_time'], 'publish_time' => $reportData['publish_time'],
'zhongjian_report_no' => (string)($reportData['zhongjian_report_no'] ?? ''), 'zhongjian_report_no' => (string)($reportData['zhongjian_report_no'] ?? ''),
@@ -283,7 +295,7 @@ class ReportsController
$generator = new ReportPdfGenerator(); $generator = new ReportPdfGenerator();
$pdfBinary = $generator->generate([ $pdfBinary = $generator->generate([
'report_title' => $report['report_title'] ?? '鉴定报告', 'report_title' => $report['report_title'] ?? '鉴定报告',
'service_provider_text' => ($report['service_provider'] ?? 'anxinyan') === 'zhongjian' ? '中检鉴定' : '实物鉴定', 'service_provider_text' => $this->serviceProviderText((string)($report['service_provider'] ?? 'anxinyan')),
'institution_name' => $this->displayInstitutionName((string)($report['service_provider'] ?? 'anxinyan')), 'institution_name' => $this->displayInstitutionName((string)($report['service_provider'] ?? 'anxinyan')),
'report_no' => $report['report_no'] ?? '', 'report_no' => $report['report_no'] ?? '',
'publish_time' => $publishTime, 'publish_time' => $publishTime,
@@ -322,7 +334,7 @@ class ReportsController
return $this->storage()->publicUrl($request, $relativePath); return $this->storage()->publicUrl($request, $relativePath);
} }
private function buildProductDisplay(array $report, array $productInfo, array $resultInfo, array $valuationInfo = []): array private function buildProductDisplay(array $report, array $productInfo, array $resultInfo, array $valuationInfo = [], array $appraisalInfo = []): array
{ {
$items = []; $items = [];
$this->appendDisplayItem( $this->appendDisplayItem(
@@ -363,6 +375,16 @@ class ReportsController
); );
} }
$this->appendDisplayItem(
$items,
'服务类型',
$this->serviceProviderText((string)($report['service_provider'] ?? 'anxinyan'))
);
$appraiserName = $this->textValue($appraisalInfo['appraiser_name'] ?? '')
?: $this->textValue($appraisalInfo['reviewer_name'] ?? '')
?: $this->textValue($report['report_entry_admin_name'] ?? '');
$this->appendDisplayItem($items, '鉴定师', $appraiserName);
$conditionGrade = $this->textValue($valuationInfo['condition_grade'] ?? ''); $conditionGrade = $this->textValue($valuationInfo['condition_grade'] ?? '');
$conditionDesc = $this->textValue($valuationInfo['condition_desc'] ?? ''); $conditionDesc = $this->textValue($valuationInfo['condition_desc'] ?? '');
if ($conditionGrade !== '' || $conditionDesc !== '') { if ($conditionGrade !== '' || $conditionDesc !== '') {
@@ -654,6 +676,11 @@ class ReportsController
return trim((string)($value ?? '')); return trim((string)($value ?? ''));
} }
private function serviceProviderText(string $serviceProvider): string
{
return $serviceProvider === 'zhongjian' ? '中检鉴定' : '实物鉴定';
}
private function displayInstitutionName(string $serviceProvider): string private function displayInstitutionName(string $serviceProvider): string
{ {
return $serviceProvider === 'zhongjian' ? '中检鉴定中心' : '安心检验'; return $serviceProvider === 'zhongjian' ? '中检鉴定中心' : '安心检验';

View File

@@ -12,7 +12,7 @@ class AppAuthMiddleware implements MiddlewareInterface
public function process(Request $request, callable $handler): Response public function process(Request $request, callable $handler): Response
{ {
$path = $request->path(); $path = $request->path();
if (!str_starts_with($path, '/api/app')) { if (strpos($path, '/api/app') !== 0) {
return $handler($request); return $handler($request);
} }
@@ -53,6 +53,9 @@ class AppAuthMiddleware implements MiddlewareInterface
'/api/app/auth/send-code', '/api/app/auth/send-code',
'/api/app/auth/login/code', '/api/app/auth/login/code',
'/api/app/auth/login/password', '/api/app/auth/login/password',
'/api/app/auth/wechat/config',
'/api/app/auth/wechat/exchange',
'/api/app/auth/wechat/bind-mobile',
], true); ], true);
} }
} }

View File

@@ -7,13 +7,33 @@ use support\think\Db;
class AppAuthService class AppAuthService
{ {
private const WECHAT_H5_AUTH_TYPE = 'wechat_h5';
private const H5_OAUTH_REDIRECT_HASH_PATH = '/#/pages/auth/login';
private const WECHAT_BIND_TICKET_TTL = 600;
public function __construct() public function __construct()
{ {
$this->ensurePasswordColumn(); $this->ensurePasswordColumn();
$this->ensureUserAuthsTable();
$this->ensureTokenTable(); $this->ensureTokenTable();
$this->ensureSmsCodeTable(); $this->ensureSmsCodeTable();
} }
public function wechatConfig(): array
{
$appId = $this->systemConfig('h5', 'app_id');
$appSecret = $this->systemConfig('h5', 'app_secret');
$redirectUrl = $this->resolveH5OAuthRedirectUrl();
return [
'appid' => $appId,
'oauth_redirect_url' => $redirectUrl,
'enabled' => $appId !== '' && $appSecret !== '' && $redirectUrl !== '',
'scope' => 'snsapi_userinfo',
'state' => $this->createWechatOAuthState(),
];
}
public function sendLoginCode(string $mobile, Request $request): array public function sendLoginCode(string $mobile, Request $request): array
{ {
$mobile = $this->normalizeMobile($mobile); $mobile = $this->normalizeMobile($mobile);
@@ -98,35 +118,8 @@ class AppAuthService
public function loginByCode(string $mobile, string $code, Request $request): array public function loginByCode(string $mobile, string $code, Request $request): array
{ {
$mobile = $this->normalizeMobile($mobile); $mobile = $this->normalizeMobile($mobile);
$code = trim($code);
if (!preg_match('/^\d{6}$/', $code)) {
throw new \RuntimeException('验证码格式不正确');
}
$record = Db::name('sms_code_logs')
->where('mobile', $mobile)
->where('scene', 'login')
->whereIn('send_status', ['success', 'mock'])
->whereNull('used_at')
->order('id', 'desc')
->find();
if (!$record) {
throw new \RuntimeException('验证码不存在或已失效');
}
if (strtotime((string)$record['expire_time']) < time()) {
throw new \RuntimeException('验证码已过期,请重新获取');
}
if (!hash_equals((string)$record['code_hash'], $this->codeHash($mobile, 'login', $code))) {
throw new \RuntimeException('验证码错误');
}
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
Db::name('sms_code_logs')->where('id', $record['id'])->update([ $this->verifyLoginCode($mobile, $code, $now);
'used_at' => $now,
'updated_at' => $now,
]);
$user = Db::name('users')->where('mobile', $mobile)->find(); $user = Db::name('users')->where('mobile', $mobile)->find();
if ($user && ($user['status'] ?? 'enabled') !== 'enabled') { if ($user && ($user['status'] ?? 'enabled') !== 'enabled') {
@@ -152,6 +145,96 @@ class AppAuthService
return $this->issueToken($userId, $request, 'sms_code'); return $this->issueToken($userId, $request, 'sms_code');
} }
public function exchangeWechatCode(string $code, string $state, Request $request): array
{
$code = trim($code);
if ($code === '') {
throw new \RuntimeException('微信授权 code 不能为空');
}
$this->verifyWechatOAuthState($state);
$config = $this->wechatConfig();
if (!$config['enabled']) {
throw new \RuntimeException('微信授权登录未启用,请先在后台补全 H5 公众号配置和页面根地址');
}
$oauthPayload = $this->fetchWechatOAuthAccessToken($code);
$profilePayload = $this->fetchWechatUserInfoIfPossible($oauthPayload);
$identity = $this->buildWechatIdentity($oauthPayload, $profilePayload, $state);
$auth = $this->findWechatAuth($identity['openid'], $identity['unionid']);
if ($auth) {
$user = Db::name('users')->where('id', $auth['user_id'])->find();
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('微信已绑定账号不存在或已停用');
}
$this->syncWechatAuth((int)$auth['user_id'], $identity, date('Y-m-d H:i:s'), (int)$auth['id']);
return array_merge([
'status' => 'logged_in',
], $this->issueToken((int)$auth['user_id'], $request, self::WECHAT_H5_AUTH_TYPE));
}
return [
'status' => 'need_bind',
'bind_ticket' => $this->createWechatBindTicket($identity),
'expire_seconds' => self::WECHAT_BIND_TICKET_TTL,
'profile' => [
'nickname' => $identity['nickname'],
'avatar' => $identity['avatar'],
],
];
}
public function bindWechatMobile(string $bindTicket, string $mobile, string $code, Request $request): array
{
$identity = $this->verifyWechatBindTicket($bindTicket);
$mobile = $this->normalizeMobile($mobile);
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$user = Db::name('users')->where('mobile', $mobile)->lock(true)->find();
if ($user && ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号已停用,无法绑定微信');
}
$this->assertWechatIdentityAvailable($identity, $user ? (int)$user['id'] : null);
$this->verifyLoginCode($mobile, $code, $now);
if (!$user) {
$userId = (int)Db::name('users')->insertGetId([
'nickname' => $identity['nickname'] !== '' ? $this->truncateText($identity['nickname'], 64) : '安心验用户' . substr($mobile, -4),
'avatar' => $this->truncateText($identity['avatar'], 255),
'mobile' => $mobile,
'password' => '',
'status' => 'enabled',
'last_login_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
} else {
$userId = (int)$user['id'];
$profilePatch = $this->buildWechatProfilePatch($user, $identity, $now);
if ($profilePatch) {
Db::name('users')->where('id', $userId)->update($profilePatch);
}
}
$this->syncMobileAuth($userId, $mobile, $now);
$this->syncWechatAuth($userId, $identity, $now);
$payload = array_merge([
'status' => 'logged_in',
], $this->issueToken($userId, $request, self::WECHAT_H5_AUTH_TYPE));
Db::commit();
return $payload;
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
}
public function loginByPassword(string $mobile, string $password, Request $request): array public function loginByPassword(string $mobile, string $password, Request $request): array
{ {
$mobile = $this->normalizeMobile($mobile); $mobile = $this->normalizeMobile($mobile);
@@ -254,6 +337,41 @@ class AppAuthService
]; ];
} }
private function verifyLoginCode(string $mobile, string $code, ?string $now = null): void
{
$code = trim($code);
if (!preg_match('/^\d{6}$/', $code)) {
throw new \RuntimeException('验证码格式不正确');
}
$record = Db::name('sms_code_logs')
->where('mobile', $mobile)
->where('scene', 'login')
->whereIn('send_status', ['success', 'mock'])
->whereNull('used_at')
->order('id', 'desc')
->find();
if (!$record) {
throw new \RuntimeException('验证码不存在或已失效');
}
if (strtotime((string)$record['expire_time']) < time()) {
throw new \RuntimeException('验证码已过期,请重新获取');
}
if (!hash_equals((string)$record['code_hash'], $this->codeHash($mobile, 'login', $code))) {
throw new \RuntimeException('验证码错误');
}
if ($now === null) {
$now = date('Y-m-d H:i:s');
}
Db::name('sms_code_logs')->where('id', $record['id'])->update([
'used_at' => $now,
'updated_at' => $now,
]);
}
private function issueToken(int $userId, Request $request, string $authType): array private function issueToken(int $userId, Request $request, string $authType): array
{ {
$user = Db::name('users')->where('id', $userId)->find(); $user = Db::name('users')->where('id', $userId)->find();
@@ -329,6 +447,381 @@ class AppAuthService
Db::name('user_auths')->insert($payload); Db::name('user_auths')->insert($payload);
} }
private function syncWechatAuth(int $userId, array $identity, string $now, ?int $preferredAuthId = null): void
{
$openid = (string)($identity['openid'] ?? '');
$unionid = (string)($identity['unionid'] ?? '');
if ($openid === '') {
throw new \RuntimeException('微信 openid 不能为空');
}
$existing = null;
if ($preferredAuthId) {
$existing = Db::name('user_auths')->where('id', $preferredAuthId)->find();
}
if (!$existing) {
$existing = Db::name('user_auths')
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
->where('auth_key', $openid)
->find();
}
if ($existing && (int)$existing['user_id'] !== $userId) {
throw new \RuntimeException('该微信已绑定其他账号,请先解绑或联系管理员');
}
if ($unionid !== '') {
$unionAuth = Db::name('user_auths')
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
->where('auth_union_id', $unionid)
->find();
if ($unionAuth && (int)$unionAuth['user_id'] !== $userId) {
throw new \RuntimeException('该微信已绑定其他账号,请先解绑或联系管理员');
}
if (!$existing && $unionAuth) {
$existing = $unionAuth;
}
}
$payload = [
'user_id' => $userId,
'auth_type' => self::WECHAT_H5_AUTH_TYPE,
'auth_key' => $openid,
'auth_open_id' => $openid,
'auth_union_id' => $unionid,
'auth_extra' => json_encode($identity['auth_extra'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'updated_at' => $now,
];
if ($existing) {
Db::name('user_auths')->where('id', $existing['id'])->update($payload);
return;
}
$payload['created_at'] = $now;
Db::name('user_auths')->insert($payload);
}
private function assertWechatIdentityAvailable(array $identity, ?int $allowedUserId): void
{
$openid = (string)($identity['openid'] ?? '');
$unionid = (string)($identity['unionid'] ?? '');
$openidAuth = Db::name('user_auths')
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
->where('auth_key', $openid)
->lock(true)
->find();
if ($openidAuth && ($allowedUserId === null || (int)$openidAuth['user_id'] !== $allowedUserId)) {
throw new \RuntimeException('该微信已绑定其他账号,请先解绑或联系管理员');
}
if ($unionid === '') {
return;
}
$unionAuth = Db::name('user_auths')
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
->where('auth_union_id', $unionid)
->lock(true)
->find();
if ($unionAuth && ($allowedUserId === null || (int)$unionAuth['user_id'] !== $allowedUserId)) {
throw new \RuntimeException('该微信已绑定其他账号,请先解绑或联系管理员');
}
}
private function findWechatAuth(string $openid, string $unionid): ?array
{
$auth = Db::name('user_auths')
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
->where('auth_key', $openid)
->find();
if ($auth) {
return $auth;
}
if ($unionid === '') {
return null;
}
$auth = Db::name('user_auths')
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
->where('auth_union_id', $unionid)
->order('id', 'asc')
->find();
return $auth ?: null;
}
private function buildWechatProfilePatch(array $user, array $identity, string $now): array
{
$patch = [];
$nickname = trim((string)($identity['nickname'] ?? ''));
$avatar = trim((string)($identity['avatar'] ?? ''));
$currentNickname = trim((string)($user['nickname'] ?? ''));
$currentAvatar = trim((string)($user['avatar'] ?? ''));
if ($nickname !== '' && ($currentNickname === '' || preg_match('/^安心验用户\d{4}$/u', $currentNickname))) {
$patch['nickname'] = $this->truncateText($nickname, 64);
}
if ($avatar !== '' && $currentAvatar === '') {
$patch['avatar'] = $this->truncateText($avatar, 255);
}
if ($patch) {
$patch['updated_at'] = $now;
}
return $patch;
}
private function fetchWechatOAuthAccessToken(string $code): array
{
if ($this->isWechatMockCode($code)) {
return $this->mockWechatOAuthPayload($code);
}
$appId = $this->systemConfig('h5', 'app_id');
$appSecret = $this->systemConfig('h5', 'app_secret');
$url = 'https://api.weixin.qq.com/sns/oauth2/access_token?' . http_build_query([
'appid' => $appId,
'secret' => $appSecret,
'code' => $code,
'grant_type' => 'authorization_code',
]);
return $this->wechatApiGet($url, '微信授权 code 换取失败');
}
private function fetchWechatUserInfoIfPossible(array $oauthPayload): array
{
$scope = (string)($oauthPayload['scope'] ?? '');
$accessToken = (string)($oauthPayload['access_token'] ?? '');
$openid = (string)($oauthPayload['openid'] ?? '');
if ($openid === '' || $accessToken === '' || strpos($scope, 'snsapi_userinfo') === false) {
return [];
}
if (strpos($accessToken, 'mock_access_token_') === 0) {
return $this->mockWechatProfilePayload($oauthPayload);
}
$url = 'https://api.weixin.qq.com/sns/userinfo?' . http_build_query([
'access_token' => $accessToken,
'openid' => $openid,
'lang' => 'zh_CN',
]);
try {
return $this->wechatApiGet($url, '微信用户资料获取失败');
} catch (\RuntimeException $e) {
return [];
}
}
private function buildWechatIdentity(array $oauthPayload, array $profilePayload, string $state): array
{
$openid = trim((string)($oauthPayload['openid'] ?? $profilePayload['openid'] ?? ''));
if ($openid === '') {
throw new \RuntimeException('微信授权返回缺少 openid请重新登录');
}
$unionid = trim((string)($profilePayload['unionid'] ?? $oauthPayload['unionid'] ?? ''));
$nickname = trim((string)($profilePayload['nickname'] ?? ''));
$avatar = trim((string)($profilePayload['headimgurl'] ?? ''));
return [
'openid' => $openid,
'unionid' => $unionid,
'nickname' => $nickname,
'avatar' => $avatar,
'auth_extra' => [
'oauth' => $this->redactWechatOAuthPayload($oauthPayload),
'profile' => $profilePayload,
'state' => $this->truncateText($state, 128),
'authorized_at' => date('Y-m-d H:i:s'),
],
];
}
private function createWechatBindTicket(array $identity): string
{
$now = time();
$payload = [
'typ' => 'wechat_h5_bind',
'openid' => (string)$identity['openid'],
'unionid' => (string)($identity['unionid'] ?? ''),
'nickname' => $this->truncateText((string)($identity['nickname'] ?? ''), 64),
'avatar' => $this->truncateText((string)($identity['avatar'] ?? ''), 255),
'auth_extra' => $identity['auth_extra'] ?? [],
'iat' => $now,
'exp' => $now + self::WECHAT_BIND_TICKET_TTL,
'nonce' => bin2hex(random_bytes(8)),
];
$body = $this->base64UrlEncode(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$signature = hash_hmac('sha256', $body, $this->ticketSecret());
return $body . '.' . $signature;
}
private function createWechatOAuthState(): string
{
$expiresAt = str_pad(base_convert((string)(time() + 600), 10, 36), 8, '0', STR_PAD_LEFT);
$base = 'w' . $expiresAt . bin2hex(random_bytes(4));
$signature = substr(hash_hmac('sha256', $base, $this->ticketSecret()), 0, 20);
return $base . $signature;
}
private function verifyWechatOAuthState(string $state): void
{
$state = trim($state);
if (!preg_match('/^w[a-z0-9]{36}$/i', $state)) {
throw new \RuntimeException('微信授权状态不匹配,请重新登录');
}
$base = substr($state, 0, 17);
$signature = substr($state, 17);
$expected = substr(hash_hmac('sha256', $base, $this->ticketSecret()), 0, 20);
if (!hash_equals($expected, strtolower($signature))) {
throw new \RuntimeException('微信授权状态不匹配,请重新登录');
}
$expiresAt = (int)base_convert(substr($state, 1, 8), 36, 10);
if ($expiresAt < time()) {
throw new \RuntimeException('微信授权状态已过期,请重新登录');
}
}
private function verifyWechatBindTicket(string $ticket): array
{
$ticket = trim($ticket);
if ($ticket === '' || strpos($ticket, '.') === false) {
throw new \RuntimeException('微信绑定凭证无效,请重新授权');
}
[$body, $signature] = explode('.', $ticket, 2);
$expected = hash_hmac('sha256', $body, $this->ticketSecret());
if (!hash_equals($expected, strtolower($signature))) {
throw new \RuntimeException('微信绑定凭证签名无效,请重新授权');
}
$decoded = json_decode($this->base64UrlDecode($body), true);
if (!is_array($decoded) || ($decoded['typ'] ?? '') !== 'wechat_h5_bind') {
throw new \RuntimeException('微信绑定凭证无效,请重新授权');
}
if ((int)($decoded['exp'] ?? 0) < time()) {
throw new \RuntimeException('微信绑定凭证已过期,请重新授权');
}
$openid = trim((string)($decoded['openid'] ?? ''));
if ($openid === '') {
throw new \RuntimeException('微信绑定凭证缺少 openid请重新授权');
}
return [
'openid' => $openid,
'unionid' => trim((string)($decoded['unionid'] ?? '')),
'nickname' => trim((string)($decoded['nickname'] ?? '')),
'avatar' => trim((string)($decoded['avatar'] ?? '')),
'auth_extra' => is_array($decoded['auth_extra'] ?? null) ? $decoded['auth_extra'] : [],
];
}
private function wechatApiGet(string $url, string $fallbackMessage): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 4,
]);
$response = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$httpStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno) {
throw new \RuntimeException($fallbackMessage . '' . $error);
}
if ($httpStatus < 200 || $httpStatus >= 300) {
throw new \RuntimeException($fallbackMessage . ':微信接口 HTTP ' . $httpStatus);
}
$payload = json_decode((string)$response, true);
if (!is_array($payload)) {
throw new \RuntimeException($fallbackMessage . ':微信接口返回异常');
}
$errcode = (int)($payload['errcode'] ?? 0);
if ($errcode !== 0) {
throw new \RuntimeException($this->wechatErrorMessage($errcode, (string)($payload['errmsg'] ?? ''), $fallbackMessage));
}
return $payload;
}
private function wechatErrorMessage(int $errcode, string $errmsg, string $fallbackMessage): string
{
$messages = [
40029 => '微信授权 code 无效或已过期,请重新登录',
40163 => '微信授权 code 已被使用,请重新发起授权',
40013 => 'H5 公众号 AppID 无效,请检查后台配置',
40125 => 'H5 公众号 AppSecret 无效,请检查后台配置',
];
return $messages[$errcode] ?? ($fallbackMessage . ($errmsg !== '' ? '' . $errmsg : ''));
}
private function redactWechatOAuthPayload(array $payload): array
{
unset($payload['access_token'], $payload['refresh_token']);
return $payload;
}
private function isWechatMockCode(string $code): bool
{
if (strpos($code, 'mock_') !== 0) {
return false;
}
return in_array(strtolower((string)($_ENV['WECHAT_H5_AUTH_MOCK'] ?? '')), ['1', 'true', 'yes'], true)
|| in_array(strtolower((string)($_ENV['APP_DEBUG'] ?? 'false')), ['1', 'true'], true);
}
private function mockWechatOAuthPayload(string $code): array
{
if (strpos($code, 'expired') !== false || strpos($code, 'invalid') !== false) {
throw new \RuntimeException('微信授权 code 无效或已过期,请重新登录');
}
$suffix = preg_replace('/[^A-Za-z0-9]/', '', substr($code, 5)) ?: 'user';
return [
'access_token' => 'mock_access_token_' . $suffix,
'expires_in' => 7200,
'refresh_token' => 'mock_refresh_token_' . $suffix,
'openid' => 'mock_openid_' . $suffix,
'scope' => 'snsapi_userinfo',
'unionid' => 'mock_unionid_' . $suffix,
];
}
private function mockWechatProfilePayload(array $oauthPayload): array
{
$openid = (string)($oauthPayload['openid'] ?? '');
$suffix = str_replace('mock_openid_', '', $openid) ?: 'user';
return [
'openid' => $openid,
'nickname' => '微信用户' . $suffix,
'headimgurl' => 'https://thirdwx.qlogo.cn/mmopen/mock/' . rawurlencode($suffix) . '/132',
'privilege' => [],
'unionid' => (string)($oauthPayload['unionid'] ?? ''),
];
}
private function normalizeMobile(string $mobile): string private function normalizeMobile(string $mobile): string
{ {
$mobile = preg_replace('/\D+/', '', $mobile) ?: ''; $mobile = preg_replace('/\D+/', '', $mobile) ?: '';
@@ -353,6 +846,35 @@ class AppAuthService
return hash('sha256', implode('|', [$mobile, $scene, $code])); return hash('sha256', implode('|', [$mobile, $scene, $code]));
} }
private function ticketSecret(): string
{
$seed = trim((string)($_ENV['APP_KEY'] ?? $_ENV['JWT_SECRET'] ?? ''));
if ($seed === '') {
$seed = $this->systemConfig('h5', 'app_secret');
}
if ($seed === '') {
$seed = 'anxinyan-app-auth-secret-key';
}
return hash('sha256', $seed, true);
}
private function base64UrlEncode(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
private function base64UrlDecode(string $value): string
{
$padding = strlen($value) % 4;
if ($padding > 0) {
$value .= str_repeat('=', 4 - $padding);
}
$decoded = base64_decode(strtr($value, '-_', '+/'), true);
return is_string($decoded) ? $decoded : '';
}
private function systemConfig(string $groupCode, string $configKey): string private function systemConfig(string $groupCode, string $configKey): string
{ {
$row = Db::name('system_configs') $row = Db::name('system_configs')
@@ -363,6 +885,35 @@ class AppAuthService
return trim((string)($row['config_value'] ?? '')); return trim((string)($row['config_value'] ?? ''));
} }
private function resolveH5OAuthRedirectUrl(): string
{
$pageBaseUrl = $this->normalizeH5PageBaseUrl($this->systemConfig('h5', 'page_base_url'));
if ($pageBaseUrl !== '') {
return $pageBaseUrl . self::H5_OAUTH_REDIRECT_HASH_PATH;
}
return $this->systemConfig('h5', 'oauth_redirect_url');
}
private function normalizeH5PageBaseUrl(string $value): string
{
$baseUrl = trim($value);
if ($baseUrl === '') {
return '';
}
$hashPos = strpos($baseUrl, '#');
if ($hashPos !== false) {
$baseUrl = substr($baseUrl, 0, $hashPos);
}
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
$baseUrl = 'https://' . ltrim($baseUrl, '/');
}
return rtrim($baseUrl, '/');
}
private function extractToken(Request $request): string private function extractToken(Request $request): string
{ {
$authorization = trim((string)$request->header('authorization', '')); $authorization = trim((string)$request->header('authorization', ''));
@@ -382,6 +933,32 @@ class AppAuthService
Db::execute("ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL DEFAULT '' AFTER mobile"); Db::execute("ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL DEFAULT '' AFTER mobile");
} }
private function ensureUserAuthsTable(): void
{
Db::execute(<<<'SQL'
CREATE TABLE IF NOT EXISTS user_auths (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
auth_type VARCHAR(32) NOT NULL,
auth_open_id VARCHAR(128) NOT NULL DEFAULT '',
auth_union_id VARCHAR(128) NOT NULL DEFAULT '',
auth_key VARCHAR(128) NOT NULL DEFAULT '',
auth_extra JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_user_auths_type_key (auth_type, auth_key),
KEY idx_user_auths_user_id (user_id),
KEY idx_user_auths_auth_union_id (auth_type, auth_union_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户认证映射';
SQL);
$index = Db::query("SHOW INDEX FROM user_auths WHERE Key_name = 'idx_user_auths_auth_union_id'");
if (!$index) {
Db::execute("ALTER TABLE user_auths ADD KEY idx_user_auths_auth_union_id (auth_type, auth_union_id)");
}
}
private function ensureTokenTable(): void private function ensureTokenTable(): void
{ {
Db::execute(<<<'SQL' Db::execute(<<<'SQL'

View File

@@ -154,6 +154,9 @@ Route::get('/api/app/help-article/detail', [AppHelpCenterController::class, 'det
Route::post('/api/app/auth/send-code', [AppAuthController::class, 'sendCode']); Route::post('/api/app/auth/send-code', [AppAuthController::class, 'sendCode']);
Route::post('/api/app/auth/login/code', [AppAuthController::class, 'loginByCode']); Route::post('/api/app/auth/login/code', [AppAuthController::class, 'loginByCode']);
Route::post('/api/app/auth/login/password', [AppAuthController::class, 'loginByPassword']); Route::post('/api/app/auth/login/password', [AppAuthController::class, 'loginByPassword']);
Route::get('/api/app/auth/wechat/config', [AppAuthController::class, 'wechatConfig']);
Route::post('/api/app/auth/wechat/exchange', [AppAuthController::class, 'wechatExchange']);
Route::post('/api/app/auth/wechat/bind-mobile', [AppAuthController::class, 'wechatBindMobile']);
Route::get('/api/app/auth/me', [AppAuthController::class, 'me']); Route::get('/api/app/auth/me', [AppAuthController::class, 'me']);
Route::post('/api/app/auth/password/save', [AppAuthController::class, 'savePassword']); Route::post('/api/app/auth/password/save', [AppAuthController::class, 'savePassword']);
Route::post('/api/app/auth/logout', [AppAuthController::class, 'logout']); Route::post('/api/app/auth/logout', [AppAuthController::class, 'logout']);

View File

@@ -108,7 +108,8 @@ CREATE TABLE user_auths (
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_user_auths_type_key (auth_type, auth_key), UNIQUE KEY uk_user_auths_type_key (auth_type, auth_key),
KEY idx_user_auths_user_id (user_id) KEY idx_user_auths_user_id (user_id),
KEY idx_user_auths_auth_union_id (auth_type, auth_union_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户认证映射'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户认证映射';
CREATE TABLE user_api_tokens ( CREATE TABLE user_api_tokens (

View File

@@ -51,6 +51,31 @@ function isPlaceholderApiBase(string $apiBase): bool
return str_contains($normalized, 'example.com'); return str_contains($normalized, 'example.com');
} }
function normalizeH5PageBaseUrl(string $value): string
{
$baseUrl = trim($value);
if ($baseUrl === '') {
return '';
}
$hashPos = strpos($baseUrl, '#');
if ($hashPos !== false) {
$baseUrl = substr($baseUrl, 0, $hashPos);
}
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
$baseUrl = 'https://' . ltrim($baseUrl, '/');
}
return rtrim($baseUrl, '/');
}
function buildH5OAuthRedirectUrl(string $pageBaseUrl): string
{
$baseUrl = normalizeH5PageBaseUrl($pageBaseUrl);
return $baseUrl === '' ? '' : $baseUrl . '/#/pages/auth/login';
}
function checkClientProductionApiBase(array &$issues, string $label, string $envPath): void function checkClientProductionApiBase(array &$issues, string $label, string $envPath): void
{ {
$env = @parse_ini_file($envPath); $env = @parse_ini_file($envPath);
@@ -97,6 +122,7 @@ $configMap = [];
foreach ($configRows as $row) { foreach ($configRows as $row) {
$configMap[$row['config_group'] . '.' . $row['config_key']] = (string)($row['config_value'] ?? ''); $configMap[$row['config_group'] . '.' . $row['config_key']] = (string)($row['config_value'] ?? '');
} }
$configMap['h5.oauth_redirect_url'] = buildH5OAuthRedirectUrl((string)($configMap['h5.page_base_url'] ?? ''));
$requiredConfigKeys = [ $requiredConfigKeys = [
'mini_program.app_id', 'mini_program.app_id',

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->safeLoad();
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
$_ENV['DB_HOST'] ?? '127.0.0.1',
$_ENV['DB_PORT'] ?? '3306',
$_ENV['DB_DATABASE'] ?? '',
$_ENV['DB_CHARSET'] ?? 'utf8mb4'
);
$pdo = new PDO(
$dsn,
$_ENV['DB_USERNAME'] ?? '',
$_ENV['DB_PASSWORD'] ?? '',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
function hasTable(PDO $pdo, string $table): bool
{
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?');
$stmt->execute([$table]);
return (int)$stmt->fetchColumn() > 0;
}
function hasIndex(PDO $pdo, string $table, string $index): bool
{
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?');
$stmt->execute([$table, $index]);
return (int)$stmt->fetchColumn() > 0;
}
if (!hasTable($pdo, 'user_auths')) {
$pdo->exec(<<<'SQL'
CREATE TABLE user_auths (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
auth_type VARCHAR(32) NOT NULL,
auth_open_id VARCHAR(128) NOT NULL DEFAULT '',
auth_union_id VARCHAR(128) NOT NULL DEFAULT '',
auth_key VARCHAR(128) NOT NULL DEFAULT '',
auth_extra JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_user_auths_type_key (auth_type, auth_key),
KEY idx_user_auths_user_id (user_id),
KEY idx_user_auths_auth_union_id (auth_type, auth_union_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户认证映射'
SQL);
echo "CREATE_TABLE user_auths\n";
} elseif (!hasIndex($pdo, 'user_auths', 'idx_user_auths_auth_union_id')) {
$pdo->exec('ALTER TABLE user_auths ADD KEY idx_user_auths_auth_union_id (auth_type, auth_union_id)');
echo "ADD_INDEX user_auths.idx_user_auths_auth_union_id\n";
}
echo "SCHEMA_UPGRADE_OK\n";

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->safeLoad();
$_ENV['APP_DEBUG'] = 'true';
$_ENV['WECHAT_H5_AUTH_MOCK'] = 'true';
use app\support\AppAuthService;
use support\think\Db;
use Webman\Http\Request;
Db::setConfig(require dirname(__DIR__) . '/config/think-orm.php');
function assertTrue(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
function makeRequest(): Request
{
return new class("GET /api/app/auth/wechat/mock-test HTTP/1.1\r\nHost: 127.0.0.1\r\nUser-Agent: wechat-h5-auth-mock-test\r\n\r\n") extends Request {
public function getRealIp(bool $safeMode = true): string
{
return '127.0.0.1';
}
};
}
function ensureConfig(string $group, string $key, string $value): void
{
$now = date('Y-m-d H:i:s');
$exists = Db::name('system_configs')
->where('config_group', $group)
->where('config_key', $key)
->find();
$payload = [
'config_group' => $group,
'config_key' => $key,
'config_value' => $value,
'remark' => '微信 H5 授权测试配置',
'updated_at' => $now,
];
if ($exists) {
Db::name('system_configs')->where('id', $exists['id'])->update($payload);
return;
}
$payload['created_at'] = $now;
Db::name('system_configs')->insert($payload);
}
function latestDebugCode(string $mobile): string
{
$record = Db::name('sms_code_logs')
->where('mobile', $mobile)
->where('scene', 'login')
->where('send_status', 'mock')
->whereNull('used_at')
->order('id', 'desc')
->find();
assertTrue((bool)$record, 'mock sms code record missing');
for ($code = 100000; $code <= 999999; $code++) {
$codeText = (string)$code;
if (hash_equals((string)$record['code_hash'], hash('sha256', implode('|', [$mobile, 'login', $codeText])))) {
return $codeText;
}
}
throw new RuntimeException('mock sms code not found');
}
function cleanupWechatMockData(): void
{
$userIds = Db::name('users')
->whereLike('mobile', '1399900%')
->column('id');
if ($userIds) {
Db::name('user_api_tokens')->whereIn('user_id', $userIds)->delete();
Db::name('user_auths')->whereIn('user_id', $userIds)->delete();
Db::name('users')->whereIn('id', $userIds)->delete();
}
Db::name('sms_code_logs')->whereLike('mobile', '1399900%')->delete();
Db::name('user_auths')->where('auth_type', 'wechat_h5')->whereLike('auth_key', 'mock_openid_%')->delete();
}
$service = new AppAuthService();
$request = makeRequest();
$originalConfigs = [
'h5.app_id' => Db::name('system_configs')->where('config_group', 'h5')->where('config_key', 'app_id')->value('config_value'),
'h5.app_secret' => Db::name('system_configs')->where('config_group', 'h5')->where('config_key', 'app_secret')->value('config_value'),
'h5.page_base_url' => Db::name('system_configs')->where('config_group', 'h5')->where('config_key', 'page_base_url')->value('config_value'),
];
Db::startTrans();
try {
cleanupWechatMockData();
ensureConfig('h5', 'app_id', 'wx_mock_appid');
ensureConfig('h5', 'app_secret', 'mock_secret');
ensureConfig('h5', 'page_base_url', 'https://m.example.com');
ensureConfig('sms', 'access_key_id', '');
ensureConfig('sms', 'access_key_secret', '');
ensureConfig('sms', 'sign_name', '');
ensureConfig('sms', 'login_template_code', '');
$config = $service->wechatConfig();
assertTrue($config['enabled'] === true, 'wechat config should be enabled');
assertTrue($config['oauth_redirect_url'] === 'https://m.example.com/#/pages/auth/login', 'oauth redirect url mismatch');
$stateOne = (string)$config['state'];
$exchange = $service->exchangeWechatCode('mock_newuser', $stateOne, $request);
assertTrue(($exchange['status'] ?? '') === 'need_bind', 'new wechat user should need binding');
assertTrue(!empty($exchange['bind_ticket']), 'bind ticket missing');
$mobile = '13999000001';
$service->sendLoginCode($mobile, $request);
$bind = $service->bindWechatMobile((string)$exchange['bind_ticket'], $mobile, latestDebugCode($mobile), $request);
assertTrue(($bind['status'] ?? '') === 'logged_in' && !empty($bind['token']), 'bind should return token');
$stateTwo = (string)$service->wechatConfig()['state'];
$linked = $service->exchangeWechatCode('mock_newuser', $stateTwo, $request);
assertTrue(($linked['status'] ?? '') === 'logged_in' && !empty($linked['token']), 'linked wechat user should login');
$stateThree = (string)$service->wechatConfig()['state'];
$existingUser = $service->exchangeWechatCode('mock_existingmobile', $stateThree, $request);
$existingMobile = '13999000002';
Db::name('users')->insert([
'nickname' => '已有手机号用户',
'avatar' => '',
'mobile' => $existingMobile,
'password' => '',
'status' => 'enabled',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$service->sendLoginCode($existingMobile, $request);
$boundExisting = $service->bindWechatMobile((string)$existingUser['bind_ticket'], $existingMobile, latestDebugCode($existingMobile), $request);
assertTrue(($boundExisting['status'] ?? '') === 'logged_in', 'existing mobile should bind');
assertTrue((int)Db::name('users')->where('mobile', $existingMobile)->count() === 1, 'existing mobile should not duplicate user');
$otherUserId = (int)Db::name('users')->insertGetId([
'nickname' => '占用微信用户',
'avatar' => '',
'mobile' => '13999000003',
'password' => '',
'status' => 'enabled',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
Db::name('user_auths')->insert([
'user_id' => $otherUserId,
'auth_type' => 'wechat_h5',
'auth_key' => 'mock_openid_conflict',
'auth_open_id' => 'mock_openid_conflict',
'auth_union_id' => 'mock_unionid_conflict',
'auth_extra' => json_encode(['mock' => true]),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$conflictUser = $service->exchangeWechatCode('mock_conflict', (string)$service->wechatConfig()['state'], $request);
assertTrue(($conflictUser['status'] ?? '') === 'logged_in', 'existing conflicting openid should login owner directly');
try {
$service->exchangeWechatCode('mock_expired', (string)$service->wechatConfig()['state'], $request);
throw new RuntimeException('expired code should fail');
} catch (RuntimeException $e) {
assertTrue(strpos($e->getMessage(), 'code 无效') !== false, 'expired code message mismatch');
}
try {
$service->bindWechatMobile('invalid.ticket', '13999000004', '123456', $request);
throw new RuntimeException('invalid bind ticket should fail');
} catch (RuntimeException $e) {
assertTrue(strpos($e->getMessage(), '绑定凭证') !== false, 'invalid bind ticket message mismatch');
}
try {
$service->exchangeWechatCode('mock_statefail', 'wrongstate', $request);
throw new RuntimeException('invalid state should fail');
} catch (RuntimeException $e) {
assertTrue(strpos($e->getMessage(), '状态') !== false, 'invalid state message mismatch');
}
echo "WECHAT_H5_AUTH_MOCK_TEST_OK\n";
Db::rollback();
} catch (Throwable $e) {
Db::rollback();
fwrite(STDERR, "WECHAT_H5_AUTH_MOCK_TEST_FAIL: " . $e->getMessage() . "\n");
exit(1);
} finally {
foreach ($originalConfigs as $mapKey => $value) {
[$group, $key] = explode('.', $mapKey, 2);
ensureConfig($group, $key, (string)$value);
}
}

View File

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

View File

@@ -22,6 +22,30 @@ export interface LoginResult {
user_info: AuthUserInfo; 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 = { export const authApi = {
sendLoginCode(mobile: string) { sendLoginCode(mobile: string) {
return request<SendLoginCodeResult>("/api/app/auth/send-code", { return request<SendLoginCodeResult>("/api/app/auth/send-code", {
@@ -41,6 +65,25 @@ export const authApi = {
data: { mobile, password }, 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() { getMe() {
return request<{ user_info: AuthUserInfo }>("/api/app/auth/me"); return request<{ user_info: AuthUserInfo }>("/api/app/auth/me");
}, },

View File

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

View File

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

View File

@@ -4,7 +4,19 @@ import { onLoad } from "@dcloudio/uni-app";
import { authApi } from "../../api/auth"; import { authApi } from "../../api/auth";
import { useAppraisalStore } from "../../stores/appraisal"; import { useAppraisalStore } from "../../stores/appraisal";
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback"; 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"; type LoginMode = "code" | "password";
const COUNTDOWN_STORAGE_KEY = "anxinyan_login_code_countdown_expire_at"; 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 mode = ref<LoginMode>("code");
const sending = ref(false); const sending = ref(false);
const submitting = ref(false); const submitting = ref(false);
const wechatProcessing = ref(false);
const wechatMessage = ref("");
const countdown = ref(0); const countdown = ref(0);
const redirect = ref(""); const redirect = ref("");
const sendCodeErrorMessage = ref(""); const sendCodeErrorMessage = ref("");
@@ -27,7 +41,7 @@ let countdownTimer: ReturnType<typeof setInterval> | null = null;
const browserHint = computed(() => const browserHint = computed(() =>
isWechatBrowser() isWechatBrowser()
? "微信内也支持手机号快捷登录,后续可继续补充微信授权。" ? "微信内将自动使用公众号授权登录;授权失败时也可继续用手机号登录。"
: "当前为非微信浏览器环境,可直接使用手机号验证码或密码登录。", : "当前为非微信浏览器环境,可直接使用手机号验证码或密码登录。",
); );
@@ -129,6 +143,138 @@ function goHome() {
uni.reLaunch({ url: "/pages/home/index" }); 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() { async function handleSendCode() {
if (sending.value || countdown.value > 0) return; if (sending.value || countdown.value > 0) return;
if (!validateMobile()) return; if (!validateMobile()) return;
@@ -191,6 +337,11 @@ onLoad((options) => {
restoreCountdown(); restoreCountdown();
if (isLoggedIn()) { if (isLoggedIn()) {
navigateAfterLogin(redirect.value || "/pages/mine/index"); navigateAfterLogin(redirect.value || "/pages/mine/index");
return;
}
if (isWechatBrowser()) {
handleWechatCallback();
} }
}); });
@@ -234,6 +385,14 @@ onUnmounted(clearCountdown);
</view> </view>
<view class="auth-panel"> <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">
<view :class="['auth-switch__item', mode === 'code' ? 'auth-switch__item--active' : '']" @click="mode = 'code'"> <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); 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 { .auth-switch {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); 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"; import { resolveErrorMessage } from "../../utils/feedback";
type ReportTab = "product" | "trace"; type ReportTab = "product" | "trace";
type ProductDisplayItem = ReportDetailData["product_display"]["items"][number];
const detail = ref<ReportDetailData>(reportDetailFallback); const detail = ref<ReportDetailData>(reportDetailFallback);
const downloading = ref(false); const downloading = ref(false);
@@ -37,12 +38,35 @@ const institutionName = computed(() =>
|| "-", || "-",
); );
const productItems = computed(() => { const productItems = computed(() => {
const items = detail.value.product_display?.items || []; const items: ProductDisplayItem[] = [];
if (items.length) return items; const displayItems = detail.value.product_display?.items || [];
return [ const baseItems = displayItems.length
? displayItems
: [
{ label: "检测结论", value: detail.value.result_info.result_text || "-", remark: detail.value.result_info.result_desc || "" }, { label: "检测结论", value: detail.value.result_info.result_text || "-", remark: detail.value.result_info.result_desc || "" },
{ label: "品", value: detail.value.product_info.brand_name || "-" }, { 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 publishTime = computed(() => detail.value.report_header.publish_time || "-");
const resultItem = computed(() => { 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 zhongjianOtherFiles = computed(() => zhongjianReportFiles.value.filter((item) => item.file_type !== "image"));
const reportNo = computed(() => detail.value.report_header.report_no || ""); 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) { function evidenceTypeText(fileType: string) {
if (fileType === "video") return "视频"; if (fileType === "video") return "视频";
if (fileType === "pdf") return "PDF"; if (fileType === "pdf") return "PDF";
@@ -294,6 +338,7 @@ onLoad(async (options) => {
<template v-else> <template v-else>
<view class="report-shell"> <view class="report-shell">
<view class="report-shell__watermark" aria-hidden="true"></view>
<view class="report-cover"> <view class="report-cover">
<swiper v-if="reportImages.length" class="report-cover__swiper" indicator-dots circular> <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"> <swiper-item v-for="item in reportImages" :key="item.file_url || item.file_id">
@@ -334,8 +379,6 @@ onLoad(async (options) => {
</view> </view>
<view v-if="activeTab === 'product'" class="report-panel"> <view v-if="activeTab === 'product'" class="report-panel">
<view class="report-watermark" aria-hidden="true"></view>
<view class="report-result"> <view class="report-result">
<view class="report-result__content"> <view class="report-result__content">
<view class="report-result__label">{{ resultItem.label }}</view> <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 v-if="resultItem.remark" class="report-result__desc">{{ resultItem.remark }}</view>
</view> </view>
<view class="report-seal"> <view class="report-seal">
<text class="report-seal__brand">ANXINYAN</text> <text class="report-seal__brand">安心验</text>
<text class="report-seal__main">可信</text> <text class="report-seal__main">鉴定</text>
</view> </view>
</view> </view>
@@ -490,6 +533,26 @@ onLoad(async (options) => {
box-shadow: 0 18rpx 48rpx rgba(31, 36, 48, 0.08); 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 { .report-cover {
height: 356rpx; height: 356rpx;
margin: 28rpx 28rpx 0; margin: 28rpx 28rpx 0;
@@ -614,28 +677,14 @@ onLoad(async (options) => {
overflow: hidden; 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 { .report-result {
position: relative; position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 24rpx; gap: 24rpx;
padding: 10rpx 0 30rpx; min-height: 132rpx;
padding: 10rpx 136rpx 30rpx 0;
border: 0; border: 0;
border-bottom: 1px solid #e5e5e5; border-bottom: 1px solid #e5e5e5;
border-radius: 0; border-radius: 0;
@@ -672,29 +721,57 @@ onLoad(async (options) => {
} }
.report-seal { .report-seal {
flex: 0 0 auto; position: absolute;
right: 2rpx;
bottom: 22rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 104rpx; width: 106rpx;
height: 104rpx; height: 106rpx;
margin-top: 2rpx; border: 4rpx solid rgba(40, 151, 73, 0.82);
border: 4rpx solid rgba(56, 164, 73, 0.8);
border-radius: 999rpx; border-radius: 999rpx;
color: #39a54b; background: rgba(255, 255, 255, 0.42);
transform: rotate(-10deg); 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 { .report-seal__brand {
font-size: 16rpx; position: relative;
font-weight: 800; z-index: 1;
font-size: 18rpx;
font-weight: 900;
line-height: 1; line-height: 1;
} }
.report-seal__main { .report-seal__main {
margin-top: 8rpx; position: relative;
font-size: 28rpx; z-index: 1;
margin-top: 9rpx;
font-size: 26rpx;
font-weight: 900; font-weight: 900;
line-height: 1; 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 TOKEN_KEY = "anxinyan_user_token";
const LOGIN_REDIRECT_KEY = "anxinyan_user_login_redirect"; 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([ const TABBAR_PAGES = new Set([
"/pages/home/index", "/pages/home/index",
@@ -16,6 +20,7 @@ const PUBLIC_PAGES = new Set([
"/pages/verify/result", "/pages/verify/result",
"/pages/material-tag/detail", "/pages/material-tag/detail",
"/pages/auth/login", "/pages/auth/login",
"/pages/auth/wechat-bind",
]); ]);
let redirecting = false; let redirecting = false;
@@ -56,6 +61,12 @@ export function isLoggedIn() {
return getUserToken() !== ""; return getUserToken() !== "";
} }
export function rememberLoginRedirect(targetUrl: string) {
if (targetUrl) {
uni.setStorageSync(LOGIN_REDIRECT_KEY, targetUrl);
}
}
export function buildAuthHeaders(headers: Record<string, string> = {}) { export function buildAuthHeaders(headers: Record<string, string> = {}) {
const token = getUserToken(); const token = getUserToken();
if (!token) { if (!token) {
@@ -75,6 +86,61 @@ export function isWechatBrowser() {
return false; 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) { export function isPublicPage(urlOrPath: string) {
const { path } = splitUrl(urlOrPath); const { path } = splitUrl(urlOrPath);
return PUBLIC_PAGES.has(path); return PUBLIC_PAGES.has(path);
@@ -100,9 +166,7 @@ export function redirectToLogin(targetUrl?: string) {
return; return;
} }
if (currentUrl) { rememberLoginRedirect(currentUrl);
uni.setStorageSync(LOGIN_REDIRECT_KEY, currentUrl);
}
redirecting = true; redirecting = true;
uni.navigateTo({ uni.navigateTo({

View File

@@ -57,6 +57,7 @@ const productSpecItems = computed(() => {
appendSpecItem(items, "序列号/编码", product.serial_no); appendSpecItem(items, "序列号/编码", product.serial_no);
for (const point of normalizedKeyPoints(result.key_points)) { for (const point of normalizedKeyPoints(result.key_points)) {
if (hasSpecItem(items, point.point_name)) continue;
appendSpecItem(items, point.point_name, point.point_value, point.point_remark); appendSpecItem(items, point.point_name, point.point_value, point.point_remark);
} }
@@ -92,6 +93,11 @@ function appendSpecItem(
}); });
} }
function hasSpecItem(items: Array<{ label: string }>, label: unknown) {
const labelText = textValue(label);
return Boolean(labelText && items.some((item) => item.label === labelText));
}
function textValue(value: unknown) { function textValue(value: unknown) {
return String(value ?? "").trim(); return String(value ?? "").trim();
} }
@@ -276,6 +282,7 @@ onShow(() => {
</view> </view>
<view class="report-shell"> <view class="report-shell">
<view class="report-shell__watermark" aria-hidden="true"></view>
<view class="report-cover"> <view class="report-cover">
<swiper v-if="reportImages.length" class="report-cover__swiper" indicator-dots circular> <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"> <swiper-item v-for="item in reportImages" :key="item.file_url || item.file_id">
@@ -316,8 +323,6 @@ onShow(() => {
</view> </view>
<view v-if="activeTab === 'product'" class="report-panel"> <view v-if="activeTab === 'product'" class="report-panel">
<view class="report-watermark" aria-hidden="true"></view>
<view class="report-result"> <view class="report-result">
<view class="report-result__content"> <view class="report-result__content">
<view class="report-result__label">{{ resultItem.label }}</view> <view class="report-result__label">{{ resultItem.label }}</view>
@@ -325,8 +330,8 @@ onShow(() => {
<view v-if="resultItem.remark" class="report-result__desc">{{ resultItem.remark }}</view> <view v-if="resultItem.remark" class="report-result__desc">{{ resultItem.remark }}</view>
</view> </view>
<view class="report-seal"> <view class="report-seal">
<text class="report-seal__brand">ANXINYAN</text> <text class="report-seal__brand">安心验</text>
<text class="report-seal__main">可信</text> <text class="report-seal__main">鉴定</text>
</view> </view>
</view> </view>
@@ -496,6 +501,26 @@ onShow(() => {
box-shadow: 0 18rpx 48rpx rgba(31, 36, 48, 0.08); 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 { .report-cover {
height: 356rpx; height: 356rpx;
margin: 28rpx 28rpx 0; margin: 28rpx 28rpx 0;
@@ -620,28 +645,14 @@ onShow(() => {
overflow: hidden; 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 { .report-result {
position: relative; position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 24rpx; gap: 24rpx;
padding: 10rpx 0 30rpx; min-height: 132rpx;
padding: 10rpx 136rpx 30rpx 0;
border-bottom: 1px solid #e5e5e5; border-bottom: 1px solid #e5e5e5;
} }
@@ -674,29 +685,57 @@ onShow(() => {
} }
.report-seal { .report-seal {
flex: 0 0 auto; position: absolute;
right: 2rpx;
bottom: 22rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 104rpx; width: 106rpx;
height: 104rpx; height: 106rpx;
margin-top: 2rpx; border: 4rpx solid rgba(40, 151, 73, 0.82);
border: 4rpx solid rgba(56, 164, 73, 0.8);
border-radius: 999rpx; border-radius: 999rpx;
color: #39a54b; background: rgba(255, 255, 255, 0.42);
transform: rotate(-10deg); 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 { .report-seal__brand {
font-size: 16rpx; position: relative;
font-weight: 800; z-index: 1;
font-size: 18rpx;
font-weight: 900;
line-height: 1; line-height: 1;
} }
.report-seal__main { .report-seal__main {
margin-top: 8rpx; position: relative;
font-size: 28rpx; z-index: 1;
margin-top: 9rpx;
font-size: 26rpx;
font-weight: 900; font-weight: 900;
line-height: 1; 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