新增商品编号搜索

This commit is contained in:
yml
2026-06-08 19:44:07 +08:00
parent c4420dc753
commit 38d7469965
15 changed files with 191 additions and 6 deletions
+1
View File
@@ -30,6 +30,7 @@ func (GameAccount) TableName() string {
type RentalListing struct { type RentalListing struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
ListingNo string `gorm:"column:listing_no;size:20;not null;uniqueIndex" json:"listing_no"`
AccountID uint64 `gorm:"not null;index" json:"account_id"` AccountID uint64 `gorm:"not null;index" json:"account_id"`
OwnerID uint64 `gorm:"not null;index" json:"owner_id"` OwnerID uint64 `gorm:"not null;index" json:"owner_id"`
Price float64 `gorm:"type:decimal(12,2);not null;default:0" json:"price"` Price float64 `gorm:"type:decimal(12,2);not null;default:0" json:"price"`
+3
View File
@@ -10,6 +10,7 @@ import (
type ListingDTO struct { type ListingDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
ListingNo string `json:"listing_no"`
AccountID uint64 `json:"account_id"` AccountID uint64 `json:"account_id"`
OwnerID uint64 `json:"owner_id"` OwnerID uint64 `json:"owner_id"`
OwnerPhone string `json:"owner_phone,omitempty"` OwnerPhone string `json:"owner_phone,omitempty"`
@@ -187,6 +188,7 @@ type ExternalUploadMeta struct {
type ExternalUploadResult struct { type ExternalUploadResult struct {
Index int `json:"index"` Index int `json:"index"`
ListingID uint64 `json:"listing_id,omitempty"` ListingID uint64 `json:"listing_id,omitempty"`
ListingNo string `json:"listing_no,omitempty"`
AccountID uint64 `json:"account_id,omitempty"` AccountID uint64 `json:"account_id,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
ReviewStatus string `json:"review_status,omitempty"` ReviewStatus string `json:"review_status,omitempty"`
@@ -195,6 +197,7 @@ type ExternalUploadResult struct {
type ExternalUploadResponse struct { type ExternalUploadResponse struct {
ListingID uint64 `json:"listing_id,omitempty"` ListingID uint64 `json:"listing_id,omitempty"`
ListingNo string `json:"listing_no,omitempty"`
AccountID uint64 `json:"account_id,omitempty"` AccountID uint64 `json:"account_id,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
ReviewStatus string `json:"review_status,omitempty"` ReviewStatus string `json:"review_status,omitempty"`
@@ -50,6 +50,10 @@ func initialPublishState(reviewRequired bool) (string, string, *time.Time) {
func (r *Repository) Create(ownerID uint64, req CreateRequest, reviewRequired bool) (*ListingDTO, error) { func (r *Repository) Create(ownerID uint64, req CreateRequest, reviewRequired bool) (*ListingDTO, error) {
var dto *ListingDTO var dto *ListingDTO
err := r.db.Transaction(func(tx *gorm.DB) error { err := r.db.Transaction(func(tx *gorm.DB) error {
listingNo, err := r.nextListingNo(tx, time.Now())
if err != nil {
return err
}
screenshots, err := marshalScreenshots(req.ScreenshotURLS) screenshots, err := marshalScreenshots(req.ScreenshotURLS)
if err != nil { if err != nil {
return err return err
@@ -78,6 +82,7 @@ func (r *Repository) Create(ownerID uint64, req CreateRequest, reviewRequired bo
price := normalizedListingPrice(req) price := normalizedListingPrice(req)
depositAmount := roundMoney(req.DepositAmount) depositAmount := roundMoney(req.DepositAmount)
listing := model.RentalListing{ listing := model.RentalListing{
ListingNo: listingNo,
AccountID: account.ID, AccountID: account.ID,
OwnerID: ownerID, OwnerID: ownerID,
Price: price, Price: price,
@@ -106,6 +111,10 @@ type externalUploadCreate struct {
func (r *Repository) CreateFromExternalUpload(upload externalUploadCreate, req CreateRequest) (*ListingDTO, error) { func (r *Repository) CreateFromExternalUpload(upload externalUploadCreate, req CreateRequest) (*ListingDTO, error) {
var dto *ListingDTO var dto *ListingDTO
err := r.db.Transaction(func(tx *gorm.DB) error { err := r.db.Transaction(func(tx *gorm.DB) error {
listingNo, err := r.nextListingNo(tx, time.Now())
if err != nil {
return err
}
admin, err := r.findActiveUploadAdmin(tx, upload.UploaderName) admin, err := r.findActiveUploadAdmin(tx, upload.UploaderName)
if err != nil { if err != nil {
return err return err
@@ -139,6 +148,7 @@ func (r *Repository) CreateFromExternalUpload(upload externalUploadCreate, req C
return err return err
} }
listing := model.RentalListing{ listing := model.RentalListing{
ListingNo: listingNo,
AccountID: account.ID, AccountID: account.ID,
OwnerID: owner.ID, OwnerID: owner.ID,
Price: normalizedListingPrice(req), Price: normalizedListingPrice(req),
@@ -1009,6 +1019,9 @@ func publicZoneCounts(items []ListingDTO) map[string]int64 {
func publicSearchText(item ListingDTO) string { func publicSearchText(item ListingDTO) string {
parts := []string{ parts := []string{
item.ListingNo,
strconv.FormatUint(item.ID, 10),
strconv.FormatUint(item.AccountID, 10),
item.Title, item.Title,
item.Description, item.Description,
item.RankLevel, item.RankLevel,
@@ -1303,6 +1316,39 @@ func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.
return &listing, &account, nil return &listing, &account, nil
} }
func (r *Repository) nextListingNo(tx *gorm.DB, now time.Time) (string, error) {
bizDate := now.Format("20060102")
if tx.Dialector.Name() == "mysql" {
if err := tx.Exec(`
INSERT INTO listing_no_sequences (biz_date, next_seq)
VALUES (?, LAST_INSERT_ID(1))
ON DUPLICATE KEY UPDATE next_seq = LAST_INSERT_ID(next_seq + 1)
`, bizDate).Error; err != nil {
return "", err
}
var seq int
if err := tx.Raw("SELECT LAST_INSERT_ID()").Scan(&seq).Error; err != nil {
return "", err
}
return fmt.Sprintf("%s%04d", bizDate, seq), nil
}
var maxNo string
if err := tx.Table("rental_listings").
Select("COALESCE(MAX(listing_no), '')").
Where("listing_no LIKE ?", bizDate+"%").
Scan(&maxNo).Error; err != nil {
return "", err
}
seq := 1
if len(maxNo) > len(bizDate) {
if parsed, err := strconv.Atoi(maxNo[len(bizDate):]); err == nil {
seq = parsed + 1
}
}
return fmt.Sprintf("%s%04d", bizDate, seq), nil
}
type listingRow struct { type listingRow struct {
model.RentalListing model.RentalListing
Title string Title string
@@ -1394,6 +1440,7 @@ func (row listingRow) toDTO() ListingDTO {
reviewStatus, reviewReason := normalizedReviewState(row.Status, row.ReviewStatus, row.ReviewReason) reviewStatus, reviewReason := normalizedReviewState(row.Status, row.ReviewStatus, row.ReviewReason)
return ListingDTO{ return ListingDTO{
ID: row.ID, ID: row.ID,
ListingNo: row.ListingNo,
AccountID: row.AccountID, AccountID: row.AccountID,
OwnerID: row.OwnerID, OwnerID: row.OwnerID,
OwnerPhone: row.OwnerPhone, OwnerPhone: row.OwnerPhone,
@@ -1427,6 +1474,7 @@ func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO {
reviewStatus, reviewReason := normalizedReviewState(listing.Status, listing.ReviewStatus, listing.ReviewReason) reviewStatus, reviewReason := normalizedReviewState(listing.Status, listing.ReviewStatus, listing.ReviewReason)
return &ListingDTO{ return &ListingDTO{
ID: listing.ID, ID: listing.ID,
ListingNo: listing.ListingNo,
AccountID: account.ID, AccountID: account.ID,
OwnerID: listing.OwnerID, OwnerID: listing.OwnerID,
Title: account.Title, Title: account.Title,
@@ -150,6 +150,7 @@ func (s *Service) ImportExternalUpload(req ExternalUploadRequest, meta ExternalU
result := ExternalUploadResult{ result := ExternalUploadResult{
Index: index, Index: index,
ListingID: dto.ID, ListingID: dto.ID,
ListingNo: dto.ListingNo,
AccountID: dto.AccountID, AccountID: dto.AccountID,
Status: dto.Status, Status: dto.Status,
ReviewStatus: dto.ReviewStatus, ReviewStatus: dto.ReviewStatus,
@@ -157,6 +158,7 @@ func (s *Service) ImportExternalUpload(req ExternalUploadRequest, meta ExternalU
results = append(results, result) results = append(results, result)
if len(items) == 1 { if len(items) == 1 {
resp.ListingID = dto.ID resp.ListingID = dto.ID
resp.ListingNo = dto.ListingNo
resp.AccountID = dto.AccountID resp.AccountID = dto.AccountID
resp.Status = dto.Status resp.Status = dto.Status
resp.ReviewStatus = dto.ReviewStatus resp.ReviewStatus = dto.ReviewStatus
@@ -0,0 +1,33 @@
-- 商品编号:用于前台展示、搜索和客服定位
ALTER TABLE rental_listings
ADD COLUMN listing_no VARCHAR(20) NULL COMMENT '商品编号,格式yyyyMMddNNNN' AFTER id;
UPDATE rental_listings AS l
JOIN (
SELECT
id,
CONCAT(
DATE_FORMAT(created_at, '%Y%m%d'),
LPAD(ROW_NUMBER() OVER (PARTITION BY DATE(created_at) ORDER BY id), 4, '0')
) AS generated_listing_no
FROM rental_listings
) AS seq ON seq.id = l.id
SET l.listing_no = seq.generated_listing_no
WHERE l.listing_no IS NULL OR l.listing_no = '';
ALTER TABLE rental_listings
MODIFY COLUMN listing_no VARCHAR(20) NOT NULL COMMENT '商品编号,格式yyyyMMddNNNN',
ADD UNIQUE KEY uk_rental_listings_listing_no (listing_no);
CREATE TABLE IF NOT EXISTS listing_no_sequences (
biz_date CHAR(8) PRIMARY KEY COMMENT '业务日期yyyyMMdd',
next_seq INT NOT NULL DEFAULT 0 COMMENT '当日已分配最大序号',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商品编号日序列表';
INSERT INTO listing_no_sequences (biz_date, next_seq)
SELECT DATE_FORMAT(created_at, '%Y%m%d') AS biz_date, COUNT(*) AS next_seq
FROM rental_listings
GROUP BY DATE_FORMAT(created_at, '%Y%m%d')
ON DUPLICATE KEY UPDATE next_seq = GREATEST(next_seq, VALUES(next_seq));
@@ -4,6 +4,7 @@ import type { ListingReviewStatus, ListingStatus } from '@/shared/types/status'
export interface Listing { export interface Listing {
id: number id: number
listing_no: string
account_id: number account_id: number
owner_id: number owner_id: number
owner_phone?: string owner_phone?: string
@@ -7,6 +7,7 @@ import {
getCoinWan, getCoinWan,
getDailyLoss, getDailyLoss,
getListingDisplayPrice, getListingDisplayPrice,
formatListingCode,
getListingResources, getListingResources,
getListingTitle, getListingTitle,
getLoginMethod, getLoginMethod,
@@ -100,8 +101,11 @@ function formatStatNumber(value: number) {
<div class="card-details"> <div class="card-details">
<div class="card-title-row"> <div class="card-title-row">
<h3>{{ getListingTitle(listing) }}</h3> <h3>{{ getListingTitle(listing) }}</h3>
<div class="card-title-meta">
<span class="listing-no">编号 {{ formatListingCode(listing) }}</span>
<span v-if="dailyLoss" class="daily-loss">日耗 {{ dailyLoss }}</span> <span v-if="dailyLoss" class="daily-loss">日耗 {{ dailyLoss }}</span>
</div> </div>
</div>
<div class="info-panel"> <div class="info-panel">
<div class="panel-title">基础资料</div> <div class="panel-title">基础资料</div>
@@ -252,6 +256,23 @@ function formatStatNumber(value: number) {
white-space: nowrap; white-space: nowrap;
} }
.card-title-meta {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.listing-no {
padding: 4px 10px;
border: 1px solid #dbeafe;
border-radius: 6px;
background: #eff6ff;
font-size: 12px;
font-weight: 800;
color: #2563eb;
}
.daily-loss { .daily-loss {
flex: 0 0 auto; flex: 0 0 auto;
padding: 4px 10px; padding: 4px 10px;
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { import {
defaultHomeAnnouncements, defaultHomeAnnouncements,
defaultHomeBanners, defaultHomeBanners,
@@ -24,6 +25,8 @@ const banners = ref<HomeBannerSlide[]>(defaultHomeBanners)
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions) const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
const sortBy = ref('recommended') const sortBy = ref('recommended')
const activeZone = ref('all') const activeZone = ref('all')
const route = useRoute()
const router = useRouter()
const { const {
filters, filters,
@@ -40,6 +43,8 @@ const {
computed(() => listings.value) computed(() => listings.value)
) )
filters.keyword = routeKeyword()
const { const {
loading, loading,
loadingMore, loadingMore,
@@ -50,6 +55,13 @@ const {
zoneCount, zoneCount,
} = useListingQuery(filters, sortBy, activeZone) } = useListingQuery(filters, sortBy, activeZone)
watch(
() => route.query.keyword,
() => {
filters.keyword = routeKeyword()
}
)
const statCards = computed(() => [ const statCards = computed(() => [
{ label: '可租账号', value: `${totalListings.value}`, hint: '当前筛选结果' }, { label: '可租账号', value: `${totalListings.value}`, hint: '当前筛选结果' },
{ {
@@ -127,6 +139,19 @@ function handleResetFilters() {
resetFilters() resetFilters()
activeZone.value = 'all' activeZone.value = 'all'
sortBy.value = 'recommended' sortBy.value = 'recommended'
router.replace({
path: '/',
query: {
...route.query,
keyword: undefined,
},
})
}
function routeKeyword() {
const keyword = route.query.keyword
if (Array.isArray(keyword)) return String(keyword[0] || '')
return typeof keyword === 'string' ? keyword : ''
} }
loadHome() loadHome()
@@ -323,6 +323,18 @@
white-space: nowrap; white-space: nowrap;
} }
.mobile-listing-no {
display: inline-flex;
width: fit-content;
margin-top: 6px;
padding: 3px 7px;
border-radius: 999px;
background: #eff6ff;
color: #2563eb;
font-size: 11px;
font-weight: 800;
}
.card-subtitle { .card-subtitle {
margin: 6px 0 10px; margin: 6px 0 10px;
color: #64748b; color: #64748b;
@@ -26,6 +26,7 @@ import {
getListingDisplayPrice, getListingDisplayPrice,
getListingSubtitle, getListingSubtitle,
getListingTitle, getListingTitle,
formatListingCode,
getLoginMethod, getLoginMethod,
getServerRegion, getServerRegion,
hasAcceleratedSaleRatio, hasAcceleratedSaleRatio,
@@ -377,7 +378,7 @@ function uniqueOptions(values: string[]) {
<van-search <van-search
v-model="searchValue" v-model="searchValue"
shape="round" shape="round"
placeholder="搜区服 / 段位" placeholder="搜编号 / 区服 / 段位"
class="home-search" class="home-search"
/> />
<button <button
@@ -510,6 +511,7 @@ function uniqueOptions(values: string[]) {
<div class="card-title-row"> <div class="card-title-row">
<h2>{{ getListingTitle(item) }}</h2> <h2>{{ getListingTitle(item) }}</h2>
</div> </div>
<div class="mobile-listing-no">编号 {{ formatListingCode(item) }}</div>
<p class="card-subtitle">{{ getListingSubtitle(item) }}</p> <p class="card-subtitle">{{ getListingSubtitle(item) }}</p>
<div class="card-badges-row"> <div class="card-badges-row">
<span class="server-badge">{{ getServerRegion(item) }}</span> <span class="server-badge">{{ getServerRegion(item) }}</span>
+36 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
ArrowDown, ArrowDown,
@@ -28,6 +28,7 @@ import { useSessionStore } from '@/stores/session'
const session = useSessionStore() const session = useSessionStore()
const router = useRouter() const router = useRouter()
const route = useRoute()
const navItems = [ const navItems = [
{ label: '首页', to: '/', icon: House }, { label: '首页', to: '/', icon: House },
@@ -50,6 +51,7 @@ const sellerServiceItems = [
const showUserDropdown = ref(false) const showUserDropdown = ref(false)
const supportLoading = ref(false) const supportLoading = ref(false)
const topSearchKeyword = ref('')
const realnameBadge = computed(() => { const realnameBadge = computed(() => {
switch (session.realnameStatus) { switch (session.realnameStatus) {
@@ -64,6 +66,33 @@ const realnameBadge = computed(() => {
} }
}) })
watch(
() => [route.path, route.query.keyword],
() => {
if (route.path !== '/') return
topSearchKeyword.value = normalizeRouteKeyword(route.query.keyword)
},
{ immediate: true }
)
function normalizeRouteKeyword(value: unknown) {
if (Array.isArray(value)) return String(value[0] || '')
return typeof value === 'string' ? value : ''
}
function handleTopSearchInput(event: Event) {
const value = (event.target as HTMLInputElement).value
topSearchKeyword.value = value
const keyword = value.trim()
router.replace({
path: '/',
query: {
...route.query,
keyword: keyword || undefined,
},
})
}
function handleLogout() { function handleLogout() {
session.logout() session.logout()
showUserDropdown.value = false showUserDropdown.value = false
@@ -108,7 +137,11 @@ async function handleSupportClick() {
<div class="pc-search"> <div class="pc-search">
<el-icon><Search /></el-icon> <el-icon><Search /></el-icon>
<input placeholder="搜索区服 / 段位 / 哈夫币数量 / 皮肤" /> <input
:value="topSearchKeyword"
placeholder="搜索编号 / 区服 / 段位 / 哈夫币数量 / 皮肤"
@input="handleTopSearchInput"
/>
</div> </div>
<div class="pc-user-actions"> <div class="pc-user-actions">
@@ -23,6 +23,7 @@ export function getCoinM(item: Listing) {
} }
export function formatListingCode(item: Listing) { export function formatListingCode(item: Listing) {
if (item.listing_no) return item.listing_no
return `SP${String(item.id).padStart(6, '0')}` return `SP${String(item.id).padStart(6, '0')}`
} }
+1
View File
@@ -9,6 +9,7 @@
.filter-header { .filter-header {
display: flex; display: flex;
gap: 16px;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 20px; margin-bottom: 20px;
+1
View File
@@ -23,6 +23,7 @@ export function getCoinM(item: Listing) {
} }
export function formatListingCode(item: Listing) { export function formatListingCode(item: Listing) {
if (item.listing_no) return item.listing_no
return `SP${String(item.id).padStart(6, '0')}` return `SP${String(item.id).padStart(6, '0')}`
} }
+1
View File
@@ -2,6 +2,7 @@
"extends": "@vue/tsconfig/tsconfig.dom.json", "extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
"noEmit": true,
"types": ["vite/client"], "types": ["vite/client"],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]