@@ -5,6 +5,7 @@ import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import UsersPage from './pages/UsersPage';
|
||||
import OrdersPage from './pages/OrdersPage';
|
||||
import BigScreenPage from './pages/BigScreenPage';
|
||||
import StorePackageAuditsPage from './pages/StorePackageAuditsPage';
|
||||
import StoresPage from './pages/StoresPage';
|
||||
import StoreRatingsPage from './pages/StoreRatingsPage';
|
||||
@@ -65,6 +66,14 @@ export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/orders/big-screen"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<BigScreenPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
|
||||
@@ -104,3 +104,605 @@ body,
|
||||
overflow: visible;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* v3.5.1 #1 订单大屏:发布会现场 */
|
||||
html:has(.big-screen-page),
|
||||
body:has(.big-screen-page),
|
||||
#root:has(.big-screen-page) {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.big-screen-page {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: radial-gradient(ellipse at 50% 18%, #163a6b 0%, #0b1e3a 42%, #061224 100%);
|
||||
color: #d6e8ff;
|
||||
padding: 20px 48px 24px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.big-screen-stars {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
radial-gradient(1px 1px at 8% 18%, rgba(255, 255, 255, 0.35), transparent),
|
||||
radial-gradient(1px 1px at 22% 72%, rgba(160, 210, 255, 0.28), transparent),
|
||||
radial-gradient(1.5px 1.5px at 78% 24%, rgba(255, 255, 255, 0.22), transparent),
|
||||
radial-gradient(1px 1px at 91% 68%, rgba(160, 210, 255, 0.3), transparent),
|
||||
radial-gradient(1px 1px at 46% 88%, rgba(255, 255, 255, 0.18), transparent),
|
||||
radial-gradient(1.5px 1.5px at 61% 12%, rgba(160, 210, 255, 0.25), transparent);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.big-screen-frame {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 14px 18px;
|
||||
}
|
||||
|
||||
.big-screen-corner {
|
||||
position: absolute;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid rgba(105, 192, 255, 0.7);
|
||||
}
|
||||
|
||||
.big-screen-corner--tl {
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.big-screen-corner--tr {
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-left: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.big-screen-corner--bl {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.big-screen-corner--br {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.big-screen-header {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(105, 192, 255, 0.18);
|
||||
}
|
||||
|
||||
.big-screen-brand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.big-screen-title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
letter-spacing: 4px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.big-screen-subtitle {
|
||||
font-size: 16px;
|
||||
color: rgba(200, 220, 245, 0.55);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.big-screen-live-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.big-screen-live-label {
|
||||
font-size: 18px;
|
||||
color: rgba(230, 244, 255, 0.88);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.big-screen-live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px 4px 10px;
|
||||
border: 1px solid rgba(64, 169, 255, 0.7);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: 2px;
|
||||
background: rgba(8, 28, 56, 0.55);
|
||||
}
|
||||
|
||||
.big-screen-live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ff4d4f;
|
||||
box-shadow: 0 0 8px #ff4d4f;
|
||||
animation: big-screen-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes big-screen-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.75);
|
||||
}
|
||||
}
|
||||
|
||||
.big-screen-hero {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 0 0 auto;
|
||||
text-align: center;
|
||||
padding: 16px 0 12px;
|
||||
}
|
||||
|
||||
.big-screen-clock {
|
||||
font-size: 84px;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 10px;
|
||||
color: #9fd3ff;
|
||||
text-shadow: 0 0 28px rgba(105, 192, 255, 0.45);
|
||||
}
|
||||
|
||||
.big-screen-date {
|
||||
margin-top: 8px;
|
||||
font-size: 16px;
|
||||
letter-spacing: 4px;
|
||||
color: rgba(180, 210, 240, 0.65);
|
||||
}
|
||||
|
||||
.big-screen-list {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.big-screen-list-head,
|
||||
.big-screen-row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr 160px 200px;
|
||||
align-items: center;
|
||||
column-gap: 16px;
|
||||
}
|
||||
|
||||
.big-screen-list-head {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 28px 10px 40px;
|
||||
font-size: 15px;
|
||||
color: rgba(170, 200, 230, 0.55);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.big-screen-list-body {
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.big-screen-track {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.big-screen-track.is-rolling {
|
||||
animation: big-screen-marquee-up var(--marquee-ms, 20s) linear infinite;
|
||||
}
|
||||
|
||||
.big-screen-track.is-paused {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@keyframes big-screen-marquee-up {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.big-screen-row {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
height: 68px;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 28px 0 40px;
|
||||
background: rgba(18, 48, 88, 0.45);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
font-size: 22px;
|
||||
color: #e8f4ff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.big-screen-row.is-latest {
|
||||
border-color: rgba(64, 169, 255, 0.85);
|
||||
box-shadow: 0 0 16px rgba(24, 144, 255, 0.28);
|
||||
}
|
||||
|
||||
.big-screen-row--t1 {
|
||||
background: rgba(22, 54, 98, 0.48);
|
||||
border-color: rgba(105, 192, 255, 0.16);
|
||||
}
|
||||
|
||||
.big-screen-row--t1 .big-screen-amount {
|
||||
color: #e6f4ff;
|
||||
}
|
||||
|
||||
.big-screen-row--t2.is-latest {
|
||||
border-color: rgba(255, 229, 143, 0.95);
|
||||
box-shadow: 0 0 18px rgba(255, 229, 143, 0.42);
|
||||
}
|
||||
|
||||
.big-screen-row--t3.is-latest {
|
||||
border-color: #ffd666;
|
||||
box-shadow: 0 0 22px rgba(250, 173, 20, 0.55);
|
||||
}
|
||||
|
||||
.big-screen-row--t2 {
|
||||
background: rgba(64, 48, 8, 0.42);
|
||||
border-color: rgba(255, 229, 143, 0.45);
|
||||
color: #fff7d6;
|
||||
}
|
||||
|
||||
.big-screen-row--t2 .big-screen-amount {
|
||||
color: #ffe58f;
|
||||
text-shadow: 0 0 10px rgba(255, 229, 143, 0.45);
|
||||
}
|
||||
|
||||
.big-screen-row--t3 {
|
||||
background: rgba(72, 48, 0, 0.5);
|
||||
border-color: rgba(250, 173, 20, 0.75);
|
||||
color: #ffe7a3;
|
||||
box-shadow: 0 0 18px rgba(250, 173, 20, 0.28);
|
||||
}
|
||||
|
||||
.big-screen-row--t3 .big-screen-amount {
|
||||
color: #ffd666;
|
||||
text-shadow: 0 0 14px rgba(255, 214, 102, 0.7);
|
||||
}
|
||||
|
||||
.big-screen-row-mark {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
height: 22px;
|
||||
margin-top: -11px;
|
||||
border-radius: 6px;
|
||||
background: #40a9ff;
|
||||
box-shadow: 0 0 10px #40a9ff;
|
||||
}
|
||||
|
||||
.big-screen-row--t2 .big-screen-row-mark {
|
||||
background: #ffe58f;
|
||||
box-shadow: 0 0 10px #ffe58f;
|
||||
}
|
||||
|
||||
.big-screen-row--t3 .big-screen-row-mark {
|
||||
background: #ffd666;
|
||||
box-shadow: 0 0 12px #ffd666;
|
||||
}
|
||||
|
||||
.big-screen-amount {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.big-screen-items {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.big-screen-time,
|
||||
.big-screen-phone {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.big-screen-empty {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
padding: 48px 20px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.big-screen-fx {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
background: rgba(4, 12, 28, 0.38);
|
||||
}
|
||||
|
||||
.big-screen-fx-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.big-screen-fx-shock {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255, 214, 102, 0.85);
|
||||
animation: big-screen-shock 1.1s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes big-screen-shock {
|
||||
0% {
|
||||
transform: scale(0.2);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(28);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.big-screen-fx-sweep {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
115deg,
|
||||
transparent 38%,
|
||||
rgba(255, 245, 200, 0.22) 50%,
|
||||
transparent 62%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
animation: big-screen-sweep 1.4s ease-out 0.15s both;
|
||||
}
|
||||
|
||||
.big-screen-fx-sweep--alt {
|
||||
animation-delay: 0.55s;
|
||||
background: linear-gradient(
|
||||
65deg,
|
||||
transparent 38%,
|
||||
rgba(255, 214, 102, 0.2) 50%,
|
||||
transparent 62%
|
||||
);
|
||||
}
|
||||
|
||||
.big-screen-fx-shock--late {
|
||||
animation-delay: 0.35s;
|
||||
border-color: rgba(255, 236, 179, 0.55);
|
||||
}
|
||||
|
||||
@keyframes big-screen-sweep {
|
||||
from {
|
||||
background-position: 120% 0;
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
background-position: -40% 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.big-screen-fx-card {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
min-width: 420px;
|
||||
max-width: 72vw;
|
||||
padding: 28px 40px 32px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
background: rgba(8, 22, 48, 0.82);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.big-screen-fx-card--t1 {
|
||||
border: 2px solid rgba(105, 192, 255, 0.85);
|
||||
box-shadow: 0 0 32px rgba(24, 144, 255, 0.45);
|
||||
animation: big-screen-card-in-t1 0.55s cubic-bezier(0.2, 0.9, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.big-screen-fx-card--t2 {
|
||||
border: 2px solid rgba(255, 229, 143, 0.95);
|
||||
box-shadow: 0 0 40px rgba(255, 214, 102, 0.5);
|
||||
animation: big-screen-card-in-t2 0.6s cubic-bezier(0.16, 1.2, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.big-screen-fx-card--t3 {
|
||||
min-width: 520px;
|
||||
padding: 36px 48px 40px;
|
||||
border: 3px solid #ffd666;
|
||||
box-shadow:
|
||||
0 0 28px rgba(255, 214, 102, 0.85),
|
||||
0 0 80px rgba(250, 173, 20, 0.45);
|
||||
animation: big-screen-card-in-t3 0.7s cubic-bezier(0.12, 1.4, 0.2, 1) both;
|
||||
}
|
||||
|
||||
@keyframes big-screen-card-in-t1 {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(80px) scale(0.92);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes big-screen-card-in-t2 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.6);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes big-screen-card-in-t3 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.4) rotate(-4deg);
|
||||
}
|
||||
55% {
|
||||
transform: scale(1.12) rotate(1deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0);
|
||||
}
|
||||
}
|
||||
|
||||
.big-screen-fx-kicker {
|
||||
font-size: 16px;
|
||||
letter-spacing: 8px;
|
||||
color: rgba(230, 244, 255, 0.7);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.big-screen-fx-card--t2 .big-screen-fx-kicker,
|
||||
.big-screen-fx-card--t3 .big-screen-fx-kicker {
|
||||
color: #ffe58f;
|
||||
}
|
||||
|
||||
.big-screen-fx-amount {
|
||||
font-size: 64px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 2px;
|
||||
line-height: 1.1;
|
||||
color: #e6f7ff;
|
||||
}
|
||||
|
||||
.big-screen-fx-card--t1 .big-screen-fx-amount {
|
||||
color: #91d5ff;
|
||||
text-shadow: 0 0 18px rgba(105, 192, 255, 0.6);
|
||||
}
|
||||
|
||||
.big-screen-fx-card--t2 .big-screen-fx-amount {
|
||||
color: #ffe58f;
|
||||
text-shadow: 0 0 20px rgba(255, 229, 143, 0.7);
|
||||
animation: big-screen-amount-pop 0.8s ease-out 0.15s both;
|
||||
}
|
||||
|
||||
.big-screen-fx-card--t3 .big-screen-fx-amount {
|
||||
font-size: 84px;
|
||||
color: #ffd666;
|
||||
text-shadow:
|
||||
0 0 12px #ffd666,
|
||||
0 0 36px rgba(250, 173, 20, 0.8);
|
||||
animation: big-screen-amount-flash 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes big-screen-amount-pop {
|
||||
0% {
|
||||
transform: scale(0.7);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes big-screen-amount-flash {
|
||||
0%,
|
||||
100% {
|
||||
filter: brightness(1);
|
||||
}
|
||||
50% {
|
||||
filter: brightness(1.35);
|
||||
}
|
||||
}
|
||||
|
||||
.big-screen-fx-items {
|
||||
margin-top: 12px;
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.big-screen-fx-meta {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 28px;
|
||||
font-size: 16px;
|
||||
color: rgba(210, 228, 250, 0.75);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.big-screen-clock {
|
||||
font-size: 56px;
|
||||
}
|
||||
.big-screen-list-head,
|
||||
.big-screen-row {
|
||||
grid-template-columns: 140px 1fr 120px 160px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.big-screen-fx-amount {
|
||||
font-size: 44px;
|
||||
}
|
||||
.big-screen-fx-card--t3 .big-screen-fx-amount {
|
||||
font-size: 56px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
|
||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||
import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
||||
import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
@@ -61,7 +61,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
label: '门店',
|
||||
children: [
|
||||
{ key: '/stores', label: '门店列表' },
|
||||
{ key: '/store-package-audits', label: '套餐审核' },
|
||||
{ key: '/store-package-audits', label: '审核通知' },
|
||||
{ key: '/store-ratings', label: '门店评价' },
|
||||
{ key: '/store-categories', label: '门店分类' },
|
||||
{ key: '/store-accounts', label: '门店账户' },
|
||||
@@ -244,14 +244,14 @@ function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): M
|
||||
.filter(Boolean) as MenuProps['items'];
|
||||
}
|
||||
|
||||
function attachPackageAuditBadge(items: MenuProps['items'], pendingCount: number): MenuProps['items'] {
|
||||
function attachAuditBadge(items: MenuProps['items'], pendingCount: number): MenuProps['items'] {
|
||||
if (!items) return items;
|
||||
return items.map((item) => {
|
||||
if (!item || typeof item !== 'object' || !('key' in item)) return item;
|
||||
if ('children' in item && Array.isArray(item.children)) {
|
||||
return {
|
||||
...item,
|
||||
children: attachPackageAuditBadge(item.children as MenuProps['items'], pendingCount),
|
||||
children: attachAuditBadge(item.children as MenuProps['items'], pendingCount),
|
||||
} as MenuItem;
|
||||
}
|
||||
if (String(item.key) === '/store-package-audits') {
|
||||
@@ -259,7 +259,7 @@ function attachPackageAuditBadge(items: MenuProps['items'], pendingCount: number
|
||||
...item,
|
||||
label: (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
套餐审核
|
||||
审核通知
|
||||
{pendingCount > 0 && <Badge count={pendingCount} size="small" />}
|
||||
</span>
|
||||
),
|
||||
@@ -276,11 +276,14 @@ export default function AdminLayout() {
|
||||
const location = useLocation();
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [packagePendingCount, setPackagePendingCount] = useState(0);
|
||||
const [auditPendingCount, setAuditPendingCount] = useState(0);
|
||||
|
||||
function refreshPackagePendingCount() {
|
||||
request<{ pendingCount: number }>('/admin/store-package-audits/summary')
|
||||
.then((data) => setPackagePendingCount(data.pendingCount ?? 0))
|
||||
function refreshAuditPendingCount() {
|
||||
Promise.all([
|
||||
request<{ pendingCount: number }>('/admin/store-package-audits/summary').catch(() => ({ pendingCount: 0 })),
|
||||
request<{ pendingCount: number; packagePendingCount?: number }>('/admin/store-info-change-requests/summary').catch(() => ({ pendingCount: 0 })),
|
||||
])
|
||||
.then(([pkg, info]) => setAuditPendingCount((pkg.pendingCount ?? 0) + (info.pendingCount ?? 0)))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
@@ -289,13 +292,17 @@ export default function AdminLayout() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshPackagePendingCount();
|
||||
refreshAuditPendingCount();
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChanged = () => refreshPackagePendingCount();
|
||||
const onChanged = () => refreshAuditPendingCount();
|
||||
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||
return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||
window.addEventListener(AUDIT_NOTICE_CHANGED_EVENT, onChanged);
|
||||
return () => {
|
||||
window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||
window.removeEventListener(AUDIT_NOTICE_CHANGED_EVENT, onChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -320,8 +327,8 @@ export default function AdminLayout() {
|
||||
!profile || profile.adminRole === 'SUPER_ADMIN'
|
||||
? MENU_ITEMS
|
||||
: filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
|
||||
return attachPackageAuditBadge(base, packagePendingCount);
|
||||
}, [profile, packagePendingCount]);
|
||||
return attachAuditBadge(base, auditPendingCount);
|
||||
}, [profile, auditPendingCount]);
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
export const PACKAGE_AUDIT_CHANGED_EVENT = 'admin:package-audit-changed';
|
||||
export const AUDIT_NOTICE_CHANGED_EVENT = 'dukang:audit-notice-changed';
|
||||
export const BIG_SCREEN_DEMO_CHANNEL = 'dukang-big-screen';
|
||||
export const BIG_SCREEN_DEMO_STORAGE_KEY = 'dukang:big-screen-demo';
|
||||
|
||||
export type BigScreenDemoOrder = {
|
||||
payAmount: number;
|
||||
items: string;
|
||||
userPhoneMasked: string;
|
||||
};
|
||||
|
||||
export type BigScreenDemoPayload = {
|
||||
type: 'demo';
|
||||
id: string;
|
||||
at: number;
|
||||
orders: BigScreenDemoOrder[];
|
||||
};
|
||||
|
||||
export function buildBigScreenDemoPayload(): BigScreenDemoPayload {
|
||||
return {
|
||||
type: 'demo',
|
||||
id: `demo-${Date.now()}`,
|
||||
at: Date.now(),
|
||||
orders: [
|
||||
{ payAmount: 1288, items: '杜康君酿 · 浓香型 × 6瓶', userPhoneMasked: '138****8888' },
|
||||
{ payAmount: 688, items: '杜康君酿 · 浓香型 × 2瓶', userPhoneMasked: '139****6666' },
|
||||
{ payAmount: 188, items: '杜康君酿 · 浓香型 × 1瓶', userPhoneMasked: '137****1888' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function triggerBigScreenDemo() {
|
||||
const payload = buildBigScreenDemoPayload();
|
||||
try {
|
||||
localStorage.setItem(BIG_SCREEN_DEMO_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch {
|
||||
/* ignore quota */
|
||||
}
|
||||
try {
|
||||
const ch = new BroadcastChannel(BIG_SCREEN_DEMO_CHANNEL);
|
||||
ch.postMessage(payload);
|
||||
ch.close();
|
||||
} catch {
|
||||
/* BroadcastChannel 不可用时靠 localStorage 冷启动 */
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function readPendingBigScreenDemo(maxAgeMs = 8000): BigScreenDemoPayload | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(BIG_SCREEN_DEMO_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as BigScreenDemoPayload;
|
||||
if (parsed?.type !== 'demo' || !parsed.id || !Array.isArray(parsed.orders)) return null;
|
||||
if (Date.now() - Number(parsed.at || 0) > maxAgeMs) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPendingBigScreenDemo() {
|
||||
try {
|
||||
localStorage.removeItem(BIG_SCREEN_DEMO_STORAGE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyPackageAuditChanged() {
|
||||
window.dispatchEvent(new Event(PACKAGE_AUDIT_CHANGED_EVENT));
|
||||
window.dispatchEvent(new Event(AUDIT_NOTICE_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
export function notifyAuditNoticeChanged() {
|
||||
notifyPackageAuditChanged();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { request } from '../lib/api';
|
||||
import {
|
||||
BIG_SCREEN_DEMO_CHANNEL,
|
||||
clearPendingBigScreenDemo,
|
||||
readPendingBigScreenDemo,
|
||||
type BigScreenDemoPayload,
|
||||
} from '../lib/admin-events';
|
||||
|
||||
export type BigScreenOrder = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
payAmount: number;
|
||||
items: string;
|
||||
createdAt: string;
|
||||
userPhoneMasked: string | null;
|
||||
};
|
||||
|
||||
type AmountTier = 1 | 2 | 3;
|
||||
|
||||
const POLL_MS = 3000;
|
||||
const ROW_MS = 2500;
|
||||
const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
|
||||
const TIER_DURATION: Record<AmountTier, number> = { 1: 3000, 2: 4500, 3: 6000 };
|
||||
|
||||
function pad(n: number) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatClock(d: Date) {
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function formatDateLine(d: Date) {
|
||||
return `${d.getFullYear()} / ${pad(d.getMonth() + 1)} / ${pad(d.getDate())} ${WEEKDAYS[d.getDay()]}`;
|
||||
}
|
||||
|
||||
function formatHm(iso: string): string {
|
||||
try {
|
||||
return formatClock(new Date(iso));
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAmount(n: number): string {
|
||||
return Number(n || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function amountTier(payAmount: number): AmountTier {
|
||||
if (payAmount >= 1000) return 3;
|
||||
if (payAmount >= 500) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function LiveClock() {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return (
|
||||
<div className="big-screen-hero">
|
||||
<div className="big-screen-clock">{formatClock(now)}</div>
|
||||
<div className="big-screen-date">{formatDateLine(now)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderRow({
|
||||
order,
|
||||
latest,
|
||||
}: {
|
||||
order: BigScreenOrder;
|
||||
latest?: boolean;
|
||||
}) {
|
||||
const tier = amountTier(order.payAmount);
|
||||
return (
|
||||
<div
|
||||
className={`big-screen-row big-screen-row--t${tier}${latest ? ' is-latest' : ''}`}
|
||||
>
|
||||
{latest ? <span className="big-screen-row-mark" /> : null}
|
||||
<span className="big-screen-amount">¥ {formatAmount(order.payAmount)}</span>
|
||||
<span className="big-screen-items">{order.items || '—'}</span>
|
||||
<span className="big-screen-time">{formatHm(order.createdAt)}</span>
|
||||
<span className="big-screen-phone">{order.userPhoneMasked || '—'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Particle = {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
rot: number;
|
||||
vr: number;
|
||||
w: number;
|
||||
h: number;
|
||||
color: string;
|
||||
life: number;
|
||||
kind: 'rect' | 'ribbon' | 'spark';
|
||||
rain?: boolean;
|
||||
};
|
||||
|
||||
const TIER_COLORS: Record<AmountTier, string[]> = {
|
||||
1: ['#e6f7ff', '#91d5ff', '#40a9ff', '#ffffff', '#69c0ff'],
|
||||
2: ['#fff1b8', '#ffe58f', '#ffd666', '#fffbe6', '#ffe7ba'],
|
||||
3: ['#ffd666', '#faad14', '#ffec3d', '#fff1b8', '#ff4d4f', '#ff7a45', '#ffffff'],
|
||||
};
|
||||
|
||||
function spawnParticles(tier: AmountTier, w: number, h: number): Particle[] {
|
||||
const colors = TIER_COLORS[tier];
|
||||
const count = tier === 3 ? 180 : tier === 2 ? 110 : 70;
|
||||
const out: Particle[] = [];
|
||||
const cx = w / 2;
|
||||
const cy = h * 0.42;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = (tier === 3 ? 8 : tier === 2 ? 6 : 4) * (0.4 + Math.random());
|
||||
const kind: Particle['kind'] =
|
||||
tier === 3 && Math.random() < 0.25 ? 'ribbon' : Math.random() < 0.2 ? 'spark' : 'rect';
|
||||
out.push({
|
||||
x: cx + (Math.random() - 0.5) * 80,
|
||||
y: cy + (Math.random() - 0.5) * 40,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed - (tier === 3 ? 6 : 3),
|
||||
rot: Math.random() * 360,
|
||||
vr: (Math.random() - 0.5) * 18,
|
||||
w: kind === 'ribbon' ? 10 + Math.random() * 16 : kind === 'spark' ? 2 : 6 + Math.random() * 8,
|
||||
h: kind === 'ribbon' ? 28 + Math.random() * 24 : kind === 'spark' ? 10 + Math.random() * 8 : 4 + Math.random() * 6,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
life: 1,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
if (tier >= 2) {
|
||||
for (let i = 0; i < (tier === 3 ? 80 : 40); i++) {
|
||||
out.push({
|
||||
x: Math.random() * w,
|
||||
y: -20 - Math.random() * 80,
|
||||
vx: (Math.random() - 0.5) * 1.4,
|
||||
vy: 3 + Math.random() * 5,
|
||||
rot: Math.random() * 360,
|
||||
vr: (Math.random() - 0.5) * 10,
|
||||
w: 5 + Math.random() * 8,
|
||||
h: 10 + Math.random() * 14,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
life: 1,
|
||||
kind: 'rect',
|
||||
rain: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => void }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const onDoneRef = useRef(onDone);
|
||||
onDoneRef.current = onDone;
|
||||
const tier = amountTier(order.payAmount);
|
||||
const [displayAmount, setDisplayAmount] = useState(tier === 3 ? 0 : order.payAmount);
|
||||
|
||||
useEffect(() => {
|
||||
const duration = TIER_DURATION[tier];
|
||||
const timer = window.setTimeout(() => onDoneRef.current(), duration);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [order.id, tier]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tier !== 3) return;
|
||||
const start = performance.now();
|
||||
const dur = 900;
|
||||
let raf = 0;
|
||||
const tick = (now: number) => {
|
||||
const p = Math.min(1, (now - start) / dur);
|
||||
const eased = 1 - (1 - p) ** 3;
|
||||
setDisplayAmount(Math.round(order.payAmount * eased));
|
||||
if (p < 1) raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [order.payAmount, order.id, tier]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const resize = () => {
|
||||
canvas.width = canvas.clientWidth * devicePixelRatio;
|
||||
canvas.height = canvas.clientHeight * devicePixelRatio;
|
||||
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
|
||||
};
|
||||
resize();
|
||||
const particles = spawnParticles(tier, canvas.clientWidth, canvas.clientHeight);
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
const started = performance.now();
|
||||
const duration = TIER_DURATION[tier];
|
||||
const gravity = tier === 3 ? 0.18 : 0.14;
|
||||
const loop = (now: number) => {
|
||||
const dt = Math.min(32, now - last) / 16.6;
|
||||
last = now;
|
||||
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
|
||||
for (const p of particles) {
|
||||
p.vy += gravity * dt;
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.rot += p.vr * dt;
|
||||
p.life -= (tier === 3 ? 0.0032 : 0.005) * dt;
|
||||
if (p.rain && (p.y > canvas.clientHeight + 30 || p.life <= 0) && now - started < duration - 400) {
|
||||
p.x = Math.random() * canvas.clientWidth;
|
||||
p.y = -16 - Math.random() * 60;
|
||||
p.vx = (Math.random() - 0.5) * 1.4;
|
||||
p.vy = 3 + Math.random() * 5;
|
||||
p.life = 1;
|
||||
}
|
||||
if (p.life <= 0) continue;
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate((p.rot * Math.PI) / 180);
|
||||
ctx.globalAlpha = Math.max(0, p.life);
|
||||
ctx.fillStyle = p.color;
|
||||
if (p.kind === 'spark') {
|
||||
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||
} else {
|
||||
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
const onResize = () => resize();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, [order.id, tier]);
|
||||
|
||||
return (
|
||||
<div className={`big-screen-fx big-screen-fx--t${tier}`} role="presentation">
|
||||
<canvas ref={canvasRef} className="big-screen-fx-canvas" />
|
||||
{tier === 3 ? <div className="big-screen-fx-shock" /> : null}
|
||||
{tier === 3 ? <div className="big-screen-fx-shock big-screen-fx-shock--late" /> : null}
|
||||
{tier >= 2 ? <div className="big-screen-fx-sweep" /> : null}
|
||||
{tier >= 2 ? <div className="big-screen-fx-sweep big-screen-fx-sweep--alt" /> : null}
|
||||
<div className={`big-screen-fx-card big-screen-fx-card--t${tier}`}>
|
||||
<div className="big-screen-fx-kicker">{tier === 3 ? '高额成交' : tier === 2 ? '大额成交' : '新成交'}</div>
|
||||
<div className="big-screen-fx-amount">¥ {formatAmount(displayAmount)}</div>
|
||||
<div className="big-screen-fx-items">{order.items || '—'}</div>
|
||||
<div className="big-screen-fx-meta">
|
||||
<span>{order.userPhoneMasked || '—'}</span>
|
||||
<span>{formatHm(order.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BigScreenPage() {
|
||||
const [items, setItems] = useState<BigScreenOrder[]>([]);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [celebrate, setCelebrate] = useState<BigScreenOrder | null>(null);
|
||||
const seenIdsRef = useRef<Set<string> | null>(null);
|
||||
const queueRef = useRef<BigScreenOrder[]>([]);
|
||||
const celebratingRef = useRef(false);
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const demoSeenRef = useRef<Set<string>>(new Set());
|
||||
const [viewportH, setViewportH] = useState(0);
|
||||
|
||||
const playNext = useCallback(() => {
|
||||
const next = queueRef.current.shift() ?? null;
|
||||
celebratingRef.current = !!next;
|
||||
setCelebrate(next);
|
||||
setPaused(!!next);
|
||||
}, []);
|
||||
|
||||
const enqueueNew = useCallback(
|
||||
(fresh: BigScreenOrder[]) => {
|
||||
if (!fresh.length) return;
|
||||
const ranked = [...fresh].sort((a, b) => {
|
||||
const td = amountTier(b.payAmount) - amountTier(a.payAmount);
|
||||
if (td !== 0) return td;
|
||||
return +new Date(b.createdAt) - +new Date(a.createdAt);
|
||||
});
|
||||
queueRef.current.push(...ranked);
|
||||
if (!celebratingRef.current) playNext();
|
||||
},
|
||||
[playNext],
|
||||
);
|
||||
|
||||
const playDemo = useCallback(
|
||||
(payload: BigScreenDemoPayload) => {
|
||||
if (!payload?.orders?.length || demoSeenRef.current.has(payload.id)) return;
|
||||
demoSeenRef.current.add(payload.id);
|
||||
const now = new Date().toISOString();
|
||||
const fake: BigScreenOrder[] = payload.orders.map((o, i) => ({
|
||||
id: `${payload.id}-${i}`,
|
||||
orderNo: `${payload.id}-${i}`,
|
||||
payAmount: o.payAmount,
|
||||
items: o.items,
|
||||
createdAt: now,
|
||||
userPhoneMasked: o.userPhoneMasked,
|
||||
}));
|
||||
setItems((prev) => [...fake, ...prev.filter((row) => !row.id.startsWith('demo-'))]);
|
||||
enqueueNew(fake);
|
||||
clearPendingBigScreenDemo();
|
||||
},
|
||||
[enqueueNew],
|
||||
);
|
||||
|
||||
const fetchData = useCallback(() => {
|
||||
return request<{ items: BigScreenOrder[] }>('/admin/orders/big-screen?limit=2000')
|
||||
.then((d) => {
|
||||
const list = d.items ?? [];
|
||||
setItems((prev) => {
|
||||
const demos = prev.filter((o) => o.id.startsWith('demo-'));
|
||||
return demos.length ? [...demos, ...list] : list;
|
||||
});
|
||||
setLoadError('');
|
||||
const seen = seenIdsRef.current;
|
||||
if (!seen) {
|
||||
seenIdsRef.current = new Set(list.map((o) => o.id));
|
||||
return;
|
||||
}
|
||||
const fresh = list.filter((o) => !seen.has(o.id));
|
||||
for (const o of fresh) seen.add(o.id);
|
||||
enqueueNew(fresh);
|
||||
})
|
||||
.catch((e) => {
|
||||
setLoadError(e instanceof Error ? e.message : '加载失败');
|
||||
});
|
||||
}, [enqueueNew]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchData();
|
||||
const id = setInterval(() => void fetchData(), POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = readPendingBigScreenDemo();
|
||||
if (pending) playDemo(pending);
|
||||
|
||||
let ch: BroadcastChannel | null = null;
|
||||
try {
|
||||
ch = new BroadcastChannel(BIG_SCREEN_DEMO_CHANNEL);
|
||||
ch.onmessage = (ev: MessageEvent<BigScreenDemoPayload>) => {
|
||||
if (ev.data?.type === 'demo' && ev.data.orders?.length) playDemo(ev.data);
|
||||
};
|
||||
} catch {
|
||||
ch = null;
|
||||
}
|
||||
return () => {
|
||||
ch?.close();
|
||||
};
|
||||
}, [playDemo]);
|
||||
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
const prevHtml = html.style.overflow;
|
||||
const prevBody = document.body.style.overflow;
|
||||
html.style.overflow = 'hidden';
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
html.style.overflow = prevHtml;
|
||||
document.body.style.overflow = prevBody;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = viewportRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => setViewportH(el.clientHeight);
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const unitItems = useMemo(() => {
|
||||
if (!items.length) return [];
|
||||
const rowH = 80;
|
||||
const visible = Math.max(1, Math.ceil((viewportH || 480) / rowH));
|
||||
const minCount = visible + 1;
|
||||
const unit: BigScreenOrder[] = [];
|
||||
while (unit.length < minCount) unit.push(...items);
|
||||
return unit;
|
||||
}, [items, viewportH]);
|
||||
|
||||
const trackItems = useMemo(() => unitItems.concat(unitItems), [unitItems]);
|
||||
const marqueeMs = Math.max(unitItems.length, 1) * ROW_MS;
|
||||
const rolling = items.length > 0 && unitItems.length > 0;
|
||||
const latestId = items[0]?.id;
|
||||
|
||||
return (
|
||||
<div className="big-screen-page">
|
||||
<div className="big-screen-stars" aria-hidden />
|
||||
<div className="big-screen-frame" aria-hidden>
|
||||
<span className="big-screen-corner big-screen-corner--tl" />
|
||||
<span className="big-screen-corner big-screen-corner--tr" />
|
||||
<span className="big-screen-corner big-screen-corner--bl" />
|
||||
<span className="big-screen-corner big-screen-corner--br" />
|
||||
</div>
|
||||
|
||||
<header className="big-screen-header">
|
||||
<div className="big-screen-brand">
|
||||
<h1 className="big-screen-title">杜康好客</h1>
|
||||
<span className="big-screen-subtitle">· 发布会现场</span>
|
||||
</div>
|
||||
<div className="big-screen-live-wrap">
|
||||
<span className="big-screen-live-label">实时成交</span>
|
||||
<span className="big-screen-live">
|
||||
<span className="big-screen-live-dot" />
|
||||
LIVE
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<LiveClock />
|
||||
|
||||
<div className="big-screen-list">
|
||||
<div className="big-screen-list-head">
|
||||
<span>下单金额</span>
|
||||
<span>下单商品</span>
|
||||
<span>下单时间</span>
|
||||
<span>下单人</span>
|
||||
</div>
|
||||
<div className="big-screen-list-body" ref={viewportRef}>
|
||||
{items.length === 0 ? (
|
||||
<div className="big-screen-empty">{loadError || '暂无订单'}</div>
|
||||
) : (
|
||||
<div
|
||||
className={`big-screen-track${rolling ? ' is-rolling' : ''}${paused ? ' is-paused' : ''}`}
|
||||
style={rolling ? ({ ['--marquee-ms']: `${marqueeMs}ms` } as CSSProperties) : undefined}
|
||||
>
|
||||
{trackItems.map((o, idx) => (
|
||||
<OrderRow
|
||||
key={`${o.id}-${idx}`}
|
||||
order={o}
|
||||
latest={idx % items.length === 0 && o.id === latestId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{celebrate ? (
|
||||
<CelebrateFx
|
||||
key={celebrate.id}
|
||||
order={celebrate}
|
||||
onDone={playNext}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
|
||||
import { triggerBigScreenDemo } from '../lib/admin-events';
|
||||
import {
|
||||
ADMIN_OPTIONS_PAGE_SIZE,
|
||||
DELIVERY_PROVIDER_LABELS,
|
||||
@@ -191,6 +192,7 @@ export default function OrdersPage() {
|
||||
const [trackOpen, setTrackOpen] = useState(false);
|
||||
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
||||
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
|
||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||
|
||||
const selectedOrders = useMemo(
|
||||
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
||||
@@ -542,6 +544,18 @@ export default function OrdersPage() {
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
||||
<Space>
|
||||
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}>大屏</Button>
|
||||
{isSuperAdmin ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.open('/orders/big-screen', 'dukang-big-screen');
|
||||
triggerBigScreenDemo();
|
||||
message.success('已发送测试成交动效(三级 / 二级 / 一级)');
|
||||
}}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
) : null}
|
||||
{canProxyOrder ? (
|
||||
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
||||
代下单
|
||||
|
||||
@@ -63,10 +63,12 @@ const KIND_COLORS: Record<StoreSettlementKind, string> = {
|
||||
export default function StoreBillsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
|
||||
const initialStoreId = searchParams.get('storeId') || '';
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({
|
||||
kind: initialKind,
|
||||
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
|
||||
storeId: initialStoreId,
|
||||
});
|
||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
@@ -97,8 +99,9 @@ export default function StoreBillsPage() {
|
||||
form.setFieldsValue({
|
||||
kind: filters.kind || undefined,
|
||||
status: filters.status || undefined,
|
||||
storeId: filters.storeId || undefined,
|
||||
});
|
||||
}, [filters.kind, filters.status, form]);
|
||||
}, [filters.kind, filters.status, filters.storeId, form]);
|
||||
|
||||
useEffect(() => {
|
||||
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
@@ -359,6 +362,7 @@ export default function StoreBillsPage() {
|
||||
initialValues={{
|
||||
kind: filters.kind || undefined,
|
||||
status: filters.status || undefined,
|
||||
storeId: filters.storeId || undefined,
|
||||
}}
|
||||
onFinish={(v: {
|
||||
kind?: string;
|
||||
|
||||
@@ -3,18 +3,23 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type {
|
||||
StoreInfoChangeFieldDiff,
|
||||
StoreInfoChangeRequestDto,
|
||||
StoreInfoChangeStatus,
|
||||
StorePackageAuditDetailDto,
|
||||
StorePackageAuditSummaryDto,
|
||||
StorePackageChangeRequestDto,
|
||||
@@ -22,7 +27,10 @@ import type {
|
||||
StorePackageItemDto,
|
||||
StorePackageViewDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||
import {
|
||||
STORE_INFO_CHANGE_STATUS_LABELS,
|
||||
normalizeStorePackageImageUrls,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
@@ -297,6 +305,289 @@ function PackageDetailCard({
|
||||
);
|
||||
}
|
||||
|
||||
const INFO_CHANGE_FIELD_LABELS: Record<string, string> = {
|
||||
name: '门店名称',
|
||||
contactPhone: '联系电话',
|
||||
address: '详细地址',
|
||||
intro: '门店简介',
|
||||
benefitUsageRule: '权益券使用规则',
|
||||
latitude: '纬度',
|
||||
longitude: '经度',
|
||||
openTime: '营业开始',
|
||||
closeTime: '营业结束',
|
||||
openTime2: '第二段开始',
|
||||
closeTime2: '第二段结束',
|
||||
avgPrice: '人均费用',
|
||||
};
|
||||
|
||||
function fmtFieldValue(field: string, v: unknown): string {
|
||||
if (v == null || String(v).trim() === '') return '(空)';
|
||||
if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') {
|
||||
return String(v);
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function InfoChangeAuditPanel({
|
||||
initialRequestId,
|
||||
}: {
|
||||
initialRequestId?: string | null;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [items, setItems] = useState<StoreInfoChangeRequestDto[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [status, setStatus] = useState<string>('PENDING');
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<(StoreInfoChangeRequestDto & { diffs?: StoreInfoChangeFieldDiff[] }) | null>(null);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
async function reload(nextPage = page, nextStatus = status) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const qs = new URLSearchParams({ page: String(nextPage), pageSize: '20' });
|
||||
if (nextStatus) qs.set('status', nextStatus);
|
||||
const [data, summary] = await Promise.all([
|
||||
request<{ items: StoreInfoChangeRequestDto[]; total: number; page?: number }>(
|
||||
`/admin/store-info-change-requests?${qs}`,
|
||||
),
|
||||
request<{ pendingCount: number }>('/admin/store-info-change-requests/summary'),
|
||||
]);
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
setPage(data.page ?? nextPage);
|
||||
setPendingCount(summary.pendingCount ?? 0);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void reload(1, status);
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialRequestId) void openDetail(initialRequestId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setDetailOpen(true);
|
||||
setDetailLoading(true);
|
||||
setDetail(null);
|
||||
try {
|
||||
const data = await request<StoreInfoChangeRequestDto & { diffs?: StoreInfoChangeFieldDiff[] }>(
|
||||
`/admin/store-info-change-requests/${id}`,
|
||||
);
|
||||
setDetail(data);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载详情失败');
|
||||
setDetailOpen(false);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
|
||||
try {
|
||||
await request(`/admin/store-info-change-requests/${id}/audit`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(
|
||||
action === 'REJECT' ? { action, rejectReason: reason } : { action },
|
||||
),
|
||||
});
|
||||
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
|
||||
setDetailOpen(false);
|
||||
notifyPackageAuditChanged();
|
||||
void reload(page, status);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<StoreInfoChangeRequestDto> = [
|
||||
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (v: StoreInfoChangeStatus) => (
|
||||
<Tag>{STORE_INFO_CHANGE_STATUS_LABELS[v] ?? v}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '变更字段',
|
||||
render: (_, row) =>
|
||||
row.changedFields?.length
|
||||
? row.changedFields.map((f) => (
|
||||
<Tag key={f}>{INFO_CHANGE_FIELD_LABELS[f] ?? f}</Tag>
|
||||
))
|
||||
: '—',
|
||||
},
|
||||
{
|
||||
title: '提交方',
|
||||
render: (_, row) =>
|
||||
row.submitterType === 'PARTNER' ? '合伙人' : row.submitterType === 'SHOP' ? '门店' : '总部',
|
||||
},
|
||||
{ title: '提交时间', dataIndex: 'createdAt', render: (v) => fmtTime(String(v)) },
|
||||
{
|
||||
title: '操作',
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => void openDetail(row.id)}>
|
||||
查看
|
||||
</Button>
|
||||
{row.status === 'PENDING' ? (
|
||||
<>
|
||||
<Button type="link" onClick={() => void audit(row.id, 'APPROVE')}>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
onClick={() => {
|
||||
setActiveId(row.id);
|
||||
setRejectReason('');
|
||||
setRejectOpen(true);
|
||||
}}
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
row.rejectReason || null
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
||||
<Button
|
||||
key={s || 'all'}
|
||||
type={status === s ? 'primary' : 'default'}
|
||||
onClick={() => setStatus(s)}
|
||||
>
|
||||
{s === 'PENDING' ? (
|
||||
<Badge count={pendingCount} size="small" offset={[8, -2]}>
|
||||
{STORE_INFO_CHANGE_STATUS_LABELS.PENDING}
|
||||
</Badge>
|
||||
) : s ? (
|
||||
STORE_INFO_CHANGE_STATUS_LABELS[s]
|
||||
) : (
|
||||
'全部'
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => void reload(p, status),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title={detail ? `${detail.storeName || detail.storeId} · 信息变更` : '信息变更详情'}
|
||||
width={680}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
extra={
|
||||
detail?.status === 'PENDING' ? (
|
||||
<Space>
|
||||
<Button onClick={() => void audit(detail.id, 'APPROVE')}>通过</Button>
|
||||
<Button
|
||||
danger
|
||||
onClick={() => {
|
||||
setActiveId(detail.id);
|
||||
setRejectReason('');
|
||||
setRejectOpen(true);
|
||||
}}
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{detailLoading ? (
|
||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||
) : detail ? (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Tag>{STORE_INFO_CHANGE_STATUS_LABELS[detail.status]}</Tag>
|
||||
<Typography.Text type="secondary">
|
||||
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : detail.submitterType === 'SHOP' ? '门店' : '总部'} · {fmtTime(detail.createdAt)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
{detail.rejectReason ? (
|
||||
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
||||
) : null}
|
||||
{detail.diffs && detail.diffs.length ? (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
{detail.diffs.map((d) => (
|
||||
<Descriptions.Item
|
||||
key={d.field}
|
||||
label={INFO_CHANGE_FIELD_LABELS[d.field] ?? d.field}
|
||||
>
|
||||
<span>
|
||||
<Typography.Text delete type="secondary">
|
||||
{fmtFieldValue(d.field, d.live)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary"> → </Typography.Text>
|
||||
<Typography.Text strong>
|
||||
{fmtFieldValue(d.field, d.proposed)}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
) : (
|
||||
<Typography.Text type="secondary">无变更字段明细</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="驳回信息变更"
|
||||
open={rejectOpen}
|
||||
onCancel={() => setRejectOpen(false)}
|
||||
onOk={() => {
|
||||
if (!activeId) return;
|
||||
if (!rejectReason.trim()) {
|
||||
message.warning('请填写驳回原因');
|
||||
return;
|
||||
}
|
||||
void audit(activeId, 'REJECT', rejectReason.trim());
|
||||
setRejectOpen(false);
|
||||
}}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={rejectReason}
|
||||
placeholder="驳回原因"
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StorePackageAuditsPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
|
||||
@@ -340,6 +631,9 @@ export default function StorePackageAuditsPage() {
|
||||
|
||||
// 从门店详情 / 门店列表跳转过来时,带 requestId 自动打开审核(对比)抽屉
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialTab = searchParams.get('tab') === 'info' ? 'info' : 'package';
|
||||
const [activeTab, setActiveTab] = useState<string>(initialTab);
|
||||
const infoRequestId = searchParams.get('infoRequestId');
|
||||
useEffect(() => {
|
||||
const rid = searchParams.get('requestId');
|
||||
if (rid) void openDetail(rid);
|
||||
@@ -438,33 +732,52 @@ export default function StorePackageAuditsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>套餐变更审核</Typography.Title>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
||||
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
|
||||
{s === 'PENDING' ? (
|
||||
<Badge count={pendingCount} size="small" offset={[8, -2]}>
|
||||
{HQ_PACKAGE_STATUS_LABELS.PENDING}
|
||||
</Badge>
|
||||
) : s ? (
|
||||
HQ_PACKAGE_STATUS_LABELS[s]
|
||||
) : (
|
||||
'全部'
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => void reload(p, status),
|
||||
}}
|
||||
<Typography.Title level={4}>审核通知</Typography.Title>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{
|
||||
key: 'package',
|
||||
label: '套餐审核',
|
||||
children: (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
||||
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
|
||||
{s === 'PENDING' ? (
|
||||
<Badge count={pendingCount} size="small" offset={[8, -2]}>
|
||||
{HQ_PACKAGE_STATUS_LABELS.PENDING}
|
||||
</Badge>
|
||||
) : s ? (
|
||||
HQ_PACKAGE_STATUS_LABELS[s]
|
||||
) : (
|
||||
'全部'
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => void reload(p, status),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'info',
|
||||
label: '信息变更',
|
||||
children: <InfoChangeAuditPanel initialRequestId={infoRequestId} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
|
||||
@@ -192,6 +192,8 @@ type StoreRow = {
|
||||
visibilityPhones?: string[];
|
||||
/** 该门店当前待审核套餐变更的 requestId(无则为空) */
|
||||
pendingPackageAuditId?: string | null;
|
||||
/** 该门店当前待审核信息变更的 requestId(无则为空) */
|
||||
pendingInfoChangeId?: string | null;
|
||||
isTest?: boolean;
|
||||
cityRef?: { name: string; code: string };
|
||||
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
||||
@@ -767,7 +769,7 @@ export default function StoresPage() {
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
width: 280,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0} wrap>
|
||||
@@ -791,6 +793,31 @@ export default function StoresPage() {
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{row.pendingInfoChangeId ? (
|
||||
<>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => navigate(`/store-package-audits?tab=info&infoRequestId=${row.pendingInfoChangeId}`)}
|
||||
>
|
||||
审核信息
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => navigate(`/store-package-audits?tab=info&infoRequestId=${row.pendingInfoChangeId}`)}
|
||||
>
|
||||
对比
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => navigate(`/finance/store-bills?storeId=${row.id}`)}
|
||||
>
|
||||
提现
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user