@@ -180,22 +204,26 @@ export default function HomePage() {
查看全部
- {previewLeaderboard.map((entry) => (
-
-
{entry.rank}
-
-
- {entry.name}
- ({entry.role})
-
-
累计拓店 {entry.totalStores} 间
+ {leaderboardPreview.length === 0 ? (
+
暂无排行数据
+ ) : (
+ leaderboardPreview.map((entry) => (
+
+
{entry.rank}
+
+
+ {entry.name}
+ ({entry.roleLabel})
+
+
累计拓店 {entry.totalStores} 间
+
+
+
{entry.periodStores} 间
+
本月新增
+
-
-
{entry.monthStores} 间
-
本月新增
-
-
- ))}
+ ))
+ )}
diff --git a/apps/h5-partner/src/pages/LeaderboardPage.tsx b/apps/h5-partner/src/pages/LeaderboardPage.tsx
index e2f2885..bcc3985 100644
--- a/apps/h5-partner/src/pages/LeaderboardPage.tsx
+++ b/apps/h5-partner/src/pages/LeaderboardPage.tsx
@@ -1,36 +1,129 @@
-import { useNavigate } from 'react-router-dom';
+import { useEffect, useState } from 'react';
+import { useNavigate, Link } from 'react-router-dom';
+import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types';
import PageHeader from '@dukang/shared-ui/PageHeader';
-import { LEADERBOARD_MOCK } from '../lib/leaderboard';
+import { fetchPartnerLeaderboard } from '../lib/leaderboard';
+import { usePartnerSession } from '../contexts/PartnerSessionContext';
+import { isPrimaryAccount } from '../lib/partnerAccess';
+
+const PERIOD_TABS: { key: PartnerLeaderboardPeriod; label: string }[] = [
+ { key: 'month', label: '本月' },
+ { key: 'lastMonth', label: '上月' },
+ { key: 'total', label: '累计' },
+];
+
+function periodStatLabel(period: PartnerLeaderboardPeriod): string {
+ if (period === 'month') return '本月拓店';
+ if (period === 'lastMonth') return '上月拓店';
+ return '累计拓店';
+}
+
+function periodSubLabel(period: PartnerLeaderboardPeriod): string {
+ if (period === 'total') return '累计';
+ if (period === 'month') return '本月新增';
+ return '上月新增';
+}
export default function LeaderboardPage() {
const navigate = useNavigate();
+ const { account } = usePartnerSession();
+ const showStaffFab = isPrimaryAccount(account);
+ const [period, setPeriod] = useState('month');
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ setLoading(true);
+ setError('');
+ fetchPartnerLeaderboard(period)
+ .then(setData)
+ .catch((e) => {
+ setData(null);
+ setError(e instanceof Error ? e.message : '加载失败');
+ })
+ .finally(() => setLoading(false));
+ }, [period]);
return (
navigate('/')} />
-
- 按拓店数排行,数据每周更新
-
-
-
- {LEADERBOARD_MOCK.map((entry) => (
-
-
{entry.rank}
-
-
- {entry.name}
- ({entry.role})
-
-
累计拓店 {entry.totalStores} 间
-
-
-
{entry.monthStores} 间
-
本月新增
-
-
+
+ {PERIOD_TABS.map((tab) => (
+
))}
+
+
+ 按拓店数排行,数据实时更新
+
+
+ {loading &&
加载中...
}
+ {!loading && error &&
{error}
}
+
+ {!loading && !error && data && (
+
+ {data.self && (
+
+
+
第 {data.self.rank} 名
+
+
+ {data.self.name}
+ {data.self.isSelf ? ' (我)' : ''}
+
+
{data.self.roleLabel}
+
+
+
+ {periodStatLabel(period)}: {data.self.periodStores} 间
+ {data.self.beatPercent != null && (
+ 击败 {data.self.beatPercent}% 合伙人
+ )}
+
+
+ )}
+
+
实时排行
+
+ {data.list.length === 0 ? (
+
暂无排行数据
+ ) : (
+ data.list.map((entry) => (
+
+
{entry.rank}
+
+
+ {entry.name}
+
+ ({entry.roleLabel})
+
+
+
累计拓店 {entry.totalStores} 间
+
+
+
{entry.periodStores} 间
+
{periodSubLabel(period)}
+
+
+ ))
+ )}
+
+ )}
+
+ {showStaffFab && (
+
+
add
+
+ )}
);
}
diff --git a/apps/h5-partner/src/pages/LoginPage.tsx b/apps/h5-partner/src/pages/LoginPage.tsx
index 8bfa3f5..5d56e11 100644
--- a/apps/h5-partner/src/pages/LoginPage.tsx
+++ b/apps/h5-partner/src/pages/LoginPage.tsx
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { request, saveAuth } from '../lib/api';
+import { partnerHomePath } from '../lib/partnerAccess';
+import type { PartnerMe } from '@dukang/shared-types';
import {
bindPartnerWechatAfterSmsLogin,
fetchClientConfig,
@@ -132,18 +134,20 @@ export default function LoginPage() {
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
});
}
- const data = await request<{ accessToken: string }>('PARTNER_H5', '/partner/auth/login/sms', {
+ const data = await request<{ accessToken: string; partner?: PartnerMe }>('PARTNER_H5', '/partner/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone, code }),
});
saveAuth(data);
persistRememberAccount(phone);
+ const home = partnerHomePath(data.partner);
if (isWechatEnv() && wxAuthorize) {
setMsg('登录成功,正在关联微信…');
await bindPartnerWechatAfterSmsLogin();
+ navigate(home);
return;
}
- navigate('/');
+ navigate(home);
} catch (e) {
setMsg(formatPartnerError(e));
} finally {
diff --git a/apps/h5-partner/src/pages/SettlementPage.tsx b/apps/h5-partner/src/pages/SettlementPage.tsx
new file mode 100644
index 0000000..71b645c
--- /dev/null
+++ b/apps/h5-partner/src/pages/SettlementPage.tsx
@@ -0,0 +1,207 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import PageHeader from '@dukang/shared-ui/PageHeader';
+import type { PartnerBillDto } from '@dukang/shared-types';
+import { isLoggedIn, request } from '../lib/api';
+
+type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing';
+
+function fmtMoney(n: number) {
+ return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+}
+
+function billStatusLabel(status: string) {
+ switch (status) {
+ case 'DRAFT': return '待结算';
+ case 'CONFIRMED': return '审核中';
+ case 'PAID': return '已结算';
+ default: return status;
+ }
+}
+
+function billStatusClass(status: string) {
+ switch (status) {
+ case 'DRAFT': return 'partner-settlement-status--pending';
+ case 'CONFIRMED': return 'partner-settlement-status--reviewing';
+ case 'PAID': return 'partner-settlement-status--settled';
+ default: return '';
+ }
+}
+
+function billPeriodLabel(periodStart: string) {
+ const d = new Date(periodStart);
+ if (Number.isNaN(d.getTime())) return '—';
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
+}
+
+function billMonthLabel(year: number, month: number) {
+ return `${year}年${month}月`;
+}
+
+export default function SettlementPage() {
+ const navigate = useNavigate();
+ const [bills, setBills] = useState([]);
+ const now = new Date();
+ const [month, setMonth] = useState({ year: now.getFullYear(), month: now.getMonth() + 1 });
+ const [statusFilter, setStatusFilter] = useState('all');
+
+ useEffect(() => {
+ if (!isLoggedIn()) { navigate('/login'); return; }
+ request('PARTNER_H5', '/partner/settlement/bills').then(setBills);
+ }, [navigate]);
+
+ const summary = useMemo(() => {
+ const pending = bills.filter((b) => b.status === 'DRAFT');
+ const settled = bills.filter((b) => b.status === 'PAID');
+ const currentMonth = bills.filter((b) => {
+ const d = new Date(b.periodStart);
+ return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth();
+ });
+ return {
+ pendingTotal: pending.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
+ settledTotal: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
+ monthEstimate: currentMonth.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
+ };
+ }, [bills, now]);
+
+ const filteredBills = useMemo(() => {
+ return bills.filter((b) => {
+ const d = new Date(b.periodStart);
+ const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month;
+ if (!matchMonth) return false;
+ if (statusFilter === 'all') return true;
+ if (statusFilter === 'pending') return b.status === 'DRAFT';
+ if (statusFilter === 'settled') return b.status === 'PAID';
+ if (statusFilter === 'reviewing') return b.status === 'CONFIRMED';
+ return true;
+ });
+ }, [bills, month, statusFilter]);
+
+ const monthOptions = useMemo(() => {
+ const opts: Array<{ year: number; month: number }> = [];
+ for (let i = 0; i < 6; i++) {
+ const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
+ opts.push({ year: d.getFullYear(), month: d.getMonth() + 1 });
+ }
+ return opts;
+ }, [now]);
+
+ function openBill(bill: PartnerBillDto) {
+ navigate(bill.id ? `/center/bills?id=${bill.id}` : '/center/bills');
+ }
+
+ const statusTabs: Array<{ key: StatusFilter; label: string }> = [
+ { key: 'all', label: '全部' },
+ { key: 'pending', label: '待结算' },
+ { key: 'settled', label: '已结算' },
+ { key: 'reviewing', label: '审核中' },
+ ];
+
+ return (
+
+
navigate('/')} />
+
+
+
+
+ account_balance_wallet
+
+ 待结算总额
+
+ ¥
+ {fmtMoney(summary.pendingTotal)}
+
+
+
+
累计已结算
+
¥{fmtMoney(summary.settledTotal)}
+
+
+
+
本月预估收益
+
+ ¥{fmtMoney(summary.monthEstimate)}
+
+
+
+
+
+
+
+
+
+
+
+ {statusTabs.map((tab) => (
+
+ ))}
+
+
+
+ 对账明细记录
+
+ {filteredBills.length === 0 && (
+ 暂无对账记录
+ )}
+
+
+ {filteredBills.map((bill) => (
+
+ ))}
+
+
+ {filteredBills.length > 0 && (
+ 已展示全部记录
+ )}
+
+
+
+ );
+}
diff --git a/apps/h5-partner/src/pages/StaffCreatePage.tsx b/apps/h5-partner/src/pages/StaffCreatePage.tsx
new file mode 100644
index 0000000..22dc937
--- /dev/null
+++ b/apps/h5-partner/src/pages/StaffCreatePage.tsx
@@ -0,0 +1,139 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import PageHeader from '@dukang/shared-ui/PageHeader';
+import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole } from '@dukang/shared-types';
+import { createPartnerStaff, sendStaffAddSms } from '../lib/staff';
+
+const ROLE_OPTIONS = Object.values(PartnerStaffRole);
+
+export default function StaffCreatePage() {
+ const navigate = useNavigate();
+ const [name, setName] = useState('');
+ const [phone, setPhone] = useState('');
+ const [code, setCode] = useState('');
+ const [staffRole, setStaffRole] = useState(PartnerStaffRole.INTERNAL);
+ const [sending, setSending] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [msg, setMsg] = useState('');
+
+ async function sendCode() {
+ if (!/^1[3-9]\d{9}$/.test(phone.trim())) {
+ setMsg('请输入正确的11位手机号');
+ return;
+ }
+ setSending(true);
+ setMsg('');
+ try {
+ await sendStaffAddSms(phone.trim());
+ setMsg('验证码已发送');
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : '发送失败');
+ } finally {
+ setSending(false);
+ }
+ }
+
+ async function submit() {
+ if (!name.trim()) {
+ setMsg('请填写真实姓名');
+ return;
+ }
+ if (!/^1[3-9]\d{9}$/.test(phone.trim())) {
+ setMsg('请输入正确的11位手机号');
+ return;
+ }
+ if (!code.trim()) {
+ setMsg('请输入验证码');
+ return;
+ }
+ setSubmitting(true);
+ setMsg('');
+ try {
+ await createPartnerStaff({
+ name: name.trim(),
+ phone: phone.trim(),
+ staffRole,
+ code: code.trim(),
+ });
+ navigate('/center/staff');
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : '创建失败');
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+
navigate('/center/staff')} />
+
+
+ {msg && {msg}
}
+
+
+
+
+ setName(e.target.value)}
+ />
+ person
+
+
+
+
+
+
+
+
+
+ expand_more
+
+
+
+
+ 创建即代表同意合伙人管理协议。子账号默认禁用,需手动启用。
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/h5-partner/src/pages/StaffListPage.tsx b/apps/h5-partner/src/pages/StaffListPage.tsx
new file mode 100644
index 0000000..5aaa5f2
--- /dev/null
+++ b/apps/h5-partner/src/pages/StaffListPage.tsx
@@ -0,0 +1,156 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import PageHeader from '@dukang/shared-ui/PageHeader';
+import {
+ AccountStatus,
+ PARTNER_STAFF_ROLE_LABELS,
+ PartnerStaffRole,
+ type PartnerStaffItem,
+} from '@dukang/shared-types';
+import { deletePartnerStaff, listPartnerStaff, updatePartnerStaff } from '../lib/staff';
+
+export default function StaffListPage() {
+ const navigate = useNavigate();
+ const [staff, setStaff] = useState([]);
+ const [q, setQ] = useState('');
+ const [error, setError] = useState('');
+ const [busyId, setBusyId] = useState(null);
+
+ const loadStaff = useCallback(() => {
+ return listPartnerStaff().then(setStaff).catch((e) => {
+ setError(e instanceof Error ? e.message : '加载失败');
+ });
+ }, []);
+
+ useEffect(() => {
+ void loadStaff();
+ }, [loadStaff]);
+
+ const filtered = useMemo(() => staff.filter((item) => {
+ if (!q.trim()) return true;
+ const keyword = q.trim();
+ return item.name.includes(keyword) || item.phone.includes(keyword);
+ }), [staff, q]);
+
+ async function toggleStatus(item: PartnerStaffItem) {
+ const next = item.status === AccountStatus.ACTIVE ? AccountStatus.DISABLED : AccountStatus.ACTIVE;
+ setBusyId(item.id);
+ setError('');
+ try {
+ await updatePartnerStaff(item.id, { status: next });
+ await loadStaff();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : '操作失败');
+ } finally {
+ setBusyId(null);
+ }
+ }
+
+ async function removeStaff(item: PartnerStaffItem) {
+ const ok = window.confirm(`确认删除子账号「${item.name}」?`);
+ if (!ok) return;
+ setBusyId(item.id);
+ setError('');
+ try {
+ await deletePartnerStaff(item.id);
+ await loadStaff();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : '删除失败');
+ } finally {
+ setBusyId(null);
+ }
+ }
+
+ async function editStaff(item: PartnerStaffItem) {
+ const name = window.prompt('修改姓名', item.name);
+ if (name == null || !name.trim()) return;
+ const roleOptions = Object.values(PartnerStaffRole);
+ const roleLabels = roleOptions.map((r) => PARTNER_STAFF_ROLE_LABELS[r]).join(' / ');
+ const roleInput = window.prompt(`分配角色(${roleLabels})`, item.staffRole);
+ if (roleInput == null) return;
+ const staffRole = roleOptions.find((r) => r === roleInput || PARTNER_STAFF_ROLE_LABELS[r] === roleInput);
+ if (!staffRole) {
+ setError('无效的角色');
+ return;
+ }
+ setBusyId(item.id);
+ setError('');
+ try {
+ await updatePartnerStaff(item.id, { name: name.trim(), staffRole });
+ await loadStaff();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : '编辑失败');
+ } finally {
+ setBusyId(null);
+ }
+ }
+
+ return (
+
+
navigate('/center')} />
+
+
+
+ search
+ setQ(e.target.value)} />
+
+
+
+ {error && {error}
}
+
+
+ {filtered.length === 0 && (
+
+
暂无子账号
+
点击下方按钮添加首个子账号
+
+ )}
+
+ {filtered.map((item) => {
+ const active = item.status === AccountStatus.ACTIVE;
+ const busy = busyId === item.id;
+ return (
+
+
{item.name.slice(0, 1)}
+
+
+ {item.name}
+
+ {PARTNER_STAFF_ROLE_LABELS[item.staffRole]}
+
+
+
{item.phone}
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+
+ person_add
+ 添加子账号
+
+
+
+ );
+}
diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx
index 6644c9e..a669e9d 100644
--- a/apps/h5-partner/src/pages/StoreCreatePage.tsx
+++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx
@@ -1,367 +1,929 @@
import { useEffect, useMemo, useState } from 'react';
+
import { useNavigate, useSearchParams } from 'react-router-dom';
+
import PageHeader from '@dukang/shared-ui/PageHeader';
+
import ChinaRegionPicker from '../components/ChinaRegionPicker';
+
import OssUploadField from '../components/OssUploadField';
+
import { request } from '../lib/api';
+
import { resolveRegionBinding } from '../lib/china-region';
+
+import { checkStorePhoneAvailable } from '../lib/storePhone';
+
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
+
import {
+
fetchPartnerProfile,
+
handlePartnerWechatCallback,
+
savePartnerWechatAuth,
+
} from '../lib/wechat-auth';
+
import { isWechatEnv, weixinSdk } from '../lib/weixin';
+
+import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
+
import {
+
clearStoreDraft,
+
defaultStoreForm,
+
loadStoreDraft,
+
saveStoreDraft,
+
type StoreDraftForm,
+
validateStoreStep1,
+
+ validateStoreStep2,
+
validateStoreStep3,
+
} from '../lib/storeDraft';
+
+
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
+
+
+type FieldErrors = {
+
+ phone?: string;
+
+};
+
+
+
+function isPhoneConflictMessage(message: string) {
+
+ return message.includes('手机号已绑定');
+
+}
+
+
+
export default function StoreCreatePage() {
+
const navigate = useNavigate();
+
const [params, setParams] = useSearchParams();
+
const saved = loadStoreDraft();
+
const [form, setForm] = useState(saved?.form ?? defaultStoreForm());
- const [error, setError] = useState('');
+
+ const [submitError, setSubmitError] = useState('');
+
+ const [fieldErrors, setFieldErrors] = useState({});
+
const [submitting, setSubmitting] = useState(false);
+
+ const [checkingPhone, setCheckingPhone] = useState(false);
+
const [cities, setCities] = useState([]);
+
const [citiesError, setCitiesError] = useState('');
+
const [wechatReady, setWechatReady] = useState(false);
+
+
const stepFromUrl = Number(params.get('step') || 0);
+
const step = stepFromUrl >= 1 && stepFromUrl <= 3 ? stepFromUrl : (saved?.step ?? 1);
+
+
useEffect(() => {
+
if (!stepFromUrl && saved?.step) {
+
setParams({ step: String(saved.step) }, { replace: true });
+
}
+
}, [stepFromUrl, saved?.step, setParams]);
+
+
useEffect(() => {
+
saveStoreDraft({ step, form });
+
}, [step, form]);
+
+
useEffect(() => {
+
if (step !== 2 || !isWechatEnv()) return;
+
void fetchPartnerProfile()
+
.then((me) => setWechatReady(!!me.hasWechat))
+
.catch(() => setWechatReady(false));
+
void weixinSdk.init().catch(() => {
+
/* OssUploadField 点击时会再次初始化 */
+
});
+
}, [step]);
- useEffect(() => {
- if (!isWechatEnv() || !params.get('code')) return;
- void handlePartnerWechatCallback()
- .then((result) => {
- if (result && savePartnerWechatAuth(result)) {
- setWechatReady(true);
- }
- })
- .catch((e) => setError(e instanceof Error ? e.message : '微信授权失败'));
- }, [params]);
+
useEffect(() => {
- void fetchPartnerCities()
- .then((items) => {
- setCities(items);
- if (!items.length) setCitiesError('暂无开城城市,请联系总部在后台配置');
+
+ if (!isWechatEnv() || !params.get('code')) return;
+
+ void handlePartnerWechatCallback()
+
+ .then((result) => {
+
+ if (result && savePartnerWechatAuth(result)) {
+
+ setWechatReady(true);
+
+ }
+
+ stripOAuthParamsFromLocation();
+
+ const next = new URLSearchParams(params);
+
+ next.delete('code');
+
+ next.delete('state');
+
+ setParams(next, { replace: true });
+
+ void weixinSdk.init().catch(() => {
+
+ /* OssUploadField 点击时会再次初始化 */
+
+ });
+
})
+
+ .catch((e) => setSubmitError(e instanceof Error ? e.message : '微信授权失败'));
+
+ }, [params, setParams]);
+
+
+
+ useEffect(() => {
+
+ void fetchPartnerCities()
+
+ .then((items) => {
+
+ setCities(items);
+
+ if (!items.length) setCitiesError('暂无开城城市,请联系总部在后台配置');
+
+ })
+
.catch((e) => setCitiesError(e instanceof Error ? e.message : '加载开城城市失败'));
+
}, []);
+
+
const regionBindingHint = useMemo(() => {
+
if (!form.regionCodes.length) return null;
+
const binding = resolveRegionBinding(form.regionCodes, cities);
+
if (!binding) return null;
+
if (binding.cityId && binding.matchedCity) {
+
return `已匹配开城城市:${binding.matchedCity.name}`;
+
}
+
return '所选地区暂未开城,请联系总部配置对应区划';
+
}, [form.regionCodes, cities]);
+
+
function patchForm(patch: Partial) {
+
setForm((prev) => ({ ...prev, ...patch }));
- setError('');
+
+ setSubmitError('');
+
+ if ('phone' in patch) {
+
+ setFieldErrors((prev) => ({ ...prev, phone: undefined }));
+
+ }
+
}
+
+
function bindRegionSelection(codes: string[]) {
+
const binding = resolveRegionBinding(codes, cities);
+
if (!binding) {
+
patchForm({ regionCodes: codes, cityId: '', province: '', city: '', district: '' });
+
return;
+
}
+
patchForm({
+
regionCodes: codes,
+
cityId: binding.cityId ?? '',
+
province: binding.region.province,
+
city: binding.region.city,
+
district: binding.region.district,
+
});
+
}
+
+
function goStep(n: number) {
+
setParams({ step: String(n) });
- setError('');
+
+ setSubmitError('');
+
}
- function handleNext() {
+
+
+ async function handleNext() {
+
if (step === 1) {
+
const msg = validateStoreStep1(form);
- if (msg) { setError(msg); return; }
+
+ if (msg) {
+
+ setSubmitError(msg);
+
+ return;
+
+ }
+
+ setCheckingPhone(true);
+
+ setSubmitError('');
+
+ setFieldErrors({});
+
+ try {
+
+ const phoneCheck = await checkStorePhoneAvailable(form.phone.trim());
+
+ if (!phoneCheck.available) {
+
+ setFieldErrors({
+
+ phone: phoneCheck.message ?? '该手机号已绑定门店,请更换',
+
+ });
+
+ return;
+
+ }
+
+ } catch (e) {
+
+ setSubmitError(e instanceof Error ? e.message : '手机号校验失败');
+
+ return;
+
+ } finally {
+
+ setCheckingPhone(false);
+
+ }
+
}
+
+ if (step === 2) {
+
+ const msg = validateStoreStep2(form);
+
+ if (msg) {
+
+ setSubmitError(msg);
+
+ return;
+
+ }
+
+ }
+
goStep(step + 1);
+
}
+
+
async function submit() {
+
const msg = validateStoreStep3(form);
- if (msg) { setError(msg); return; }
+
+ if (msg) {
+
+ setSubmitError(msg);
+
+ return;
+
+ }
+
const step1Msg = validateStoreStep1(form);
- if (step1Msg) { setError(step1Msg); return; }
+
+ if (step1Msg) {
+
+ setSubmitError(step1Msg);
+
+ goStep(1);
+
+ return;
+
+ }
+
+ const step2Msg = validateStoreStep2(form);
+
+ if (step2Msg) {
+
+ setSubmitError(step2Msg);
+
+ goStep(2);
+
+ return;
+
+ }
+
+
setSubmitting(true);
- setError('');
+
+ setSubmitError('');
+
+ setFieldErrors({});
+
try {
+
+ const phoneCheck = await checkStorePhoneAvailable(form.phone.trim());
+
+ if (!phoneCheck.available) {
+
+ setFieldErrors({
+
+ phone: phoneCheck.message ?? '该手机号已绑定门店,请更换',
+
+ });
+
+ goStep(1);
+
+ return;
+
+ }
+
+
+
const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean);
+
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
+
method: 'POST',
+
body: JSON.stringify({
+
cityId: form.cityId,
+
province: form.province,
+
city: form.city,
+
name: form.name.trim(),
+
phone: form.phone.trim(),
+
district: form.district.trim(),
+
address: form.address.trim(),
+
intro: form.intro.trim() || undefined,
+
coverUrl: form.coverUrl.trim() || undefined,
+
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
+
contractUrl: form.contractUrl.trim() || undefined,
+
bankAccountName: form.bankAccountName.trim(),
+
bankAccountNo: form.bankAccountNo.replace(/\s/g, ''),
+
bankBranch: form.bankBranch.trim(),
+
}),
+
});
+
clearStoreDraft();
+
navigate(`/stores/${result.store.id}`);
+
} catch (e) {
- setError(e instanceof Error ? e.message : '提交失败');
+
+ const message = e instanceof Error ? e.message : '提交失败';
+
+ if (isPhoneConflictMessage(message)) {
+
+ setFieldErrors({ phone: message });
+
+ goStep(1);
+
+ return;
+
+ }
+
+ setSubmitError(message);
+
} finally {
+
setSubmitting(false);
+
}
+
}
+
+
const progress = step === 1 ? 0 : step === 2 ? 50 : 100;
+ const nextDisabled = submitting || checkingPhone;
+
+
+
return (
+
+
navigate('/stores')} />
+
+
- {(error || citiesError) && (
- {error || citiesError}
+
+
+ {(submitError || citiesError) && (
+
+ {submitError || citiesError}
+
)}
+
+
{step === 1 && (
+
<>
+
+
+
+
+
+
{regionBindingHint && (
+
+
{regionBindingHint}
+
+
)}
+
+
+
+
+
store
+
patchForm({ name: e.target.value })} />
+
+
+
+
+
+
call
- patchForm({ phone: e.target.value })} />
+
+ patchForm({ phone: e.target.value })}
+
+ />
+
+
+ {fieldErrors.phone && (
+
+
{fieldErrors.phone}
+
+ )}
+
+
+
+
+
+
+
+
+
verified_user
+
+
+
杜康合伙人身份认证
+
+
填写内容将自动保存,退出后可继续录入。
+
+
+
+
>
+
)}
+
+
{step === 2 && (
+
<>
+
+
+
photo_library
+
+
门店图片上传
+
+
+
- 门头照
- 建议至少 1 张,需包含完整招牌
+
+ 门头照 *
+
+ 至少 1 张,需包含完整招牌
+
patchForm({ coverUrl })}
+
label="点击或拖拽上传"
+
/>
+
+
+
- 环境照片
- 建议至少 3 张,展示店内整洁环境
+
+ 环境照片 *
+
+ 至少 3 张,展示店内整洁环境
+
+
{form.envPhotoUrls.map((url, index) => (
+
{
+
const envPhotoUrls = [...form.envPhotoUrls];
+
envPhotoUrls[index] = nextUrl;
+
patchForm({ envPhotoUrls });
+
}}
+
/>
+
))}
+
+
+
+
- 签约合同
+
+ 签约合同 *
+
拍照上传签约协议首页与盖章页
+
patchForm({ contractUrl })}
+
label="上传合同副本"
+
/>
+
+
+
+
info
+
- 照片与合同将上传至 OSS 存储;与总部后台一致,选填项可跳过直接下一步。
+
+ 温馨提示:请确保照片清晰无反光,避免遮挡关键信息。如上传失败,请检查网络或联系客户经理。
+
+
+
>
+
)}
+
+
{step === 3 && (
+
<>
+
+
account_balance
+
结算信息配置
+
-
-
-
- patchForm({ bankAccountName: e.target.value })} />
-
-
-
- patchForm({ bankAccountNo: e.target.value })} />
-
-
-
-
patchForm({ bankBranch: e.target.value })} />
+
+
+
+
+
+ 门店登录手机号
+
+ {form.phone.trim() || '未填写'}
+
+
+
+
+
+
+
+
info
+
+
请确保银行卡信息准确,以免影响每月的餐费结算。
+
+
+
>
+
)}
+
+
+
+
);
+
}
+
+
diff --git a/apps/h5-partner/src/pages/StoreDetailPage.tsx b/apps/h5-partner/src/pages/StoreDetailPage.tsx
index eb23634..48dba30 100644
--- a/apps/h5-partner/src/pages/StoreDetailPage.tsx
+++ b/apps/h5-partner/src/pages/StoreDetailPage.tsx
@@ -3,6 +3,8 @@ import { useNavigate, useParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
+import { usePartnerSession } from '../contexts/PartnerSessionContext';
+import { isSubAccount } from '../lib/partnerAccess';
import { storeStatusLabel, storeStatusPillClass, type StoreStatusValue } from '../lib/storeStatus';
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
@@ -10,6 +12,8 @@ const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
export default function StoreDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
+ const { account } = usePartnerSession();
+ const subReadonly = isSubAccount(account);
const [store, setStore] = useState | null>(null);
const [loadError, setLoadError] = useState('');
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
@@ -107,7 +111,7 @@ export default function StoreDetailPage() {
const envPhotos = Array.isArray(store.media)
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV')
: [];
- const readOnly = status === 'CLOSED';
+ const readOnly = subReadonly || status === 'CLOSED';
return (
@@ -116,6 +120,7 @@ export default function StoreDetailPage() {
{error && {error}
}
+ {!subReadonly && (
运营状态
@@ -137,10 +142,11 @@ export default function StoreDetailPage() {
))}
- {readOnly && (
+ {status === 'CLOSED' && (
门店已关闭,不可再变更状态或编辑资料
)}
+ )}
基本信息
@@ -204,10 +210,12 @@ export default function StoreDetailPage() {
{toast && {toast}
}
diff --git a/apps/h5-partner/src/pages/StoreListPage.tsx b/apps/h5-partner/src/pages/StoreListPage.tsx
index 2c78591..5f59ef5 100644
--- a/apps/h5-partner/src/pages/StoreListPage.tsx
+++ b/apps/h5-partner/src/pages/StoreListPage.tsx
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api';
+import { usePartnerSession } from '../contexts/PartnerSessionContext';
+import { isSubAccount } from '../lib/partnerAccess';
import { storeStatusLabel, storeStatusPillClass, type StoreStatusValue } from '../lib/storeStatus';
type StatusFilter = 'ALL' | StoreStatusValue;
@@ -14,6 +16,8 @@ const FILTERS: { key: StatusFilter; label: string }[] = [
export default function StoreListPage() {
const navigate = useNavigate();
+ const { account } = usePartnerSession();
+ const readonly = isSubAccount(account);
const [stores, setStores] = useState>>([]);
const [q, setQ] = useState('');
const [filter, setFilter] = useState('ALL');
@@ -62,8 +66,8 @@ export default function StoreListPage() {
-
门店管理
-
管理您的合作门店及其运营状态
+
{readonly ? '我的门店' : '门店管理'}
+
{readonly ? '查看您录入的合作门店' : '管理您的合作门店及其运营状态'}
{error && {error}
}
@@ -111,6 +115,7 @@ export default function StoreListPage() {
+ {!readonly && (
+ )}
);
})}
diff --git a/apps/h5-partner/src/pages/WeeklyReportPage.tsx b/apps/h5-partner/src/pages/WeeklyReportPage.tsx
index ea22d50..543b16f 100644
--- a/apps/h5-partner/src/pages/WeeklyReportPage.tsx
+++ b/apps/h5-partner/src/pages/WeeklyReportPage.tsx
@@ -1,42 +1,215 @@
+import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
+import type { PartnerWeeklyReportResponse } from '@dukang/shared-types';
import PageHeader from '@dukang/shared-ui/PageHeader';
+import { fetchPartnerWeeklyReport } from '../lib/weeklyReport';
function fmtMoney(n: number) {
- return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+ return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
+}
+
+function fmtGrowth(n: number) {
+ const sign = n > 0 ? '+' : '';
+ return `${sign}${n}%`;
}
-/** BUWR-25:经营周报(preV1 占位,待 `/partner/reports/weekly` 接入) */
export default function WeeklyReportPage() {
const navigate = useNavigate();
+ const [selectedStart, setSelectedStart] = useState();
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ setLoading(true);
+ setError('');
+ fetchPartnerWeeklyReport(selectedStart)
+ .then(setData)
+ .catch((e) => {
+ setData(null);
+ setError(e instanceof Error ? e.message : '加载失败');
+ })
+ .finally(() => setLoading(false));
+ }, [selectedStart]);
+
+ const maxDailyGmv = useMemo(() => {
+ if (!data?.dailyGmv.length) return 1;
+ return Math.max(1, ...data.dailyGmv.map((item) => item.amount));
+ }, [data]);
+
+ const topStores = data?.storeRanking.slice(0, 3) ?? [];
+ const summary = data?.summary;
+ const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0;
return (
navigate('/')} />
-
-
- 近 7 日 GMV (CNY)
- {fmtMoney(128450)}
+
+
+ 经营数据周报
+ {data && (
+
+ {data.availablePeriods.map((period) => (
+
+ ))}
+
+ )}
-
+ {loading && 加载中…
}
+ {!loading && error && (
+ {error}
+ )}
-
- 本周新签门店
- 3 家
- 详细趋势图待 API 接入后展示
-
+ {!loading && !error && data && summary && (
+ <>
+
+
+
+
累计成交额 (GMV)
+
¥{fmtMoney(summary.gmv)}
+
+
+
+ {growthPositive ? 'trending_up' : 'trending_down'}
+
+ {fmtGrowth(summary.gmvGrowthPercent)}
+
+
+
+
+
活跃门店
+
+ {summary.activeStoreCount}
+ / {summary.totalStoreCount}
+
+
+
+
订单总量
+
{summary.orderCount}
+
+
+
+
+
+
+
+ storefront
+
本周新签约门店
+
+
+ {summary.newStoreCount}
+ / {summary.newStoreTarget}
+
+
+
+
+ 已完成本周目标的 {summary.newStoreProgressPercent}%。
+ {summary.newStoreCount < summary.newStoreTarget
+ ? `还差 ${summary.newStoreTarget - summary.newStoreCount} 家!`
+ : '目标已达成!'}
+
+
+
+
+
+
每日成交趋势
+ 过去 7 天
+
+
+
+ {data.dailyGmv.map((item) => {
+ const height = Math.max(8, Math.round((item.amount / maxDailyGmv) * 100));
+ const isMax = item.amount === maxDailyGmv && item.amount > 0;
+ return (
+
+ );
+ })}
+
+
+ {data.dailyGmv.map((item) => (
+ {item.weekdayLabel}
+ ))}
+
+
+
+
+
+
+
门店业绩排行
+
+
+ {topStores.length === 0 ? (
+ 本周暂无核销排行数据
+ ) : (
+
+ {topStores.map((store) => (
+
+
+ {store.rank === 1 ? (
+ workspace_premium
+ ) : (
+ {store.rank}
+ )}
+
+
+
{store.name}
+ {store.subtitle && (
+
{store.subtitle}
+ )}
+
+
¥{fmtMoney(store.redeemAmount)}
+
+ ))}
+
+ )}
+
+
+
+ tips_and_updates
+
+
合伙人经营策略
+
{data.insight}
+
+
+ >
+ )}
);
diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css
index 9c31a1c..f7b40f1 100644
--- a/apps/h5-partner/src/styles.css
+++ b/apps/h5-partner/src/styles.css
@@ -512,25 +512,19 @@ html {
}
.partner-quick-actions {
- display: flex;
- gap: 12px;
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- scrollbar-width: none;
- padding-bottom: 4px;
- margin: 0 calc(-1 * var(--space-md));
- padding-left: var(--space-md);
- padding-right: var(--space-md);
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 8px;
+ min-width: 0;
}
-.partner-quick-actions::-webkit-scrollbar { display: none; }
-
.partner-quick-action {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
- flex-shrink: 0;
+ min-width: 0;
+ width: 100%;
border: none;
background: none;
cursor: pointer;
@@ -557,6 +551,10 @@ html {
font-size: 10px;
font-weight: 500;
color: var(--color-on-surface-variant);
+ width: 100%;
+ text-align: center;
+ line-height: 1.2;
+ word-break: keep-all;
}
.partner-quick-badge-count {
@@ -646,6 +644,27 @@ html {
font-size: 14px;
}
+.partner-field-error {
+ margin: 8px 0 0;
+ padding: 0;
+ font-size: 13px;
+ line-height: 1.4;
+ color: var(--color-error);
+}
+
+.partner-store-phone-summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.partner-store-phone-summary-main {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
.partner-store-name-list {
list-style: none;
margin: 0 0 16px;
@@ -677,6 +696,105 @@ html {
border-bottom: 1px solid rgba(226, 190, 188, 0.15);
}
+.partner-home-store-section {
+ background: var(--color-surface-container-lowest, #fff);
+ border-radius: 12px;
+ padding: 16px;
+ margin-bottom: 16px;
+ box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
+}
+
+.partner-home-store-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+}
+
+.partner-home-store-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 0;
+ border-bottom: 1px solid rgba(226, 190, 188, 0.15);
+ text-decoration: none;
+ color: inherit;
+}
+
+.partner-home-store-row:last-child {
+ border-bottom: none;
+ padding-bottom: 0;
+}
+
+.partner-home-store-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.partner-home-store-empty {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 0;
+}
+
+.partner-leaderboard-tabs {
+ display: flex;
+ gap: 8px;
+ padding: 0 20px 12px;
+ overflow-x: auto;
+}
+
+.partner-leaderboard-tab {
+ border: none;
+ background: var(--color-surface-container-high, #e9e8e6);
+ color: var(--color-on-surface-variant, #5a413f);
+ padding: 8px 20px;
+ border-radius: 999px;
+ font-size: 13px;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+.partner-leaderboard-tab.active {
+ background: var(--color-heritage-red, #a61d24);
+ color: #fff;
+}
+
+.partner-leaderboard-self-card {
+ background: var(--color-heritage-red, #a61d24);
+ border-radius: 12px;
+ padding: 16px;
+ color: #fff;
+ margin-bottom: 8px;
+ box-shadow: 0 4px 20px rgba(166, 29, 36, 0.15);
+}
+
+.partner-leaderboard-self-main {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.partner-leaderboard-self-rank {
+ background: rgba(255, 255, 255, 0.15);
+ border-radius: 999px;
+ padding: 4px 10px;
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.partner-leaderboard-self-stats {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid rgba(255, 255, 255, 0.15);
+ font-size: 13px;
+}
+
.partner-field-input--block {
width: 100%;
padding: 0 16px;
@@ -737,6 +855,363 @@ html {
flex-shrink: 0;
}
+/* ── Weekly report ── */
+.partner-weekly-page {
+ padding: 0 20px 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.partner-weekly-title-section {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.partner-weekly-title-section h2 {
+ font-size: 20px;
+ line-height: 28px;
+ font-weight: 600;
+ color: var(--color-ink-black, #1a1a1a);
+}
+
+.partner-weekly-tabs {
+ display: flex;
+ gap: 4px;
+ overflow-x: auto;
+ padding-bottom: 4px;
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+.partner-weekly-tabs::-webkit-scrollbar {
+ display: none;
+}
+
+.partner-weekly-tab {
+ flex: none;
+ padding: 8px 16px;
+ border: none;
+ border-radius: 12px;
+ background: var(--color-surface-container, #efeeec);
+ color: var(--color-on-surface-variant, #5a413f);
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.05em;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.partner-weekly-tab.active {
+ background: var(--color-heritage-red);
+ color: #fff;
+ box-shadow: 0 2px 8px rgba(166, 29, 36, 0.25);
+}
+
+.partner-weekly-state {
+ padding: 24px 0;
+ text-align: center;
+}
+
+.partner-weekly-hero {
+ position: relative;
+ overflow: hidden;
+ border-radius: 12px;
+ background: #fff;
+ padding: 16px;
+ border: 1px solid rgba(166, 29, 36, 0.05);
+ box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
+}
+
+.partner-weekly-hero-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+}
+
+.partner-weekly-hero-label {
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--color-subtle-gray);
+}
+
+.partner-weekly-hero-gmv {
+ margin-top: 4px;
+ font-size: 32px;
+ line-height: 40px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ color: var(--color-heritage-red);
+}
+
+.partner-weekly-growth-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 8px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.partner-weekly-growth-badge .material-symbols-outlined {
+ font-size: 14px;
+}
+
+.partner-weekly-growth-badge--up {
+ background: rgba(45, 106, 79, 0.1);
+ color: var(--color-success-green, #2d6a4f);
+}
+
+.partner-weekly-growth-badge--down {
+ background: rgba(186, 26, 26, 0.1);
+ color: var(--color-error, #ba1a1a);
+}
+
+.partner-weekly-hero-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--color-surface-container-highest, #e3e2e0);
+}
+
+.partner-weekly-stat-value {
+ margin-top: 4px;
+ font-size: 18px;
+ line-height: 26px;
+ font-weight: 600;
+ color: var(--color-ink-black, #1a1a1a);
+}
+
+.partner-weekly-stat-sub {
+ margin-left: 4px;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--color-subtle-gray);
+}
+
+.partner-weekly-card {
+ background: #fff;
+ border-radius: 12px;
+ padding: 16px;
+ box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.partner-weekly-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+}
+
+.partner-weekly-card-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.partner-weekly-icon-amber {
+ color: var(--color-aged-amber);
+ font-variation-settings: 'FILL' 1;
+}
+
+.partner-weekly-target {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--color-aged-amber);
+}
+
+.partner-weekly-target-sub {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--color-subtle-gray);
+}
+
+.partner-weekly-progress-track {
+ position: relative;
+ height: 12px;
+ width: 100%;
+ border-radius: 999px;
+ background: var(--color-surface-container-highest, #e3e2e0);
+ overflow: hidden;
+}
+
+.partner-weekly-progress-bar {
+ height: 100%;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--color-heritage-red), var(--color-aged-amber));
+ transition: width 0.6s ease;
+}
+
+.partner-weekly-progress-hint {
+ font-style: italic;
+}
+
+.partner-weekly-chart {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.partner-weekly-chart-bars {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 8px;
+ height: 96px;
+ padding: 0 8px;
+}
+
+.partner-weekly-chart-bar {
+ flex: 1;
+ max-width: 16px;
+ border-radius: 999px 999px 0 0;
+ background: rgba(166, 29, 36, 0.45);
+ min-height: 8px;
+}
+
+.partner-weekly-chart-bar--peak {
+ background: var(--color-aged-amber);
+ box-shadow: 0 2px 6px rgba(255, 191, 0, 0.35);
+}
+
+.partner-weekly-chart-labels {
+ display: flex;
+ justify-content: space-between;
+ gap: 4px;
+ font-size: 10px;
+ color: var(--color-subtle-gray);
+ padding: 0 4px;
+}
+
+.partner-weekly-chart-labels span {
+ flex: 1;
+ text-align: center;
+}
+
+.partner-weekly-rank-section {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.partner-weekly-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ border: none;
+ background: none;
+ padding: 0;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--color-heritage-red);
+ cursor: pointer;
+}
+
+.partner-weekly-link .material-symbols-outlined {
+ font-size: 14px;
+}
+
+.partner-weekly-rank-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.partner-weekly-rank-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 8px;
+ border-radius: 12px;
+ background: #fff;
+ box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
+}
+
+.partner-weekly-rank-row--first {
+ border-left: 4px solid var(--color-aged-amber);
+}
+
+.partner-weekly-rank-badge {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: var(--color-surface-container, #efeeec);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ font-size: 18px;
+ font-weight: 600;
+ font-style: italic;
+ color: var(--color-subtle-gray);
+}
+
+.partner-weekly-rank-row--first .partner-weekly-rank-badge {
+ background: rgba(255, 191, 0, 0.1);
+ color: var(--color-aged-amber);
+ font-style: normal;
+}
+
+.partner-weekly-rank-row--first .partner-weekly-rank-badge .material-symbols-outlined {
+ font-variation-settings: 'FILL' 1;
+}
+
+.partner-weekly-rank-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.partner-weekly-rank-name {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--color-ink-black, #1a1a1a);
+}
+
+.partner-weekly-rank-amount {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--color-heritage-red);
+ flex-shrink: 0;
+}
+
+.partner-weekly-rank-row:not(.partner-weekly-rank-row--first) .partner-weekly-rank-amount {
+ color: var(--color-ink-black, #1a1a1a);
+}
+
+.partner-weekly-empty {
+ padding: 12px 0;
+}
+
+.partner-weekly-insight {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 16px;
+ border-radius: 12px;
+ background: var(--color-surface-container-low, #f4f3f1);
+ border: 2px dashed rgba(166, 29, 36, 0.1);
+}
+
+.partner-weekly-insight .material-symbols-outlined {
+ color: var(--color-heritage-red);
+ font-variation-settings: 'FILL' 1;
+ flex-shrink: 0;
+}
+
+.partner-weekly-insight .headline-md {
+ margin-bottom: 4px;
+}
+
/* ── Partner bottom tabbar(锁定尺寸,仅切换颜色)── */
nav.app-tabbar {
display: flex;
@@ -1261,22 +1736,29 @@ nav.app-tabbar .app-tabbar-label {
.partner-sticky-footer {
position: fixed;
+ left: 0;
+ right: 0;
bottom: 0;
- left: 50%;
- transform: translateX(-50%);
width: 100%;
max-width: 480px;
- background: var(--color-card);
+ margin: 0 auto;
+ transform: none;
+ background: rgba(250, 249, 247, 0.92);
+ backdrop-filter: blur(8px);
+ border-top: 1px solid rgba(226, 190, 188, 0.2);
box-shadow: 0 -4px 20px rgba(166, 29, 36, 0.05);
padding: 16px var(--space-page) calc(16px + env(safe-area-inset-bottom, 0px));
z-index: 60;
display: flex;
+ align-items: center;
+ justify-content: center;
gap: 8px;
box-sizing: border-box;
}
.partner-sticky-footer .partner-btn-primary {
flex: 2;
+ width: auto;
min-height: 48px;
padding: 12px 16px;
display: flex;
@@ -1284,7 +1766,13 @@ nav.app-tabbar .app-tabbar-label {
justify-content: center;
gap: 4px;
}
-.partner-sticky-footer .partner-btn-outline { flex: 1; }
+
+.partner-sticky-footer .partner-btn-outline {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
/* ── Store list ── */
.partner-store-page {
@@ -2027,6 +2515,288 @@ nav.app-tabbar .app-tabbar-label {
gap: 8px;
}
+/* ── Settlement page ── */
+.partner-settlement-page {
+ min-height: 100vh;
+ padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px));
+}
+
+.partner-settlement-body {
+ padding: 0 var(--space-page) 24px;
+}
+
+.partner-settlement-summary {
+ position: relative;
+ overflow: hidden;
+ background: var(--color-heritage-red);
+ border-radius: var(--radius-md);
+ padding: 24px;
+ color: #fff;
+ box-shadow: 0 8px 32px rgba(166, 29, 36, 0.15);
+ margin-bottom: var(--space-lg);
+}
+
+.partner-settlement-summary-deco {
+ position: absolute;
+ top: -24px;
+ right: -24px;
+ opacity: 0.1;
+}
+
+.partner-settlement-summary-deco .material-symbols-outlined {
+ font-size: 160px;
+ font-variation-settings: 'wght' 700;
+}
+
+.partner-settlement-summary-label {
+ position: relative;
+ z-index: 1;
+ text-align: center;
+ font-family: var(--font-label);
+ font-size: 12px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ opacity: 0.8;
+ margin-bottom: 8px;
+}
+
+.partner-settlement-summary-amount {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ align-items: baseline;
+ justify-content: center;
+ gap: 4px;
+ font-family: var(--font-headline);
+ font-size: 32px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ margin-bottom: 24px;
+}
+
+.partner-settlement-currency {
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.partner-settlement-summary-grid {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ gap: 16px;
+ padding-top: 24px;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.partner-settlement-summary-sub {
+ font-family: var(--font-label);
+ font-size: 12px;
+ opacity: 0.7;
+ margin-bottom: 4px;
+ text-align: center;
+}
+
+.partner-settlement-summary-value {
+ font-family: var(--font-headline);
+ font-size: 18px;
+ font-weight: 600;
+ text-align: center;
+}
+
+.partner-settlement-summary-value--accent {
+ color: var(--color-secondary-fixed);
+}
+
+.partner-settlement-summary-divider {
+ width: 1px;
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.partner-settlement-filters {
+ display: flex;
+ gap: 12px;
+ margin-bottom: var(--space-md);
+}
+
+.partner-settlement-month,
+.partner-settlement-status-btn {
+ height: 48px;
+ border: none;
+ border-radius: var(--radius-md);
+ background: var(--color-surface-container-low);
+ font-family: var(--font-body);
+ font-size: 14px;
+ color: var(--color-on-surface-variant);
+}
+
+.partner-settlement-month {
+ flex: 1;
+ padding: 0 16px;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='%23999'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 8px center;
+ background-size: 20px;
+}
+
+.partner-settlement-status-btn {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 16px;
+ cursor: pointer;
+}
+
+.partner-settlement-status-btn .material-symbols-outlined {
+ color: var(--color-heritage-red);
+ font-size: 20px;
+}
+
+.partner-settlement-chips {
+ display: flex;
+ gap: 8px;
+ overflow-x: auto;
+ margin-bottom: var(--space-md);
+ padding-bottom: 4px;
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+.partner-settlement-chips::-webkit-scrollbar { display: none; }
+
+.partner-settlement-chip {
+ flex-shrink: 0;
+ padding: 6px 16px;
+ border: 1px solid transparent;
+ border-radius: var(--radius-full);
+ background: var(--color-surface-container-high);
+ color: var(--color-on-surface-variant);
+ font-family: var(--font-label);
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.partner-settlement-chip--active {
+ background: var(--color-heritage-red);
+ color: #fff;
+}
+
+.partner-settlement-list-title {
+ font-family: var(--font-label);
+ font-size: 12px;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--color-subtle-gray);
+ margin-bottom: 12px;
+ padding: 0 4px;
+}
+
+.partner-settlement-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.partner-settlement-item {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 16px;
+ border: 1px solid transparent;
+ border-radius: var(--radius-md);
+ background: var(--color-card);
+ box-shadow: var(--shadow-card);
+ text-align: left;
+ cursor: pointer;
+ color: inherit;
+}
+
+.partner-settlement-item:active {
+ transform: scale(0.98);
+ border-color: rgba(166, 29, 36, 0.1);
+}
+
+.partner-settlement-item-main {
+ flex: 1;
+ min-width: 0;
+}
+
+.partner-settlement-item-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+
+.partner-settlement-item-no {
+ font-weight: 600;
+ font-size: 14px;
+}
+
+.partner-settlement-status {
+ padding: 2px 8px;
+ border-radius: 6px;
+ font-family: var(--font-label);
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+
+.partner-settlement-status--pending {
+ background: rgba(255, 224, 136, 0.3);
+ color: var(--color-on-secondary-fixed);
+}
+
+.partner-settlement-status--reviewing {
+ background: rgba(42, 110, 187, 0.12);
+ color: var(--color-status-blue);
+}
+
+.partner-settlement-status--settled {
+ background: rgba(45, 106, 79, 0.1);
+ color: var(--color-success-green);
+}
+
+.partner-settlement-item-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-family: var(--font-label);
+ font-size: 12px;
+ color: var(--color-subtle-gray);
+ margin-bottom: 8px;
+}
+
+.partner-settlement-item-meta .material-symbols-outlined {
+ font-size: 14px;
+}
+
+.partner-settlement-dot {
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background: var(--color-outline-variant);
+}
+
+.partner-settlement-item-amount {
+ font-family: var(--font-headline);
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.partner-settlement-footer-note {
+ text-align: center;
+ padding: 32px 0 8px;
+ font-family: var(--font-label);
+ font-size: 12px;
+ color: var(--color-subtle-gray);
+}
+
/* ── Bills page ── */
.partner-bills-page {
min-height: 100vh;
@@ -2125,3 +2895,132 @@ body {
min-height: 100dvh;
background: var(--color-background);
}
+
+/* ── 子账号管理 ── */
+.partner-staff-card {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px;
+ margin-bottom: 12px;
+ background: var(--color-surface-container-lowest, #fff);
+ border-radius: 12px;
+ box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
+}
+
+.partner-staff-avatar {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ background: rgba(166, 29, 36, 0.08);
+ color: var(--color-heritage-red);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 700;
+ flex-shrink: 0;
+}
+
+.partner-staff-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.partner-staff-actions {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.partner-role-badge--subtle {
+ background: var(--color-surface-container, #efeeec);
+ color: var(--color-on-surface-variant, #5a413f);
+ font-size: 10px;
+ padding: 2px 8px;
+ border-radius: 999px;
+}
+
+.partner-toggle {
+ width: 48px;
+ height: 24px;
+ border-radius: 999px;
+ border: none;
+ background: #ccc;
+ position: relative;
+ padding: 0;
+ cursor: pointer;
+}
+
+.partner-toggle--on {
+ background: var(--color-success-green, #2d6a4f);
+}
+
+.partner-toggle-thumb {
+ position: absolute;
+ top: 4px;
+ left: 4px;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: #fff;
+ transition: transform 0.2s ease;
+}
+
+.partner-toggle--on .partner-toggle-thumb {
+ transform: translateX(24px);
+}
+
+.partner-icon-btn {
+ border: none;
+ background: transparent;
+ color: var(--color-outline, #8d706e);
+ padding: 0;
+ cursor: pointer;
+}
+
+.partner-icon-btn--danger {
+ color: var(--color-error, #ba1a1a);
+}
+
+.partner-fab-round {
+ position: fixed;
+ right: 20px;
+ bottom: calc(24px + env(safe-area-inset-bottom, 0px));
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ background: var(--color-heritage-red);
+ color: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 4px 20px rgba(166, 29, 36, 0.3);
+ text-decoration: none;
+ z-index: 30;
+}
+
+.partner-sub-header {
+ padding: 12px 20px;
+}
+
+.partner-form-label {
+ display: block;
+ font-size: 13px;
+ color: var(--color-on-surface-variant, #5a413f);
+ margin-bottom: 8px;
+}
+
+.partner-form-input-wrap {
+ position: relative;
+}
+
+.partner-form-input-wrap .material-symbols-outlined {
+ position: absolute;
+ right: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--color-outline, #8d706e);
+ pointer-events: none;
+}
diff --git a/packages/shared-types/src/enums.ts b/packages/shared-types/src/enums.ts
index 3e31d4b..1d9f1cb 100644
--- a/packages/shared-types/src/enums.ts
+++ b/packages/shared-types/src/enums.ts
@@ -62,6 +62,17 @@ export enum StoreStatus {
CLOSED = 'CLOSED',
}
+export enum PartnerStaffRole {
+ PARTNER = 'PARTNER',
+ INTERNAL = 'INTERNAL',
+ PROMOTER = 'PROMOTER',
+}
+
+export enum AccountStatus {
+ ACTIVE = 'ACTIVE',
+ DISABLED = 'DISABLED',
+}
+
export enum BenefitCouponStatus {
ACTIVE = 'ACTIVE',
USED_UP = 'USED_UP',
diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts
index c04afc5..3dcfb7c 100644
--- a/packages/shared-types/src/index.ts
+++ b/packages/shared-types/src/index.ts
@@ -14,3 +14,4 @@ export * from './store-log';
export * from './partner-log';
export * from './promo';
export * from './hq-permissions';
+export * from './partner';
diff --git a/packages/shared-types/src/partner.ts b/packages/shared-types/src/partner.ts
new file mode 100644
index 0000000..9f3246b
--- /dev/null
+++ b/packages/shared-types/src/partner.ts
@@ -0,0 +1,101 @@
+import type { AccountStatus, PartnerStaffRole } from './enums';
+
+export interface PartnerMe {
+ id: string;
+ name: string;
+ phone: string;
+ isPrimary: boolean;
+ companyName?: string;
+ hasWechat?: boolean;
+ staffRole?: PartnerStaffRole;
+}
+
+export interface PartnerStaffItem {
+ id: string;
+ name: string;
+ phone: string;
+ staffRole: PartnerStaffRole;
+ status: AccountStatus;
+ lastLoginAt?: string;
+}
+
+export interface CreatePartnerStaffRequest {
+ phone: string;
+ name: string;
+ staffRole: PartnerStaffRole;
+ code: string;
+}
+
+export interface UpdatePartnerStaffRequest {
+ name?: string;
+ staffRole?: PartnerStaffRole;
+ status?: AccountStatus;
+}
+
+export const PARTNER_STAFF_ROLE_LABELS: Record = {
+ PARTNER: '城市合伙人',
+ INTERNAL: '内部员工',
+ PROMOTER: '推广员',
+};
+
+export type PartnerLeaderboardPeriod = 'month' | 'lastMonth' | 'total';
+
+export interface PartnerLeaderboardEntry {
+ rank: number;
+ accountId: string;
+ name: string;
+ staffRole?: PartnerStaffRole;
+ roleLabel: string;
+ totalStores: number;
+ periodStores: number;
+ isSelf?: boolean;
+}
+
+export interface PartnerLeaderboardResponse {
+ period: PartnerLeaderboardPeriod;
+ list: PartnerLeaderboardEntry[];
+ self?: PartnerLeaderboardEntry & { beatPercent?: number };
+}
+
+export interface PartnerStorePhoneAvailableResponse {
+ available: boolean;
+ message?: string;
+}
+
+export interface PartnerWeeklyReportPeriod {
+ startDate: string;
+ endDate: string;
+ label: string;
+}
+
+export interface PartnerWeeklyReportDailyGmv {
+ date: string;
+ weekdayLabel: string;
+ amount: number;
+}
+
+export interface PartnerWeeklyReportStoreRank {
+ rank: number;
+ storeId: string;
+ name: string;
+ subtitle?: string;
+ redeemAmount: number;
+}
+
+export interface PartnerWeeklyReportResponse {
+ period: PartnerWeeklyReportPeriod;
+ availablePeriods: PartnerWeeklyReportPeriod[];
+ summary: {
+ gmv: number;
+ gmvGrowthPercent: number;
+ activeStoreCount: number;
+ totalStoreCount: number;
+ orderCount: number;
+ newStoreCount: number;
+ newStoreTarget: number;
+ newStoreProgressPercent: number;
+ };
+ dailyGmv: PartnerWeeklyReportDailyGmv[];
+ storeRanking: PartnerWeeklyReportStoreRank[];
+ insight: string;
+}
diff --git a/packages/weixin-sdk/src/chooseImage.ts b/packages/weixin-sdk/src/chooseImage.ts
index bfcced3..3506384 100644
--- a/packages/weixin-sdk/src/chooseImage.ts
+++ b/packages/weixin-sdk/src/chooseImage.ts
@@ -1,5 +1,5 @@
import { getRuntimePlatform } from './env';
-import { ensureJssdkReady } from './jssdk';
+import { ensureJssdkReady, normalizeJssdkPageUrl } from './jssdk';
import type { WeixinSdkConfig } from './types';
export type ChooseWechatImageOptions = {
@@ -32,7 +32,7 @@ async function reportChooseImageEvent(
errMsg: payload.errMsg,
sourceType: payload.sourceType,
stage: payload.stage,
- pageUrl: typeof window !== 'undefined' ? window.location.href.split('#')[0] : undefined,
+ pageUrl: typeof window !== 'undefined' ? normalizeJssdkPageUrl(window.location.href) : undefined,
}),
});
} catch {
@@ -84,7 +84,7 @@ export async function chooseWechatImages(
const sourceType = options.sourceType ?? ['album', 'camera'];
if (platform === 'wechat-h5') {
- const pageUrl = typeof window !== 'undefined' ? window.location.href.split('#')[0] : '';
+ const pageUrl = typeof window !== 'undefined' ? normalizeJssdkPageUrl(window.location.href) : '';
const sourceTypeKey = sourceType.join(',');
try {
await ensureJssdkReady({
diff --git a/pages/ROUTE_MAP.md b/pages/ROUTE_MAP.md
index dd2d17b..6d70ad4 100644
--- a/pages/ROUTE_MAP.md
+++ b/pages/ROUTE_MAP.md
@@ -57,6 +57,7 @@
| P1 | 合伙人订单中心页 | b7c630ed | `/orders` | partner/20_* | ui-aligned |
| P1 | 城市合伙人订单详情页 | 423aac86 | `/orders/:id` | partner/18_* | ui-aligned |
| P1 | 合伙人中心 | c31c874b | `/center` | partner/04_* | ui-aligned |
+| P1 | 财务对账中心 | 0e41c1b4 | `/center/settlement` | _archive/财务对账页_中文版 | ui-aligned |
| P1 | 合伙人待确认账单页 | f9b208b8 | `/center/bills` | partner/14_* | ui-aligned |
## 总部管理端 mini-hq(Taro,先 H5 后小程序)
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index c3f8346..c39c378 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -3,7 +3,7 @@ packages:
- 'packages/*'
- 'server/*'
allowBuilds:
- '@alicloud/openapi-core': set this to true or false
+ '@alicloud/openapi-core': true
'@nestjs/core': true
'@parcel/watcher': true
'@prisma/client': true
diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma
index 2f456b7..18d8d69 100644
--- a/server/dukang-api/prisma/schema.prisma
+++ b/server/dukang-api/prisma/schema.prisma
@@ -433,6 +433,7 @@ model Partner {
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
bankBranch String? @map("bank_branch") @db.VarChar(128)
+ weeklyStoreTarget Int? @default(20) @map("weekly_store_target")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
diff --git a/server/dukang-api/prisma/seed-v31.ts b/server/dukang-api/prisma/seed-v31.ts
index a6e238c..87c15e1 100644
--- a/server/dukang-api/prisma/seed-v31.ts
+++ b/server/dukang-api/prisma/seed-v31.ts
@@ -61,6 +61,7 @@ async function main() {
bankAccountName: '郑州合伙人公司',
bankAccountNo: '6222021234567890',
bankBranch: '工商银行郑州分行',
+ weeklyStoreTarget: 20,
},
});
@@ -150,6 +151,22 @@ async function main() {
},
});
+ const primaryAccount = await prisma.partnerAccount.findUniqueOrThrow({
+ where: { phone: '13700000001' },
+ });
+
+ await prisma.partnerAccount.create({
+ data: {
+ partnerId: partner.id,
+ phone: '13700000002',
+ name: '拓店员小李',
+ isPrimary: 0,
+ parentAccountId: primaryAccount.id,
+ staffRole: 'INTERNAL',
+ status: 'DISABLED',
+ },
+ });
+
const storeDefs = [
{
name: '郑州老城店',
@@ -173,6 +190,7 @@ async function main() {
},
];
+ const createdStores: { id: bigint; name: string }[] = [];
for (const def of storeDefs) {
const store = await prisma.store.create({
data: {
@@ -201,8 +219,42 @@ async function main() {
data: { storeId: store.id, phone: def.phone, name: def.name },
});
}
+ createdStores.push({ id: store.id, name: def.name });
}
+ const weekStart = (() => {
+ const d = new Date();
+ d.setHours(12, 0, 0, 0);
+ const day = d.getDay();
+ const diff = day === 0 ? 6 : day - 1;
+ d.setDate(d.getDate() - diff);
+ d.setHours(10, 0, 0, 0);
+ return d;
+ })();
+
+ const newWeekStore = await prisma.store.create({
+ data: {
+ cityId: city.id,
+ partnerId: partner.id,
+ categoryId: categories[0].id,
+ name: '本周新签体验店',
+ phone: '13910000003',
+ province: '河南省',
+ cityName: '郑州市',
+ district: '中原区',
+ address: '建设路88号',
+ intro: '本周新签约门店',
+ status: 'OPEN',
+ openTime: '10:00',
+ closeTime: '22:00',
+ bankAccountName: '本周新签体验店',
+ bankAccountNo: '6222029876543211',
+ bankBranch: '农业银行郑州分行',
+ createdAt: new Date(weekStart.getTime() + 2 * 24 * 60 * 60 * 1000),
+ },
+ });
+ createdStores.push({ id: newWeekStore.id, name: newWeekStore.name });
+
await prisma.user.create({
data: {
userNo: 'DK88293401',
@@ -244,6 +296,91 @@ async function main() {
},
});
+ const user = await prisma.user.findUniqueOrThrow({ where: { phone: '13800000001' } });
+ const product = products[0];
+
+ const orderDefs = [
+ { dayOffset: 0, payAmount: 1198, quantity: 2 },
+ { dayOffset: 1, payAmount: 599, quantity: 1 },
+ { dayOffset: 2, payAmount: 1760, quantity: 2 },
+ { dayOffset: 4, payAmount: 1299, quantity: 1 },
+ { dayOffset: 5, payAmount: 880, quantity: 1 },
+ ];
+
+ const coupons: { id: bigint; balance: number }[] = [];
+ for (const [index, def] of orderDefs.entries()) {
+ const paidAt = new Date(weekStart.getTime() + def.dayOffset * 24 * 60 * 60 * 1000 + 14 * 60 * 60 * 1000);
+ const listAmount = def.payAmount;
+ const order = await prisma.order.create({
+ data: {
+ orderNo: `WK${Date.now()}${index}`,
+ userId: user.id,
+ cityId: city.id,
+ status: 'COMPLETED',
+ payStatus: 'PAID',
+ deliveryType: 'LOCAL',
+ productId: product.id,
+ barcode69: product.barcode69,
+ productName: product.name,
+ productSpec: product.spec,
+ quantity: def.quantity,
+ listUnitPrice: product.price,
+ listAmount,
+ productAmount: listAmount,
+ payAmount: listAmount,
+ benefitAmount: listAmount,
+ receiverName: '测试用户',
+ receiverPhone: user.phone,
+ receiverAddress: '郑州市金水区测试路1号',
+ receiverProvince: '河南省',
+ receiverCity: '郑州市',
+ receiverDistrict: '金水区',
+ paidAt,
+ completedAt: paidAt,
+ },
+ });
+ const coupon = await prisma.benefitCoupon.create({
+ data: {
+ couponNo: `CPN${Date.now()}${index}`,
+ userId: user.id,
+ orderId: order.id,
+ totalAmount: listAmount,
+ balance: listAmount,
+ sourceProduct: product.name,
+ },
+ });
+ coupons.push({ id: coupon.id, balance: listAmount });
+ }
+
+ const redeemDefs = [
+ { storeIndex: 0, amount: 42800, dayOffset: 1 },
+ { storeIndex: 1, amount: 38400, dayOffset: 2 },
+ { storeIndex: 2, amount: 31200, dayOffset: 3 },
+ ];
+ for (const [index, def] of redeemDefs.entries()) {
+ const coupon = coupons[index];
+ const store = createdStores[def.storeIndex];
+ const createdAt = new Date(weekStart.getTime() + def.dayOffset * 24 * 60 * 60 * 1000 + 16 * 60 * 60 * 1000);
+ await prisma.redeemRecord.create({
+ data: {
+ redeemNo: `RD${Date.now()}${index}`,
+ userId: user.id,
+ couponId: coupon.id,
+ storeId: store.id,
+ amount: def.amount,
+ settleAmount: Math.round(def.amount * 0.6 * 100) / 100,
+ createdAt,
+ },
+ });
+ await prisma.benefitCoupon.update({
+ where: { id: coupon.id },
+ data: {
+ usedAmount: def.amount,
+ balance: Math.max(0, coupon.balance - def.amount),
+ },
+ });
+ }
+
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
diff --git a/server/dukang-api/src/common/guards/partner-primary.guard.ts b/server/dukang-api/src/common/guards/partner-primary.guard.ts
new file mode 100644
index 0000000..c23501c
--- /dev/null
+++ b/server/dukang-api/src/common/guards/partner-primary.guard.ts
@@ -0,0 +1,32 @@
+import {
+ CanActivate,
+ ExecutionContext,
+ ForbiddenException,
+ Injectable,
+} from '@nestjs/common';
+import { PrismaService } from '../prisma/prisma.module';
+import { AuthUser, JwtAuthGuard } from './jwt-auth.guard';
+
+@Injectable()
+export class PartnerPrimaryGuard implements CanActivate {
+ constructor(
+ private readonly jwtAuthGuard: JwtAuthGuard,
+ private readonly prisma: PrismaService,
+ ) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ this.jwtAuthGuard.canActivate(context);
+ const req = context.switchToHttp().getRequest();
+ const user = req.user as AuthUser;
+ if (user.actorType !== 'PARTNER') {
+ throw new ForbiddenException('仅合伙人主账号可操作');
+ }
+ const account = await this.prisma.partnerAccount.findUniqueOrThrow({
+ where: { id: user.actorId },
+ });
+ if (account.isPrimary !== 1) {
+ throw new ForbiddenException('仅主账号可操作');
+ }
+ return true;
+ }
+}
diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts
index 8c0b373..b1ae06d 100644
--- a/server/dukang-api/src/modules/iam/auth.service.ts
+++ b/server/dukang-api/src/modules/iam/auth.service.ts
@@ -217,8 +217,14 @@ export class AuthService {
if (existing) throw new BadRequestException('该手机号已绑定门店');
return;
}
- if (scene === SmsScene.PARTNER_LOGIN || scene === SmsScene.PARTNER_STAFF_ADD) {
+ if (scene === SmsScene.PARTNER_LOGIN) {
await this.assertPartnerAccountByPhone(phone);
+ return;
+ }
+ if (scene === SmsScene.PARTNER_STAFF_ADD) {
+ const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
+ if (existing) throw new BadRequestException('该手机号已被使用');
+ return;
}
}
@@ -602,6 +608,7 @@ export class AuthService {
name: account.name,
phone: account.phone,
isPrimary: account.isPrimary === 1,
+ staffRole: account.staffRole ?? undefined,
companyName: account.partner.companyName,
});
}
@@ -1152,6 +1159,7 @@ export class AuthService {
name: account.name,
phone: account.phone,
isPrimary: account.isPrimary === 1,
+ staffRole: account.staffRole ?? undefined,
companyName: account.partner.companyName,
});
}
diff --git a/server/dukang-api/src/modules/iam/dto/partner-staff.dto.ts b/server/dukang-api/src/modules/iam/dto/partner-staff.dto.ts
new file mode 100644
index 0000000..7ac31f6
--- /dev/null
+++ b/server/dukang-api/src/modules/iam/dto/partner-staff.dto.ts
@@ -0,0 +1,36 @@
+import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
+import { AccountStatus, PartnerStaffRole } from '@dukang/shared-types';
+
+export class CreatePartnerStaffDto {
+ @IsString()
+ @IsNotEmpty()
+ phone: string;
+
+ @IsString()
+ @IsNotEmpty()
+ name: string;
+
+ @IsString()
+ @IsIn(Object.values(PartnerStaffRole))
+ staffRole: string;
+
+ @IsString()
+ @IsNotEmpty()
+ code: string;
+}
+
+export class UpdatePartnerStaffDto {
+ @IsString()
+ @IsOptional()
+ name?: string;
+
+ @IsString()
+ @IsIn(Object.values(PartnerStaffRole))
+ @IsOptional()
+ staffRole?: string;
+
+ @IsString()
+ @IsIn(Object.values(AccountStatus))
+ @IsOptional()
+ status?: string;
+}
diff --git a/server/dukang-api/src/modules/iam/iam.module.ts b/server/dukang-api/src/modules/iam/iam.module.ts
index ed12d77..009d072 100644
--- a/server/dukang-api/src/modules/iam/iam.module.ts
+++ b/server/dukang-api/src/modules/iam/iam.module.ts
@@ -11,11 +11,14 @@ import {
} from './auth.controller';
import { UserAddressController } from './user-address.controller';
import { UserAddressService } from './user-address.service';
+import { PartnerStaffController } from './partner-staff.controller';
+import { PartnerStaffService } from './partner-staff.service';
import { AdminAuthController } from './admin-auth.controller';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
+import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
@Module({
imports: [
@@ -30,11 +33,12 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
UserAuthController,
ShopAuthController,
PartnerAuthController,
+ PartnerStaffController,
UserProfileController,
UserAddressController,
AdminAuthController,
],
- providers: [AuthService, UserAddressService, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard],
- exports: [AuthService, UserAddressService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard],
+ providers: [AuthService, UserAddressService, PartnerStaffService, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard],
+ exports: [AuthService, UserAddressService, PartnerStaffService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard],
})
export class IamModule {}
diff --git a/server/dukang-api/src/modules/iam/partner-staff.controller.ts b/server/dukang-api/src/modules/iam/partner-staff.controller.ts
new file mode 100644
index 0000000..b2181f9
--- /dev/null
+++ b/server/dukang-api/src/modules/iam/partner-staff.controller.ts
@@ -0,0 +1,36 @@
+import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
+import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
+import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
+import { CurrentUser } from '../../common/decorators/current-user.decorator';
+import { PartnerStaffService } from './partner-staff.service';
+import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
+
+@Controller('partner/staff')
+@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
+export class PartnerStaffController {
+ constructor(private readonly staffService: PartnerStaffService) {}
+
+ @Get()
+ list(@CurrentUser() user: AuthUser) {
+ return this.staffService.listStaff(user.actorId);
+ }
+
+ @Post()
+ create(@CurrentUser() user: AuthUser, @Body() dto: CreatePartnerStaffDto) {
+ return this.staffService.createStaff(user.actorId, dto);
+ }
+
+ @Put(':id')
+ update(
+ @CurrentUser() user: AuthUser,
+ @Param('id') id: string,
+ @Body() dto: UpdatePartnerStaffDto,
+ ) {
+ return this.staffService.updateStaff(user.actorId, BigInt(id), dto);
+ }
+
+ @Delete(':id')
+ remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
+ return this.staffService.deleteStaff(user.actorId, BigInt(id));
+ }
+}
diff --git a/server/dukang-api/src/modules/iam/partner-staff.service.ts b/server/dukang-api/src/modules/iam/partner-staff.service.ts
new file mode 100644
index 0000000..3b74c84
--- /dev/null
+++ b/server/dukang-api/src/modules/iam/partner-staff.service.ts
@@ -0,0 +1,120 @@
+import {
+ BadRequestException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { PartnerStaffRole, SmsScene } from '@dukang/shared-types';
+import { PrismaService } from '../../common/prisma/prisma.module';
+import { serializeBigInt } from '../../common/decorators/current-user.decorator';
+import { AuthService } from './auth.service';
+import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
+
+@Injectable()
+export class PartnerStaffService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly authService: AuthService,
+ ) {}
+
+ async listStaff(parentAccountId: bigint) {
+ const rows = await this.prisma.partnerAccount.findMany({
+ where: { parentAccountId },
+ orderBy: { createdAt: 'desc' },
+ });
+ return rows.map((row) => this.toStaffItem(row));
+ }
+
+ async createStaff(parentAccountId: bigint, dto: CreatePartnerStaffDto) {
+ const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
+ where: { id: parentAccountId },
+ });
+ if (parent.isPrimary !== 1) {
+ 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 } });
+ if (existing) throw new BadRequestException('该手机号已被使用');
+
+ await this.authService.verifySmsCode(phone, dto.code, SmsScene.PARTNER_STAFF_ADD);
+
+ const name = dto.name.trim();
+ if (!name) throw new BadRequestException('请填写真实姓名');
+
+ const account = await this.prisma.partnerAccount.create({
+ data: {
+ partnerId: parent.partnerId,
+ phone,
+ name,
+ staffRole: dto.staffRole as PartnerStaffRole,
+ isPrimary: 0,
+ parentAccountId: parent.id,
+ status: 'DISABLED',
+ },
+ });
+
+ return this.toStaffItem(account);
+ }
+
+ async updateStaff(parentAccountId: bigint, staffId: bigint, dto: UpdatePartnerStaffDto) {
+ const staff = await this.assertStaffOwned(parentAccountId, staffId);
+ const data: Record = {};
+ if (dto.name !== undefined) {
+ const name = dto.name.trim();
+ if (!name) throw new BadRequestException('请填写真实姓名');
+ data.name = name;
+ }
+ if (dto.staffRole !== undefined) {
+ data.staffRole = dto.staffRole as PartnerStaffRole;
+ }
+ if (dto.status !== undefined) {
+ data.status = dto.status;
+ }
+ const updated = await this.prisma.partnerAccount.update({
+ where: { id: staff.id },
+ data,
+ });
+ return this.toStaffItem(updated);
+ }
+
+ async deleteStaff(parentAccountId: bigint, staffId: bigint) {
+ const staff = await this.assertStaffOwned(parentAccountId, staffId);
+ await this.prisma.partnerAccount.delete({ where: { id: staff.id } });
+ return { ok: true };
+ }
+
+ private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
+ const staff = await this.prisma.partnerAccount.findFirst({
+ where: { id: staffId, parentAccountId },
+ });
+ if (!staff) throw new NotFoundException('子账号不存在');
+ return staff;
+ }
+
+ private toStaffItem(row: {
+ id: bigint;
+ name: string;
+ phone: string;
+ staffRole: string | null;
+ status: string;
+ lastLoginAt: Date | null;
+ }) {
+ return serializeBigInt({
+ id: row.id.toString(),
+ name: row.name,
+ phone: this.maskPhone(row.phone),
+ staffRole: row.staffRole ?? PartnerStaffRole.INTERNAL,
+ status: row.status,
+ lastLoginAt: row.lastLoginAt?.toISOString(),
+ });
+ }
+
+ private maskPhone(phone: string): string {
+ if (phone.length !== 11) return phone;
+ return `${phone.slice(0, 3)} **** ${phone.slice(7)}`;
+ }
+}
diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts
index 90343a5..225e248 100644
--- a/server/dukang-api/src/modules/settlement/settlement.controller.ts
+++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts
@@ -1,6 +1,7 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { SettlementService } from './settlement.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
+import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
@@ -8,7 +9,7 @@ import { HqOperationAction } from '../../common/hq-operation/hq-operation.consta
import { PrismaService } from '../../common/prisma/prisma.module';
@Controller('partner/settlement')
-@UseGuards(JwtAuthGuard)
+@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
export class SettlementController {
constructor(private readonly settlementService: SettlementService) {}
@@ -158,6 +159,7 @@ export class PartnerMeController {
name: account.name,
phone: account.phone,
isPrimary: account.isPrimary === 1,
+ staffRole: account.staffRole ?? undefined,
companyName: account.partner.companyName,
hasWechat: !!account.wxOpenId,
};
diff --git a/server/dukang-api/src/modules/store/store.controller.ts b/server/dukang-api/src/modules/store/store.controller.ts
index d0d092b..12de6b8 100644
--- a/server/dukang-api/src/modules/store/store.controller.ts
+++ b/server/dukang-api/src/modules/store/store.controller.ts
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nest
import { StoreService } from './store.service';
import { RedeemService } from '../redeem/redeem.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
+import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@Controller('stores')
@@ -34,6 +35,11 @@ export class PartnerStoreController {
return this.storeService.partnerListCities(user.actorId);
}
+ @Get('phone-available')
+ phoneAvailable(@Query('phone') phone?: string) {
+ return this.storeService.partnerCheckStorePhone(phone ?? '');
+ }
+
@Get(':id')
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.storeService.partnerGetStore(user.actorId, BigInt(id));
@@ -64,7 +70,7 @@ export class PartnerStoreController {
}
@Controller('partner/dashboard')
-@UseGuards(JwtAuthGuard)
+@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
export class PartnerDashboardController {
constructor(private readonly storeService: StoreService) {}
@@ -72,6 +78,30 @@ export class PartnerDashboardController {
dashboard(@CurrentUser() user: AuthUser) {
return this.storeService.partnerDashboard(user.actorId);
}
+
+ @Get('leaderboard')
+ leaderboard(
+ @CurrentUser() user: AuthUser,
+ @Query('period') period?: string,
+ ) {
+ const normalized =
+ period === 'month' || period === 'lastMonth' || period === 'total' ? period : 'total';
+ return this.storeService.partnerLeaderboard(user.actorId, normalized);
+ }
+}
+
+@Controller('partner/reports')
+@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
+export class PartnerReportController {
+ constructor(private readonly storeService: StoreService) {}
+
+ @Get('weekly')
+ weekly(
+ @CurrentUser() user: AuthUser,
+ @Query('startDate') startDate?: string,
+ ) {
+ return this.storeService.partnerWeeklyReport(user.actorId, startDate);
+ }
}
@Controller('shop/store')
diff --git a/server/dukang-api/src/modules/store/store.module.ts b/server/dukang-api/src/modules/store/store.module.ts
index 2971de1..a0792b5 100644
--- a/server/dukang-api/src/modules/store/store.module.ts
+++ b/server/dukang-api/src/modules/store/store.module.ts
@@ -5,6 +5,7 @@ import { AnalyticsModule } from '../analytics/analytics.module';
import { StoreService } from './store.service';
import {
PartnerDashboardController,
+ PartnerReportController,
PartnerStoreController,
PublicStoreController,
ShopDashboardController,
@@ -17,6 +18,7 @@ import {
PublicStoreController,
PartnerStoreController,
PartnerDashboardController,
+ PartnerReportController,
ShopStoreController,
ShopDashboardController,
],
diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts
index 699375e..7219094 100644
--- a/server/dukang-api/src/modules/store/store.service.ts
+++ b/server/dukang-api/src/modules/store/store.service.ts
@@ -1,9 +1,11 @@
import {
BadRequestException,
+ ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { loadAppConfig } from '@dukang/shared-types';
+import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat';
@@ -47,8 +49,14 @@ export class StoreService {
async partnerListStores(partnerAccountId: bigint) {
const account = await this.getPartnerAccount(partnerAccountId);
+ const where: { partnerId: bigint; id?: { in: bigint[] } } = { partnerId: account.partnerId };
+ if (this.isSubAccount(account)) {
+ const storeIds = await this.getStoreIdsCreatedByAccount(partnerAccountId);
+ if (storeIds.length === 0) return [];
+ where.id = { in: storeIds };
+ }
const stores = await this.prisma.store.findMany({
- where: { partnerId: account.partnerId },
+ where,
include: { category: true, coverResource: true },
orderBy: { createdAt: 'desc' },
});
@@ -62,6 +70,9 @@ export class StoreService {
include: { category: true, coverResource: true },
});
if (!store) throw new NotFoundException('门店不存在');
+ if (this.isSubAccount(account)) {
+ await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
+ }
const media = await this.prisma.commonResource.findMany({
where: {
@@ -85,16 +96,27 @@ export class StoreService {
return serializeBigInt(cities);
}
- async createStore(partnerAccountId: bigint, body: Record) {
- const account = await this.getPartnerAccount(partnerAccountId);
- const normalizedPhone = String(body.phone).trim();
+ async partnerCheckStorePhone(phone: string) {
+ const normalizedPhone = phone.trim();
+ if (!normalizedPhone) {
+ return { available: false, message: '请填写联系电话' };
+ }
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
- throw new BadRequestException('联系电话须为11位手机号');
+ return { available: false, message: '联系电话须为11位手机号' };
}
const existingAccount = await this.prisma.storeAccount.findUnique({
where: { phone: normalizedPhone },
});
- if (existingAccount) throw new BadRequestException('该手机号已绑定门店');
+ if (existingAccount) {
+ return { available: false, message: '该手机号已绑定门店' };
+ }
+ return { available: true };
+ }
+
+ async createStore(partnerAccountId: bigint, body: Record) {
+ const account = await this.getPartnerAccount(partnerAccountId);
+ const normalizedPhone = String(body.phone).trim();
+ await this.assertStorePhoneAvailable(normalizedPhone);
const city = await this.resolvePartnerCity(account.partnerId, body.cityId);
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
@@ -103,6 +125,10 @@ export class StoreService {
: [];
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
+ if (!coverUrl) throw new BadRequestException('请上传门头照');
+ if (envPhotoUrls.length < 3) throw new BadRequestException('请上传至少 3 张环境照片');
+ if (!contractUrl) throw new BadRequestException('请上传签约合同');
+
const store = await this.prisma.store.create({
data: {
cityId: city.id,
@@ -209,6 +235,7 @@ export class StoreService {
status: 'OPEN' | 'PAUSED' | 'CLOSED',
) {
const account = await this.getPartnerAccount(partnerAccountId);
+ this.assertPrimaryAccount(account);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerId: account.partnerId },
});
@@ -244,6 +271,7 @@ export class StoreService {
body: Record,
) {
const account = await this.getPartnerAccount(partnerAccountId);
+ this.assertPrimaryAccount(account);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerId: account.partnerId },
});
@@ -310,7 +338,13 @@ export class StoreService {
async partnerDashboard(partnerAccountId: bigint) {
const account = await this.getPartnerAccount(partnerAccountId);
- const [storeCount, orderCount, recentStores] = await Promise.all([
+ this.assertPrimaryAccount(account);
+ const partnerStoreIds = await this.prisma.store.findMany({
+ where: { partnerId: account.partnerId },
+ select: { id: true },
+ });
+ const storeIds = partnerStoreIds.map((s) => s.id);
+ const [storeCount, orderCount, recentStores, pendingAuditCount] = await Promise.all([
this.prisma.store.count({ where: { partnerId: account.partnerId } }),
this.prisma.order.count({
where: { city: { partnerId: account.partnerId } },
@@ -321,15 +355,322 @@ export class StoreService {
orderBy: { createdAt: 'desc' },
take: 10,
}),
+ storeIds.length === 0
+ ? Promise.resolve(0)
+ : this.prisma.commonEvent.count({
+ where: {
+ eventType: 'STORE_AUDIT',
+ status: 'PENDING',
+ refType: 'STORE',
+ refId: { in: storeIds },
+ },
+ }),
]);
return {
storeCount,
orderCount,
companyName: account.partner.companyName,
recentStores: serializeBigInt(recentStores),
+ pendingAuditCount,
};
}
+ async partnerLeaderboard(partnerAccountId: bigint, period: PartnerLeaderboardPeriod = 'total') {
+ const account = await this.getPartnerAccount(partnerAccountId);
+ this.assertPrimaryAccount(account);
+
+ const accounts = await this.prisma.partnerAccount.findMany({
+ where: { partnerId: account.partnerId },
+ orderBy: { id: 'asc' },
+ });
+
+ const { periodStart, periodEnd } = this.resolveLeaderboardPeriodRange(period);
+ const entries = await Promise.all(
+ accounts.map(async (row) => {
+ const totalStores = await this.prisma.commonEvent.count({
+ where: {
+ eventType: 'STORE_AUDIT',
+ param1: 'NEW',
+ actorType: 'PARTNER',
+ actorId: row.id,
+ refType: 'STORE',
+ },
+ });
+ const periodStores =
+ period === 'total'
+ ? totalStores
+ : await this.prisma.commonEvent.count({
+ where: {
+ eventType: 'STORE_AUDIT',
+ param1: 'NEW',
+ actorType: 'PARTNER',
+ actorId: row.id,
+ refType: 'STORE',
+ createdAt: { gte: periodStart, lt: periodEnd },
+ },
+ });
+ const staffRole = row.staffRole as PartnerStaffRole | null;
+ const roleLabel =
+ staffRole != null
+ ? PARTNER_STAFF_ROLE_LABELS[staffRole]
+ : row.isPrimary === 1
+ ? PARTNER_STAFF_ROLE_LABELS.PARTNER
+ : PARTNER_STAFF_ROLE_LABELS.INTERNAL;
+ return {
+ accountId: row.id.toString(),
+ name: row.name,
+ staffRole: staffRole ?? undefined,
+ roleLabel,
+ totalStores,
+ periodStores,
+ isSelf: row.id === partnerAccountId,
+ };
+ }),
+ );
+
+ entries.sort((a, b) => {
+ if (b.periodStores !== a.periodStores) return b.periodStores - a.periodStores;
+ if (b.totalStores !== a.totalStores) return b.totalStores - a.totalStores;
+ return a.accountId.localeCompare(b.accountId);
+ });
+
+ const list = entries.map((entry, index) => ({
+ ...entry,
+ rank: index + 1,
+ }));
+
+ const selfEntry = list.find((entry) => entry.isSelf);
+ let self: (typeof list)[number] & { beatPercent?: number } | undefined;
+ if (selfEntry) {
+ const below = list.filter((entry) => entry.rank > selfEntry.rank).length;
+ const beatPercent =
+ list.length <= 1 ? 0 : Math.round((below / (list.length - 1)) * 100);
+ self = { ...selfEntry, beatPercent };
+ }
+
+ return { period, list, self };
+ }
+
+ async partnerWeeklyReport(partnerAccountId: bigint, startDate?: string) {
+ const account = await this.getPartnerAccount(partnerAccountId);
+ this.assertPrimaryAccount(account);
+ const partnerId = account.partnerId;
+
+ const currentWeekStart = this.startOfWeekMonday(new Date());
+ const periodStart =
+ startDate && /^\d{4}-\d{2}-\d{2}$/.test(startDate)
+ ? this.parseLocalDate(startDate)
+ : currentWeekStart;
+ const periodEnd = this.addDays(periodStart, 7);
+ const prevPeriodStart = this.addDays(periodStart, -7);
+ const prevPeriodEnd = periodStart;
+
+ const newStoreTarget =
+ account.partner.weeklyStoreTarget ??
+ Number(process.env.PARTNER_WEEKLY_STORE_TARGET ?? 20);
+
+ const orderWhere = {
+ city: { partnerId },
+ payStatus: 'PAID' as const,
+ paidAt: { gte: periodStart, lt: periodEnd },
+ };
+ const prevOrderWhere = {
+ city: { partnerId },
+ payStatus: 'PAID' as const,
+ paidAt: { gte: prevPeriodStart, lt: prevPeriodEnd },
+ };
+
+ const [
+ gmvAgg,
+ orderCount,
+ totalStoreCount,
+ newStoreCount,
+ prevGmvAgg,
+ redeemGroups,
+ activeRedeems,
+ paidOrders,
+ ] = await Promise.all([
+ this.prisma.order.aggregate({ where: orderWhere, _sum: { payAmount: true } }),
+ this.prisma.order.count({ where: orderWhere }),
+ this.prisma.store.count({ where: { partnerId } }),
+ this.prisma.store.count({
+ where: { partnerId, createdAt: { gte: periodStart, lt: periodEnd } },
+ }),
+ this.prisma.order.aggregate({ where: prevOrderWhere, _sum: { payAmount: true } }),
+ this.prisma.redeemRecord.groupBy({
+ by: ['storeId'],
+ where: {
+ createdAt: { gte: periodStart, lt: periodEnd },
+ store: { partnerId },
+ },
+ _sum: { amount: true },
+ orderBy: { _sum: { amount: 'desc' } },
+ take: 10,
+ }),
+ this.prisma.redeemRecord.findMany({
+ where: {
+ createdAt: { gte: periodStart, lt: periodEnd },
+ store: { partnerId },
+ },
+ select: { storeId: true },
+ distinct: ['storeId'],
+ }),
+ this.prisma.order.findMany({
+ where: orderWhere,
+ select: { payAmount: true, paidAt: true },
+ }),
+ ]);
+
+ const gmv = Number(gmvAgg._sum.payAmount ?? 0);
+ const prevGmv = Number(prevGmvAgg._sum.payAmount ?? 0);
+ const gmvGrowthPercent =
+ prevGmv === 0 ? 0 : Math.round(((gmv - prevGmv) / prevGmv) * 1000) / 10;
+ const newStoreProgressPercent =
+ newStoreTarget <= 0
+ ? 0
+ : Math.min(100, Math.round((newStoreCount / newStoreTarget) * 100));
+
+ const dailyGmvMap = new Map();
+ for (let i = 0; i < 7; i += 1) {
+ dailyGmvMap.set(this.formatDateKey(this.addDays(periodStart, i)), 0);
+ }
+ for (const order of paidOrders) {
+ if (!order.paidAt) continue;
+ const key = this.formatDateKey(order.paidAt);
+ if (dailyGmvMap.has(key)) {
+ dailyGmvMap.set(key, (dailyGmvMap.get(key) ?? 0) + Number(order.payAmount));
+ }
+ }
+ const dailyGmv = Array.from({ length: 7 }, (_, i) => {
+ const day = this.addDays(periodStart, i);
+ const date = this.formatDateKey(day);
+ return {
+ date,
+ weekdayLabel: this.weekdayLabel(i),
+ amount: dailyGmvMap.get(date) ?? 0,
+ };
+ });
+
+ const rankStoreIds = redeemGroups.map((group) => group.storeId);
+ const rankStores =
+ rankStoreIds.length > 0
+ ? await this.prisma.store.findMany({
+ where: { id: { in: rankStoreIds } },
+ select: { id: true, name: true, intro: true, address: true },
+ })
+ : [];
+ const storeMap = new Map(rankStores.map((store) => [store.id.toString(), store]));
+ const storeRanking = redeemGroups.map((group, index) => {
+ const store = storeMap.get(group.storeId.toString());
+ const subtitleSource = store?.intro?.trim() || store?.address?.trim() || '';
+ const subtitle =
+ subtitleSource.length > 30 ? `${subtitleSource.slice(0, 30)}…` : subtitleSource;
+ return {
+ rank: index + 1,
+ storeId: group.storeId.toString(),
+ name: store?.name ?? '未知门店',
+ subtitle: subtitle || undefined,
+ redeemAmount: Number(group._sum.amount ?? 0),
+ };
+ });
+
+ const topStore = storeRanking[0];
+ let insight: string;
+ if (topStore && topStore.redeemAmount > 0) {
+ if (gmvGrowthPercent > 0) {
+ insight = `本周 GMV 增长 ${gmvGrowthPercent}%,主要得益于「${topStore.name}」的核销表现。建议关注同类高潜力门店。`;
+ } else if (gmvGrowthPercent < 0) {
+ insight = `本周 GMV 较上期下降 ${Math.abs(gmvGrowthPercent)}%,「${topStore.name}」仍为核销领先门店。建议复盘低效门店并复制头部经验。`;
+ } else {
+ insight = `本周 GMV 与上期持平,「${topStore.name}」核销表现领先。建议持续推动门店活跃。`;
+ }
+ } else {
+ insight = '本周暂无核销数据,建议关注门店培训与权益推广,激活辖区门店。';
+ }
+
+ const availablePeriods = [0, 1, 2, 3].map((offset) => {
+ const start = this.addDays(currentWeekStart, -7 * offset);
+ const end = this.addDays(start, 7);
+ return {
+ startDate: this.formatDateKey(start),
+ endDate: this.formatDateKey(this.addDays(end, -1)),
+ label: this.formatPeriodLabel(start, end),
+ };
+ });
+
+ return {
+ period: {
+ startDate: this.formatDateKey(periodStart),
+ endDate: this.formatDateKey(this.addDays(periodEnd, -1)),
+ label: this.formatPeriodLabel(periodStart, periodEnd),
+ },
+ availablePeriods,
+ summary: {
+ gmv,
+ gmvGrowthPercent,
+ activeStoreCount: activeRedeems.length,
+ totalStoreCount,
+ orderCount,
+ newStoreCount,
+ newStoreTarget,
+ newStoreProgressPercent,
+ },
+ dailyGmv,
+ storeRanking,
+ insight,
+ };
+ }
+
+ private resolveLeaderboardPeriodRange(period: PartnerLeaderboardPeriod) {
+ const now = new Date();
+ if (period === 'month') {
+ const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
+ return { periodStart, periodEnd: now };
+ }
+ if (period === 'lastMonth') {
+ const periodStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
+ const periodEnd = new Date(now.getFullYear(), now.getMonth(), 1);
+ return { periodStart, periodEnd };
+ }
+ return { periodStart: new Date(0), periodEnd: now };
+ }
+
+ private startOfWeekMonday(date: Date): Date {
+ const d = new Date(date);
+ d.setHours(0, 0, 0, 0);
+ const day = d.getDay();
+ const diff = day === 0 ? 6 : day - 1;
+ d.setDate(d.getDate() - diff);
+ return d;
+ }
+
+ private addDays(date: Date, days: number): Date {
+ const d = new Date(date);
+ d.setDate(d.getDate() + days);
+ return d;
+ }
+
+ private parseLocalDate(dateKey: string): Date {
+ const [year, month, day] = dateKey.split('-').map(Number);
+ return new Date(year, month - 1, day, 0, 0, 0, 0);
+ }
+
+ private formatDateKey(date: Date): string {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+ }
+
+ private formatPeriodLabel(start: Date, endExclusive: Date): string {
+ const end = this.addDays(endExclusive, -1);
+ return `${start.getMonth() + 1}月${start.getDate()}日 - ${end.getMonth() + 1}月${end.getDate()}日`;
+ }
+
+ private weekdayLabel(index: number): string {
+ return ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][index] ?? '';
+ }
+
private async getPartnerAccount(partnerAccountId: bigint) {
return this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
@@ -349,4 +690,49 @@ export class StoreService {
if (!city) throw new BadRequestException('合伙人未绑定开城');
return city;
}
+
+ private isSubAccount(account: { isPrimary: number }) {
+ return account.isPrimary !== 1;
+ }
+
+ private assertPrimaryAccount(account: { isPrimary: number }) {
+ if (this.isSubAccount(account)) {
+ throw new ForbiddenException('子账号无权执行此操作');
+ }
+ }
+
+ private async assertStorePhoneAvailable(phone: string) {
+ const result = await this.partnerCheckStorePhone(phone);
+ if (!result.available) {
+ throw new BadRequestException(result.message ?? '该手机号已绑定门店');
+ }
+ }
+
+ private async getStoreIdsCreatedByAccount(partnerAccountId: bigint): Promise {
+ const events = await this.prisma.commonEvent.findMany({
+ where: {
+ eventType: 'STORE_AUDIT',
+ param1: 'NEW',
+ actorType: 'PARTNER',
+ actorId: partnerAccountId,
+ refType: 'STORE',
+ },
+ select: { refId: true },
+ });
+ return events.map((event) => event.refId).filter((id): id is bigint => id != null);
+ }
+
+ private async assertStoreOwnedByAccount(partnerAccountId: bigint, storeId: bigint) {
+ const event = await this.prisma.commonEvent.findFirst({
+ where: {
+ eventType: 'STORE_AUDIT',
+ param1: 'NEW',
+ actorType: 'PARTNER',
+ actorId: partnerAccountId,
+ refType: 'STORE',
+ refId: storeId,
+ },
+ });
+ if (!event) throw new ForbiddenException('无权查看该门店');
+ }
}
diff --git a/server/dukang-api/src/modules/trade/trade.controller.ts b/server/dukang-api/src/modules/trade/trade.controller.ts
index f37ac6b..3dd2db4 100644
--- a/server/dukang-api/src/modules/trade/trade.controller.ts
+++ b/server/dukang-api/src/modules/trade/trade.controller.ts
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Put, Query, Req, UseGuards } from '
import type { Request } from 'express';
import { TradeService } from './trade.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
+import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@@ -67,7 +68,7 @@ export class TradeController {
}
@Controller('partner/orders')
-@UseGuards(JwtAuthGuard)
+@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
export class PartnerOrderController {
constructor(private readonly tradeService: TradeService) {}
@@ -96,7 +97,7 @@ export class PartnerOrderController {
}
@Controller('partner/reshipments')
-@UseGuards(JwtAuthGuard)
+@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
export class PartnerReshipmentController {
constructor(private readonly tradeService: TradeService) {}