diff --git a/apps/backend/src/config/app-config-keys.ts b/apps/backend/src/config/app-config-keys.ts index dd34f61a..19068944 100644 --- a/apps/backend/src/config/app-config-keys.ts +++ b/apps/backend/src/config/app-config-keys.ts @@ -5,6 +5,7 @@ export const APP_CONFIG_KEYS = { workerFinance: 'worker_finance', workerPlatformNotifications: 'worker_platform_notifications', workerProductMatch: 'worker_product_match', + workerAnnouncement: 'worker_announcement', cloudtentaclesSources: 'cloudtentacles_sources', cloudtentaclesSession: 'cloudtentacles_session', cloudtentaclesOverrideRules: 'cloudtentacles_override_rules', diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index 968e2463..3040236f 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -17,6 +17,7 @@ import { deleteAdminWorkerLevel, getAdminWorkerFinanceConfig, getAdminWorkerProductMatchConfig, + getAdminWorkerAnnouncementConfig, getAdminWorkerPlatformNotificationConfig, getAdminWorkerPlatformSummary, getAdminWorkOrderEvents, @@ -46,6 +47,7 @@ import { resetAdminWorkerPassword, saveAdminWorkerFinanceConfig, saveAdminWorkerProductMatchConfig, + saveAdminWorkerAnnouncementConfig, saveAdminWorkerPlatformNotificationConfig, saveAdminWorkerWithdrawalAccount, saveAdminWorkCategory, @@ -249,6 +251,32 @@ router.get( }), ) +router.get( + '/worker-platform/announcement', + requireAdminRoles(['admin', 'operator']), + createJsonHandler(() => getAdminWorkerAnnouncementConfig(), { + successMessage: 'ok', + errorMessage: '读取公告失败', + scope: '[admin/worker-platform/announcement]', + }), +) + +router.post( + '/worker-platform/announcement', + requireAdminRoles(['admin']), + createJsonHandler((req) => saveAdminWorkerAnnouncementConfig(req.body || {}), { + successMessage: '公告已保存', + errorMessage: '保存公告失败', + scope: '[admin/worker-platform/announcement]', + audit: (_req, data) => ({ + action: 'worker_announcement_saved', + targetType: 'worker_announcement', + targetId: 'worker_announcement', + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }), +) + router.post( '/worker-platform/product-match-config', requireAdminRoles(['admin', 'operator']), diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index c78447e4..23dceac4 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -38,6 +38,7 @@ import { supplementWorkerAcceptedOrderEvidence, submitWorkerOrderAcceptance, getWorkerCancelRequestSummary, + getWorkerAnnouncement, getWorkerOrderFeedbackSummary, submitWorkerCancelRequest, submitWorkerOrderFeedback, @@ -454,6 +455,16 @@ router.get( }), ) +router.get( + '/announcement', + requireActiveWorker, + createRouteHandler((req) => getWorkerAnnouncement(getRequiredWorkerSession(req)), { + successMessage: 'ok', + errorMessage: '读取公告失败', + scope: '[worker/announcement]', + }), +) + router.get( '/orders/feedbacks', requireActiveWorker, diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index 2a69cea1..f6ec6468 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -84,6 +84,10 @@ import { getWorkerProductMatchConfig, saveWorkerProductMatchConfig, } from './worker-product-match-config-service.js' +import { + getWorkerAnnouncementConfig, + saveWorkerAnnouncementConfig, +} from './worker-announcement-config-service.js' import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js' import { createWorkOrderMaterialAdminNotification, @@ -1208,6 +1212,14 @@ export async function getAdminWorkerProductMatchConfig() { return getWorkerProductMatchConfig() } +export async function getAdminWorkerAnnouncementConfig() { + return getWorkerAnnouncementConfig() +} + +export async function saveAdminWorkerAnnouncementConfig(payload: JsonObject = {}) { + return saveWorkerAnnouncementConfig(payload) +} + export async function saveAdminWorkerProductMatchConfig(payload: JsonObject = {}) { return saveWorkerProductMatchConfig(payload) } diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index 0e895e14..fafdfd01 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -2,3 +2,4 @@ export * from './mappers.js' export * from './worker-service.js' export * from './admin-service.js' export * from './worker-product-match-config-service.js' +export * from './worker-announcement-config-service.js' diff --git a/apps/backend/src/services/worker-platform/worker-announcement-config-service.ts b/apps/backend/src/services/worker-platform/worker-announcement-config-service.ts new file mode 100644 index 00000000..975cce27 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-announcement-config-service.ts @@ -0,0 +1,47 @@ +import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' +import type { JsonObject } from '../../types/json.js' +import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js' + +export type WorkerAnnouncementConfig = { + enabled: boolean + content: string +} + +export function getWorkerAnnouncementConfig(): WorkerAnnouncementConfig { + return readAppConfigEntry({ + configKey: APP_CONFIG_KEYS.workerAnnouncement, + fallback: createDefaultWorkerAnnouncementConfig, + normalize: normalizeWorkerAnnouncementConfig, + }) +} + +export async function saveWorkerAnnouncementConfig( + rawValue: unknown, +): Promise { + return saveAppConfigEntry({ + configKey: APP_CONFIG_KEYS.workerAnnouncement, + value: rawValue, + normalize: normalizeWorkerAnnouncementConfig, + }) +} + +export function createDefaultWorkerAnnouncementConfig(): WorkerAnnouncementConfig { + return { + enabled: true, + content: '平台担保交易,拒绝私下转账/共享验证码,交接全程留痕。', + } +} + +export function normalizeWorkerAnnouncementConfig(rawValue: unknown): WorkerAnnouncementConfig { + const source = isPlainObject(rawValue) ? rawValue : {} + return { + enabled: source.enabled !== false, + content: String(source.content || '') + .trim() + .slice(0, 300), + } +} + +function isPlainObject(value: unknown): value is JsonObject { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 94f47575..7a77d1eb 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -84,6 +84,7 @@ import { addHours, nowIso } from '../../utils/time.js' import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js' import { getSmsProvider } from '../sms/index.js' import { getWorkerFinanceConfig } from './worker-finance-config-service.js' +import { getWorkerAnnouncementConfig } from './worker-announcement-config-service.js' import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js' import { createWorkerAcceptanceAdminNotification, @@ -1756,6 +1757,11 @@ export async function getWorkerCancelRequestSummary(session: WorkerSession) { return { risk, items: requests.map(mapWorkerCancelRequest) } } +export async function getWorkerAnnouncement(session: WorkerSession) { + requireActiveWorkerSession(session) + return getWorkerAnnouncementConfig() +} + export async function getWorkerOrderFeedbackSummary(session: WorkerSession) { requireActiveWorkerSession(session) return { items: (await listWorkerOrderFeedbacks(session.workerId)).map(mapWorkerOrderFeedback) } diff --git a/apps/frontend/src/components/worker/WorkerAnnouncementBanner.tsx b/apps/frontend/src/components/worker/WorkerAnnouncementBanner.tsx new file mode 100644 index 00000000..f765681a --- /dev/null +++ b/apps/frontend/src/components/worker/WorkerAnnouncementBanner.tsx @@ -0,0 +1,30 @@ +import { SoundOutlined } from '@ant-design/icons' +import { useQuery } from '@tanstack/react-query' + +import { fetchWorkerAnnouncement } from '@/services/worker' + +export default function WorkerAnnouncementBanner() { + const announcementQuery = useQuery({ + queryKey: ['worker-announcement'], + queryFn: fetchWorkerAnnouncement, + retry: false, + }) + const announcement = announcementQuery.data?.data + const content = String(announcement?.content || '').trim() + + if (!announcement?.enabled || !content) return null + + return ( +
+ + 公告 + +
+
+ {content} + +
+
+
+ ) +} diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index f54096d0..08b6e6fe 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -14,6 +14,7 @@ import { useIsMobile } from '@/utils/use-is-mobile' import { clearWorkerSession, getWorkerStatus, getWorkerUsername } from '@/utils/worker-auth' import { getWorkerToken } from '@/utils/worker-auth' import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents' +import WorkerAnnouncementBanner from '@/components/worker/WorkerAnnouncementBanner' const menuItems: MenuProps['items'] = [ { key: '/hall', icon: , label: '抢单大厅' }, @@ -89,6 +90,7 @@ export default function WorkerLayout() {
+
@@ -146,6 +148,7 @@ export default function WorkerLayout() { + diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index 27464360..cf892be5 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -7,6 +7,7 @@ import PageHeader from '@/components/admin/PageHeader' import { fetchAdminWorkerPlatformSummary } from '@/services/admin' import { getAdminRole } from '@/utils/admin-auth' import CategoriesPanel from './panels/CategoriesPanel' +import AnnouncementPanel from './panels/AnnouncementPanel' import CancelRequestsPanel from './panels/CancelRequestsPanel' import FinancePanel from './panels/FinancePanel' import FeedbacksPanel from './panels/FeedbacksPanel' @@ -56,6 +57,9 @@ export default function AdminWorkerPlatformPage() { { key: 'orders', label: '接单工单', children: }, { key: 'cancel-requests', label: '撤单审核', children: }, { key: 'feedbacks', label: '问题反馈', children: }, + ...(isAdmin + ? [{ key: 'announcement', label: '公告管理', children: }] + : []), { key: 'rules', label: '接单模板', children: }, { key: 'product-match', label: '匹配诊断', children: }, { key: 'categories', label: '分类', children: }, @@ -79,6 +83,7 @@ function loadActiveTabPreference(): string { return saved === 'orders' || saved === 'cancel-requests' || saved === 'feedbacks' || + saved === 'announcement' || saved === 'rules' || saved === 'product-match' || saved === 'categories' || @@ -99,6 +104,7 @@ function isWorkerPlatformTab(tab: string | null): tab is string { 'orders', 'cancel-requests', 'feedbacks', + 'announcement', 'rules', 'product-match', 'categories', diff --git a/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx b/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx new file mode 100644 index 00000000..594449c1 --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx @@ -0,0 +1,61 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { App, Button, Card, Form, Input, Space, Switch, Typography } from 'antd' +import { useEffect, useState } from 'react' + +import { fetchAdminWorkerAnnouncement, saveAdminWorkerAnnouncement } from '@/services/admin' +import type { WorkerAnnouncementConfig } from '@/types/worker-platform' + +export default function AnnouncementPanel() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [form] = Form.useForm() + const [saving, setSaving] = useState(false) + const announcementQuery = useQuery({ + queryKey: ['admin-worker-announcement'], + queryFn: fetchAdminWorkerAnnouncement, + }) + + useEffect(() => { + if (announcementQuery.data?.data) form.setFieldsValue(announcementQuery.data.data) + }, [announcementQuery.data, form]) + + async function saveAnnouncement(values: WorkerAnnouncementConfig) { + setSaving(true) + try { + await saveAdminWorkerAnnouncement({ + enabled: values.enabled !== false, + content: String(values.content || '').trim(), + }) + await queryClient.invalidateQueries({ queryKey: ['admin-worker-announcement'] }) + message.success('公告已保存') + } catch (error) { + message.error(error instanceof Error ? error.message : '保存公告失败') + } finally { + setSaving(false) + } + } + + return ( + +
+ + + + + + + + + 关闭后打手端不显示公告栏。 + +
+
+ ) +} diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 569791f9..2959af75 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -17,6 +17,7 @@ import type { WorkerOrderFeedback, WorkerPlatformNotificationConfig, WorkerProductMatchConfig, + WorkerAnnouncementConfig, WorkerLevel, WorkerListResponse, WorkerUser, @@ -41,6 +42,14 @@ export function fetchAdminWorkerPlatformSummary() { }>('/api/v1/admin/worker-platform/summary') } +export function fetchAdminWorkerAnnouncement() { + return apiGet('/api/v1/admin/worker-platform/announcement') +} + +export function saveAdminWorkerAnnouncement(payload: WorkerAnnouncementConfig) { + return apiPost('/api/v1/admin/worker-platform/announcement', payload) +} + export function fetchAdminWorkerCancelRequests( status: 'pending' | 'approved' | 'rejected' = 'pending', ) { diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 9330fa54..5902a158 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -8,6 +8,7 @@ import type { WorkerFinanceRequest, WorkerCancelRequest, WorkerCancelRequestRisk, + WorkerAnnouncementConfig, WorkerOrderFeedback, WorkerHallOrdersResponse, WorkerLeaderboardResponse, @@ -130,6 +131,10 @@ export function fetchWorkerProfile() { return apiGet('/api/v1/worker/profile') } +export function fetchWorkerAnnouncement() { + return apiGet('/api/v1/worker/announcement') +} + export function fetchWorkerWalletLedgers(params?: Record) { return apiGet>( '/api/v1/worker/profile/wallet-ledgers', diff --git a/apps/frontend/src/styles/worker.css b/apps/frontend/src/styles/worker.css index 4e863131..3deb807e 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -20,6 +20,59 @@ flex-wrap: wrap; } +.worker-announcement { + display: flex; + align-items: center; + gap: 10px; + height: 42px; + margin: 0 0 16px; + padding: 0 14px; + overflow: hidden; + border: 1px solid #ffe0a3; + border-radius: 8px; + background: #fffaf0; + color: #a86600; +} + +.worker-announcement-label { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 6px; + background: #f59f00; + color: #fff; + font-size: 13px; + font-weight: 600; +} + +.worker-announcement-viewport { + min-width: 0; + overflow: hidden; + white-space: nowrap; +} + +.worker-announcement-track { + display: inline-flex; + min-width: max-content; + gap: 56px; + animation: worker-announcement-scroll 22s linear infinite; +} + +.worker-announcement:hover .worker-announcement-track { + animation-play-state: paused; +} + +@keyframes worker-announcement-scroll { + from { + transform: translateX(0); + } + to { + transform: translateX(calc(-50% - 28px)); + } +} + .worker-page-head h3, .worker-page-head > .ant-typography { margin: 0 !important; @@ -2023,6 +2076,19 @@ body { } @media (max-width: 720px) { + .worker-announcement { + height: 38px; + margin-bottom: 10px; + padding: 0 10px; + gap: 8px; + font-size: 13px; + } + + .worker-announcement-label { + padding: 3px 6px; + font-size: 12px; + } + .worker-page-head { align-items: stretch; flex-direction: column; diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 3ad64a19..bd9b3c61 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -185,6 +185,11 @@ export type WorkerProductMatchConfig = { legacyFallbackEnabled: boolean } +export type WorkerAnnouncementConfig = { + enabled: boolean + content: string +} + export type WorkerWithdrawalAccount = { accountChannel: string accountName: string