feat(expert): 大神服务包自助维护(新增/编辑/删除/上下架)+ 去掉演示服务包
- 后端:新增 POST/PUT/DELETE /api/companions/:id/services[...](仅本人大神或管理员),含名称/单价区间(平台定价规范)/长度/内容安全/每人20个上限校验 - 仓储:createExpertService/updateExpertService/deleteExpertService/countExpertServices/getCompanionByUserId;移除"首次读取自动生成 3 个演示服务包",并在建表后一次性清理历史演示数据 - 新增 GET /api/expert/me:工作台按登录用户解析真实大神档案(不再固定演示 id 101) - 小程序:api.js 增加 getMyExpertProfile/createExpertService/updateExpertService/deleteExpertService;工作台新增「新增服务」入口、每项「编辑/删除」按钮与服务包表单弹窗,收益/服务统计改用真实大神 id - 新增回归护栏 expert-service-crud.spec.ts(93 套件/662 用例全绿);冒烟 9/9(含跨大神越权 403、低价 400)
这个提交包含在:
@@ -1034,6 +1034,44 @@ export async function updateExpertServiceStatus(expertId = 101, serviceId, statu
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户的大神档案(大神工作台使用)
|
||||
*/
|
||||
export async function getMyExpertProfile(userId) {
|
||||
const uid = normalizeUserId(userId);
|
||||
try {
|
||||
const res = await http.get(`/expert/me?userId=${uid}`);
|
||||
return res || null;
|
||||
} catch (err) {
|
||||
console.warn('[API] 获取大神档案异常:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增服务包(仅本人大神)
|
||||
*/
|
||||
export async function createExpertService(expertId, payload = {}) {
|
||||
const res = await http.post(`/companions/${Number(expertId)}/services`, { ...payload, userId: normalizeUserId(payload.userId) });
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑服务包(整体覆盖,仅本人大神)
|
||||
*/
|
||||
export async function updateExpertService(expertId, serviceId, payload = {}) {
|
||||
const res = await http.put(`/companions/${Number(expertId)}/services/${serviceId}`, { ...payload, userId: normalizeUserId(payload.userId) });
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除服务包(仅本人大神)
|
||||
*/
|
||||
export async function deleteExpertService(expertId, serviceId) {
|
||||
const res = await http.delete(`/companions/${Number(expertId)}/services/${serviceId}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户收藏的陪玩列表
|
||||
*/
|
||||
|
||||
@@ -156,6 +156,7 @@
|
||||
<text class="sec-title">陪玩服务管理 ({{ expertServices.length }})</text>
|
||||
</view>
|
||||
<view class="service-actions">
|
||||
<button class="btn-wd-records btn-active" @click="openServiceForm()">新增服务</button>
|
||||
<button class="btn-wd-records btn-active" @click="goToWithdrawPage">收益提现</button>
|
||||
<button class="btn-wd-records btn-active" @click="openWithdrawRecords">提现记录</button>
|
||||
</view>
|
||||
@@ -173,6 +174,10 @@
|
||||
<view class="srv-price-row">
|
||||
<text class="srv-price">¥{{ srv.price }}</text>
|
||||
<text class="srv-unit">/{{ srv.unit }}</text>
|
||||
<view class="srv-ops">
|
||||
<text class="srv-op btn-active" @click.stop="openServiceForm(srv)">编辑</text>
|
||||
<text class="srv-op srv-op-danger btn-active" @click.stop="removeService(srv)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -386,6 +391,48 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 服务包新增/编辑表单弹窗 -->
|
||||
<view class="verify-modal-mask" v-if="serviceFormVisible" @click="serviceFormVisible = false">
|
||||
<view class="verify-modal-content" @click.stop="stopPropagation">
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">{{ serviceForm.id ? '编辑服务包' : '新增服务包' }}</text>
|
||||
</view>
|
||||
<view class="service-form-body">
|
||||
<view class="form-row">
|
||||
<text class="form-label">服务名称</text>
|
||||
<input class="form-input" v-model="serviceForm.title" maxlength="40" placeholder="如:排位保星连胜局" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">服务类目</text>
|
||||
<input class="form-input" v-model="serviceForm.category" maxlength="32" placeholder="如:王者荣耀 / 同城陪伴" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">单价(元/{{ serviceForm.unit || '小时' }})</text>
|
||||
<input class="form-input" v-model="serviceForm.price" type="digit" placeholder="如:45" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">计价单位</text>
|
||||
<input class="form-input" v-model="serviceForm.unit" maxlength="16" placeholder="小时 / 局 / 次" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">服务模式</text>
|
||||
<input class="form-input" v-model="serviceForm.mode" maxlength="32" placeholder="如:排位赛 / 一对一复盘" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">服务说明</text>
|
||||
<textarea class="form-textarea" v-model="serviceForm.desc" maxlength="200" placeholder="介绍服务内容与亮点(最多 200 字)" />
|
||||
</view>
|
||||
<text class="form-hint">单价需符合平台定价规范(可在平台配置中查看上下限)。</text>
|
||||
</view>
|
||||
<view class="modal-actions">
|
||||
<button class="btn-modal-cancel btn-active" @click="serviceFormVisible = false">取消</button>
|
||||
<button class="btn-modal-primary btn-active" :disabled="serviceSaving" @click="submitServiceForm">
|
||||
{{ serviceSaving ? '提交中…' : '保存' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部切回玩家端 -->
|
||||
<view class="bottom-switch-wrap">
|
||||
<button class="btn-back-player btn-active" @click="switchBackToPlayer">
|
||||
@@ -414,6 +461,10 @@ import {
|
||||
getExpertRevenueTrends,
|
||||
getExpertServices,
|
||||
updateExpertServiceStatus,
|
||||
getMyExpertProfile,
|
||||
createExpertService,
|
||||
updateExpertService,
|
||||
deleteExpertService,
|
||||
getUserWithdrawals
|
||||
} from '@/common/api.js';
|
||||
|
||||
@@ -491,20 +542,113 @@ const maxTrendValue = computed(() => {
|
||||
|
||||
// 大神服务包管理状态
|
||||
const expertServices = ref([]);
|
||||
const serviceFormVisible = ref(false);
|
||||
const serviceSaving = ref(false);
|
||||
const serviceForm = ref({ id: '', title: '', category: '', price: '', unit: '小时', mode: '', desc: '' });
|
||||
|
||||
const currentExpertId = () => Number(expert.value.id) || 0;
|
||||
|
||||
// 解析当前登录用户的大神档案(避免工作台固定使用演示 id)
|
||||
const loadExpertProfile = async () => {
|
||||
try {
|
||||
const profile = await getMyExpertProfile();
|
||||
if (profile && profile.id) {
|
||||
expert.value = {
|
||||
...expert.value,
|
||||
id: Number(profile.id),
|
||||
nickname: profile.name || expert.value.nickname,
|
||||
avatar: profile.avatar || expert.value.avatar,
|
||||
track: profile.track || '',
|
||||
hourlyRate: Number(profile.hourlyRate) || expert.value.hourlyRate
|
||||
};
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
const loadExpertServices = async () => {
|
||||
try {
|
||||
const list = await getExpertServices(101);
|
||||
if (list && list.length > 0) {
|
||||
expertServices.value = list;
|
||||
const list = await getExpertServices(currentExpertId());
|
||||
expertServices.value = Array.isArray(list) ? list : [];
|
||||
} catch (_) {
|
||||
expertServices.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const openServiceForm = (srv) => {
|
||||
if (srv) {
|
||||
serviceForm.value = {
|
||||
id: srv.id,
|
||||
title: srv.title,
|
||||
category: srv.category,
|
||||
price: String(srv.price),
|
||||
unit: srv.unit || '小时',
|
||||
mode: srv.mode || '',
|
||||
desc: srv.desc || ''
|
||||
};
|
||||
} else {
|
||||
serviceForm.value = { id: '', title: '', category: '', price: '', unit: '小时', mode: '', desc: '' };
|
||||
}
|
||||
serviceFormVisible.value = true;
|
||||
};
|
||||
|
||||
const submitServiceForm = async () => {
|
||||
const f = serviceForm.value;
|
||||
if (!String(f.title || '').trim()) {
|
||||
uni.showToast({ title: '请填写服务名称', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const price = Number(f.price);
|
||||
if (!price || price <= 0) {
|
||||
uni.showToast({ title: '请填写有效单价', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
title: String(f.title).trim(),
|
||||
category: String(f.category || '').trim(),
|
||||
price,
|
||||
unit: String(f.unit || '').trim() || '小时',
|
||||
mode: String(f.mode || '').trim(),
|
||||
desc: String(f.desc || '').trim()
|
||||
};
|
||||
serviceSaving.value = true;
|
||||
try {
|
||||
if (f.id) {
|
||||
await updateExpertService(currentExpertId(), f.id, payload);
|
||||
} else {
|
||||
await createExpertService(currentExpertId(), payload);
|
||||
}
|
||||
} catch (_) {}
|
||||
serviceFormVisible.value = false;
|
||||
uni.showToast({ title: f.id ? '服务包已更新' : '服务包已创建', icon: 'none' });
|
||||
await loadExpertServices();
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '保存失败', icon: 'none' });
|
||||
} finally {
|
||||
serviceSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeService = (srv) => {
|
||||
uni.showModal({
|
||||
title: '删除服务包',
|
||||
content: `确认删除「${srv.title}」?删除后买家将无法预约该服务。`,
|
||||
confirmColor: '#FF5F57',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return;
|
||||
try {
|
||||
await deleteExpertService(currentExpertId(), srv.id);
|
||||
uni.showToast({ title: '已删除', icon: 'none' });
|
||||
await loadExpertServices();
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '删除失败', icon: 'none' });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toggleServiceStatus = async (item) => {
|
||||
const newStatus = !item.status;
|
||||
try {
|
||||
await updateExpertServiceStatus(101, item.id, newStatus);
|
||||
await updateExpertServiceStatus(currentExpertId(), item.id, newStatus);
|
||||
item.status = newStatus;
|
||||
uni.showToast({
|
||||
title: newStatus ? `【${item.title}】已上架接单` : `【${item.title}】已暂停下架`,
|
||||
@@ -637,7 +781,10 @@ const fillDemoCode = () => {
|
||||
|
||||
const loadWorkspaceOrders = async () => {
|
||||
try {
|
||||
const stats = await getExpertStats(101);
|
||||
// 先解析真实大神档案,避免工作台使用演示 id
|
||||
await loadExpertProfile();
|
||||
const eid = currentExpertId() || 101;
|
||||
const stats = await getExpertStats(eid);
|
||||
if (stats) {
|
||||
if (stats.income !== undefined) todayIncome.value = stats.income;
|
||||
if (stats.orders !== undefined) todayOrdersCount.value = stats.orders;
|
||||
@@ -672,7 +819,7 @@ const loadWorkspaceOrders = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const trends = await getExpertRevenueTrends(101);
|
||||
const trends = await getExpertRevenueTrends(currentExpertId() || 101);
|
||||
if (trends && (trends.daily || trends.weekly || trends.monthly)) {
|
||||
rawTrendsData.value = trends;
|
||||
}
|
||||
@@ -769,7 +916,7 @@ const confirmCheckin = async () => {
|
||||
const grabOrder = async (order) => {
|
||||
try {
|
||||
await claimOrder(order.id, {
|
||||
expertId: 101,
|
||||
expertId: currentExpertId() || 101,
|
||||
companionName: '我 (接单大神)',
|
||||
expertAvatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150'
|
||||
});
|
||||
@@ -2009,4 +2156,60 @@ const switchBackToPlayer = () => {
|
||||
color: #64748B;
|
||||
}
|
||||
}
|
||||
|
||||
/* 服务包操作与表单弹窗 */
|
||||
.srv-ops {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
margin-left: auto;
|
||||
}
|
||||
.srv-op {
|
||||
font-size: 24rpx;
|
||||
color: #6366F1;
|
||||
padding: 4rpx 8rpx;
|
||||
}
|
||||
.srv-op-danger {
|
||||
color: #FF5F57;
|
||||
}
|
||||
.service-form-body {
|
||||
padding: 8rpx 4rpx 4rpx;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.form-label {
|
||||
width: 180rpx;
|
||||
font-size: 26rpx;
|
||||
color: #94A3B8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.form-input {
|
||||
flex: 1;
|
||||
height: 68rpx;
|
||||
padding: 0 16rpx;
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #E2E8F0;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
.form-textarea {
|
||||
flex: 1;
|
||||
min-height: 120rpx;
|
||||
padding: 12rpx 16rpx;
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #E2E8F0;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
.form-hint {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: #64748B;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
在新工单中引用
屏蔽一个用户