Merge pull request 'Dev jacy' (#8) from dev_jacy into dev

Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/8
This commit was merged in pull request #8.
This commit is contained in:
2026-07-15 20:04:10 +08:00
48 changed files with 1029 additions and 109 deletions
@@ -23,7 +23,7 @@
| Guard | 用途 | | Guard | 用途 |
|-------|------| |-------|------|
| JwtAuthGuard | 需登录 | | JwtAuthGuard | 需登录 |
| PhoneVerifiedGuard | C 端需绑定手机(下单/支付) | | PhoneVerifiedGuard | (已废弃强制)C 端手机号改为下单页可选绑定 |
| OptionalJwtAuthGuard | 可选登录(bootstrap | | OptionalJwtAuthGuard | 可选登录(bootstrap |
Admin 路由在 `modules/ops/` 下,前缀 `/admin/*` Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`
@@ -7,7 +7,7 @@
| ID | 模块 | 结论摘要 | | ID | 模块 | 结论摘要 |
|----|------|----------| |----|------|----------|
| REQ-U-001 | 导航 | 首页/门店/好客权益/个人中心 | | REQ-U-001 | 导航 | 首页/门店/好客权益/个人中心 |
| REQ-U-002 | 登录 | 无感登录;下单强制手机号;微信/短信;7 天免登 | | REQ-U-002 | 登录 | 无感登录;下单提示绑定手机号(可选);微信/短信;7 天免登 |
| REQ-U-003 | 支付 | 仅微信支付 | | REQ-U-003 | 支付 | 仅微信支付 |
| REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 | | REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 |
| REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 | | REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 |
@@ -276,6 +276,16 @@ export default function SystemSettingsPage() {
const data = await request<SystemConfigFormResponse>('/admin/system-config'); const data = await request<SystemConfigFormResponse>('/admin/system-config');
if (silent) {
// 仅刷新 Mock 验证码列表,避免轮询用服务端值覆盖未保存的表单(含 MOCK_SMS 开关)
setMeta((prev) =>
prev
? { ...prev, mockSmsCodes: data.mockSmsCodes, updatedAt: data.updatedAt }
: data,
);
return;
}
setMeta(data); setMeta(data);
form.setFieldsValue(data.values); form.setFieldsValue(data.values);
@@ -304,6 +314,7 @@ export default function SystemSettingsPage() {
useEffect(() => { useEffect(() => {
// 以表单开关为准展示验证码;轮询只刷列表,不回写表单
if (!mockSmsEnabled) return; if (!mockSmsEnabled) return;
const timer = window.setInterval(() => void load(true), 5000); const timer = window.setInterval(() => void load(true), 5000);
+3
View File
@@ -1,6 +1,7 @@
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import AuthGate from './components/AuthGate'; import AuthGate from './components/AuthGate';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import LegalPage from './pages/LegalPage';
import PartnerAppRoutes from './PartnerAppRoutes'; import PartnerAppRoutes from './PartnerAppRoutes';
export default function App() { export default function App() {
@@ -8,6 +9,8 @@ export default function App() {
<AuthGate> <AuthGate>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/legal/user-agreement" element={<LegalPage docId="user-agreement" />} />
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
<Route path="/*" element={<PartnerAppRoutes />} /> <Route path="/*" element={<PartnerAppRoutes />} />
</Routes> </Routes>
</AuthGate> </AuthGate>
+1 -1
View File
@@ -3,7 +3,7 @@ import { getPartnerProfile, hasPartnerWxSession } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { partnerHomePath } from '../lib/partnerAccess'; import { partnerHomePath } from '../lib/partnerAccess';
const PUBLIC_PATHS = new Set(['/login']); const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
export default function AuthGate({ children }: { children: React.ReactNode }) { export default function AuthGate({ children }: { children: React.ReactNode }) {
const { ready, authenticated, account } = usePartnerSession(); const { ready, authenticated, account } = usePartnerSession();
+1
View File
@@ -6,6 +6,7 @@ import App from './App';
import { PartnerSessionProvider } from './contexts/PartnerSessionContext'; import { PartnerSessionProvider } from './contexts/PartnerSessionContext';
import { PartnerToastProvider } from './contexts/PartnerToastContext'; import { PartnerToastProvider } from './contexts/PartnerToastContext';
import './styles.css'; import './styles.css';
import './styles/legal.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
+36
View File
@@ -0,0 +1,36 @@
import { Link } from 'react-router-dom';
import { getLegalDocument, type LegalDocument } from '@dukang/shared-types';
type LegalPageProps = {
docId: LegalDocument['id'];
/** 返回登录页的路径,如 /login */
backTo?: string;
};
/** H5 各端共用的协议/隐私正文页 */
export default function LegalPage({ docId, backTo = '/login' }: LegalPageProps) {
const doc = getLegalDocument(docId);
return (
<div className="legal-h5-page">
<header className="legal-h5-header">
<Link to={backTo} className="legal-h5-back" aria-label="返回">
</Link>
<h1 className="legal-h5-title">{doc.title}</h1>
</header>
<main className="legal-h5-body">
<p className="legal-h5-updated">{doc.updatedAt}</p>
<p className="legal-h5-intro">{doc.intro}</p>
{doc.sections.map((section) => (
<section key={section.heading} className="legal-h5-section">
<h2>{section.heading}</h2>
{section.paragraphs.map((p, i) => (
<p key={`${section.heading}-${i}`}>{p}</p>
))}
</section>
))}
</main>
</div>
);
}
+9 -2
View File
@@ -68,7 +68,7 @@ export default function LoginPage() {
const [phone, setPhone] = useState(remembered.phone || getLastPhone()); const [phone, setPhone] = useState(remembered.phone || getLastPhone());
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [rememberAccount, setRememberAccount] = useState(remembered.remember); const [rememberAccount, setRememberAccount] = useState(remembered.remember);
const [agreed, setAgreed] = useState(true); const [agreed, setAgreed] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false); const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
@@ -343,7 +343,14 @@ export default function LoginPage() {
<label className="partner-checkbox-row"> <label className="partner-checkbox-row">
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} /> <input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
<span> <span>
<span className="text-primary" style={{ fontWeight: 600 }}></span> <span className="text-primary" style={{ fontWeight: 600 }}></span> {' '}
<Link to="/legal/user-agreement" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
</Link>{' '}
{' '}
<Link to="/legal/privacy-policy" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
</Link>
</span> </span>
</label> </label>
</div> </div>
+75
View File
@@ -0,0 +1,75 @@
.legal-h5-page {
min-height: 100vh;
background: #faf9f7;
color: #1f1a17;
}
.legal-h5-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
height: 52px;
padding: 0 16px;
background: #faf9f7;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.legal-h5-back {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
line-height: 1;
color: #1f1a17;
text-decoration: none;
}
.legal-h5-title {
margin: 0;
font-size: 17px;
font-weight: 600;
}
.legal-h5-body {
padding: 16px 20px 40px;
max-width: 720px;
margin: 0 auto;
}
.legal-h5-updated {
margin: 0 0 12px;
font-size: 12px;
color: #8d706e;
}
.legal-h5-intro {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.7;
}
.legal-h5-section {
margin-bottom: 18px;
}
.legal-h5-section h2 {
margin: 0 0 8px;
font-size: 15px;
font-weight: 600;
}
.legal-h5-section p {
margin: 0 0 8px;
font-size: 13px;
line-height: 1.7;
color: #5c504c;
}
+3
View File
@@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import AuthGate from './components/AuthGate'; import AuthGate from './components/AuthGate';
import TabLayout from './layouts/TabLayout'; import TabLayout from './layouts/TabLayout';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import LegalPage from './pages/LegalPage';
import SelectStorePage from './pages/SelectStorePage'; import SelectStorePage from './pages/SelectStorePage';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
import RedeemConfirmPage from './pages/RedeemConfirmPage'; import RedeemConfirmPage from './pages/RedeemConfirmPage';
@@ -17,6 +18,8 @@ export default function App() {
<AuthGate> <AuthGate>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/legal/user-agreement" element={<LegalPage docId="user-agreement" />} />
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
<Route path="/select-store" element={<SelectStorePage />} /> <Route path="/select-store" element={<SelectStorePage />} />
<Route path="/staff" element={<StaffPage />} /> <Route path="/staff" element={<StaffPage />} />
<Route path="/redeem" element={<RedeemConfirmPage />} /> <Route path="/redeem" element={<RedeemConfirmPage />} />
+1 -1
View File
@@ -2,7 +2,7 @@ import { Navigate, useLocation } from 'react-router-dom';
import { getStoreProfile, hasShopWxSession } from '../lib/api'; import { getStoreProfile, hasShopWxSession } from '../lib/api';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
const PUBLIC_PATHS = new Set(['/login']); const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
const SELECT_STORE_PATH = '/select-store'; const SELECT_STORE_PATH = '/select-store';
export default function AuthGate({ children }: { children: React.ReactNode }) { export default function AuthGate({ children }: { children: React.ReactNode }) {
+1
View File
@@ -5,6 +5,7 @@ import { getRouterBasename } from '@dukang/weixin-sdk';
import { StoreSessionProvider } from './contexts/StoreSessionContext'; import { StoreSessionProvider } from './contexts/StoreSessionContext';
import App from './App'; import App from './App';
import './styles.css'; import './styles.css';
import './styles/legal.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
+36
View File
@@ -0,0 +1,36 @@
import { Link } from 'react-router-dom';
import { getLegalDocument, type LegalDocument } from '@dukang/shared-types';
type LegalPageProps = {
docId: LegalDocument['id'];
/** 返回登录页的路径,如 /login */
backTo?: string;
};
/** H5 各端共用的协议/隐私正文页 */
export default function LegalPage({ docId, backTo = '/login' }: LegalPageProps) {
const doc = getLegalDocument(docId);
return (
<div className="legal-h5-page">
<header className="legal-h5-header">
<Link to={backTo} className="legal-h5-back" aria-label="返回">
</Link>
<h1 className="legal-h5-title">{doc.title}</h1>
</header>
<main className="legal-h5-body">
<p className="legal-h5-updated">{doc.updatedAt}</p>
<p className="legal-h5-intro">{doc.intro}</p>
{doc.sections.map((section) => (
<section key={section.heading} className="legal-h5-section">
<h2>{section.heading}</h2>
{section.paragraphs.map((p, i) => (
<p key={`${section.heading}-${i}`}>{p}</p>
))}
</section>
))}
</main>
</div>
);
}
+7 -3
View File
@@ -36,7 +36,7 @@ export default function LoginPage() {
const savedProfile = getStoreProfile(); const savedProfile = getStoreProfile();
const [phone, setPhone] = useState(getLastPhone()); const [phone, setPhone] = useState(getLastPhone());
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [agreed, setAgreed] = useState(true); const [agreed, setAgreed] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false); const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
@@ -309,9 +309,13 @@ export default function LoginPage() {
/> />
<span> <span>
<a href="#user-agreement"></a> <Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
</Link>
<a href="#privacy"></a> <Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
</Link>
</span> </span>
</label> </label>
</main> </main>
+75
View File
@@ -0,0 +1,75 @@
.legal-h5-page {
min-height: 100vh;
background: #faf9f7;
color: #1f1a17;
}
.legal-h5-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
height: 52px;
padding: 0 16px;
background: #faf9f7;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.legal-h5-back {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
line-height: 1;
color: #1f1a17;
text-decoration: none;
}
.legal-h5-title {
margin: 0;
font-size: 17px;
font-weight: 600;
}
.legal-h5-body {
padding: 16px 20px 40px;
max-width: 720px;
margin: 0 auto;
}
.legal-h5-updated {
margin: 0 0 12px;
font-size: 12px;
color: #8d706e;
}
.legal-h5-intro {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.7;
}
.legal-h5-section {
margin-bottom: 18px;
}
.legal-h5-section h2 {
margin: 0 0 8px;
font-size: 15px;
font-weight: 600;
}
.legal-h5-section p {
margin: 0 0 8px;
font-size: 13px;
line-height: 1.7;
color: #5c504c;
}
+3
View File
@@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import { useEffect } from 'react'; import { useEffect } from 'react';
import TabLayout from './layouts/TabLayout'; import TabLayout from './layouts/TabLayout';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import LegalPage from './pages/LegalPage';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
import ProductDetailPage from './pages/ProductDetailPage'; import ProductDetailPage from './pages/ProductDetailPage';
import OrderConfirmPage from './pages/OrderConfirmPage'; import OrderConfirmPage from './pages/OrderConfirmPage';
@@ -37,6 +38,8 @@ export default function App() {
<WechatShareBootstrap /> <WechatShareBootstrap />
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/legal/user-agreement" element={<LegalPage docId="user-agreement" />} />
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
<Route element={<TabLayout />}> <Route element={<TabLayout />}>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/stores" element={<StoreListPage />} /> <Route path="/stores" element={<StoreListPage />} />
@@ -105,8 +105,8 @@ export default function PhoneVerifySheet({
const sheetDesc = const sheetDesc =
description ?? description ??
(mode === 'wechat_bind_phone' (mode === 'wechat_bind_phone'
? '微信授权成功,请绑定手机号以完成支付' ? '建议绑定手机号,便于订单通知与售后;关闭可跳过继续支付'
: '下单前需验证手机号,以便接收订单通知'); : '建议绑定手机号,便于订单通知与售后;关闭可跳过继续下单');
return ( return (
<div className="phone-verify-overlay" role="dialog" aria-modal="true"> <div className="phone-verify-overlay" role="dialog" aria-modal="true">
@@ -159,6 +159,9 @@ export default function PhoneVerifySheet({
<button type="button" className="login-sms-btn" disabled={loading} onClick={submit}> <button type="button" className="login-sms-btn" disabled={loading} onClick={submit}>
{loading ? '验证中...' : mode === 'wechat_bind_phone' ? '确认绑定' : '确认验证'} {loading ? '验证中...' : mode === 'wechat_bind_phone' ? '确认绑定' : '确认验证'}
</button> </button>
<button type="button" className="phone-verify-skip" onClick={onClose}>
</button>
</div> </div>
</div> </div>
); );
+1
View File
@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom';
import { getRouterBasename } from '@dukang/weixin-sdk'; import { getRouterBasename } from '@dukang/weixin-sdk';
import App from './App'; import App from './App';
import './styles.css'; import './styles.css';
import './styles/legal.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
+36
View File
@@ -0,0 +1,36 @@
import { Link } from 'react-router-dom';
import { getLegalDocument, type LegalDocument } from '@dukang/shared-types';
type LegalPageProps = {
docId: LegalDocument['id'];
/** 返回登录页的路径,如 /login */
backTo?: string;
};
/** H5 各端共用的协议/隐私正文页 */
export default function LegalPage({ docId, backTo = '/login' }: LegalPageProps) {
const doc = getLegalDocument(docId);
return (
<div className="legal-h5-page">
<header className="legal-h5-header">
<Link to={backTo} className="legal-h5-back" aria-label="返回">
</Link>
<h1 className="legal-h5-title">{doc.title}</h1>
</header>
<main className="legal-h5-body">
<p className="legal-h5-updated">{doc.updatedAt}</p>
<p className="legal-h5-intro">{doc.intro}</p>
{doc.sections.map((section) => (
<section key={section.heading} className="legal-h5-section">
<h2>{section.heading}</h2>
{section.paragraphs.map((p, i) => (
<p key={`${section.heading}-${i}`}>{p}</p>
))}
</section>
))}
</main>
</div>
);
}
+14 -10
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage'; import AppImage from '@dukang/shared-ui/AppImage';
import type { WechatLoginResult } from '@dukang/shared-types'; import type { WechatLoginResult } from '@dukang/shared-types';
import { SmsScene } from '@dukang/shared-types'; import { SmsScene } from '@dukang/shared-types';
@@ -26,7 +26,7 @@ export default function LoginPage() {
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [agreed, setAgreed] = useState(true); const [agreed, setAgreed] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null); const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
const [bindMode, setBindMode] = useState(false); const [bindMode, setBindMode] = useState(false);
@@ -51,12 +51,6 @@ export default function LoginPage() {
}, [wxAuthorize]); }, [wxAuthorize]);
function handleWechatLoginResult(result: WechatLoginResult) { function handleWechatLoginResult(result: WechatLoginResult) {
if (result.needBindPhone && result.wxSessionKey) {
setBindMode(true);
setWxSessionKey(result.wxSessionKey);
setMsg('微信授权成功,请绑定手机号完成登录');
return;
}
if (result.accessToken) { if (result.accessToken) {
applySession({ applySession({
accessToken: result.accessToken, accessToken: result.accessToken,
@@ -68,6 +62,12 @@ export default function LoginPage() {
void finishLogin(navigate, returnTo); void finishLogin(navigate, returnTo);
return; return;
} }
if (result.needBindPhone && result.wxSessionKey) {
setBindMode(true);
setWxSessionKey(result.wxSessionKey);
setMsg('微信授权成功,可绑定手机号(也可跳过,稍后在下单时再绑定)');
return;
}
} }
function ensureAgreed() { function ensureAgreed() {
@@ -229,9 +229,13 @@ export default function LoginPage() {
/> />
<span> <span>
<a href="#user-agreement"></a> <Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
</Link>
<a href="#privacy"></a> <Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
</Link>
</span> </span>
</label> </label>
</footer> </footer>
+48 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import SubPageHeader from '../components/SubPageHeader'; import SubPageHeader from '../components/SubPageHeader';
import AppImage from '@dukang/shared-ui/AppImage'; import AppImage from '@dukang/shared-ui/AppImage';
@@ -73,6 +73,7 @@ export default function OrderConfirmPage() {
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const phonePromptSkipped = useRef(false);
useEffect(() => { useEffect(() => {
request<Address[]>('USER_H5', '/user/addresses').then((list) => { request<Address[]>('USER_H5', '/user/addresses').then((list) => {
@@ -195,7 +196,7 @@ export default function OrderConfirmPage() {
setMsg('请选择收货地址'); setMsg('请选择收货地址');
return; return;
} }
if (!phoneVerified) { if (!phoneVerified && !phonePromptSkipped.current) {
setPendingSubmit(true); setPendingSubmit(true);
setShowPhoneVerify(true); setShowPhoneVerify(true);
return; return;
@@ -204,6 +205,7 @@ export default function OrderConfirmPage() {
const authResult = await ensureWechatAuthForPay(); const authResult = await ensureWechatAuthForPay();
if (!authResult.ok) { if (!authResult.ok) {
if ('needBindPhone' in authResult) { if ('needBindPhone' in authResult) {
// 兼容旧接口:微信授权后提示可选绑定
setWxSessionKey(authResult.wxSessionKey); setWxSessionKey(authResult.wxSessionKey);
setShowWechatBindPhone(true); setShowWechatBindPhone(true);
setPendingSubmit(true); setPendingSubmit(true);
@@ -418,9 +420,35 @@ export default function OrderConfirmPage() {
<PhoneVerifySheet <PhoneVerifySheet
open={showPhoneVerify} open={showPhoneVerify}
defaultPhone={selectedAddress?.phone} defaultPhone={selectedAddress?.phone}
title="建议绑定手机号"
description="绑定后便于订单通知与售后联系;关闭即可跳过,不绑定也能继续下单。"
onClose={() => { onClose={() => {
setShowPhoneVerify(false); setShowPhoneVerify(false);
setPendingSubmit(false); if (pendingSubmit) {
phonePromptSkipped.current = true;
setPendingSubmit(false);
void (async () => {
setLoading(true);
setMsg('');
try {
const authResult = await ensureWechatAuthForPay();
if (!authResult.ok) {
if ('needBindPhone' in authResult) {
setWxSessionKey(authResult.wxSessionKey);
setShowWechatBindPhone(true);
setPendingSubmit(true);
return;
}
return;
}
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
})();
}
}} }}
onSuccess={handlePhoneVerified} onSuccess={handlePhoneVerified}
/> />
@@ -430,10 +458,26 @@ export default function OrderConfirmPage() {
mode="wechat_bind_phone" mode="wechat_bind_phone"
wxSessionKey={wxSessionKey ?? undefined} wxSessionKey={wxSessionKey ?? undefined}
defaultPhone={selectedAddress?.phone} defaultPhone={selectedAddress?.phone}
title="建议绑定手机号"
description="绑定后便于订单通知与售后联系;关闭即可跳过继续下单。"
onClose={() => { onClose={() => {
setShowWechatBindPhone(false); setShowWechatBindPhone(false);
setWxSessionKey(null); setWxSessionKey(null);
setPendingSubmit(false); if (pendingSubmit) {
phonePromptSkipped.current = true;
setPendingSubmit(false);
void (async () => {
setLoading(true);
setMsg('');
try {
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
})();
}
}} }}
onSuccess={handleWechatPhoneBound} onSuccess={handleWechatPhoneBound}
/> />
+12
View File
@@ -5663,6 +5663,18 @@
line-height: 1.5; line-height: 1.5;
} }
.phone-verify-skip {
display: block;
width: 100%;
margin-top: 12px;
padding: 10px;
border: none;
background: transparent;
font-size: 14px;
color: var(--color-on-surface-variant, #888);
cursor: pointer;
}
.login-msg--hint { .login-msg--hint {
color: var(--color-success, #2d6a4f); color: var(--color-success, #2d6a4f);
} }
+75
View File
@@ -0,0 +1,75 @@
.legal-h5-page {
min-height: 100vh;
background: #faf9f7;
color: #1f1a17;
}
.legal-h5-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
height: 52px;
padding: 0 16px;
background: #faf9f7;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.legal-h5-back {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
line-height: 1;
color: #1f1a17;
text-decoration: none;
}
.legal-h5-title {
margin: 0;
font-size: 17px;
font-weight: 600;
}
.legal-h5-body {
padding: 16px 20px 40px;
max-width: 720px;
margin: 0 auto;
}
.legal-h5-updated {
margin: 0 0 12px;
font-size: 12px;
color: #8d706e;
}
.legal-h5-intro {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.7;
}
.legal-h5-section {
margin-bottom: 18px;
}
.legal-h5-section h2 {
margin: 0 0 8px;
font-size: 15px;
font-weight: 600;
}
.legal-h5-section p {
margin: 0 0 8px;
font-size: 13px;
line-height: 1.7;
color: #5c504c;
}
+2
View File
@@ -5,6 +5,8 @@ export default defineAppConfig({
'pages/settlement/index', 'pages/settlement/index',
'pages/tickets/index', 'pages/tickets/index',
'pages/login/index', 'pages/login/index',
'pages/user-agreement/index',
'pages/privacy-policy/index',
'pages/stores/detail', 'pages/stores/detail',
'pages/orders/index', 'pages/orders/index',
'pages/orders/detail', 'pages/orders/detail',
+19 -3
View File
@@ -43,7 +43,7 @@ export default function LoginPage() {
const [phone, setPhone] = useState(remembered.phone || DEMO_PHONE); const [phone, setPhone] = useState(remembered.phone || DEMO_PHONE);
const [code, setCode] = useState('123456'); const [code, setCode] = useState('123456');
const [rememberAccount, setRememberAccount] = useState(remembered.remember); const [rememberAccount, setRememberAccount] = useState(remembered.remember);
const [agreed, setAgreed] = useState(true); const [agreed, setAgreed] = useState(false);
const [cooldown, setCooldown] = useState(0); const [cooldown, setCooldown] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false); const [wxLoading, setWxLoading] = useState(false);
@@ -234,9 +234,25 @@ export default function LoginPage() {
<View className={`login-checkbox${agreed ? ' is-checked' : ''}`} /> <View className={`login-checkbox${agreed ? ' is-checked' : ''}`} />
<Text className="login-agreement-text"> <Text className="login-agreement-text">
<Text className="login-agreement-link"></Text> <Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/user-agreement/index' });
}}
>
</Text>
<Text className="login-agreement-link"></Text> <Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
}}
>
</Text>
</Text> </Text>
</View> </View>
</View> </View>
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '隐私政策',
});
@@ -0,0 +1,32 @@
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { getLegalDocument } from '@dukang/shared-types';
import '../user-agreement/legal.css';
export default function PrivacyPolicyPage() {
const doc = getLegalDocument('privacy-policy');
return (
<View className="hq-legal-page">
<View className="hq-legal-header">
<Text className="hq-legal-back" onClick={() => Taro.navigateBack()}>
</Text>
<Text className="hq-legal-title">{doc.title}</Text>
</View>
<ScrollView scrollY className="hq-legal-body">
<Text className="hq-legal-updated">{doc.updatedAt}</Text>
<Text className="hq-legal-intro">{doc.intro}</Text>
{doc.sections.map((section) => (
<View key={section.heading} className="hq-legal-section">
<Text className="hq-legal-heading">{section.heading}</Text>
{section.paragraphs.map((p, i) => (
<Text key={`${section.heading}-${i}`} className="hq-legal-paragraph">
{p}
</Text>
))}
</View>
))}
</ScrollView>
</View>
);
}
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '用户协议',
});
@@ -0,0 +1,32 @@
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { getLegalDocument } from '@dukang/shared-types';
import './legal.css';
export default function UserAgreementPage() {
const doc = getLegalDocument('user-agreement');
return (
<View className="hq-legal-page">
<View className="hq-legal-header">
<Text className="hq-legal-back" onClick={() => Taro.navigateBack()}>
</Text>
<Text className="hq-legal-title">{doc.title}</Text>
</View>
<ScrollView scrollY className="hq-legal-body">
<Text className="hq-legal-updated">{doc.updatedAt}</Text>
<Text className="hq-legal-intro">{doc.intro}</Text>
{doc.sections.map((section) => (
<View key={section.heading} className="hq-legal-section">
<Text className="hq-legal-heading">{section.heading}</Text>
{section.paragraphs.map((p, i) => (
<Text key={`${section.heading}-${i}`} className="hq-legal-paragraph">
{p}
</Text>
))}
</View>
))}
</ScrollView>
</View>
);
}
@@ -0,0 +1,68 @@
.hq-legal-page {
min-height: 100vh;
background: #faf9f7;
}
.hq-legal-header {
position: sticky;
top: 0;
z-index: 10;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
background: #faf9f7;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.hq-legal-back {
position: absolute;
left: 12px;
font-size: 28px;
line-height: 1;
padding: 4px 8px;
}
.hq-legal-title {
font-size: 17px;
font-weight: 600;
}
.hq-legal-body {
height: calc(100vh - 52px);
padding: 16px 20px 40px;
box-sizing: border-box;
}
.hq-legal-updated {
display: block;
font-size: 12px;
color: #8d706e;
margin-bottom: 12px;
}
.hq-legal-intro {
display: block;
font-size: 14px;
line-height: 1.7;
margin-bottom: 20px;
}
.hq-legal-section {
margin-bottom: 18px;
}
.hq-legal-heading {
display: block;
font-size: 15px;
font-weight: 600;
margin-bottom: 8px;
}
.hq-legal-paragraph {
display: block;
font-size: 13px;
line-height: 1.7;
color: #5c504c;
margin-bottom: 8px;
}
+2
View File
@@ -18,6 +18,8 @@ export default defineAppConfig({
'pages/redeem-code/index', 'pages/redeem-code/index',
'pages/redeem-success/index', 'pages/redeem-success/index',
'pages/login/index', 'pages/login/index',
'pages/user-agreement/index',
'pages/privacy-policy/index',
], ],
window: { window: {
backgroundTextStyle: 'light', backgroundTextStyle: 'light',
+1
View File
@@ -8,6 +8,7 @@
@import './styles/product-detail.css'; @import './styles/product-detail.css';
@import './styles/store-detail.css'; @import './styles/store-detail.css';
@import './styles/login.css'; @import './styles/login.css';
@import './styles/legal.css';
@import './styles/order.css'; @import './styles/order.css';
@import './styles/address.css'; @import './styles/address.css';
@import './styles/redeem.css'; @import './styles/redeem.css';
@@ -68,13 +68,6 @@ export default function WechatShareBootstrap() {
handleWechatAuthCallback() handleWechatAuthCallback()
.then((result) => { .then((result) => {
if (!result) return; if (!result) return;
if (result.needBindPhone && result.wxSessionKey) {
goLogin(returnFromLogin, {
bindMode: '1',
wxSessionKey: result.wxSessionKey,
});
return;
}
if (saveWechatLoginResult(result)) { if (saveWechatLoginResult(result)) {
toast('微信授权成功', 'success'); toast('微信授权成功', 'success');
if ( if (
@@ -83,6 +76,14 @@ export default function WechatShareBootstrap() {
) { ) {
finishLoginNavigate(returnFromLogin || params.get('return') || undefined); finishLoginNavigate(returnFromLogin || params.get('return') || undefined);
} }
return;
}
// 兼容旧接口:仅 needBindPhone 时引导可选绑定,不阻塞浏览
if (result.needBindPhone && result.wxSessionKey) {
goLogin(returnFromLogin, {
bindMode: '1',
wxSessionKey: result.wxSessionKey,
});
} }
}) })
.catch((e) => { .catch((e) => {
+3 -6
View File
@@ -5,7 +5,7 @@ import { fetchClientConfig, fetchUserProfile, needsWechatAuthForPay } from './pa
/** /**
* 支付前门禁: * 支付前门禁:
* - 未登录 / 未验手机 → 跳转登录页 * - 未登录 → 跳转登录页(微信授权即可,不强制手机号)
* - H5 微信内缺 openId → 尝试 OAuth(可能跳转微信授权页) * - H5 微信内缺 openId → 尝试 OAuth(可能跳转微信授权页)
* - 小程序缺绑定 → 跳转登录页 needWechat * - 小程序缺绑定 → 跳转登录页 needWechat
*/ */
@@ -17,10 +17,6 @@ export async function ensurePayReady(returnPath: string): Promise<boolean> {
try { try {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]); const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
if (!profile.phoneVerified) {
goLogin(returnPath, { needPhone: '1' });
return false;
}
if (!needsWechatAuthForPay(config, profile)) { if (!needsWechatAuthForPay(config, profile)) {
return true; return true;
} }
@@ -28,8 +24,9 @@ export async function ensurePayReady(returnPath: string): Promise<boolean> {
if (process.env.TARO_ENV === 'h5') { if (process.env.TARO_ENV === 'h5') {
const auth = await ensureWechatAuthForPay(); const auth = await ensureWechatAuthForPay();
if (auth.ok) return true; if (auth.ok) return true;
// 旧版 needBindPhone 已不再返回;缺 openId 时走登录补微信绑定
if ('needBindPhone' in auth && auth.needBindPhone) { if ('needBindPhone' in auth && auth.needBindPhone) {
goLogin(returnPath, { bindMode: '1', wxSessionKey: auth.wxSessionKey }); goLogin(returnPath, { needWechat: '1' });
return false; return false;
} }
// redirecting:正在跳转微信 OAuth // redirecting:正在跳转微信 OAuth
+57 -25
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { View, Text, Input, Button, Image } from '@tarojs/components'; import { View, Text, Input, Image } from '@tarojs/components';
import { useRouter } from '@tarojs/taro'; import Taro, { useRouter } from '@tarojs/taro';
import { import {
SmsScene, SmsScene,
isWxAuthorizeEnabled, isWxAuthorizeEnabled,
@@ -47,7 +47,7 @@ export default function LoginPage() {
const [wxLoading, setWxLoading] = useState(false); const [wxLoading, setWxLoading] = useState(false);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [cooldown, setCooldown] = useState(0); const [cooldown, setCooldown] = useState(0);
const [agreed, setAgreed] = useState(true); const [agreed, setAgreed] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [sentHint, setSentHint] = useState(''); const [sentHint, setSentHint] = useState('');
const [bindMode, setBindMode] = useState(initialBindMode); const [bindMode, setBindMode] = useState(initialBindMode);
@@ -121,17 +121,18 @@ export default function LoginPage() {
} }
function handleWechatLoginResult(result: WechatLoginResult, wxInfo?: MiniWechatProfile | null) { function handleWechatLoginResult(result: WechatLoginResult, wxInfo?: MiniWechatProfile | null) {
// 微信授权成功即登录;手机号改为下单页可选绑定
if (result.accessToken) {
applySessionAndLeave(result, undefined, wxInfo);
return;
}
if (result.needBindPhone && result.wxSessionKey) { if (result.needBindPhone && result.wxSessionKey) {
setBindMode(true); setBindMode(true);
setWxSessionKey(result.wxSessionKey); setWxSessionKey(result.wxSessionKey);
setMsg('微信授权成功,绑定手机号完成登录'); setMsg('微信授权成功,绑定手机号(也可跳过,稍后在下单时再绑定)');
setSentHint(''); setSentHint('');
return; return;
} }
if (result.accessToken) {
applySessionAndLeave(result, undefined, wxInfo);
return;
}
setMsg('微信登录未完成,请重试或使用手机号登录'); setMsg('微信登录未完成,请重试或使用手机号登录');
} }
@@ -286,13 +287,17 @@ export default function LoginPage() {
<View className="login-welcome"> <View className="login-welcome">
<Text className="login-welcome-title"> <Text className="login-welcome-title">
{completeMode === 'phone' {completeMode === 'phone'
? '完成手机验证' ? '建议绑定手机号'
: completeMode === 'wechat' : completeMode === 'wechat'
? '完成微信授权' ? '完成微信授权'
: '欢迎来到杜康好客'} : '欢迎来到杜康好客'}
</Text> </Text>
<Text className="login-welcome-sub"> <Text className="login-welcome-sub">
{completeMode ? '完成后将返回继续支付' : '买美酒,享好礼'} {completeMode === 'phone'
? '便于订单通知与售后,也可稍后绑定'
: completeMode === 'wechat'
? '完成后将返回继续支付'
: '买美酒,享好礼'}
</Text> </Text>
</View> </View>
</View> </View>
@@ -351,20 +356,31 @@ export default function LoginPage() {
</Text> </Text>
) : null} ) : null}
<Button <View
className="login-sms-btn" className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
loading={loading} onClick={loading ? undefined : () => void login()}
disabled={loading}
onClick={() => void login()}
> >
{loading <Text className="login-sms-btn__text">
? '处理中...' {loading
: completeMode === 'phone' ? '处理中...'
? '完成验证' : completeMode === 'phone'
: bindMode ? '完成验证'
? '绑定并登录' : bindMode
: '登录'} ? '绑定并登录'
</Button> : '登录'}
</Text>
</View>
{completeMode === 'phone' ? (
<View
className="login-skip-bind"
onClick={() => finishLoginNavigate(returnTo)}
style={{ marginTop: 12, textAlign: 'center' }}
>
<Text className="u-muted" style={{ fontSize: 14 }}>
</Text>
</View>
) : null}
</View> </View>
)} )}
@@ -387,9 +403,25 @@ export default function LoginPage() {
</View> </View>
<Text className="login-agreement-text"> <Text className="login-agreement-text">
<Text className="login-agreement-link"></Text> <Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/user-agreement/index' });
}}
>
</Text>
<Text className="login-agreement-link"></Text> <Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
}}
>
</Text>
</Text> </Text>
</View> </View>
</View> </View>
@@ -1,12 +1,14 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image } from '@tarojs/components'; import { View, Text, Image } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro'; import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import { goLogin } from '../../lib/auth-nav';
import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav'; import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav';
import { tryGetClientGpsLocation } from '../../lib/client-location'; import { tryGetClientGpsLocation } from '../../lib/client-location';
import { maskPhone } from '../../lib/phone'; import { maskPhone } from '../../lib/phone';
import { ensurePayReady } from '../../lib/pay-ready'; import { ensurePayReady } from '../../lib/pay-ready';
import { fetchUserProfile } from '../../lib/pay-wechat';
import { request } from '../../lib/api'; import { request } from '../../lib/api';
import { getProductMainImage } from '../../lib/product-images'; import { getProductMainImage } from '../../lib/product-images';
@@ -58,6 +60,7 @@ export default function OrderConfirmPage() {
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const phonePromptSkipped = useRef(false);
useEffect(() => { useEffect(() => {
request<Address[]>('/user/addresses') request<Address[]>('/user/addresses')
@@ -161,6 +164,30 @@ export default function OrderConfirmPage() {
} }
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${forceCross ? '&cross=1' : ''}`; const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${forceCross ? '&cross=1' : ''}`;
if (!phonePromptSkipped.current) {
try {
const profile = await fetchUserProfile();
if (!profile.phoneVerified) {
const { confirm, cancel } = await Taro.showModal({
title: '建议绑定手机号',
content: '绑定后便于订单通知与售后联系;也可跳过,不绑定也能继续下单。',
confirmText: '去绑定',
cancelText: '暂不绑定',
});
if (confirm) {
goLogin(returnPath, { needPhone: '1' });
return;
}
if (cancel) {
phonePromptSkipped.current = true;
}
}
} catch {
/* 拉取档案失败不阻塞下单 */
}
}
const ready = await ensurePayReady(returnPath); const ready = await ensurePayReady(returnPath);
if (!ready) return; if (!ready) return;
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '隐私政策',
navigationStyle: 'custom',
});
@@ -0,0 +1,29 @@
import { View, Text, ScrollView } from '@tarojs/components';
import { getLegalDocument } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
export default function PrivacyPolicyPage() {
const doc = getLegalDocument('privacy-policy');
return (
<PageShell variant="sub" className="legal-page">
<SubPageHeader title={doc.title} />
<View className="sub-page-body inset-page">
<ScrollView scrollY className="legal-scroll">
<Text className="legal-updated">{doc.updatedAt}</Text>
<Text className="legal-intro">{doc.intro}</Text>
{doc.sections.map((section) => (
<View key={section.heading} className="legal-section">
<Text className="legal-heading">{section.heading}</Text>
{section.paragraphs.map((p, i) => (
<Text key={`${section.heading}-${i}`} className="legal-paragraph">
{p}
</Text>
))}
</View>
))}
</ScrollView>
</View>
</PageShell>
);
}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '用户协议',
navigationStyle: 'custom',
});
@@ -0,0 +1,29 @@
import { View, Text, ScrollView } from '@tarojs/components';
import { getLegalDocument } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
export default function UserAgreementPage() {
const doc = getLegalDocument('user-agreement');
return (
<PageShell variant="sub" className="legal-page">
<SubPageHeader title={doc.title} />
<View className="sub-page-body inset-page">
<ScrollView scrollY className="legal-scroll">
<Text className="legal-updated">{doc.updatedAt}</Text>
<Text className="legal-intro">{doc.intro}</Text>
{doc.sections.map((section) => (
<View key={section.heading} className="legal-section">
<Text className="legal-heading">{section.heading}</Text>
{section.paragraphs.map((p, i) => (
<Text key={`${section.heading}-${i}`} className="legal-paragraph">
{p}
</Text>
))}
</View>
))}
</ScrollView>
</View>
</PageShell>
);
}
+40
View File
@@ -0,0 +1,40 @@
.legal-page .legal-scroll {
height: 100%;
box-sizing: border-box;
padding-bottom: 32px;
}
.legal-updated {
display: block;
font-size: 12px;
color: var(--color-on-surface-variant, #8d706e);
margin-bottom: 12px;
}
.legal-intro {
display: block;
font-size: 14px;
line-height: 1.7;
color: var(--color-on-surface, #1f1a17);
margin-bottom: 20px;
}
.legal-section {
margin-bottom: 18px;
}
.legal-heading {
display: block;
font-size: 15px;
font-weight: 600;
color: var(--color-on-surface, #1f1a17);
margin-bottom: 8px;
}
.legal-paragraph {
display: block;
font-size: 13px;
line-height: 1.7;
color: var(--color-on-surface-variant, #5c504c);
margin-bottom: 8px;
}
+16 -6
View File
@@ -155,10 +155,10 @@
width: 100%; width: 100%;
height: 48px; height: 48px;
margin-top: 8px; margin-top: 8px;
border: 1px solid rgba(166, 29, 36, 0.2); border: 1.5px solid var(--color-heritage-red, #a61d24);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: transparent; background: #fff;
color: var(--color-heritage-red); color: var(--color-heritage-red, #a61d24);
font-family: var(--font-headline); font-family: var(--font-headline);
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
@@ -167,14 +167,24 @@
justify-content: center; justify-content: center;
line-height: 1; line-height: 1;
padding: 0; padding: 0;
box-sizing: border-box;
} }
.login-sms-btn::after { .login-sms-btn__text {
border: none; color: var(--color-heritage-red, #a61d24);
font-size: 18px;
font-weight: 600;
line-height: 26px;
} }
.login-sms-btn[disabled] { .login-sms-btn:active {
background: rgba(166, 29, 36, 0.06);
transform: scale(0.98);
}
.login-sms-btn--disabled {
opacity: 0.6; opacity: 0.6;
pointer-events: none;
} }
.login-divider { .login-divider {
+1
View File
@@ -19,3 +19,4 @@ export * from './shop';
export * from './city-partner'; export * from './city-partner';
export * from './city-warehouse'; export * from './city-warehouse';
export * from './system-config'; export * from './system-config';
export * from './legal';
+161
View File
@@ -0,0 +1,161 @@
import { CUSTOMER_SERVICE_PHONE } from './config';
export type LegalSection = {
heading: string;
paragraphs: string[];
};
export type LegalDocument = {
id: 'user-agreement' | 'privacy-policy';
title: string;
updatedAt: string;
intro: string;
sections: LegalSection[];
};
/**
* 杜康好客 · 用户协议 / 隐私政策(多端共用)。
* 结构对齐《个人信息保护法》及 App 合规常见披露要点。
* 参考:小米开发者「隐私政策不合规修改指引」、FreeBuf APP 隐私合规规范(禁止默认勾选等)。
* 正式上线前请法务审定主体名称与联系方式。
*/
export const USER_AGREEMENT: LegalDocument = {
id: 'user-agreement',
title: '用户协议',
updatedAt: '2026-07-15',
intro:
'欢迎使用「杜康好客」平台(含微信小程序、微信内置浏览器 H5 及门店端/合伙人端相关服务,以下统称「本平台」)。请您在注册、登录或以其他方式使用本服务前仔细阅读并充分理解本协议。您勾选同意并继续使用,即视为已阅读并接受本协议全部内容。',
sections: [
{
heading: '一、服务说明',
paragraphs: [
'杜康好客是杜康酒业 O2O 消费服务平台:用户可在线浏览与购买酒类商品,获得对应「好客权益」并在合作门店核销;门店与城市合伙人可使用管理端完成核销、门店与订单相关履约操作。',
'我们有权根据业务需要调整服务内容、功能或规则,并在合理范围内通过平台公告、页面提示等方式通知您。',
],
},
{
heading: '二、账号注册与安全',
paragraphs: [
'您可通过手机号验证码、微信授权等方式注册或登录。您应保证提供的信息真实、准确、完整,并及时更新。',
'您应妥善保管账号、验证码及设备。因您自身原因导致的账号被盗用、信息泄露等风险,由您自行承担;如发现异常请立即联系客服。',
'您不得利用本平台从事违法违规、侵害他人权益或扰乱平台秩序的行为,否则我们有权限制或终止服务。',
],
},
{
heading: '三、订单、支付与权益',
paragraphs: [
'下单、支付、配送及「好客权益」发放/核销规则以平台页面展示及相关业务规则为准。支付成功后产生的权益额度按产品说明计入您的账户。',
'核销须在合作门店按规则完成;超出可用余额、已失效或违反使用规则的核销请求将被拒绝。',
'如发生退款、补发等售后事宜,将按平台售后规则及客服处理结果执行。',
],
},
{
heading: '四、用户行为规范',
paragraphs: [
'您承诺依法使用本平台,不得利用技术手段恶意刷单、伪造核销、攻击系统或传播违法信息。',
'您理解并同意:酒类商品及相关服务可能仅面向符合法律法规要求的主体;若您不具备相应资格,请勿使用购买或核销功能。',
],
},
{
heading: '五、知识产权',
paragraphs: [
'本平台中的文字、图片、标识、界面设计、软件等知识产权归平台运营方或合法权利人所有。未经许可,您不得擅自复制、传播或用于商业目的。',
],
},
{
heading: '六、免责与责任限制',
paragraphs: [
'因不可抗力、网络故障、第三方支付或配送服务异常等非我们可控因素导致的服务中断或延误,我们将在合理范围内协助处理,但不承担因此产生的间接损失。',
'在法律允许的范围内,我们对因使用或无法使用本服务所产生的损害责任以您就相关服务实际支付的费用为限(免费服务除外另有约定)。',
],
},
{
heading: '七、协议变更与终止',
paragraphs: [
'我们可能适时修订本协议,修订后的协议将在平台公布并自公布之日起生效(法律法规另有要求的除外)。若您继续使用服务,视为接受修订后的协议。',
'您可停止使用并申请注销账号;我们亦可在您严重违反本协议时中止或终止向您提供服务。',
],
},
{
heading: '八、联系我们',
paragraphs: [
`如对本协议有任何疑问,请通过客服热线 ${CUSTOMER_SERVICE_PHONE}(工作时间 9:00–21:00)与我们联系。`,
],
},
],
};
export const PRIVACY_POLICY: LegalDocument = {
id: 'privacy-policy',
title: '隐私政策',
updatedAt: '2026-07-15',
intro:
'杜康好客平台运营方(以下简称「我们」)深知个人信息对您的重要性,将按《中华人民共和国个人信息保护法》等相关法律法规保护您的个人信息。请您在使用服务前仔细阅读本政策。您勾选同意,即表示您已充分理解并同意我们按本政策处理相关个人信息。',
sections: [
{
heading: '一、我们如何收集与使用个人信息',
paragraphs: [
'为向您提供注册登录、下单支付、配送履约、门店核销、客服支持等核心功能,我们可能收集并使用下列信息:',
'1)账号信息:手机号码、验证码、微信 OpenID/UnionID、昵称与头像(若您授权微信);用于注册登录、账号绑定与安全保障。',
'2)交易信息:订单内容、收货地址、支付状态、配送状态、权益与核销记录;用于履约、售后与对账。',
'3)位置信息:在您授权后获取大致位置或精确位置,用于展示所在城市商品与附近门店;您可拒绝授权,我们将使用默认开城城市兜底。',
'4)设备与日志信息:设备型号、操作系统、网络类型、崩溃日志、操作日志等;用于安全风控、故障排查与服务优化。',
'5)您主动提供的其他信息:如客服沟通内容、反馈建议等。',
'我们不会以默认勾选等方式强制您同意本政策;未征得同意前,我们不会超范围收集与实现业务功能无关的个人信息。',
],
},
{
heading: '二、我们如何共享、转让、公开披露',
paragraphs: [
'我们不会向第三方出售您的个人信息。仅在以下情形共享:',
'1)获得您的明确同意;',
'2)为实现支付、短信、配送、地图/定位、微信登录与支付等功能,与必要的服务提供商共享履行服务所必需的信息,并要求其依法保护;',
'3)根据法律法规、行政或司法机关要求;',
'4)在合并、分立、资产转让等情形下,如涉及个人信息转移,我们将要求新的持有方继续受本政策约束,或重新征得您的同意。',
],
},
{
heading: '三、我们如何存储与保护',
paragraphs: [
'您的个人信息存储于中华人民共和国境内。我们仅在实现本政策所述目的所必需的期限内保存;超出期限后将删除或匿名化处理(法律法规另有规定的除外)。',
'我们采取合理的技术与管理措施保护信息安全,防止未经授权的访问、披露、篡改或丢失。如发生安全事件,我们将按法规要求及时告知并采取补救措施。',
],
},
{
heading: '四、第三方 SDK / 服务说明',
paragraphs: [
'为实现登录、支付、分享、定位等功能,本平台可能接入微信开放平台、支付、短信、地图定位等第三方服务。该类服务会按其自身隐私政策处理相关信息。我们仅在实现功能所必需的范围内启用,并尽量采用最小化授权。',
],
},
{
heading: '五、您的权利',
paragraphs: [
'您有权查阅、复制、更正、补充、删除您的个人信息,有权撤回同意、注销账号,以及在符合条件时限制或拒绝我们处理您的个人信息。',
'您可通过「我的」相关功能或联系客服行使上述权利。为保障安全,我们可能需要先验证您的身份。我们将在合理期限内答复。',
'您撤回同意后,我们将停止基于相应同意的处理活动,但不影响此前基于同意已进行的处理。',
],
},
{
heading: '六、未成年人保护',
paragraphs: [
'本平台主要面向成年人。若您为未成年人,请在监护人陪同下阅读本政策,并在监护人同意后使用服务。我们不会主动收集未成年人的个人信息;如发现误收集,将尽快删除。',
],
},
{
heading: '七、本政策的更新',
paragraphs: [
'我们可能适时更新本政策,并通过平台公告、弹窗或页面提示等方式告知。重大变更时,我们会提供更显著的通知,并在必要时重新征得您的同意。',
],
},
{
heading: '八、联系我们',
paragraphs: [
`个人信息保护相关事宜,请拨打客服热线 ${CUSTOMER_SERVICE_PHONE}(工作时间 9:00–21:00)。我们将尽快处理您的请求。`,
],
},
],
};
export function getLegalDocument(id: LegalDocument['id']): LegalDocument {
return id === 'privacy-policy' ? PRIVACY_POLICY : USER_AGREEMENT;
}
@@ -1059,7 +1059,8 @@ export class AuthService {
include: { avatar: true }, include: { avatar: true },
}); });
if (user?.phone && user.phoneVerifiedAt) { // 微信登录不再强制绑定手机号;phoneVerified=false 也可签发会话,下单页仅提示可选绑定
if (user) {
let activeUser: UserRow = let activeUser: UserRow =
guestId && guestId !== user.id ? await this.mergeUsers(guestId, user.id) : (user as UserRow); guestId && guestId !== user.id ? await this.mergeUsers(guestId, user.id) : (user as UserRow);
activeUser = await this.prisma.user.update({ activeUser = await this.prisma.user.update({
@@ -1087,7 +1088,7 @@ export class AuthService {
if (guestId) { if (guestId) {
try { try {
const guest = await this.assertActiveUser(guestId); const guest = await this.assertActiveUser(guestId);
if (guest.phoneVerifiedAt && !guest.wxOpenId) { if (!guest.wxOpenId) {
const activeUser = await this.attachWechatToUser(guest.id, session, clientApp, platform); const activeUser = await this.attachWechatToUser(guest.id, session, clientApp, platform);
return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey); return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey);
} }
@@ -1096,36 +1097,34 @@ export class AuthService {
} }
} }
const wxSessionKey = randomUUID(); let created = await this.prisma.user.create({
await this.redis.setJson( data: {
`wx:session:${wxSessionKey}`, userNo: generateUserNo(),
{ wxOpenId: session.openId,
openId: session.openId, wxUnionId: session.unionId,
unionId: session.unionId, nickname: '微信用户',
sessionKey: 'sessionKey' in session ? session.sessionKey : undefined, cityPreference: {
accessToken: session.accessToken, create: {
clientApp, selectedCityCode: '410100',
guestId: guestId?.toString(), selectedDistrict: '郑州市',
} satisfies WxSessionPayload, },
1800, },
); },
include: { avatar: true },
if (user && !user.phoneVerifiedAt) { });
return { if (session.accessToken) {
needBindPhone: true, const synced = await this.syncWechatUserProfile(created.id, session.accessToken, session.openId);
wxSessionKey, if (synced) created = synced as UserRow;
actorType: 'USER',
actorId: user.id.toString(),
phoneVerified: false,
user: this.formatUserProfile(user),
};
} }
this.analyticsService.trackOneSafe(created.id, clientApp, {
return { eventName: 'wechat_login',
needBindPhone: true, extraJson: { platform },
wxSessionKey, });
phoneVerified: false, this.analyticsService.trackOneSafe(created.id, clientApp, {
}; eventName: 'login_success',
extraJson: { method: 'wechat', platform },
});
return this.buildSessionResponse(created, clientApp, created.deviceKey);
} }
async bindWechatPhone( async bindWechatPhone(
@@ -3,7 +3,6 @@ import type { Request } from 'express';
import { TradeService } from './trade.service'; import { TradeService } from './trade.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { import {
PartnerProxyOrderCreateDto, PartnerProxyOrderCreateDto,
@@ -22,7 +21,6 @@ export class TradeController {
} }
@Post() @Post()
@UseGuards(PhoneVerifiedGuard)
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>, @Req() req: Request) { create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>, @Req() req: Request) {
return this.tradeService.createOrder(user.actorId, body as never, req); return this.tradeService.createOrder(user.actorId, body as never, req);
} }
@@ -43,7 +41,6 @@ export class TradeController {
} }
@Post(':id/pay') @Post(':id/pay')
@UseGuards(PhoneVerifiedGuard)
pay(@CurrentUser() user: AuthUser, @Param('id') id: string) { pay(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.tradeService.payOrder(user.actorId, BigInt(id), user.clientApp); return this.tradeService.payOrder(user.actorId, BigInt(id), user.clientApp);
} }
+1 -1
View File
@@ -229,7 +229,7 @@
| 模块 | 要点 | | 模块 | 要点 |
|------|------| |------|------|
| 导航账号 | 四 Tab;无感登录;确认下单强制手机号;仅微信支付;7 天免登 | | 导航账号 | 四 Tab;无感登录;确认下单提示绑定手机号(可选、不强制);仅微信支付;7 天免登 |
| 商品下单 | 4 款酒祖杜康 SKU;同城≥2瓶免运费;跨城≥1箱;现场提货隐藏;锁单30分钟 | | 商品下单 | 4 款酒祖杜康 SKU;同城≥2瓶免运费;跨城≥1箱;现场提货隐藏;锁单30分钟 |
| 权益核销 | 1:1 发放;出码 3 分钟;核销前规则弹窗(OPT-011);附件一规则详情 | | 权益核销 | 1:1 发放;出码 3 分钟;核销前规则弹窗(OPT-011);附件一规则详情 |
| 门店 | 列表/详情/搜索/省市区筛选;仅营业中展示;立即核销 | | 门店 | 列表/详情/搜索/省市区筛选;仅营业中展示;立即核销 |
+1 -1
View File
@@ -99,7 +99,7 @@
| REQ | 功能 | 证据 | 备注 | | REQ | 功能 | 证据 | 备注 |
|-----|------|------|------| |-----|------|------|------|
| REQ-U-001 | 四 Tab 导航 | `src/layouts/TabLayout.tsx` | 形态为 H5 非小程序 | | REQ-U-001 | 四 Tab 导航 | `src/layouts/TabLayout.tsx` | 形态为 H5 非小程序 |
| REQ-U-002 | 登录 + 下单强制手机号 | `OrderConfirmPage.tsx``PhoneVerifiedGuard` | ✅ | | REQ-U-002 | 登录 + 下单提示手机号(可选) | `OrderConfirmPage` / `order-confirm`、微信登录签发会话 | ✅ |
| REQ-U-003 | 微信支付 | `pay-wechat.ts` + 后端 callback | Mock/真实均有 | | REQ-U-003 | 微信支付 | `pay-wechat.ts` + 后端 callback | Mock/真实均有 |
| REQ-U-004 | 定位/开城 | `HomePage.tsx``wechat-location.ts` | 🔶 H5 定位 API | | REQ-U-004 | 定位/开城 | `HomePage.tsx``wechat-location.ts` | 🔶 H5 定位 API |
| REQ-U-005~006 | 商品列表/详情 | `HomePage.tsx``ProductDetailPage.tsx` | ✅ seed 已对齐酒祖杜康 | | REQ-U-005~006 | 商品列表/详情 | `HomePage.tsx``ProductDetailPage.tsx` | ✅ seed 已对齐酒祖杜康 |