Compare commits
13 Commits
c0d66cfe50
...
v3.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 81a6e3674b | |||
| 76270b20bf | |||
| c2914c37e5 | |||
| 9f9b7cb2bd | |||
| de396442a4 | |||
| 02efe6fa14 | |||
| 14b867a3a5 | |||
| f750fea667 | |||
| 55e91158b3 | |||
| 19cb13d26c | |||
| 04253b9120 | |||
| aaf6ee81d5 | |||
| 972bdde650 |
@@ -292,7 +292,7 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
|
|||||||
subs={detail.children ?? []}
|
subs={detail.children ?? []}
|
||||||
onAdd={() => {
|
onAdd={() => {
|
||||||
subForm.resetFields();
|
subForm.resetFields();
|
||||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||||
setSubOpen(true);
|
setSubOpen(true);
|
||||||
}}
|
}}
|
||||||
onEdit={(row) => {
|
onEdit={(row) => {
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export default function CityPartnersPage() {
|
|||||||
async function openAddSubAccount(parentId: string) {
|
async function openAddSubAccount(parentId: string) {
|
||||||
await openPartner(parentId);
|
await openPartner(parentId);
|
||||||
subForm.resetFields();
|
subForm.resetFields();
|
||||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||||
setSubOpen(true);
|
setSubOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,7 +482,7 @@ export default function CityPartnersPage() {
|
|||||||
subs={detail.children ?? []}
|
subs={detail.children ?? []}
|
||||||
onAdd={() => {
|
onAdd={() => {
|
||||||
subForm.resetFields();
|
subForm.resetFields();
|
||||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||||
setSubOpen(true);
|
setSubOpen(true);
|
||||||
}}
|
}}
|
||||||
onEdit={(sub) => openSubEdit(sub, detail.id)}
|
onEdit={(sub) => openSubEdit(sub, detail.id)}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export default function PartnerAccountsPage() {
|
|||||||
function openAddSub(parent: AccountTreeRow) {
|
function openAddSub(parent: AccountTreeRow) {
|
||||||
setSubParent(parent);
|
setSubParent(parent);
|
||||||
subForm.resetFields();
|
subForm.resetFields();
|
||||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||||
setSubOpen(true);
|
setSubOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,9 +63,9 @@ function SubAccountRoutes() {
|
|||||||
<>
|
<>
|
||||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||||
{isWarehouse && <Route path="/orders/:id" element={<OrderDetailPage />} />}
|
{isWarehouse && <Route path="/orders/:id" element={<OrderDetailPage />} />}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export function fetchPartnerLeaderboard(period: PartnerLeaderboardPeriod = 'tota
|
|||||||
return request<PartnerLeaderboardResponse>(
|
return request<PartnerLeaderboardResponse>(
|
||||||
'PARTNER_H5',
|
'PARTNER_H5',
|
||||||
`/partner/dashboard/leaderboard?period=${period}`,
|
`/partner/dashboard/leaderboard?period=${period}`,
|
||||||
|
{ silent: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,52 @@ export function hasPartnerPermission(
|
|||||||
return account.permissions?.includes(permission) ?? false;
|
return account.permissions?.includes(permission) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasAnyPartnerPermission(
|
||||||
|
account: PartnerMe | null | undefined,
|
||||||
|
permissions: PartnerPermissionKey[],
|
||||||
|
): boolean {
|
||||||
|
return permissions.some((p) => hasPartnerPermission(account, p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与后端 GET /partner/dashboard 权限点一致 */
|
||||||
|
export function canAccessPartnerDashboard(account: PartnerMe | null | undefined): boolean {
|
||||||
|
return hasAnyPartnerPermission(account, [
|
||||||
|
'store:create',
|
||||||
|
'store:manage',
|
||||||
|
'order:view',
|
||||||
|
'warehouse:manage',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 门店列表/拓店统计所需权限 */
|
||||||
|
export function canAccessPartnerStores(account: PartnerMe | null | undefined): boolean {
|
||||||
|
return hasAnyPartnerPermission(account, ['store:create', 'store:manage']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑资料 / 开闭店 / 重新上传:主账号、门店权限,或历史未配权限的门店类子账号 */
|
||||||
|
export function canManagePartnerStore(account: PartnerMe | null | undefined): boolean {
|
||||||
|
if (!account) return false;
|
||||||
|
if (isPrimaryAccount(account)) return true;
|
||||||
|
if (isWarehouseStaff(account)) return false;
|
||||||
|
if (hasAnyPartnerPermission(account, ['store:manage', 'store:create'])) return true;
|
||||||
|
// 合伙人端早期创建的子账号可能 permissions 为空,按门店员工放开
|
||||||
|
return !Array.isArray(account.permissions) || account.permissions.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 录入新店 */
|
||||||
|
export function canCreatePartnerStore(account: PartnerMe | null | undefined): boolean {
|
||||||
|
if (!account) return false;
|
||||||
|
if (isPrimaryAccount(account)) return true;
|
||||||
|
if (isWarehouseStaff(account)) return false;
|
||||||
|
if (hasPartnerPermission(account, 'store:create')) return true;
|
||||||
|
return !Array.isArray(account.permissions) || account.permissions.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与后端 GET /partner/orders 权限点一致 */
|
||||||
|
export function canAccessPartnerOrders(account: PartnerMe | null | undefined): boolean {
|
||||||
|
return hasAnyPartnerPermission(account, ['order:view', 'warehouse:manage']);
|
||||||
|
}
|
||||||
|
|
||||||
/** 仓库管理员:warehouse:manage,或仅有 order:view(无门店权限) */
|
/** 仓库管理员:warehouse:manage,或仅有 order:view(无门店权限) */
|
||||||
export function isWarehouseStaff(account: PartnerMe | null | undefined): boolean {
|
export function isWarehouseStaff(account: PartnerMe | null | undefined): boolean {
|
||||||
if (!account || isPrimaryAccount(account)) return false;
|
if (!account || isPrimaryAccount(account)) return false;
|
||||||
@@ -48,8 +94,8 @@ export function partnerHomePath(account: PartnerMe | null | undefined): string {
|
|||||||
return '/';
|
return '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORE_STAFF_PREFIXES = ['/', '/stores', '/me', '/login'];
|
const STORE_STAFF_PREFIXES = ['/', '/stores', '/me', '/leaderboard', '/login'];
|
||||||
const WAREHOUSE_STAFF_PREFIXES = ['/', '/orders', '/me', '/login'];
|
const WAREHOUSE_STAFF_PREFIXES = ['/', '/orders', '/me', '/leaderboard', '/login'];
|
||||||
|
|
||||||
export function isSubAccountPath(pathname: string, navKind: PartnerNavKind = 'store_staff'): boolean {
|
export function isSubAccountPath(pathname: string, navKind: PartnerNavKind = 'store_staff'): boolean {
|
||||||
if (pathname === '/login') return true;
|
if (pathname === '/login') return true;
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import {
|
import { PartnerStaffRole, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
|
||||||
PartnerStaffRole,
|
import type { CreatePartnerStaffRequest, PartnerStaffItem, UpdatePartnerStaffRequest } from '@dukang/shared-types';
|
||||||
type CreatePartnerStaffRequest,
|
|
||||||
type PartnerStaffItem,
|
|
||||||
type UpdatePartnerStaffRequest,
|
|
||||||
} from '@dukang/shared-types';
|
|
||||||
import { request } from './api';
|
import { request } from './api';
|
||||||
|
|
||||||
export function listPartnerStaff() {
|
export function listPartnerStaff() {
|
||||||
@@ -25,7 +21,10 @@ export function createPartnerStaff(body: CreatePartnerStaffRequest) {
|
|||||||
name: body.name,
|
name: body.name,
|
||||||
phone: body.phone,
|
phone: body.phone,
|
||||||
smsCode: body.smsCode,
|
smsCode: body.smsCode,
|
||||||
staffRole: PartnerStaffRole.INTERNAL,
|
staffRole: body.staffRole ?? PartnerStaffRole.INTERNAL,
|
||||||
|
permissions: body.permissions?.length
|
||||||
|
? body.permissions
|
||||||
|
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,6 +251,15 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
|||||||
{!isPrimary && (
|
{!isPrimary && (
|
||||||
<section className="partner-menu-section">
|
<section className="partner-menu-section">
|
||||||
<div className="partner-menu-card">
|
<div className="partner-menu-card">
|
||||||
|
<Link to="/leaderboard" className="partner-menu-item">
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon">
|
||||||
|
<span className="material-symbols-outlined">emoji_events</span>
|
||||||
|
</div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>团队贡献榜</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</Link>
|
||||||
<button type="button" className="partner-menu-item" onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}>
|
<button type="button" className="partner-menu-item" onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}>
|
||||||
<div className="partner-menu-item-left">
|
<div className="partner-menu-item-left">
|
||||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">support_agent</span></div>
|
<div className="partner-menu-icon"><span className="material-symbols-outlined">support_agent</span></div>
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { Link, useNavigate } from 'react-router-dom';
|
|||||||
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
||||||
import { getPartnerNavKind, hasWarehouseAccess } from '../lib/partnerAccess';
|
import {
|
||||||
|
canAccessPartnerDashboard,
|
||||||
|
canAccessPartnerOrders,
|
||||||
|
canAccessPartnerStores,
|
||||||
|
getPartnerNavKind,
|
||||||
|
hasWarehouseAccess,
|
||||||
|
} from '../lib/partnerAccess';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
|
|
||||||
function fmtMoney(n: number) {
|
function fmtMoney(n: number) {
|
||||||
@@ -96,7 +102,7 @@ function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] })
|
|||||||
<div className="partner-leaderboard-header">
|
<div className="partner-leaderboard-header">
|
||||||
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 20 }}>emoji_events</span>
|
<span className="material-symbols-outlined text-primary" style={{ fontSize: 20 }}>emoji_events</span>
|
||||||
合伙人贡献榜
|
团队贡献榜
|
||||||
</h3>
|
</h3>
|
||||||
<Link to="/leaderboard" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<Link to="/leaderboard" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
查看全部 <span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
|
查看全部 <span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
|
||||||
@@ -136,6 +142,9 @@ export default function HomePage() {
|
|||||||
const isPrimary = navKind === 'primary';
|
const isPrimary = navKind === 'primary';
|
||||||
const isWarehouse = navKind === 'warehouse_staff';
|
const isWarehouse = navKind === 'warehouse_staff';
|
||||||
const warehouseOk = hasWarehouseAccess(account);
|
const warehouseOk = hasWarehouseAccess(account);
|
||||||
|
const canOrders = canAccessPartnerOrders(account) && warehouseOk && (isPrimary || isWarehouse);
|
||||||
|
const canDashboard = canAccessPartnerDashboard(account) && !isWarehouse;
|
||||||
|
const canStores = canAccessPartnerStores(account) && !isWarehouse;
|
||||||
|
|
||||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||||
@@ -148,27 +157,42 @@ export default function HomePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||||
if ((isWarehouse || isPrimary) && warehouseOk) {
|
// 等 session 带上账号后再按权限发请求,避免无权限接口弹错
|
||||||
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders')
|
if (!account) return;
|
||||||
|
|
||||||
|
if (canOrders) {
|
||||||
|
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true })
|
||||||
.then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
|
.then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
|
||||||
.catch(() => setOrders([]));
|
.catch(() => setOrders([]));
|
||||||
} else {
|
} else {
|
||||||
setOrders([]);
|
setOrders([]);
|
||||||
}
|
}
|
||||||
if (!isWarehouse) {
|
|
||||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {});
|
if (canDashboard) {
|
||||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores).catch(() => []);
|
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
|
||||||
fetchPartnerLeaderboard('month')
|
.then(setDash)
|
||||||
.then((data) => {
|
.catch(() => setDash(null));
|
||||||
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setLeaderboardEntries([]);
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {});
|
setDash(null);
|
||||||
}
|
}
|
||||||
}, [navigate, isWarehouse, isPrimary, warehouseOk]);
|
|
||||||
|
if (canStores) {
|
||||||
|
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true })
|
||||||
|
.then((data) => setStores(Array.isArray(data) ? data : []))
|
||||||
|
.catch(() => setStores([]));
|
||||||
|
} else {
|
||||||
|
setStores([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主账号与全部子账号均可查看同团队贡献榜(后端不校验业务权限点)
|
||||||
|
fetchPartnerLeaderboard('month')
|
||||||
|
.then((data) => {
|
||||||
|
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLeaderboardEntries([]);
|
||||||
|
});
|
||||||
|
}, [navigate, account, canOrders, canDashboard, canStores]);
|
||||||
|
|
||||||
const storeCount = Number(dash?.storeCount || stores.length || 0);
|
const storeCount = Number(dash?.storeCount || stores.length || 0);
|
||||||
const orderStats = useMemo(() => summarizeOrders(orders), [orders]);
|
const orderStats = useMemo(() => summarizeOrders(orders), [orders]);
|
||||||
@@ -247,7 +271,7 @@ export default function HomePage() {
|
|||||||
<p className="headline-md" style={{ marginBottom: 8 }}>订单管理</p>
|
<p className="headline-md" style={{ marginBottom: 8 }}>订单管理</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(isPrimary || isWarehouse) && warehouseOk && (
|
{canOrders && (
|
||||||
<OrderSummarySection
|
<OrderSummarySection
|
||||||
title={isWarehouse ? '今日订单' : undefined}
|
title={isWarehouse ? '今日订单' : undefined}
|
||||||
todayCount={orderStats.todayCount}
|
todayCount={orderStats.todayCount}
|
||||||
@@ -269,7 +293,7 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isWarehouse && (
|
{canStores && (
|
||||||
<>
|
<>
|
||||||
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
|
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
|
||||||
<div className="partner-bento-header">
|
<div className="partner-bento-header">
|
||||||
@@ -317,10 +341,10 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<LeaderboardPreview entries={leaderboardEntries} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<LeaderboardPreview entries={leaderboardEntries} />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export default function LeaderboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-no-tab">
|
<div className="page-no-tab">
|
||||||
<PageHeader title="合伙人贡献榜" onBack={() => navigate('/')} />
|
<PageHeader title="团队贡献榜" onBack={() => navigate('/')} />
|
||||||
|
|
||||||
<div className="partner-leaderboard-tabs">
|
<div className="partner-leaderboard-tabs">
|
||||||
{PERIOD_TABS.map((tab) => (
|
{PERIOD_TABS.map((tab) => (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||||
@@ -24,6 +24,32 @@ import { isWechatEnv } from '../lib/weixin';
|
|||||||
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
|
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
|
||||||
const REMEMBER_FLAG_KEY = 'partner_remember_account';
|
const REMEMBER_FLAG_KEY = 'partner_remember_account';
|
||||||
|
|
||||||
|
function AgreementCheckbox({
|
||||||
|
agreed,
|
||||||
|
onChange,
|
||||||
|
inputRef,
|
||||||
|
}: {
|
||||||
|
agreed: boolean;
|
||||||
|
onChange: (next: boolean) => void;
|
||||||
|
inputRef?: RefObject<HTMLLabelElement | null>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="partner-checkbox-row partner-checkbox-row--agreement" ref={inputRef}>
|
||||||
|
<input type="checkbox" checked={agreed} onChange={(e) => onChange(e.target.checked)} />
|
||||||
|
<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>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function maskPhone(phone: string) {
|
function maskPhone(phone: string) {
|
||||||
if (phone.length < 7) return phone;
|
if (phone.length < 7) return phone;
|
||||||
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
||||||
@@ -74,6 +100,7 @@ export default function LoginPage() {
|
|||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
const [wxAuthorize, setWxAuthorize] = useState(false);
|
||||||
|
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchClientConfig()
|
fetchClientConfig()
|
||||||
@@ -88,6 +115,7 @@ export default function LoginPage() {
|
|||||||
function ensureAgreed() {
|
function ensureAgreed() {
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
setMsg('请先勾选并同意用户协议');
|
setMsg('请先勾选并同意用户协议');
|
||||||
|
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -230,10 +258,16 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
|
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
|
||||||
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center', marginBottom: 12 }}>{msg}</p>}
|
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center', marginBottom: 12 }}>{msg}</p>}
|
||||||
|
<AgreementCheckbox
|
||||||
|
agreed={agreed}
|
||||||
|
onChange={setAgreed}
|
||||||
|
inputRef={agreementRef}
|
||||||
|
/>
|
||||||
{canWechatQuick ? (
|
{canWechatQuick ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="partner-btn-primary"
|
className="partner-btn-primary"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
disabled={wxLoading}
|
disabled={wxLoading}
|
||||||
onClick={() => void wechatLogin()}
|
onClick={() => void wechatLogin()}
|
||||||
>
|
>
|
||||||
@@ -241,7 +275,7 @@ export default function LoginPage() {
|
|||||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<p className="partner-auth-msg" style={{ textAlign: 'center', marginBottom: 12 }}>
|
<p className="partner-auth-msg" style={{ textAlign: 'center', marginTop: 12, marginBottom: 12 }}>
|
||||||
{wxAuthorize && !isWechatEnv()
|
{wxAuthorize && !isWechatEnv()
|
||||||
? '请在微信内打开以使用一键登录'
|
? '请在微信内打开以使用一键登录'
|
||||||
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
|
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
|
||||||
@@ -321,6 +355,12 @@ export default function LoginPage() {
|
|||||||
<span>记住账号</span>
|
<span>记住账号</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<AgreementCheckbox
|
||||||
|
agreed={agreed}
|
||||||
|
onChange={setAgreed}
|
||||||
|
inputRef={agreementRef}
|
||||||
|
/>
|
||||||
|
|
||||||
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>}
|
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>}
|
||||||
|
|
||||||
<button type="button" className="partner-btn-primary" onClick={() => void login()} disabled={loading}>
|
<button type="button" className="partner-btn-primary" onClick={() => void login()} disabled={loading}>
|
||||||
@@ -339,20 +379,6 @@ export default function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="partner-checkbox-row">
|
|
||||||
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
|
|
||||||
<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>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -523,7 +523,9 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean);
|
const envPhotoUrls = Array.from(
|
||||||
|
new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)),
|
||||||
|
).slice(0, 3);
|
||||||
|
|
||||||
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
|
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import AppImage from '@dukang/shared-ui/AppImage';
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { toastSuccess } from '../lib/toast';
|
import { toastSuccess } from '../lib/toast';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { isSubAccount } from '../lib/partnerAccess';
|
import { canManagePartnerStore } from '../lib/partnerAccess';
|
||||||
|
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
|
||||||
|
import OssUploadField from '../components/OssUploadField';
|
||||||
import {
|
import {
|
||||||
canPartnerOpenStore,
|
canPartnerOpenStore,
|
||||||
storeAuditLabel,
|
storeAuditLabel,
|
||||||
@@ -16,19 +18,36 @@ import {
|
|||||||
} from '../lib/storeStatus';
|
} from '../lib/storeStatus';
|
||||||
|
|
||||||
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
||||||
|
const ENV_SLOT_COUNT = 3;
|
||||||
|
|
||||||
|
function uniqueEnvUrls(urls: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const raw of urls) {
|
||||||
|
const url = raw.trim();
|
||||||
|
if (!url || seen.has(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
out.push(url);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export default function StoreDetailPage() {
|
export default function StoreDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { account } = usePartnerSession();
|
const { account } = usePartnerSession();
|
||||||
const subReadonly = isSubAccount(account);
|
const canMutate = canManagePartnerStore(account);
|
||||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
|
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
|
||||||
|
const [coverUrl, setCoverUrl] = useState('');
|
||||||
|
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
|
||||||
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
||||||
const [statusSaving, setStatusSaving] = useState(false);
|
const [statusSaving, setStatusSaving] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [mediaSaving, setMediaSaving] = useState(false);
|
||||||
const [actionError, setActionError] = useState('');
|
const [actionError, setActionError] = useState('');
|
||||||
|
const [wechatReady, setWechatReady] = useState(false);
|
||||||
|
|
||||||
function applyStore(data: Record<string, unknown>) {
|
function applyStore(data: Record<string, unknown>) {
|
||||||
setStore(data);
|
setStore(data);
|
||||||
@@ -39,6 +58,15 @@ export default function StoreDetailPage() {
|
|||||||
intro: String(data.intro || ''),
|
intro: String(data.intro || ''),
|
||||||
});
|
});
|
||||||
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
|
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
|
||||||
|
setCoverUrl(String(data.coverUrl || ''));
|
||||||
|
const envFromMedia = Array.isArray(data.media)
|
||||||
|
? uniqueEnvUrls(
|
||||||
|
(data.media as Array<{ url?: string; bizType?: string }>)
|
||||||
|
.filter((m) => m.bizType === 'ENV')
|
||||||
|
.map((m) => String(m.url || '')),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
setEnvPhotoUrls(normalizeStringArray(envFromMedia, ENV_SLOT_COUNT));
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -113,6 +141,42 @@ export default function StoreDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveMedia() {
|
||||||
|
if (!id || mediaSaving || status === 'CLOSED') return;
|
||||||
|
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
|
||||||
|
if (auditStatus === 'PENDING') {
|
||||||
|
setActionError('门店审核中,暂不可修改资料');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextCover = coverUrl.trim();
|
||||||
|
const nextEnv = uniqueEnvUrls(envPhotoUrls);
|
||||||
|
if (!nextCover) {
|
||||||
|
setActionError('请上传门头照');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextEnv.length < ENV_SLOT_COUNT) {
|
||||||
|
setActionError(`请上传至少 ${ENV_SLOT_COUNT} 张环境照片`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMediaSaving(true);
|
||||||
|
setActionError('');
|
||||||
|
try {
|
||||||
|
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/media`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
coverUrl: nextCover,
|
||||||
|
envPhotoUrls: nextEnv,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
applyStore(data);
|
||||||
|
toastSuccess(auditStatus === 'REJECTED' ? '照片已更新并重新提交审核' : '照片已更新');
|
||||||
|
} catch (e) {
|
||||||
|
setActionError(e instanceof Error ? e.message : '照片更新失败');
|
||||||
|
} finally {
|
||||||
|
setMediaSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loadError) {
|
if (loadError) {
|
||||||
return (
|
return (
|
||||||
<div className="partner-detail-page">
|
<div className="partner-detail-page">
|
||||||
@@ -124,13 +188,17 @@ export default function StoreDetailPage() {
|
|||||||
|
|
||||||
if (!store) return <div className="empty">加载中...</div>;
|
if (!store) return <div className="empty">加载中...</div>;
|
||||||
|
|
||||||
const envPhotos = Array.isArray(store.media)
|
const envPhotos = uniqueEnvUrls(
|
||||||
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV')
|
Array.isArray(store.media)
|
||||||
: [];
|
? (store.media as Array<{ url?: string; bizType?: string }>)
|
||||||
|
.filter((m) => m.bizType === 'ENV')
|
||||||
|
.map((m) => String(m.url || ''))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
const auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase();
|
const auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase();
|
||||||
const auditPending = auditStatus === 'PENDING';
|
const auditPending = auditStatus === 'PENDING';
|
||||||
const auditRejected = auditStatus === 'REJECTED';
|
const auditRejected = auditStatus === 'REJECTED';
|
||||||
const readOnly = subReadonly || status === 'CLOSED' || auditPending;
|
const readOnly = !canMutate || status === 'CLOSED' || auditPending;
|
||||||
const canOpen = canPartnerOpenStore(auditStatus);
|
const canOpen = canPartnerOpenStore(auditStatus);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -170,7 +238,7 @@ export default function StoreDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{!subReadonly && (
|
{canMutate && (
|
||||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>运营状态</h3>
|
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>运营状态</h3>
|
||||||
@@ -200,13 +268,29 @@ export default function StoreDetailPage() {
|
|||||||
|
|
||||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}>基本信息</h3>
|
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}>基本信息</h3>
|
||||||
<div className="partner-cover">
|
{!canMutate || readOnly ? (
|
||||||
<AppImage
|
<div className="partner-cover">
|
||||||
src={store.coverUrl ? String(store.coverUrl) : null}
|
<AppImage
|
||||||
alt={form.name}
|
src={coverUrl || null}
|
||||||
wrapperClassName="app-image--fill"
|
alt={form.name}
|
||||||
/>
|
wrapperClassName="app-image--fill"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<p className="label-md text-muted" style={{ marginBottom: 8 }}>门头照</p>
|
||||||
|
<OssUploadField
|
||||||
|
wide
|
||||||
|
bizType="STORE_TITLE"
|
||||||
|
mediaType="IMAGE"
|
||||||
|
value={coverUrl}
|
||||||
|
wechatReady={wechatReady}
|
||||||
|
onWechatReadyChange={setWechatReady}
|
||||||
|
onChange={setCoverUrl}
|
||||||
|
label="点击更换门头照"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
<label>门店名称</label>
|
<label>门店名称</label>
|
||||||
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
|
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
|
||||||
@@ -234,13 +318,48 @@ export default function StoreDetailPage() {
|
|||||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 16 }}>
|
||||||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}>店内环境</h3>
|
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}>店内环境</h3>
|
||||||
<span className="label-md text-muted">{envPhotos.length ? `已上传 ${envPhotos.length} 张` : '暂无照片'}</span>
|
<span className="label-md text-muted">
|
||||||
|
{canMutate && !readOnly
|
||||||
|
? `需 ${ENV_SLOT_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length} 张`
|
||||||
|
: envPhotos.length
|
||||||
|
? `已上传 ${envPhotos.length} 张`
|
||||||
|
: '暂无照片'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{envPhotos.length > 0 ? (
|
{canMutate && !readOnly ? (
|
||||||
|
<>
|
||||||
|
<div className="partner-upload-grid">
|
||||||
|
{envPhotoUrls.map((url, index) => (
|
||||||
|
<OssUploadField
|
||||||
|
key={index}
|
||||||
|
compact
|
||||||
|
bizType="STORE_ENV"
|
||||||
|
mediaType="IMAGE"
|
||||||
|
value={url}
|
||||||
|
wechatReady={wechatReady}
|
||||||
|
onWechatReadyChange={setWechatReady}
|
||||||
|
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-outline"
|
||||||
|
style={{ width: '100%', marginTop: 16 }}
|
||||||
|
disabled={mediaSaving}
|
||||||
|
onClick={() => void saveMedia()}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18, verticalAlign: 'middle', marginRight: 4 }}>
|
||||||
|
upload
|
||||||
|
</span>
|
||||||
|
{mediaSaving ? '上传中…' : '重新上传照片'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : envPhotos.length > 0 ? (
|
||||||
<div className="partner-photo-grid">
|
<div className="partner-photo-grid">
|
||||||
{envPhotos.map((photo, index) => (
|
{envPhotos.map((url, index) => (
|
||||||
<div key={index} className="partner-cover" style={{ aspectRatio: '1' }}>
|
<div key={`${url}-${index}`} className="partner-cover" style={{ aspectRatio: '1', marginBottom: 0 }}>
|
||||||
<AppImage src={photo.url ? String(photo.url) : null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
|
<AppImage src={url || null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -264,7 +383,7 @@ export default function StoreDetailPage() {
|
|||||||
|
|
||||||
<footer className="partner-save-footer">
|
<footer className="partner-save-footer">
|
||||||
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
||||||
{!subReadonly && (
|
{canMutate && (
|
||||||
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
||||||
<span className="material-symbols-outlined">save</span>
|
<span className="material-symbols-outlined">save</span>
|
||||||
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
|
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { isSubAccount } from '../lib/partnerAccess';
|
import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
|
||||||
import {
|
import {
|
||||||
canPartnerOpenStore,
|
canPartnerOpenStore,
|
||||||
storeAuditLabel,
|
storeAuditLabel,
|
||||||
@@ -27,7 +27,8 @@ export default function StoreListPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { account } = usePartnerSession();
|
const { account } = usePartnerSession();
|
||||||
const readonly = isSubAccount(account);
|
const canMutate = canManagePartnerStore(account);
|
||||||
|
const canCreate = canCreatePartnerStore(account);
|
||||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter;
|
const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter;
|
||||||
@@ -45,8 +46,8 @@ export default function StoreListPage() {
|
|||||||
}, [navigate, loadStores]);
|
}, [navigate, loadStores]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = readonly ? '我的门店' : '门店管理';
|
document.title = canMutate ? '门店管理' : '我的门店';
|
||||||
}, [readonly]);
|
}, [canMutate]);
|
||||||
|
|
||||||
const filtered = useMemo(() => stores.filter((s) => {
|
const filtered = useMemo(() => stores.filter((s) => {
|
||||||
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
||||||
@@ -101,12 +102,14 @@ export default function StoreListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canCreate && (
|
||||||
<Link to="/stores/new" className="partner-fab-link">
|
<Link to="/stores/new" className="partner-fab-link">
|
||||||
<button type="button" className="partner-btn-primary">
|
<button type="button" className="partner-btn-primary">
|
||||||
<span className="material-symbols-outlined">add_business</span>
|
<span className="material-symbols-outlined">add_business</span>
|
||||||
录入新门店
|
录入新门店
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{filtered.length === 0 && <div className="empty">暂无门店</div>}
|
{filtered.length === 0 && <div className="empty">暂无门店</div>}
|
||||||
|
|
||||||
@@ -146,7 +149,7 @@ export default function StoreListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
{!readonly && (
|
{canMutate && (
|
||||||
<div className="partner-store-card-actions">
|
<div className="partner-store-card-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -13,13 +13,23 @@ html {
|
|||||||
/* ── Partner auth ── */
|
/* ── Partner auth ── */
|
||||||
.partner-auth-page {
|
.partner-auth-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
padding: 48px var(--space-page) var(--space-page);
|
padding: 48px var(--space-page) calc(24px + env(safe-area-inset-bottom, 0px));
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
|
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-height: 720px) {
|
||||||
|
.partner-auth-page {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-auth-page--quick {
|
.partner-auth-page--quick {
|
||||||
@@ -166,14 +176,27 @@ html {
|
|||||||
|
|
||||||
.partner-checkbox-row {
|
.partner-checkbox-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: var(--space-md);
|
margin-top: var(--space-md);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
color: var(--color-on-surface-variant);
|
color: var(--color-on-surface-variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-checkbox-row input { margin-top: 0; accent-color: var(--color-heritage-red); }
|
.partner-checkbox-row input {
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-checkbox-row--agreement {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.partner-auth-footer {
|
.partner-auth-footer {
|
||||||
margin-top: var(--space-lg);
|
margin-top: var(--space-lg);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||||
@@ -28,6 +28,36 @@ function formatWechatError(e: unknown): string {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ShopAgreementCheckbox({
|
||||||
|
agreed,
|
||||||
|
onChange,
|
||||||
|
labelRef,
|
||||||
|
}: {
|
||||||
|
agreed: boolean;
|
||||||
|
onChange: (next: boolean) => void;
|
||||||
|
labelRef?: RefObject<HTMLLabelElement | null>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="shop-login-agreement" ref={labelRef}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={agreed}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
我已阅读并同意
|
||||||
|
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
|
||||||
|
《用户协议》
|
||||||
|
</Link>
|
||||||
|
与
|
||||||
|
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
|
||||||
|
《隐私政策》
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { applySession } = useStoreSession();
|
const { applySession } = useStoreSession();
|
||||||
@@ -42,6 +72,7 @@ export default function LoginPage() {
|
|||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
const [wxAuthorize, setWxAuthorize] = useState(false);
|
||||||
|
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchClientConfig()
|
fetchClientConfig()
|
||||||
@@ -71,6 +102,7 @@ export default function LoginPage() {
|
|||||||
function ensureAgreed() {
|
function ensureAgreed() {
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
setMsg('请先阅读并同意用户协议');
|
setMsg('请先阅读并同意用户协议');
|
||||||
|
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -180,6 +212,11 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<div className="shop-quick-actions">
|
<div className="shop-quick-actions">
|
||||||
{msg && <p className="shop-login-msg">{msg}</p>}
|
{msg && <p className="shop-login-msg">{msg}</p>}
|
||||||
|
<ShopAgreementCheckbox
|
||||||
|
agreed={agreed}
|
||||||
|
onChange={setAgreed}
|
||||||
|
labelRef={agreementRef}
|
||||||
|
/>
|
||||||
{canWechatQuick ? (
|
{canWechatQuick ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -270,6 +307,12 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
{msg && <p className="shop-login-msg">{msg}</p>}
|
{msg && <p className="shop-login-msg">{msg}</p>}
|
||||||
|
|
||||||
|
<ShopAgreementCheckbox
|
||||||
|
agreed={agreed}
|
||||||
|
onChange={setAgreed}
|
||||||
|
labelRef={agreementRef}
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shop-login-submit"
|
className="shop-login-submit"
|
||||||
@@ -300,24 +343,6 @@ export default function LoginPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="shop-login-agreement">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={agreed}
|
|
||||||
onChange={(e) => setAgreed(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
我已阅读并同意
|
|
||||||
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
|
|
||||||
《用户协议》
|
|
||||||
</Link>
|
|
||||||
与
|
|
||||||
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
|
|
||||||
《隐私政策》
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="shop-login-footer">
|
<footer className="shop-login-footer">
|
||||||
|
|||||||
@@ -188,7 +188,7 @@
|
|||||||
|
|
||||||
.shop-login-submit {
|
.shop-login-submit {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 32px;
|
margin-top: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
@@ -248,14 +248,18 @@
|
|||||||
|
|
||||||
.shop-login-agreement {
|
.shop-login-agreement {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-width: 280px;
|
max-width: 100%;
|
||||||
margin: 32px auto 0;
|
margin: 16px 0 0;
|
||||||
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shop-login-agreement input {
|
.shop-login-agreement input {
|
||||||
margin-top: 0;
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
accent-color: var(--color-heritage-red);
|
accent-color: var(--color-heritage-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,6 +459,15 @@
|
|||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shop-quick-actions .shop-login-agreement {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-quick-actions .shop-quick-login-btn {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.shop-quick-login-btn {
|
.shop-quick-login-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams, Link } 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';
|
||||||
@@ -31,6 +31,7 @@ export default function LoginPage() {
|
|||||||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
|
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
|
||||||
const [bindMode, setBindMode] = useState(false);
|
const [bindMode, setBindMode] = useState(false);
|
||||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
const [wxAuthorize, setWxAuthorize] = useState(false);
|
||||||
|
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||||
const { sendCode, sending, codeCooldown, sentHint, error: smsError, setError: setSmsError, clearMessages } =
|
const { sendCode, sending, codeCooldown, sentHint, error: smsError, setError: setSmsError, clearMessages } =
|
||||||
useSmsCode();
|
useSmsCode();
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ export default function LoginPage() {
|
|||||||
function ensureAgreed() {
|
function ensureAgreed() {
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
setMsg('请先勾选并同意用户协议');
|
setMsg('请先勾选并同意用户协议');
|
||||||
|
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -194,6 +196,23 @@ export default function LoginPage() {
|
|||||||
{displayMsg || sentHint}
|
{displayMsg || sentHint}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<label className="login-agreement" ref={agreementRef}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={agreed}
|
||||||
|
onChange={(e) => setAgreed(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
我已阅读并同意
|
||||||
|
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
|
||||||
|
《用户协议》
|
||||||
|
</Link>
|
||||||
|
和
|
||||||
|
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
|
||||||
|
《隐私政策》
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="login-sms-btn"
|
className="login-sms-btn"
|
||||||
@@ -219,26 +238,6 @@ export default function LoginPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="login-footer">
|
|
||||||
<label className="login-agreement">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={agreed}
|
|
||||||
onChange={(e) => setAgreed(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
我已阅读并同意
|
|
||||||
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
|
|
||||||
《用户协议》
|
|
||||||
</Link>
|
|
||||||
和
|
|
||||||
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
|
|
||||||
《隐私政策》
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1558,8 +1558,9 @@
|
|||||||
|
|
||||||
.login-agreement {
|
.login-agreement {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
margin: 12px 0 16px;
|
||||||
font-family: var(--font-label);
|
font-family: var(--font-label);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -1571,8 +1572,8 @@
|
|||||||
|
|
||||||
.login-agreement input {
|
.login-agreement input {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
width: 16px;
|
width: 18px;
|
||||||
height: 16px;
|
height: 18px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
accent-color: var(--color-heritage-red);
|
accent-color: var(--color-heritage-red);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,11 @@
|
|||||||
color: var(--hq-muted);
|
color: var(--hq-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-agreement {
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.login-remember {
|
.login-remember {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
|
|||||||
@@ -212,24 +212,6 @@ export default function LoginPage() {
|
|||||||
<Text>记住账号</Text>
|
<Text>记住账号</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button className="hq-btn hq-btn--primary hq-btn--block login-submit" loading={loading} onClick={smsLogin}>
|
|
||||||
登录
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{msg ? <Text className="login-msg">{msg}</Text> : null}
|
|
||||||
|
|
||||||
<View className="login-divider">
|
|
||||||
<Text>其他登录方式</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
|
||||||
className={`login-wechat-btn${wxLoading ? ' is-disabled' : ''}`}
|
|
||||||
onClick={wxLoading ? undefined : wechatLogin}
|
|
||||||
>
|
|
||||||
<WechatIcon />
|
|
||||||
<Text>{wxLoading ? '登录中...' : '微信一键授权'}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
|
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
|
||||||
<View className={`login-checkbox${agreed ? ' is-checked' : ''}`} />
|
<View className={`login-checkbox${agreed ? ' is-checked' : ''}`} />
|
||||||
<Text className="login-agreement-text">
|
<Text className="login-agreement-text">
|
||||||
@@ -255,6 +237,24 @@ export default function LoginPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{msg ? <Text className="login-msg">{msg}</Text> : null}
|
||||||
|
|
||||||
|
<Button className="hq-btn hq-btn--primary hq-btn--block login-submit" loading={loading} onClick={smsLogin}>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<View className="login-divider">
|
||||||
|
<Text>其他登录方式</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className={`login-wechat-btn${wxLoading ? ' is-disabled' : ''}`}
|
||||||
|
onClick={wxLoading ? undefined : wechatLogin}
|
||||||
|
>
|
||||||
|
<WechatIcon />
|
||||||
|
<Text>{wxLoading ? '登录中...' : '微信一键授权'}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="login-footer">
|
<View className="login-footer">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ pnpm --filter @dukang/mini-user dev:weapp
|
|||||||
|
|
||||||
| 环节 | 文件 | 说明 |
|
| 环节 | 文件 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | 默认 `http://localhost:3000`;连远程设 `VITE_API_TARGET=https://dkapi.runxian.top` |
|
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | 本地默认 `http://localhost:3000`;`NODE_ENV=production` 默认 `https://dkapi.runxian.top`;可用 `VITE_API_TARGET` 覆盖 |
|
||||||
| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` |
|
| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` |
|
||||||
|
|
||||||
### 微信登录 `invalid code`
|
### 微信登录 `invalid code`
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { createRequire } from 'node:module';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { defineConfig } from '@tarojs/cli';
|
import { defineConfig } from '@tarojs/cli';
|
||||||
|
|
||||||
/** 小程序/H5 请求的后端 origin(不含 /api);发版/连远程请设 VITE_API_TARGET */
|
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
|
||||||
const API_ORIGIN = process.env.VITE_API_TARGET ?? 'http://localhost:3000';
|
const API_ORIGIN =
|
||||||
|
process.env.VITE_API_TARGET ??
|
||||||
|
(process.env.NODE_ENV === 'production' ? 'https://dkapi.runxian.top' : 'http://localhost:3000');
|
||||||
|
|
||||||
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
|
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
|
||||||
|
|
||||||
|
|||||||
@@ -329,6 +329,33 @@ export default function LoginPage() {
|
|||||||
<Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
|
<Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
|
||||||
使用微信支付前需授权微信账号
|
使用微信支付前需授权微信账号
|
||||||
</Text>
|
</Text>
|
||||||
|
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
|
||||||
|
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
||||||
|
{agreed ? <Text>✓</Text> : null}
|
||||||
|
</View>
|
||||||
|
<Text className="login-agreement-text">
|
||||||
|
我已阅读并同意
|
||||||
|
<Text
|
||||||
|
className="login-agreement-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
Taro.navigateTo({ url: '/pages/user-agreement/index' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
《用户协议》
|
||||||
|
</Text>
|
||||||
|
和
|
||||||
|
<Text
|
||||||
|
className="login-agreement-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
《隐私政策》
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
{showWechatLogin ? (
|
{showWechatLogin ? (
|
||||||
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
|
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -376,6 +403,34 @@ export default function LoginPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
|
||||||
|
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
||||||
|
{agreed ? <Text>✓</Text> : null}
|
||||||
|
</View>
|
||||||
|
<Text className="login-agreement-text">
|
||||||
|
我已阅读并同意
|
||||||
|
<Text
|
||||||
|
className="login-agreement-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
Taro.navigateTo({ url: '/pages/user-agreement/index' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
《用户协议》
|
||||||
|
</Text>
|
||||||
|
和
|
||||||
|
<Text
|
||||||
|
className="login-agreement-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
《隐私政策》
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
|
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
|
||||||
onClick={loading ? undefined : () => void login()}
|
onClick={loading ? undefined : () => void login()}
|
||||||
@@ -415,36 +470,6 @@ export default function LoginPage() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="login-footer">
|
|
||||||
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
|
|
||||||
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
|
||||||
{agreed ? <Text>✓</Text> : null}
|
|
||||||
</View>
|
|
||||||
<Text className="login-agreement-text">
|
|
||||||
我已阅读并同意
|
|
||||||
<Text
|
|
||||||
className="login-agreement-link"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
Taro.navigateTo({ url: '/pages/user-agreement/index' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
《用户协议》
|
|
||||||
</Text>
|
|
||||||
和
|
|
||||||
<Text
|
|
||||||
className="login-agreement-link"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
《隐私政策》
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,8 +306,9 @@
|
|||||||
|
|
||||||
.login-agreement {
|
.login-agreement {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
margin: 12px 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-agreement-check {
|
.login-agreement-check {
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=8192}"
|
|||||||
# 生产部署 path:nginx 入口为 /user/
|
# 生产部署 path:nginx 入口为 /user/
|
||||||
export TARO_H5_PUBLIC_PATH="${TARO_H5_PUBLIC_PATH:-/user/}"
|
export TARO_H5_PUBLIC_PATH="${TARO_H5_PUBLIC_PATH:-/user/}"
|
||||||
export TARO_H5_ROUTER_BASENAME="${TARO_H5_ROUTER_BASENAME:-/user}"
|
export TARO_H5_ROUTER_BASENAME="${TARO_H5_ROUTER_BASENAME:-/user}"
|
||||||
|
# C 端 H5 编译期注入的 API origin(勿落到 localhost)
|
||||||
|
export VITE_API_TARGET="${VITE_API_TARGET:-https://dkapi.runxian.top}"
|
||||||
pnpm approve-builds --all 2>/dev/null || true
|
pnpm approve-builds --all 2>/dev/null || true
|
||||||
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
||||||
|
|
||||||
|
|||||||
@@ -89,3 +89,9 @@ export const PARTNER_PERMISSION_LABELS: Record<PartnerPermissionKey, string> = {
|
|||||||
'store:create': '开店管理',
|
'store:create': '开店管理',
|
||||||
'order:view': '订单查看',
|
'order:view': '订单查看',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 门店类子账号默认权限:录入、开闭店、维护资料 */
|
||||||
|
export const DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS: PartnerPermissionKey[] = [
|
||||||
|
'store:create',
|
||||||
|
'store:manage',
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,240 +1,245 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
BadRequestException,
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
Injectable,
|
} from '@nestjs/common';
|
||||||
|
import { ClientApp, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS, PartnerStaffRole, SmsScene } from '@dukang/shared-types';
|
||||||
NotFoundException,
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
} from '@nestjs/common';
|
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
import { ClientApp, PartnerStaffRole, SmsScene } from '@dukang/shared-types';
|
import { AuthService } from './auth.service';
|
||||||
|
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
|
||||||
|
@Injectable()
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
export class PartnerStaffService {
|
||||||
|
constructor(
|
||||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly analytics: AnalyticsService,
|
||||||
import { AnalyticsService } from '../analytics/analytics.service';
|
private readonly authService: AuthService,
|
||||||
|
) {}
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
|
async listStaff(parentAccountId: bigint) {
|
||||||
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
|
const rows = await this.prisma.partnerAccount.findMany({
|
||||||
|
where: { parentAccountId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
@Injectable()
|
return rows.map((row) => this.toStaffItem(row));
|
||||||
|
}
|
||||||
export class PartnerStaffService {
|
|
||||||
|
async sendStaffPhoneSms(actor: AuthUser, phone: string) {
|
||||||
constructor(
|
const parentAccountId = actor.actorId;
|
||||||
|
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||||
private readonly prisma: PrismaService,
|
where: { id: parentAccountId },
|
||||||
|
});
|
||||||
private readonly analytics: AnalyticsService,
|
if (parent.isPrimary !== 1) {
|
||||||
|
throw new BadRequestException('仅主账号可添加子账号');
|
||||||
private readonly authService: AuthService,
|
}
|
||||||
|
|
||||||
) {}
|
const normalized = phone.trim();
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(normalized)) {
|
||||||
|
throw new BadRequestException('请输入正确的手机号码');
|
||||||
|
}
|
||||||
async listStaff(parentAccountId: bigint) {
|
|
||||||
|
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone: normalized } });
|
||||||
const rows = await this.prisma.partnerAccount.findMany({
|
if (existing) throw new BadRequestException('该手机号已被使用');
|
||||||
|
|
||||||
where: { parentAccountId },
|
const masked = this.maskPhone(normalized);
|
||||||
|
try {
|
||||||
orderBy: { createdAt: 'desc' },
|
await this.authService.sendSms(normalized, SmsScene.PARTNER_STAFF_ADD, {
|
||||||
|
clientApp: ClientApp.PARTNER_H5,
|
||||||
});
|
});
|
||||||
|
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
|
||||||
return rows.map((row) => this.toStaffItem(row));
|
phone: masked,
|
||||||
|
scene: SmsScene.PARTNER_STAFF_ADD,
|
||||||
}
|
status: 'success',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BadRequestException) {
|
||||||
async sendStaffPhoneSms(actor: AuthUser, phone: string) {
|
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
|
||||||
|
phone: masked,
|
||||||
const parentAccountId = actor.actorId;
|
scene: SmsScene.PARTNER_STAFF_ADD,
|
||||||
|
status: 'failed',
|
||||||
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
|
reason: err.message,
|
||||||
|
});
|
||||||
where: { id: parentAccountId },
|
}
|
||||||
|
throw err;
|
||||||
});
|
}
|
||||||
|
|
||||||
if (parent.isPrimary !== 1) {
|
return { ok: true, maskedPhone: masked };
|
||||||
|
}
|
||||||
throw new BadRequestException('仅主账号可添加子账号');
|
|
||||||
|
async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) {
|
||||||
}
|
const parentAccountId = actor.actorId;
|
||||||
|
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||||
|
where: { id: parentAccountId },
|
||||||
|
});
|
||||||
const normalized = phone.trim();
|
if (parent.isPrimary !== 1) {
|
||||||
|
throw new BadRequestException('仅主账号可添加子账号');
|
||||||
if (!/^1[3-9]\d{9}$/.test(normalized)) {
|
}
|
||||||
|
|
||||||
throw new BadRequestException('请输入正确的手机号码');
|
const phone = dto.phone.trim();
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||||
}
|
throw new BadRequestException('请输入正确的手机号码');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
|
||||||
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone: normalized } });
|
if (existing) throw new BadRequestException('该手机号已被使用');
|
||||||
|
|
||||||
if (existing) throw new BadRequestException('该手机号已被使用');
|
const smsCode = dto.smsCode.trim();
|
||||||
|
if (!smsCode) throw new BadRequestException('请输入手机号验证码');
|
||||||
|
try {
|
||||||
|
await this.authService.verifySmsCode(phone, smsCode, SmsScene.PARTNER_STAFF_ADD);
|
||||||
const masked = this.maskPhone(normalized);
|
} catch (err) {
|
||||||
|
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_verify_fail', parentAccountId, {
|
||||||
try {
|
phone: this.maskPhone(phone),
|
||||||
|
reason: err instanceof BadRequestException ? err.message : '验证码错误',
|
||||||
await this.authService.sendSms(normalized, SmsScene.PARTNER_STAFF_ADD, {
|
});
|
||||||
|
throw err;
|
||||||
clientApp: ClientApp.PARTNER_H5,
|
}
|
||||||
|
|
||||||
});
|
const name = dto.name.trim();
|
||||||
|
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
|
|
||||||
|
const staffRole = (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL;
|
||||||
phone: masked,
|
const permissions =
|
||||||
|
dto.permissions && dto.permissions.length > 0
|
||||||
scene: SmsScene.PARTNER_STAFF_ADD,
|
? dto.permissions
|
||||||
|
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS];
|
||||||
status: 'success',
|
|
||||||
|
const account = await this.prisma.partnerAccount.create({
|
||||||
});
|
data: {
|
||||||
|
phone,
|
||||||
} catch (err) {
|
name,
|
||||||
|
staffRole,
|
||||||
if (err instanceof BadRequestException) {
|
permissions,
|
||||||
|
isPrimary: 0,
|
||||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
|
parentAccountId: parent.id,
|
||||||
|
status: 'DISABLED',
|
||||||
phone: masked,
|
},
|
||||||
|
});
|
||||||
scene: SmsScene.PARTNER_STAFF_ADD,
|
|
||||||
|
this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, {
|
||||||
status: 'failed',
|
name,
|
||||||
|
phone: this.maskPhone(phone),
|
||||||
reason: err.message,
|
staffRole,
|
||||||
|
permissions,
|
||||||
});
|
status: account.status,
|
||||||
|
phoneVerified: true,
|
||||||
}
|
});
|
||||||
|
|
||||||
throw err;
|
return this.toStaffItem(account);
|
||||||
|
}
|
||||||
}
|
|
||||||
|
async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdatePartnerStaffDto) {
|
||||||
|
const parentAccountId = actor.actorId;
|
||||||
|
const staff = await this.assertStaffOwned(parentAccountId, staffId);
|
||||||
return { ok: true, maskedPhone: masked };
|
const before = {
|
||||||
|
name: staff.name,
|
||||||
}
|
staffRole: staff.staffRole,
|
||||||
|
status: staff.status,
|
||||||
|
};
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) {
|
if (dto.name !== undefined) {
|
||||||
|
const name = dto.name.trim();
|
||||||
const parentAccountId = actor.actorId;
|
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||||
|
data.name = name;
|
||||||
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
|
}
|
||||||
|
if (dto.staffRole !== undefined) {
|
||||||
where: { id: parentAccountId },
|
data.staffRole = dto.staffRole as PartnerStaffRole;
|
||||||
|
}
|
||||||
});
|
if (dto.permissions !== undefined) {
|
||||||
|
data.permissions = dto.permissions;
|
||||||
if (parent.isPrimary !== 1) {
|
}
|
||||||
|
if (dto.status !== undefined) {
|
||||||
throw new BadRequestException('仅主账号可添加子账号');
|
data.status = dto.status;
|
||||||
|
}
|
||||||
}
|
const updated = await this.prisma.partnerAccount.update({
|
||||||
|
where: { id: staff.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
const phone = dto.phone.trim();
|
|
||||||
|
const onlyRoleChange =
|
||||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
(dto.staffRole !== undefined || dto.permissions !== undefined) &&
|
||||||
|
dto.name === undefined &&
|
||||||
throw new BadRequestException('请输入正确的手机号码');
|
dto.status === undefined;
|
||||||
|
const eventName = onlyRoleChange ? 'partner_staff_permission_update' : 'partner_staff_update';
|
||||||
}
|
|
||||||
|
const primaryId = parentAccountId;
|
||||||
|
this.trackStaffEvent(actor, primaryId, eventName, staff.id, {
|
||||||
|
before,
|
||||||
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
|
after: {
|
||||||
|
name: updated.name,
|
||||||
if (existing) throw new BadRequestException('该手机号已被使用');
|
staffRole: updated.staffRole,
|
||||||
|
status: updated.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
const smsCode = dto.smsCode.trim();
|
|
||||||
|
return this.toStaffItem(updated);
|
||||||
if (!smsCode) throw new BadRequestException('请输入手机号验证码');
|
}
|
||||||
|
|
||||||
try {
|
async deleteStaff(actor: AuthUser, staffId: bigint) {
|
||||||
|
const parentAccountId = actor.actorId;
|
||||||
await this.authService.verifySmsCode(phone, smsCode, SmsScene.PARTNER_STAFF_ADD);
|
const staff = await this.assertStaffOwned(parentAccountId, staffId);
|
||||||
|
|
||||||
} catch (err) {
|
this.trackStaffEvent(actor, parentAccountId, 'partner_staff_delete', staff.id, {
|
||||||
|
name: staff.name,
|
||||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_verify_fail', parentAccountId, {
|
phone: this.maskPhone(staff.phone),
|
||||||
|
staffRole: staff.staffRole,
|
||||||
phone: this.maskPhone(phone),
|
status: staff.status,
|
||||||
|
});
|
||||||
reason: err instanceof BadRequestException ? err.message : '验证码错误',
|
|
||||||
|
await this.prisma.partnerAccount.delete({ where: { id: staff.id } });
|
||||||
});
|
return { ok: true };
|
||||||
|
}
|
||||||
throw err;
|
|
||||||
|
private trackStaffEvent(
|
||||||
}
|
actor: AuthUser,
|
||||||
|
primaryAccountId: bigint,
|
||||||
|
eventName: string,
|
||||||
|
refId: bigint,
|
||||||
const name = dto.name.trim();
|
extraJson?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
if (!name) throw new BadRequestException('请填写真实姓名');
|
this.analytics.trackPartnerOneSafe(actor.actorId, actor.clientApp, {
|
||||||
|
partnerAccountId: primaryAccountId,
|
||||||
|
eventName,
|
||||||
|
refType: 'PARTNER_ACCOUNT',
|
||||||
const staffRole = (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL;
|
refId,
|
||||||
|
extraJson,
|
||||||
|
});
|
||||||
|
}
|
||||||
const account = await this.prisma.partnerAccount.create({
|
|
||||||
|
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
|
||||||
data: {
|
const staff = await this.prisma.partnerAccount.findFirst({
|
||||||
|
where: { id: staffId, parentAccountId },
|
||||||
phone,
|
});
|
||||||
|
if (!staff) throw new NotFoundException('子账号不存在');
|
||||||
name,
|
return staff;
|
||||||
|
}
|
||||||
staffRole,
|
|
||||||
|
private toStaffItem(row: {
|
||||||
permissions: dto.permissions ?? undefined,
|
id: bigint;
|
||||||
|
name: string;
|
||||||
isPrimary: 0,
|
phone: string;
|
||||||
|
staffRole: string | null;
|
||||||
parentAccountId: parent.id,
|
permissions?: unknown;
|
||||||
|
status: string;
|
||||||
status: 'DISABLED',
|
lastLoginAt: Date | null;
|
||||||
|
}) {
|
||||||
},
|
return serializeBigInt({
|
||||||
|
id: row.id.toString(),
|
||||||
});
|
name: row.name,
|
||||||
|
phone: this.maskPhone(row.phone),
|
||||||
|
staffRole: row.staffRole ?? PartnerStaffRole.INTERNAL,
|
||||||
|
permissions: Array.isArray(row.permissions) ? row.permissions : undefined,
|
||||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, {
|
status: row.status,
|
||||||
|
lastLoginAt: row.lastLoginAt?.toISOString(),
|
||||||
name,
|
});
|
||||||
|
}
|
||||||
phone: this.maskPhone(phone),
|
|
||||||
|
private maskPhone(phone: string): string {
|
||||||
|
if (phone.length !== 11) return phone;
|
||||||
|
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||||
import { IsOptional, IsString } from 'class-validator';
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
import { DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
|
||||||
import { SettlementService } from './settlement.service';
|
import { SettlementService } from './settlement.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';
|
||||||
@@ -269,7 +270,7 @@ export class PartnerMeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async buildPartnerMe(actorId: bigint) {
|
private async buildPartnerMe(actorId: bigint) {
|
||||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
let account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||||
where: { id: actorId },
|
where: { id: actorId },
|
||||||
});
|
});
|
||||||
let primary = account;
|
let primary = account;
|
||||||
@@ -278,6 +279,23 @@ export class PartnerMeController {
|
|||||||
where: { id: account.parentAccountId },
|
where: { id: account.parentAccountId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 门店类子账号若未配置权限,补齐开店/门店管理,便于开闭店与重传资料
|
||||||
|
if (account.isPrimary !== 1) {
|
||||||
|
const perms = Array.isArray(account.permissions) ? (account.permissions as string[]) : [];
|
||||||
|
const hasStorePerm = perms.includes('store:create') || perms.includes('store:manage');
|
||||||
|
const warehouseOnly =
|
||||||
|
!hasStorePerm &&
|
||||||
|
perms.length > 0 &&
|
||||||
|
(perms.includes('warehouse:manage') || perms.includes('order:view'));
|
||||||
|
if (!hasStorePerm && !warehouseOnly) {
|
||||||
|
account = await this.prisma.partnerAccount.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: { permissions: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hasWarehouseAccess = await this.partnerCityService.hasManagedWarehouse(primary.id);
|
const hasWarehouseAccess = await this.partnerCityService.hasManagedWarehouse(primary.id);
|
||||||
return {
|
return {
|
||||||
id: account.id.toString(),
|
id: account.id.toString(),
|
||||||
|
|||||||
@@ -75,19 +75,29 @@ export class PartnerStoreController {
|
|||||||
) {
|
) {
|
||||||
return this.storeService.partnerUpdateStoreBasic(user.actorId, BigInt(id), body);
|
return this.storeService.partnerUpdateStoreBasic(user.actorId, BigInt(id), body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put(':id/media')
|
||||||
|
updateMedia(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
return this.storeService.partnerUpdateStoreMedia(user.actorId, BigInt(id), body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('partner/dashboard')
|
@Controller('partner/dashboard')
|
||||||
@UseGuards(JwtAuthGuard, PartnerPermissionGuard)
|
@UseGuards(JwtAuthGuard, PartnerPermissionGuard)
|
||||||
@RequirePartnerPermissions('store:create', 'store:manage', 'order:view', 'warehouse:manage')
|
|
||||||
export class PartnerDashboardController {
|
export class PartnerDashboardController {
|
||||||
constructor(private readonly storeService: StoreService) {}
|
constructor(private readonly storeService: StoreService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequirePartnerPermissions('store:create', 'store:manage', 'order:view', 'warehouse:manage')
|
||||||
dashboard(@CurrentUser() user: AuthUser) {
|
dashboard(@CurrentUser() user: AuthUser) {
|
||||||
return this.storeService.partnerDashboard(user.actorId);
|
return this.storeService.partnerDashboard(user.actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 任意合伙人子账号可看同主账号排行(激励),不校验业务权限点 */
|
||||||
@Get('leaderboard')
|
@Get('leaderboard')
|
||||||
leaderboard(
|
leaderboard(
|
||||||
@CurrentUser() user: AuthUser,
|
@CurrentUser() user: AuthUser,
|
||||||
|
|||||||
@@ -85,15 +85,7 @@ export class StoreService {
|
|||||||
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
|
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const media = await this.prisma.commonResource.findMany({
|
const media = await this.loadPartnerStoreMedia(storeId);
|
||||||
where: {
|
|
||||||
ownerType: 'STORE',
|
|
||||||
ownerId: storeId,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
bizType: { in: ['ENV', 'CONTRACT'] },
|
|
||||||
},
|
|
||||||
orderBy: { sortOrder: 'asc' },
|
|
||||||
});
|
|
||||||
return serializeBigInt(mapStoreCompat({ ...store, media }));
|
return serializeBigInt(mapStoreCompat({ ...store, media }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,9 +161,7 @@ export class StoreService {
|
|||||||
|
|
||||||
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
|
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
|
||||||
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
||||||
const envPhotoUrls = Array.isArray(body.envPhotoUrls)
|
const envPhotoUrls = this.normalizeEnvPhotoUrls(body.envPhotoUrls);
|
||||||
? body.envPhotoUrls.map((u) => String(u).trim()).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
|
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
|
||||||
|
|
||||||
if (!coverUrl) throw new BadRequestException('请上传门头照');
|
if (!coverUrl) throw new BadRequestException('请上传门头照');
|
||||||
@@ -317,7 +307,7 @@ export class StoreService {
|
|||||||
status: 'OPEN' | 'PAUSED' | 'CLOSED',
|
status: 'OPEN' | 'PAUSED' | 'CLOSED',
|
||||||
) {
|
) {
|
||||||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||||
this.assertPrimaryAccount(account);
|
await this.assertCanMutateStore(account, partnerAccountId, storeId);
|
||||||
const store = await this.prisma.store.findFirst({
|
const store = await this.prisma.store.findFirst({
|
||||||
where: { id: storeId, partnerAccountId: primaryId },
|
where: { id: storeId, partnerAccountId: primaryId },
|
||||||
});
|
});
|
||||||
@@ -365,7 +355,7 @@ export class StoreService {
|
|||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||||
this.assertPrimaryAccount(account);
|
await this.assertCanMutateStore(account, partnerAccountId, storeId);
|
||||||
const store = await this.prisma.store.findFirst({
|
const store = await this.prisma.store.findFirst({
|
||||||
where: { id: storeId, partnerAccountId: primaryId },
|
where: { id: storeId, partnerAccountId: primaryId },
|
||||||
});
|
});
|
||||||
@@ -429,6 +419,115 @@ export class StoreService {
|
|||||||
return this.partnerGetStore(partnerAccountId, storeId);
|
return this.partnerGetStore(partnerAccountId, storeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 重新上传门头照 / 环境照:先软删旧 ENV,再写入去重后的新图(最多 3 张) */
|
||||||
|
async partnerUpdateStoreMedia(
|
||||||
|
partnerAccountId: bigint,
|
||||||
|
storeId: bigint,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||||
|
await this.assertCanMutateStore(account, partnerAccountId, storeId);
|
||||||
|
const store = await this.prisma.store.findFirst({
|
||||||
|
where: { id: storeId, partnerAccountId: primaryId },
|
||||||
|
});
|
||||||
|
if (!store) throw new NotFoundException('门店不存在');
|
||||||
|
if (store.status === 'CLOSED') {
|
||||||
|
throw new BadRequestException('门店已关闭,不可编辑');
|
||||||
|
}
|
||||||
|
if (store.auditStatus === 'PENDING') {
|
||||||
|
throw new BadRequestException('门店审核中,暂不可修改资料');
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverUrl = body.coverUrl !== undefined ? String(body.coverUrl ?? '').trim() : undefined;
|
||||||
|
const hasEnv = body.envPhotoUrls !== undefined;
|
||||||
|
const envPhotoUrls = hasEnv ? this.normalizeEnvPhotoUrls(body.envPhotoUrls) : undefined;
|
||||||
|
if (coverUrl !== undefined && !coverUrl) {
|
||||||
|
throw new BadRequestException('请上传门头照');
|
||||||
|
}
|
||||||
|
if (envPhotoUrls !== undefined && envPhotoUrls.length < 3) {
|
||||||
|
throw new BadRequestException('请上传至少 3 张环境照片');
|
||||||
|
}
|
||||||
|
if (coverUrl === undefined && envPhotoUrls === undefined) {
|
||||||
|
throw new BadRequestException('请至少更新门头照或环境照片');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
|
||||||
|
|
||||||
|
if (coverUrl !== undefined) {
|
||||||
|
if (store.coverResourceId) {
|
||||||
|
await this.prisma.commonResource.update({
|
||||||
|
where: { id: store.coverResourceId },
|
||||||
|
data: { url: coverUrl, ossKey: coverUrl, status: 'ACTIVE' },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const cover = await this.prisma.commonResource.create({
|
||||||
|
data: {
|
||||||
|
ownerType: 'STORE',
|
||||||
|
ownerId: storeId,
|
||||||
|
bizType: 'COVER',
|
||||||
|
mediaType: 'IMAGE',
|
||||||
|
ossBucket,
|
||||||
|
ossKey: coverUrl,
|
||||||
|
url: coverUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.prisma.store.update({
|
||||||
|
where: { id: storeId },
|
||||||
|
data: { coverResourceId: cover.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envPhotoUrls !== undefined) {
|
||||||
|
await this.prisma.commonResource.updateMany({
|
||||||
|
where: { ownerType: 'STORE', ownerId: storeId, bizType: 'ENV', status: 'ACTIVE' },
|
||||||
|
data: { status: 'DELETED' },
|
||||||
|
});
|
||||||
|
for (let i = 0; i < envPhotoUrls.length; i++) {
|
||||||
|
await this.prisma.commonResource.create({
|
||||||
|
data: {
|
||||||
|
ownerType: 'STORE',
|
||||||
|
ownerId: storeId,
|
||||||
|
bizType: 'ENV',
|
||||||
|
mediaType: 'IMAGE',
|
||||||
|
ossBucket,
|
||||||
|
ossKey: envPhotoUrls[i],
|
||||||
|
url: envPhotoUrls[i],
|
||||||
|
sortOrder: i,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resubmitAudit = store.auditStatus === 'REJECTED';
|
||||||
|
if (resubmitAudit) {
|
||||||
|
await this.prisma.store.update({
|
||||||
|
where: { id: storeId },
|
||||||
|
data: {
|
||||||
|
auditStatus: 'PENDING',
|
||||||
|
rejectReason: null,
|
||||||
|
auditedAt: null,
|
||||||
|
status: store.status === 'OPEN' ? 'PAUSED' : store.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.prisma.commonEvent.create({
|
||||||
|
data: {
|
||||||
|
eventType: 'STORE_AUDIT',
|
||||||
|
refType: 'STORE',
|
||||||
|
refId: storeId,
|
||||||
|
actorType: 'PARTNER',
|
||||||
|
actorId: partnerAccountId,
|
||||||
|
status: 'PENDING',
|
||||||
|
param1: 'RESUBMIT',
|
||||||
|
param1Desc: 'audit_type',
|
||||||
|
remark: '合伙人重新上传资料后重新提交审核',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.partnerGetStore(partnerAccountId, storeId);
|
||||||
|
}
|
||||||
|
|
||||||
async getShopStore(storeAccountId: bigint, storeId: bigint) {
|
async getShopStore(storeAccountId: bigint, storeId: bigint) {
|
||||||
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||||
@@ -889,6 +988,88 @@ export class StoreService {
|
|||||||
return account.isPrimary !== 1;
|
return account.isPrimary !== 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private partnerPermissionList(account: { permissions?: unknown }): string[] {
|
||||||
|
return Array.isArray(account.permissions) ? (account.permissions as string[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主账号,或门店类子账号(含历史空权限)可改门店 */
|
||||||
|
private async assertCanMutateStore(
|
||||||
|
account: { isPrimary: number; permissions?: unknown },
|
||||||
|
partnerAccountId: bigint,
|
||||||
|
storeId: bigint,
|
||||||
|
) {
|
||||||
|
if (!this.isSubAccount(account)) return;
|
||||||
|
const perms = this.partnerPermissionList(account);
|
||||||
|
const canManage = perms.includes('store:manage');
|
||||||
|
const canCreate = perms.includes('store:create');
|
||||||
|
const legacyStoreStaff = perms.length === 0;
|
||||||
|
const warehouseOnly =
|
||||||
|
!canManage &&
|
||||||
|
!canCreate &&
|
||||||
|
!legacyStoreStaff &&
|
||||||
|
(perms.includes('warehouse:manage') ||
|
||||||
|
(perms.includes('order:view') && !perms.includes('store:create') && !perms.includes('store:manage')));
|
||||||
|
if (warehouseOnly || (!canManage && !canCreate && !legacyStoreStaff)) {
|
||||||
|
throw new ForbiddenException('子账号无门店管理权限');
|
||||||
|
}
|
||||||
|
// store:manage 可管团队门店;仅 store:create / 历史空权限只能改自己录入的店
|
||||||
|
if (canManage) return;
|
||||||
|
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeEnvPhotoUrls(raw: unknown, max = 3): string[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const urls: string[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
const url = String(item ?? '').trim();
|
||||||
|
if (!url || seen.has(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
urls.push(url);
|
||||||
|
if (urls.length >= max) break;
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取门店媒体;对重复 ENV URL 软删并只返回一份,修复历史 3→6 脏数据 */
|
||||||
|
private async loadPartnerStoreMedia(storeId: bigint) {
|
||||||
|
const media = await this.prisma.commonResource.findMany({
|
||||||
|
where: {
|
||||||
|
ownerType: 'STORE',
|
||||||
|
ownerId: storeId,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
bizType: { in: ['ENV', 'CONTRACT'] },
|
||||||
|
},
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const seenEnv = new Set<string>();
|
||||||
|
const duplicateEnvIds: bigint[] = [];
|
||||||
|
const kept: typeof media = [];
|
||||||
|
for (const row of media) {
|
||||||
|
if (row.bizType !== 'ENV') {
|
||||||
|
kept.push(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = row.url.trim();
|
||||||
|
if (seenEnv.has(key)) {
|
||||||
|
duplicateEnvIds.push(row.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenEnv.add(key);
|
||||||
|
kept.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicateEnvIds.length > 0) {
|
||||||
|
await this.prisma.commonResource.updateMany({
|
||||||
|
where: { id: { in: duplicateEnvIds } },
|
||||||
|
data: { status: 'DELETED' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
private assertPrimaryAccount(account: { isPrimary: number }) {
|
private assertPrimaryAccount(account: { isPrimary: number }) {
|
||||||
if (this.isSubAccount(account)) {
|
if (this.isSubAccount(account)) {
|
||||||
throw new ForbiddenException('子账号无权执行此操作');
|
throw new ForbiddenException('子账号无权执行此操作');
|
||||||
|
|||||||
Reference in New Issue
Block a user