feat: 微信授权登录 + 我的中心角色切换 + 师傅工作台全模块 + 图标美化

登录与角色:
- Auth::wxlogin 微信 code → jscode2session → 落库发 token(appid/secret 走 .env)
- 新增 fa_user_wechat 表,openid 幂等建号(CLI 已验证 is_new 1→0 不重复建号)
- Auth::switchRole 角色切换(未入驻 2001 / 审核中 2002 / 未通过 2003)
- pages/login 改为微信一键授权;pages/mine 我的中心(师傅入口 + 切换师傅端)

师傅工作台(对齐需求文档):
- pages/master/workbench 工作台:接单开关 / 待处理待验收 / 可提现 / 六宫格入口 / 信用四指标 / 技能标签
- pages/master/hall 订单大厅:类型筛选(一口价/报价) + 4 种排序 + 抢单倒计时 + 报价入口
- pages/master/quote 报价竞标:金额/说明/有效期 + 报价记录
- pages/master/wallet 钱包:可提现/今日/累计 + 账单明细 + 提现校验
- pages/master/credit 信用与申诉:五项指标 + 规则 + 申诉提交/记录
- 新增 fa_appeal 表

视觉:
- 15 个类目图标重画为双色扁平 SVG,用无头 Chrome 渲染为 192px 透明 PNG
- 首页改用 <image src=static/icons/*.png>,移除 200 行 CSS 图标数据

验证:page_flow 47 + e2e_flow 65 + phase2_verify 27 = 139 项断言全通过
这个提交包含在:
linda
2026-10-28 16:10:34 +08:00
父节点 78722b3912
当前提交 1d92202b1a
共有 31 个文件被更改,包括 1293 次插入 和 155 次删除

查看文件

@@ -86,14 +86,7 @@
<view class="cats">
<view class="cat" v-for="(c, i) in cats" :key="c.id" @tap="onCat(c)">
<view class="cat-circle">
<view class="ic">
<view
v-for="(p, j) in iconOf(c.icon)"
:key="j"
:class="p[0] === 'o' ? 'ol' : (p[0] === 'd' ? 'ol ct' : (p[0] === 't' ? 'tri' : 'ln'))"
:style="partStyle(p)"
></view>
</view>
<image class="cat-img" :src="'/static/icons/' + iconOf(c.icon) + '.png'" mode="aspectFit"></image>
</view>
<text class="cat-name">{{ c.name }}</text>
</view>
@@ -254,29 +247,9 @@
/* ---------- 服务图标:纯 CSS 图元,零图片依赖
o=描边框 l=实心线 d=圆形描边 t=三角
格式 [类型, left, top, w, h, radius, rotate],画布 56×56rpx */
const iconCats = [
{ n: '家具', p: [['o',12,10,32,16,6],['o',6,26,44,14,5],['l',14,40,3,7],['l',39,40,3,7]] },
{ n: '卫浴', p: [['l',20,6,3,12],['l',20,6,13,3],['o',4,21,48,21,'4 4 16 16'],['l',12,42,3,6],['l',41,42,3,6]] },
{ n: '灯具', p: [['l',27,4,3,13],['o',12,17,32,16,'16 16 0 0'],['l',8,33,40,3],['l',16,40,3,8],['l',27,40,3,8],['l',38,40,3,8]] },
{ n: '锁具', p: [['o',14,16,26,34,5],['o',19,21,16,9,2],['l',19,36,5,5],['l',26,36,5,5],['l',19,43,5,5],['l',26,43,5,5],['l',40,26,8,3]] },
{ n: '窗帘', p: [['l',2,8,52,3],['o',6,11,19,37,'0 0 5 5'],['l',12,11,2,31],['l',19,11,2,31],['o',31,11,19,37,'0 0 5 5'],['l',37,11,2,31],['l',44,11,2,31]] },
{ n: '墙纸/壁画', p: [['o',6,10,22,30,3],['l',11,30,12,3],['o',29,16,22,30,3],['l',34,36,12,3]] },
{ n: '净水器', p: [['o',19,8,18,6,2],['o',8,14,40,26,4],['l',17,40,3,8],['l',13,45,7,3],['l',32,40,3,8],['l',32,45,7,3]] },
{ n: '晾衣架', p: [['l',5,11,46,3],['l',11,14,3,9],['l',42,14,3,9],['l',5,23,46,3],['l',17,26,3,12],['l',27,26,3,17],['l',37,26,3,12]] },
{ n: '门窗', p: [['o',15,8,26,40,'4 4 0 0'],['o',21,14,14,11,2],['l',35,30,3,8],['l',11,48,34,3]] },
{ n: '家电', p: [['o',17,8,24,40,5],['l',17,22,24,3],['l',33,12,3,7],['l',33,26,3,7]] },
{ n: '电视/投影', p: [['o',5,12,46,30,4],['t',24,20,11,7],['l',26,42,4,5],['l',19,47,18,3]] },
{ n: '淋浴房', p: [['o',8,10,40,38,'4 4 0 0'],['l',30,10,3,38],['l',24,26,3,8],['l',6,48,44,3]] },
{ n: '商用厨电', p: [['o',15,8,28,40,4],['l',28,8,3,40],['l',21,20,3,9],['l',34,20,3,9]] },
{ n: '健身器材', p: [['d',3,28,24,24],['l',12,37,6,6],['l',14,39,28,3,null,-43],['l',33,14,3,16],['l',26,10,16,4,2],['l',42,10,3,14],['l',36,10,14,3]] },
{ n: '更多', p: [['o',8,8,18,18,5],['o',30,8,18,18,5],['o',8,30,18,18,5],['o',30,30,18,18,5]] }
]
/* 图标库:按后端返回的 icon 键匹配(顺序与 iconCats 一致) */
const ICON_KEYS = ['sofa', 'bath', 'lamp', 'lock', 'curtain', 'frame', 'water', 'rack', 'door', 'appliance', 'tv', 'shower', 'kitchen', 'fitness', 'more']
const iconLib = {}
iconCats.forEach((c, i) => { iconLib[ICON_KEYS[i]] = c.p })
const iconOf = (key) => iconLib[key] || iconLib.more
/* 服务图标资源:static/icons/<key>.png(双色扁平,由 SVG 渲染) */
const ICON_KEYS = ['sofa','bath','lamp','lock','curtain','frame','water','rack','door','appliance','tv','shower','kitchen','fitness','more']
const iconOf = (key) => (ICON_KEYS.indexOf(key) > -1 ? key : 'more')
/* 服务类目(真实接口) */
const cats = ref([])
@@ -291,15 +264,6 @@
} catch (e) {}
}
const partStyle = (p) => {
if (p[0] === 't') {
return 'left:' + p[1] + 'rpx;top:' + p[2] + 'rpx;border-left:' + p[3] + 'rpx solid #B85E2A;border-top:' + p[4] + 'rpx solid transparent;border-bottom:' + p[4] + 'rpx solid transparent;'
}
let s = 'left:' + p[1] + 'rpx;top:' + p[2] + 'rpx;width:' + p[3] + 'rpx;height:' + p[4] + 'rpx;'
if (p[5]) s += 'border-radius:' + String(p[5]).split(/\s+/).map(function (v) { return v + 'rpx' }).join(' ') + ';'
if (p[6]) s += 'transform:rotate(' + p[6] + 'deg);transform-origin:0 50%;'
return s
}
/* ---------- 交互 ---------- */
const toast = (title) => uni.showToast({ title, icon: 'none' })
@@ -327,7 +291,7 @@
}
const onUser = () => {
if (!needLogin()) return
uni.navigateTo({ url: '/pages/order/list' })
uni.navigateTo({ url: '/pages/mine/mine' })
}
const switchTab = (i) => {
@@ -717,6 +681,10 @@
align-items: center;
justify-content: center;
}
.cat-img {
width: 58rpx;
height: 58rpx;
}
.cat-name {
display: block;
margin-top: 12rpx;

41
db/schema_phase2.sql 普通文件
查看文件

@@ -0,0 +1,41 @@
-- ============================================================================
-- 优匠优服 · Phase 2(微信授权登录 / 申诉中心)
-- 纯新增表,不改动既有表结构
-- ============================================================================
SET NAMES utf8mb4;
-- 微信授权账号绑定(不动 fa_user 结构,独立表存放)
CREATE TABLE IF NOT EXISTS `fa_user_wechat` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '关联 fa_user.id',
`openid` varchar(64) NOT NULL DEFAULT '' COMMENT '小程序 openid',
`unionid` varchar(64) NOT NULL DEFAULT '' COMMENT '开放平台 unionid',
`session_key` varchar(128) NOT NULL DEFAULT '',
`nickname` varchar(64) NOT NULL DEFAULT '' COMMENT '微信昵称',
`avatar` varchar(255) NOT NULL DEFAULT '' COMMENT '微信头像',
`createtime` bigint(16) DEFAULT NULL,
`updatetime` bigint(16) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `openid` (`openid`),
KEY `user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信授权账号';
-- 申诉中心(误判 / 恶意评价 / 扣款申诉)
CREATE TABLE IF NOT EXISTS `fa_appeal` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`master_id` int(10) unsigned NOT NULL DEFAULT '0',
`order_id` int(10) unsigned NOT NULL DEFAULT '0',
`type` enum('misjudge','review','deduct','other') NOT NULL DEFAULT 'other' COMMENT '误判/恶意评价/扣款/其他',
`content` varchar(500) NOT NULL DEFAULT '',
`images` varchar(1000) NOT NULL DEFAULT '',
`status` enum('wait','handling','done','reject') NOT NULL DEFAULT 'wait',
`reply` varchar(500) NOT NULL DEFAULT '' COMMENT '平台回复',
`createtime` bigint(16) DEFAULT NULL,
`updatetime` bigint(16) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `master_id` (`master_id`),
KEY `status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='申诉工单';
-- 回滚:
-- DROP TABLE IF EXISTS `fa_appeal`,`fa_user_wechat`;

查看文件

@@ -42,10 +42,30 @@
"path": "pages/master/apply",
"style": { "navigationBarTitleText": "师傅入驻" }
},
{
"path": "pages/master/workbench",
"style": { "navigationBarTitleText": "师傅工作台" }
},
{
"path": "pages/master/hall",
"style": { "navigationBarTitleText": "订单大厅", "enablePullDownRefresh": false }
},
{
"path": "pages/master/quote",
"style": { "navigationBarTitleText": "报价管理" }
},
{
"path": "pages/master/wallet",
"style": { "navigationBarTitleText": "我的钱包" }
},
{
"path": "pages/master/credit",
"style": { "navigationBarTitleText": "信用与申诉" }
},
{
"path": "pages/mine/mine",
"style": { "navigationStyle": "custom", "backgroundColor": "#F5F6F8" }
},
{
"path": "pages/master/orders",
"style": {

查看文件

@@ -19,83 +19,58 @@
</view>
</view>
<view class="field">
<text class="label">手机号</text>
<input class="input" v-model="mobile" type="number" maxlength="11" placeholder="请输入手机号" placeholder-class="ph" />
<view class="wx-btn" :class="{ disabled: loading }" @tap="wxLogin">
<view class="wx-icon"><text class="wx-icon-t">微</text></view>
<text class="wx-txt">{{ loading ? '微信授权中...' : '微信一键登录' }}</text>
</view>
<view class="field">
<text class="label">验证码</text>
<input class="input" v-model="code" type="number" maxlength="6" placeholder="联调验证码 123456" placeholder-class="ph" />
<view class="code-btn" :class="{ disabled: counting }" @tap="sendCode">{{ codeText }}</view>
</view>
<view class="submit" :class="{ disabled: loading }" @tap="doLogin">{{ loading ? '登录中...' : '登录 / 注册' }}</view>
<text class="tip">未注册的手机号将自动创建账号</text>
<view class="tip">授权仅用于账号识别,不会获取你的其他信息</view>
<view class="agree">登录即代表同意《用户服务协议》与《隐私政策》</view>
</view>
<text class="agree">登录即代表同意《用户服务协议》与《隐私政策》</text>
</view>
</template>
<script setup>
import { ref, computed } from 'vue';
import { ref } from 'vue';
import api from '@/utils/api.js';
import { setToken, setUser } from '@/utils/request.js';
import { setRole } from '@/utils/role.js';
const role = ref('user');
const mobile = ref('');
const code = ref('');
const loading = ref(false);
const countdown = ref(0);
let timer = null;
const counting = computed(() => countdown.value > 0);
const codeText = computed(() => (countdown.value > 0 ? countdown.value + 's' : '获取验证码'));
function toast(t) { uni.showToast({ title: t, icon: 'none' }); }
function toast(title) {
uni.showToast({ title: title, icon: 'none' });
}
function sendCode() {
if (counting.value) return;
if (!/^1\d{10}$/.test(mobile.value)) {
toast('请输入正确的手机号');
return;
}
countdown.value = 60;
timer = setInterval(() => {
countdown.value -= 1;
if (countdown.value <= 0) {
clearInterval(timer);
timer = null;
}
}, 1000);
toast('联调环境验证码为 123456');
}
async function doLogin() {
/** 微信授权登录:uni.login 取 code → 后端换 openid → 下发 token */
function wxLogin() {
if (loading.value) return;
if (!/^1\d{10}$/.test(mobile.value)) {
toast('请输入正确的手机号');
return;
}
if (!code.value) {
toast('请输入验证码');
return;
}
loading.value = true;
try {
const data = await api.login({ mobile: mobile.value, code: code.value });
setToken(data.token);
setUser(data.userinfo);
toast('登录成功');
setTimeout(() => routeAfterLogin(), 500);
} catch (e) {
toast(e.message || '登录失败');
} finally {
loading.value = false;
}
uni.login({
provider: 'weixin',
success: async (res) => {
if (!res.code) {
loading.value = false;
toast('微信授权失败,请重试');
return;
}
try {
const data = await api.wxLogin({ code: res.code });
setToken(data.token);
setUser(data.userinfo);
setRole(role.value);
toast(data.is_new ? '登录成功,欢迎加入' : '登录成功');
setTimeout(() => routeAfterLogin(), 600);
} catch (e) {
toast(e.message || '登录失败');
} finally {
loading.value = false;
}
},
fail: () => {
loading.value = false;
toast('未检测到微信环境,请在小程序内打开');
}
});
}
function routeAfterLogin() {
@@ -107,59 +82,35 @@
if (!res.applied || res.master.verify_status !== 'pass') {
uni.redirectTo({ url: '/pages/master/apply' });
} else {
uni.redirectTo({ url: '/pages/master/hall' });
uni.redirectTo({ url: '/pages/master/workbench' });
}
}).catch(() => {
uni.redirectTo({ url: '/pages/master/hall' });
uni.redirectTo({ url: '/pages/master/apply' });
});
}
</script>
<style scoped>
.page { min-height: 100vh; background: #F5F6F8; }
.top {
position: relative; height: 420rpx; padding: 120rpx 0 0 48rpx; overflow: hidden;
background: linear-gradient(160deg, #FFB85C 0%, #F98D25 46%, #ED6F0E 100%);
border-radius: 0 0 48rpx 48rpx;
}
.top { position: relative; height: 420rpx; padding: 120rpx 0 0 48rpx; overflow: hidden;
background: linear-gradient(160deg, #FFB85C 0%, #F98D25 46%, #ED6F0E 100%); border-radius: 0 0 48rpx 48rpx; }
.deco { position: absolute; border-radius: 50%; }
.d1 { width: 360rpx; height: 360rpx; right: -120rpx; top: -160rpx; background: rgba(255,255,255,0.14); }
.d2 { width: 220rpx; height: 220rpx; left: -90rpx; bottom: -80rpx; background: rgba(255,255,255,0.10); }
.brand {
position: relative; display: block; font-size: 52rpx; font-weight: 700; color: #fff;
letter-spacing: 6rpx; text-shadow: 0 3rpx 12rpx rgba(180,80,10,0.3);
}
.slogan { position: relative; display: block; margin-top: 14rpx; font-size: 26rpx; color: rgba(255,255,255,0.88); letter-spacing: 2rpx; }
.card {
position: relative; margin: -120rpx 40rpx 0; padding: 40rpx 34rpx 34rpx;
background: #fff; border-radius: 24rpx; box-shadow: 0 16rpx 40rpx rgba(226,120,26,0.16);
}
.role { display: flex; margin-bottom: 34rpx; }
.role-item {
flex: 1; padding: 22rpx 0; border-radius: 16rpx; background: #F7F7F7;
display: flex; flex-direction: column; align-items: center; border: 2rpx solid transparent;
}
.d1 { width: 360rpx; height: 360rpx; right: -120rpx; top: -160rpx; background: rgba(255,255,255,.14); }
.d2 { width: 220rpx; height: 220rpx; left: -90rpx; bottom: -80rpx; background: rgba(255,255,255,.10); }
.brand { position: relative; display: block; font-size: 52rpx; font-weight: 700; color: #fff; letter-spacing: 6rpx; text-shadow: 0 3rpx 12rpx rgba(180,80,10,.3); }
.slogan { position: relative; display: block; margin-top: 14rpx; font-size: 26rpx; color: rgba(255,255,255,.88); letter-spacing: 2rpx; }
.card { position: relative; margin: -140rpx 40rpx 0; padding: 40rpx 34rpx 34rpx; background: #fff; border-radius: 24rpx; box-shadow: 0 16rpx 40rpx rgba(226,120,26,.16); }
.role { display: flex; margin-bottom: 36rpx; }
.role-item { flex: 1; padding: 22rpx 0; border-radius: 16rpx; background: #F7F7F7; display: flex; flex-direction: column; align-items: center; border: 2rpx solid transparent; }
.role-item.on { background: linear-gradient(135deg, #FFF8EE 0%, #FFE9D2 100%); border-color: #F9A65B; }
.role-name { font-size: 30rpx; font-weight: 600; color: #333; }
.role-item.on .role-name { color: #C4580A; }
.role-desc { margin-top: 6rpx; font-size: 22rpx; color: #9A9A9A; }
.field { display: flex; align-items: center; height: 100rpx; margin-top: 22rpx; padding: 0 24rpx; background: #F7F7F7; border-radius: 16rpx; }
.label { width: 110rpx; font-size: 28rpx; color: #666; }
.input { flex: 1; height: 100rpx; font-size: 30rpx; color: #222; }
.ph { color: #B5B5B5; }
.code-btn {
padding: 0 20rpx; line-height: 56rpx; border-radius: 28rpx;
background: linear-gradient(135deg, #FFA93F 0%, #F0710D 100%); color: #fff; font-size: 24rpx;
}
.code-btn.disabled { opacity: 0.55; }
.submit {
height: 96rpx; margin-top: 40rpx; display: flex; align-items: center; justify-content: center;
border-radius: 48rpx; background: linear-gradient(135deg, #FFA93F 0%, #F0710D 100%);
color: #fff; font-size: 32rpx; font-weight: 600; letter-spacing: 2rpx;
box-shadow: 0 10rpx 24rpx rgba(240,113,13,0.32);
}
.submit.disabled { opacity: 0.7; }
.tip { display: block; margin-top: 22rpx; text-align: center; font-size: 22rpx; color: #B5B5B5; }
.agree { display: block; margin-top: 48rpx; text-align: center; font-size: 20rpx; color: #BDBDBD; }
.wx-btn { height: 96rpx; display: flex; align-items: center; justify-content: center; border-radius: 48rpx; background: linear-gradient(135deg, #4BC86A 0%, #2FA84F 100%); box-shadow: 0 10rpx 24rpx rgba(47,168,79,.28); }
.wx-btn.disabled { opacity: .7; }
.wx-icon { width: 44rpx; height: 44rpx; border-radius: 50%; background: #fff; display: flex; align-items: center; justify-content: center; margin-right: 14rpx; }
.wx-icon-t { font-size: 24rpx; font-weight: 700; color: #2FA84F; }
.wx-txt { font-size: 32rpx; font-weight: 600; color: #fff; letter-spacing: 1rpx; }
.tip { display: block; margin-top: 24rpx; text-align: center; font-size: 20rpx; color: #B5B5B5; }
.agree { display: block; margin-top: 12rpx; text-align: center; font-size: 20rpx; color: #C4C4C4; }
</style>

121
pages/master/credit.vue 普通文件
查看文件

@@ -0,0 +1,121 @@
<template>
<view class="page">
<view class="card" v-if="credit">
<view class="card-title">信用指标</view>
<view class="grid">
<view class="g"><text class="g-num">{{ credit.score }}</text><text class="g-lab">综合评分</text></view>
<view class="g"><text class="g-num">{{ credit.accept_rate }}%</text><text class="g-lab">接单率</text></view>
<view class="g"><text class="g-num">{{ credit.ontime_rate }}%</text><text class="g-lab">准时率</text></view>
<view class="g"><text class="g-num">{{ credit.good_rate }}%</text><text class="g-lab">好评率</text></view>
<view class="g"><text class="g-num">{{ credit.complaint_rate }}%</text><text class="g-lab">投诉率</text></view>
<view class="g"><text class="g-num">Lv{{ credit.level }}</text><text class="g-lab">服务等级</text></view>
</view>
<view class="rules">
<text class="rule" v-for="(r, i) in credit.rules" :key="i">· {{ r }}</text>
</view>
</view>
<view class="card">
<view class="card-title">提交申诉</view>
<view class="types">
<view v-for="t in types" :key="t.key" class="type" :class="{ on: form.type === t.key }" @tap="form.type = t.key">{{ t.name }}</view>
</view>
<textarea class="ta" v-model="form.content" maxlength="300" placeholder="请描述申诉情况(如:客户恶意差评、订单误判扣款等)" />
<view class="btn" :class="{ disabled: submitting }" @tap="submit">{{ submitting ? '提交中...' : '提交申诉' }}</view>
</view>
<view class="card">
<view class="card-head">
<text class="card-title">申诉记录</text>
<text class="card-more">{{ list.length }} 条</text>
</view>
<view class="ap" v-for="a in list" :key="a.id">
<view class="ap-top">
<text class="ap-type">{{ a.type_text }}</text>
<text class="ap-status" :class="'st-' + a.status">{{ a.status_text }}</text>
</view>
<text class="ap-content">{{ a.content }}</text>
<text class="ap-time">{{ a.time_text }}</text>
<text class="ap-reply" v-if="a.reply">平台回复:{{ a.reply }}</text>
</view>
<view class="empty" v-if="!list.length">暂无申诉记录</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import api from '@/utils/api.js';
const credit = ref(null);
const list = ref([]);
const submitting = ref(false);
const form = reactive({ type: 'misjudge', content: '' });
const types = [
{ key: 'misjudge', name: '误判申诉' },
{ key: 'review', name: '恶意评价' },
{ key: 'deduct', name: '扣款申诉' },
{ key: 'other', name: '其他问题' }
];
async function load() {
try { credit.value = await api.masterCredit(); } catch (e) {}
try {
const res = await api.masterAppeals();
list.value = res.list || [];
} catch (e) {}
}
async function submit() {
if (submitting.value) return;
if (form.content.trim().length < 5) {
uni.showToast({ title: '请至少填写 5 个字', icon: 'none' });
return;
}
submitting.value = true;
try {
await api.masterAppeal({ type: form.type, content: form.content });
uni.showToast({ title: '申诉已提交' });
form.content = '';
load();
} catch (e) {
} finally {
submitting.value = false;
}
}
onShow(() => load());
</script>
<style scoped>
.page { min-height: 100vh; background: #F5F6F8; padding: 22rpx 28rpx 60rpx; }
.card { padding: 24rpx; background: #fff; border-radius: 20rpx; margin-bottom: 20rpx; box-shadow: 0 6rpx 20rpx rgba(240,129,31,.06); }
.card-head { display: flex; align-items: center; justify-content: space-between; }
.card-title { font-size: 28rpx; font-weight: 700; color: #1F1F1F; }
.card-more { font-size: 22rpx; color: #9A9A9A; }
.grid { display: flex; flex-wrap: wrap; margin-top: 16rpx; }
.g { width: 33.33%; display: flex; flex-direction: column; align-items: center; padding: 14rpx 0; }
.g-num { font-size: 32rpx; font-weight: 700; color: #F0710D; }
.g-lab { margin-top: 4rpx; font-size: 20rpx; color: #9A9A9A; }
.rules { margin-top: 14rpx; padding-top: 16rpx; border-top: 1rpx solid #F4F4F4; display: flex; flex-direction: column; }
.rule { font-size: 20rpx; color: #9A9A9A; line-height: 34rpx; }
.types { display: flex; flex-wrap: wrap; padding: 14rpx 0 6rpx; }
.type { padding: 0 22rpx; line-height: 56rpx; margin: 0 12rpx 12rpx 0; border-radius: 28rpx; background: #F5F5F5; font-size: 24rpx; color: #666; }
.type.on { background: #FFF3E6; color: #E36B0C; font-weight: 600; }
.ta { width: 100%; height: 160rpx; margin-top: 10rpx; font-size: 26rpx; color: #333; line-height: 38rpx; }
.btn { height: 88rpx; margin-top: 12rpx; display: flex; align-items: center; justify-content: center; border-radius: 44rpx; color: #fff; font-size: 30rpx; font-weight: 600; background: linear-gradient(135deg,#FFA93F 0%,#F0710D 100%); }
.btn.disabled { opacity: .7; }
.ap { padding: 18rpx 0; border-bottom: 1rpx solid #F4F4F4; display: flex; flex-direction: column; }
.ap:last-of-type { border-bottom: none; }
.ap-top { display: flex; align-items: center; justify-content: space-between; }
.ap-type { font-size: 26rpx; font-weight: 600; color: #333; }
.ap-status { font-size: 22rpx; color: #F0710D; }
.ap-status.st-done { color: #2FA84F; }
.ap-status.st-reject { color: #9A9A9A; }
.ap-content { margin-top: 8rpx; font-size: 24rpx; color: #666; line-height: 34rpx; }
.ap-time { margin-top: 6rpx; font-size: 20rpx; color: #C4C4C4; }
.ap-reply { margin-top: 8rpx; padding: 12rpx 16rpx; background: #F7F7F7; border-radius: 10rpx; font-size: 22rpx; color: #666; }
.empty { padding: 60rpx 0; text-align: center; font-size: 24rpx; color: #9A9A9A; }
</style>

查看文件

@@ -10,6 +10,15 @@
</view>
</view>
<view class="filters">
<view class="ftabs">
<view v-for="t in typeTabs" :key="t.key" class="ftab" :class="{ on: type === t.key }" @tap="type = t.key">{{ t.name }}</view>
</view>
<view class="sorts">
<view v-for="s in sortTabs" :key="s.key" class="sort" :class="{ on: sort === s.key }" @tap="sort = s.key">{{ s.name }}</view>
</view>
</view>
<view class="list">
<view class="item" v-for="o in list" :key="o.id">
<view class="hd">
@@ -24,8 +33,11 @@
<text class="meta" v-if="o.remark">备注:{{ o.remark }}</text>
</view>
<view class="ft">
<text class="phone">{{ o.mobile }}</text>
<view class="grab" @tap="grab(o)">立即抢单</view>
<text class="phone">{{ o.left_seconds > 0 ? ('剩余 ' + countdown(o.left_seconds)) : '即将关闭' }}</text>
<view class="ft-btns">
<view class="quote-btn" v-if="o.price_type === 'quote'" @tap="goQuote(o)">我要报价({{ o.quote_count }})</view>
<view class="grab" v-else @tap="grab(o)">立即抢单</view>
</view>
</view>
</view>
</view>
@@ -36,20 +48,49 @@
</template>
<script setup>
import { ref } from 'vue';
import { ref, watch } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import api from '@/utils/api.js';
import { isLogin, toLogin } from '@/utils/request.js';
/** 订单大厅 */
const list = ref([]);
const info = ref(null);
const loading = ref(false);
const type = ref('all');
const sort = ref('new');
const typeTabs = [
{ key: 'all', name: '全部订单' },
{ key: 'fixed', name: '一口价' },
{ key: 'quote', name: '报价订单' }
];
const sortTabs = [
{ key: 'new', name: '最新' },
{ key: 'amount', name: '报酬高' },
{ key: 'urgent', name: '急单优先' },
{ key: 'time', name: '时效近' }
];
watch([type, sort], () => load());
function countdown(sec) {
const s = Number(sec) || 0;
if (s <= 0) return '已结束';
const m = Math.floor(s / 60);
return m >= 60 ? (Math.floor(m / 60) + '小时' + (m % 60) + '分') : (m + '分');
}
function goQuote(o) {
uni.navigateTo({ url: '/pages/master/quote?order_id=' + o.id });
}
async function load() {
if (!isLogin()) { toLogin(); return; }
loading.value = true;
try {
const res = await api.masterHall({ limit: 20 });
const res = await api.masterHall({ limit: 20, type: type.value, sort: sort.value });
list.value = res.list || [];
} catch (e) {
}
@@ -115,5 +156,14 @@
.phone { font-size: 24rpx; color: #666; }
.grab { padding: 0 34rpx; line-height: 68rpx; border-radius: 34rpx; color: #fff; font-size: 26rpx; font-weight: 600; background: linear-gradient(135deg, #FFA93F 0%, #F0710D 100%); box-shadow: 0 6rpx 16rpx rgba(240,113,13,0.28); }
.empty { padding: 90rpx 0 30rpx; text-align: center; font-size: 24rpx; color: #9A9A9A; }
.filters { padding: 20rpx 28rpx 0; }
.ftabs { display: flex; }
.ftab { padding: 0 26rpx; line-height: 64rpx; margin-right: 14rpx; border-radius: 32rpx; background: #fff; font-size: 24rpx; color: #666; }
.ftab.on { background: linear-gradient(135deg,#FFA93F 0%,#F0710D 100%); color: #fff; font-weight: 600; }
.sorts { display: flex; padding: 16rpx 0 4rpx; }
.sort { padding: 0 20rpx; line-height: 52rpx; margin-right: 12rpx; border-radius: 26rpx; background: #fff; font-size: 22rpx; color: #9A9A9A; }
.sort.on { background: #FFF3E6; color: #E36B0C; font-weight: 600; }
.ft-btns { display: flex; align-items: center; }
.quote-btn { padding: 0 30rpx; line-height: 68rpx; border-radius: 34rpx; color: #fff; font-size: 26rpx; font-weight: 600; background: linear-gradient(135deg,#7A5AF8 0%,#5B3FD8 100%); box-shadow: 0 6rpx 16rpx rgba(122,90,248,.28); }
.refresh { margin: 10rpx 28rpx; height: 84rpx; display: flex; align-items: center; justify-content: center; border-radius: 42rpx; background: #fff; color: #F0710D; font-size: 26rpx; font-weight: 600; }
</style>

96
pages/master/quote.vue 普通文件
查看文件

@@ -0,0 +1,96 @@
<template>
<view class="page">
<view class="card" v-if="orderId">
<view class="card-title">提交报价</view>
<view class="fi"><text class="fk">报价金额</text><input class="fv" type="digit" v-model="amount" placeholder="请输入你的报价(元)" /></view>
<view class="fi"><text class="fk">报价有效期</text>
<view class="seg">
<view v-for="h in [24, 48, 72]" :key="h" class="seg-i" :class="{ on: hours === h }" @tap="hours = h">{{ h }}h</view>
</view>
</view>
<textarea class="ta" v-model="desc" maxlength="200" placeholder="服务说明:包含项目、材料、预计工期" />
<view class="btn" :class="{ disabled: submitting }" @tap="submitQuote">{{ submitting ? '提交中...' : '提交报价' }}</view>
</view>
<view class="card">
<view class="card-head"><text class="card-title">我的报价记录</text><text class="card-more">{{ list.length }} 条</text></view>
<view class="q-item" v-for="q in list" :key="q.id">
<view class="q-top">
<text class="q-svc">{{ q.service_name || '订单' }}</text>
<text class="q-amt">¥{{ q.amount }}</text>
</view>
<text class="q-meta">{{ q.order_no }} · 提交 {{ q.time_text }}</text>
<text class="q-meta">有效期至 {{ q.expire_text }}</text>
<text class="q-status">{{ q.status_text }}</text>
</view>
<view class="empty" v-if="!list.length">暂无报价记录</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onShow, onLoad } from '@dcloudio/uni-app';
import api from '@/utils/api.js';
const list = ref([]);
const amount = ref('');
const desc = ref('');
const hours = ref(24);
const submitting = ref(false);
let orderId = 0;
async function load() {
try {
const res = await api.masterQuotes();
list.value = res.list || [];
} catch (e) {}
}
async function submitQuote() {
if (submitting.value) return;
if (!amount.value || Number(amount.value) <= 0) {
uni.showToast({ title: '请填写报价金额', icon: 'none' });
return;
}
submitting.value = true;
try {
await api.masterQuote({ order_id: orderId, amount: amount.value, description: desc.value, valid_hours: hours.value });
uni.showToast({ title: '报价已提交' });
amount.value = '';
desc.value = '';
setTimeout(() => uni.navigateBack(), 600);
} catch (e) {
} finally {
submitting.value = false;
}
}
onLoad((o) => { orderId = Number(o.order_id || 0); });
onShow(() => load());
</script>
<style scoped>
.page { min-height: 100vh; background: #F5F6F8; padding: 22rpx 28rpx 60rpx; }
.card { padding: 24rpx; background: #fff; border-radius: 20rpx; margin-bottom: 20rpx; box-shadow: 0 6rpx 20rpx rgba(240,129,31,.06); }
.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6rpx; }
.card-title { font-size: 28rpx; font-weight: 700; color: #1F1F1F; }
.card-more { font-size: 22rpx; color: #9A9A9A; }
.fi { display: flex; align-items: center; min-height: 88rpx; border-bottom: 1rpx solid #F4F4F4; }
.fk { width: 170rpx; font-size: 26rpx; color: #666; }
.fv { flex: 1; height: 88rpx; font-size: 28rpx; color: #222; }
.seg { display: flex; }
.seg-i { padding: 0 24rpx; line-height: 56rpx; margin-right: 12rpx; border-radius: 28rpx; background: #F5F5F5; font-size: 24rpx; color: #666; }
.seg-i.on { background: linear-gradient(135deg,#FFA93F 0%,#F0710D 100%); color: #fff; }
.ta { width: 100%; height: 150rpx; margin-top: 16rpx; font-size: 26rpx; color: #333; line-height: 38rpx; }
.btn { height: 88rpx; margin-top: 12rpx; display: flex; align-items: center; justify-content: center; border-radius: 44rpx; color: #fff; font-size: 30rpx; font-weight: 600; background: linear-gradient(135deg,#FFA93F 0%,#F0710D 100%); }
.btn.disabled { opacity: .7; }
.q-item { padding: 18rpx 0; border-bottom: 1rpx solid #F4F4F4; display: flex; flex-direction: column; }
.q-item:last-child { border-bottom: none; }
.q-top { display: flex; align-items: baseline; justify-content: space-between; }
.q-svc { font-size: 28rpx; font-weight: 600; color: #1F1F1F; }
.q-amt { font-size: 32rpx; font-weight: 700; color: #F0710D; }
.q-meta { margin-top: 6rpx; font-size: 22rpx; color: #9A9A9A; }
.q-status { margin-top: 8rpx; font-size: 22rpx; color: #4A7CFF; }
.empty { padding: 60rpx 0; text-align: center; font-size: 24rpx; color: #9A9A9A; }
</style>

105
pages/master/wallet.vue 普通文件
查看文件

@@ -0,0 +1,105 @@
<template>
<view class="page">
<view class="hero">
<text class="hero-lab">可提现余额(元)</text>
<text class="hero-num">{{ income ? income.money : 0 }}</text>
<view class="hero-row">
<text class="hero-sub">今日收入 ¥{{ income ? income.today_income : 0 }}</text>
<text class="hero-sub">累计收入 ¥{{ income ? income.total_income : 0 }}</text>
</view>
<view class="hero-btn" @tap="openWithdraw">申请提现</view>
</view>
<view class="card">
<view class="card-head"><text class="card-title">账单明细</text></view>
<view class="log" v-for="(l, i) in logs" :key="i">
<view class="log-l">
<text class="log-t">{{ logTypeText(l.type) }}</text>
<text class="log-r">{{ l.remark }}</text>
<text class="log-time">{{ timeText(l.createtime) }}</text>
</view>
<view class="log-r2">
<text class="log-amt" :class="{ minus: Number(l.amount) < 0 }">{{ Number(l.amount) > 0 ? '+' : '' }}{{ l.amount }}</text>
<text class="log-bal">余额 {{ l.balance }}</text>
</view>
</view>
<view class="empty" v-if="!logs.length">暂无账单</view>
</view>
<view class="tips">提现 T+1 到账,手续费 0.6%;结算周期:客户验收后即时入账</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import api from '@/utils/api.js';
const income = ref(null);
const logs = ref([]);
function logTypeText(t) {
return { settle: '订单结算', withdraw: '提现', income: '收入', refund: '退款' }[t] || '账单';
}
function timeText(ts) {
const d = new Date(Number(ts) * 1000);
const p = (n) => (n < 10 ? '0' + n : n);
return `${d.getMonth() + 1}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
async function load() {
try {
const res = await api.masterIncome();
income.value = res;
logs.value = res.logs || [];
} catch (e) {}
}
function openWithdraw() {
uni.showModal({
title: '申请提现',
editable: true,
placeholderText: '请输入提现金额(≥10 元)',
success: async (r) => {
if (!r.confirm) return;
const amount = Number(r.content);
if (!amount || amount < 10) {
uni.showToast({ title: '单次不少于 10 元', icon: 'none' });
return;
}
try {
const res = await api.masterWithdraw({ amount: amount, channel: 'wechat' });
uni.showToast({ title: '已提交,预计到账 ¥' + res.arrive_amount, icon: 'none' });
load();
} catch (e) {}
}
});
}
onShow(() => load());
</script>
<style scoped>
.page { min-height: 100vh; background: #F5F6F8; padding-bottom: 60rpx; }
.hero { padding: 40rpx 36rpx 34rpx; background: linear-gradient(160deg,#FFB85C 0%,#F98D25 55%,#ED6F0E 100%); display: flex; flex-direction: column; }
.hero-lab { font-size: 22rpx; color: rgba(255,255,255,.9); }
.hero-num { margin-top: 10rpx; font-size: 68rpx; font-weight: 700; color: #fff; letter-spacing: 2rpx; }
.hero-row { display: flex; margin-top: 8rpx; }
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,.88); margin-right: 26rpx; }
.hero-btn { margin-top: 26rpx; align-self: flex-start; height: 72rpx; padding: 0 40rpx; display: flex; align-items: center; border-radius: 36rpx; background: #fff; color: #E36B0C; font-size: 26rpx; font-weight: 700; }
.card { margin: 22rpx 28rpx 0; padding: 24rpx; background: #fff; border-radius: 20rpx; box-shadow: 0 6rpx 20rpx rgba(240,129,31,.06); }
.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8rpx; }
.card-title { font-size: 28rpx; font-weight: 700; color: #1F1F1F; }
.log { display: flex; align-items: center; justify-content: space-between; padding: 18rpx 0; border-bottom: 1rpx solid #F4F4F4; }
.log:last-of-type { border-bottom: none; }
.log-l { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.log-t { font-size: 26rpx; font-weight: 600; color: #333; }
.log-r { margin-top: 4rpx; font-size: 22rpx; color: #9A9A9A; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.log-time { margin-top: 4rpx; font-size: 20rpx; color: #C4C4C4; }
.log-r2 { display: flex; flex-direction: column; align-items: flex-end; }
.log-amt { font-size: 30rpx; font-weight: 700; color: #2FA84F; }
.log-amt.minus { color: #E8331B; }
.log-bal { margin-top: 4rpx; font-size: 20rpx; color: #C4C4C4; }
.empty { padding: 60rpx 0; text-align: center; font-size: 24rpx; color: #9A9A9A; }
.tips { margin: 22rpx 28rpx 0; padding: 18rpx 22rpx; background: #FFF7F0; border-radius: 16rpx; font-size: 20rpx; color: #C4580A; line-height: 30rpx; }
</style>

161
pages/master/workbench.vue 普通文件
查看文件

@@ -0,0 +1,161 @@
<template>
<view class="page">
<view class="header">
<view class="deco d1"></view>
<view class="deco d2"></view>
<view class="h-top">
<view class="m-info">
<text class="m-name">{{ info ? info.realname : '师傅' }}</text>
<text class="m-meta">{{ info ? ('Lv' + info.level + ' · 评分 ' + info.score + ' · 完成 ' + info.order_count + ' 单') : '加载中...' }}</text>
</view>
<view class="switch" :class="{ off: info && info.work_status === 'off' }" @tap="toggleWork">
<text class="switch-t">{{ info && info.work_status === 'off' ? '已暂停' : '接单中' }}</text>
</view>
</view>
<view class="todo">
<view class="todo-item" @tap="go('/pages/master/orders')">
<text class="todo-num">{{ info ? info.todo_count : 0 }}</text>
<text class="todo-lab">待处理</text>
</view>
<view class="todo-item" @tap="go('/pages/master/orders')">
<text class="todo-num">{{ info ? info.verify_count : 0 }}</text>
<text class="todo-lab">待验收</text>
</view>
<view class="todo-item" @tap="go('/pages/master/wallet')">
<text class="todo-num">{{ info ? info.money : 0 }}</text>
<text class="todo-lab">可提现</text>
</view>
</view>
</view>
<view class="grid">
<view class="grid-item" v-for="g in entries" :key="g.key" @tap="g.action">
<view class="g-icon" :style="{ background: g.bg }"><text class="g-icon-t" :style="{ color: g.color }">{{ g.icon }}</text></view>
<text class="g-txt">{{ g.name }}</text>
</view>
</view>
<view class="card" v-if="credit">
<view class="card-head">
<text class="card-title">信用体系</text>
<text class="card-more" @tap="go('/pages/master/credit')">明细 ›</text>
</view>
<view class="credit-row">
<view class="c-item"><text class="c-num">{{ credit.score }}</text><text class="c-lab">综合评分</text></view>
<view class="c-item"><text class="c-num">{{ credit.accept_rate }}%</text><text class="c-lab">接单率</text></view>
<view class="c-item"><text class="c-num">{{ credit.ontime_rate }}%</text><text class="c-lab">准时率</text></view>
<view class="c-item"><text class="c-num">{{ credit.good_rate }}%</text><text class="c-lab">好评率</text></view>
</view>
</view>
<view class="card">
<view class="card-head"><text class="card-title">我的服务类目</text></view>
<view class="skills">
<text class="skill" v-for="s in skills" :key="s.cat_id">{{ s.name }}</text>
<text class="skill empty" v-if="!skills.length">暂无技能标签</text>
</view>
</view>
<view class="tips">接单后请在 2 小时内联系客户,超时订单将自动回流大厅</view>
<view class="back" @tap="backToUser">切换到业主身份</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import api from '@/utils/api.js';
import { isLogin, toLogin } from '@/utils/request.js';
import { setRole } from '@/utils/role.js';
const info = ref(null);
const credit = ref(null);
const skills = ref([]);
const entries = [
{ key: 'hall', name: '订单大厅', icon: '厅', bg: '#FFF1E0', color: '#E07A2E', action: () => go('/pages/master/hall') },
{ key: 'orders', name: '我的接单', icon: '单', bg: '#EAF2FF', color: '#4A7CFF', action: () => go('/pages/master/orders') },
{ key: 'quotes', name: '我的报价', icon: '价', bg: '#F0EBFF', color: '#7A5AF8', action: () => go('/pages/master/quote') },
{ key: 'wallet', name: '钱包提现', icon: '钱', bg: '#E8F7EE', color: '#2FA84F', action: () => go('/pages/master/wallet') },
{ key: 'credit', name: '信用中心', icon: '信', bg: '#FFF0F0', color: '#E8331B', action: () => go('/pages/master/credit') },
{ key: 'service', name: '联系客服', icon: '服', bg: '#EAF6FF', color: '#2A8FD8', action: callService }
];
function go(url) { uni.navigateTo({ url: url }); }
function callService() { uni.makePhoneCall({ phoneNumber: '4000000000' }); }
async function load() {
if (!isLogin()) { toLogin(); return; }
try {
const res = await api.masterInfo();
if (!res.applied || res.master.verify_status !== 'pass') {
uni.redirectTo({ url: '/pages/master/apply' });
return;
}
info.value = res.master;
skills.value = res.skills || [];
} catch (e) {}
try {
credit.value = await api.masterCredit();
} catch (e) {}
}
async function toggleWork() {
if (!info.value) return;
const next = info.value.work_status === 'off' ? 'on' : 'off';
try {
await api.masterToggle({ work_status: next });
info.value.work_status = next;
uni.showToast({ title: next === 'on' ? '已开启接单' : '已暂停接单', icon: 'none' });
} catch (e) {}
}
function backToUser() {
api.switchRole({ role: 'user' }).then((res) => {
setRole('user');
uni.reLaunch({ url: res.redirect || '/YJYF/YJYF' });
}).catch(() => {});
}
onShow(() => load());
</script>
<style scoped>
.page { min-height: 100vh; background: #F5F6F8; padding-bottom: 60rpx; }
.header { position: relative; padding: 34rpx 30rpx 26rpx; overflow: hidden; background: linear-gradient(160deg, #FFB85C 0%, #F98D25 55%, #ED6F0E 100%); }
.deco { position: absolute; border-radius: 50%; background: rgba(255,255,255,.13); }
.d1 { width: 320rpx; height: 320rpx; right: -120rpx; top: -130rpx; }
.d2 { width: 180rpx; height: 180rpx; left: -80rpx; bottom: -60rpx; }
.h-top { position: relative; display: flex; align-items: center; justify-content: space-between; }
.m-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.m-name { font-size: 36rpx; font-weight: 700; color: #fff; }
.m-meta { margin-top: 8rpx; font-size: 22rpx; color: rgba(255,255,255,.9); }
.switch { flex-shrink: 0; padding: 0 24rpx; line-height: 58rpx; border-radius: 29rpx; background: #fff; }
.switch.off { background: rgba(255,255,255,.35); }
.switch-t { font-size: 22rpx; font-weight: 600; color: #E36B0C; }
.switch.off .switch-t { color: #fff; }
.todo { position: relative; display: flex; margin-top: 28rpx; padding-top: 22rpx; border-top: 1rpx solid rgba(255,255,255,.25); }
.todo-item { flex: 1; display: flex; flex-direction: column; align-items: center; }
.todo-num { font-size: 36rpx; font-weight: 700; color: #fff; }
.todo-lab { margin-top: 4rpx; font-size: 20rpx; color: rgba(255,255,255,.88); }
.grid { display: flex; flex-wrap: wrap; margin: 22rpx 28rpx 0; padding: 16rpx 0; background: #fff; border-radius: 22rpx; box-shadow: 0 6rpx 20rpx rgba(240,129,31,.06); }
.grid-item { width: 33.33%; display: flex; flex-direction: column; align-items: center; padding: 18rpx 0; }
.g-icon { width: 84rpx; height: 84rpx; border-radius: 24rpx; display: flex; align-items: center; justify-content: center; }
.g-icon-t { font-size: 30rpx; font-weight: 700; }
.g-txt { margin-top: 12rpx; font-size: 22rpx; color: #333; }
.card { margin: 22rpx 28rpx 0; padding: 24rpx; background: #fff; border-radius: 22rpx; box-shadow: 0 6rpx 20rpx rgba(240,129,31,.06); }
.card-head { display: flex; align-items: center; justify-content: space-between; }
.card-title { font-size: 28rpx; font-weight: 700; color: #1F1F1F; }
.card-more { font-size: 22rpx; color: #9A9A9A; }
.credit-row { display: flex; margin-top: 20rpx; }
.c-item { flex: 1; display: flex; flex-direction: column; align-items: center; }
.c-num { font-size: 32rpx; font-weight: 700; color: #F0710D; }
.c-lab { margin-top: 6rpx; font-size: 20rpx; color: #9A9A9A; }
.skills { display: flex; flex-wrap: wrap; padding-top: 14rpx; }
.skill { padding: 0 20rpx; line-height: 54rpx; margin: 0 12rpx 12rpx 0; border-radius: 27rpx; background: #FFF3E6; color: #C4580A; font-size: 22rpx; }
.skill.empty { background: #F5F5F5; color: #9A9A9A; }
.tips { margin: 22rpx 28rpx 0; padding: 18rpx 22rpx; background: #FFF7F0; border-radius: 16rpx; font-size: 22rpx; color: #C4580A; line-height: 32rpx; }
.back { margin: 26rpx 28rpx 0; height: 92rpx; display: flex; align-items: center; justify-content: center; background: #fff; border-radius: 46rpx; font-size: 28rpx; color: #F0710D; font-weight: 600; }
</style>

161
pages/mine/mine.vue 普通文件
查看文件

@@ -0,0 +1,161 @@
<template>
<view class="page">
<view class="header">
<view class="deco d1"></view>
<view class="h-row">
<view class="avatar">
<image v-if="user && user.avatar" class="avatar-img" :src="user.avatar" mode="aspectFill"></image>
<text v-else class="avatar-ph">{{ avatarText }}</text>
</view>
<view class="h-info">
<text class="nick">{{ user ? user.nickname : '未登录' }}</text>
<view class="badges">
<text class="badge">{{ roleText }}</text>
<text class="badge light" v-if="master">评分 {{ master.score }}</text>
</view>
</view>
</view>
<view class="h-stats">
<view class="h-stat">
<text class="h-num">{{ user ? user.balance : 0 }}</text>
<text class="h-lab">余额</text>
</view>
<view class="h-stat">
<text class="h-num">{{ unread }}</text>
<text class="h-lab">未读消息</text>
</view>
<view class="h-stat">
<text class="h-num">{{ coupon }}</text>
<text class="h-lab">优惠券</text>
</view>
</view>
</view>
<view class="rolecard">
<view class="role-top">
<text class="role-title">{{ masterCard.title }}</text>
<text class="role-sub">{{ masterCard.desc }}</text>
</view>
<view class="role-btn" @tap="masterCard.action">{{ masterCard.btn }}</view>
</view>
<view class="menu">
<view class="menu-item" @tap="go('/pages/order/list')">
<text class="menu-t">我的订单</text><text class="menu-a">›</text>
</view>
<view class="menu-item" @tap="toast('地址管理开发中')">
<text class="menu-t">服务地址</text><text class="menu-a">›</text>
</view>
<view class="menu-item" @tap="toast('优惠券中心开发中')">
<text class="menu-t">优惠券</text><text class="menu-a">›</text>
</view>
<view class="menu-item" @tap="toast('消息中心开发中')">
<text class="menu-t">消息中心</text><text class="menu-a">›</text>
</view>
<view class="menu-item" @tap="callService">
<text class="menu-t">联系客服</text><text class="menu-a">›</text>
</view>
</view>
<view class="logout" @tap="logout">退出登录</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import api from '@/utils/api.js';
import { isLogin, toLogin, setUser, clearAuth } from '@/utils/request.js';
import { setRole } from '@/utils/role.js';
const user = ref(null);
const master = ref(null);
const unread = ref(0);
const coupon = ref(0);
const avatarText = computed(() => (user.value && user.value.nickname ? user.value.nickname.slice(0, 1) : '我'));
const roleText = computed(() => (master.value && master.value.verify_status === 'pass' ? '认证师傅' : '业主'));
const masterCard = computed(() => {
const m = master.value;
if (!m) {
return { title: '成为优匠优服师傅', desc: '实名认证 + 技能认证,接单赚收入', btn: '立即入驻', action: () => go('/pages/master/apply') };
}
if (m.verify_status === 'pending') {
return { title: '师傅资质审核中', desc: '平台将在 1-2 个工作日内完成审核', btn: '查看进度', action: () => go('/pages/master/apply') };
}
if (m.verify_status === 'reject') {
return { title: '资质未通过', desc: m.audit_msg || '请补充资料后重新提交', btn: '重新提交', action: () => go('/pages/master/apply') };
}
return { title: '切换为师傅身份', desc: '进入师傅工作台接单', btn: '切换师傅端', action: switchToMaster };
});
function toast(t) { uni.showToast({ title: t, icon: 'none' }); }
function go(url) { uni.navigateTo({ url: url }); }
function callService() { uni.makePhoneCall({ phoneNumber: '4000000000' }); }
async function load() {
if (!isLogin()) { toLogin(); return; }
try {
const res = await api.profile();
user.value = res;
master.value = res.master;
unread.value = res.unread_count || 0;
coupon.value = res.coupon_count || 0;
setUser(res);
} catch (e) {}
}
async function switchToMaster() {
try {
const res = await api.switchRole({ role: 'master' });
setRole('master');
uni.showToast({ title: '已切换为师傅身份' });
setTimeout(() => uni.reLaunch({ url: res.redirect }), 500);
} catch (e) {}
}
function logout() {
uni.showModal({
title: '退出登录', content: '确认退出当前账号?',
success: (r) => {
if (!r.confirm) return;
clearAuth();
uni.reLaunch({ url: '/pages/login/login' });
}
});
}
onShow(() => load());
</script>
<style scoped>
.page { min-height: 100vh; background: #F5F6F8; padding-bottom: 60rpx; }
.header { position: relative; padding: 40rpx 32rpx 30rpx; overflow: hidden; background: linear-gradient(160deg, #FFB85C 0%, #F98D25 55%, #ED6F0E 100%); }
.deco { position: absolute; width: 320rpx; height: 320rpx; right: -110rpx; top: -130rpx; border-radius: 50%; background: rgba(255,255,255,.14); }
.h-row { position: relative; display: flex; align-items: center; }
.avatar { width: 120rpx; height: 120rpx; border-radius: 50%; background: rgba(255,255,255,.92); display: flex; align-items: center; justify-content: center; overflow: hidden; }
.avatar-img { width: 120rpx; height: 120rpx; }
.avatar-ph { font-size: 44rpx; font-weight: 700; color: #E88A2C; }
.h-info { flex: 1; min-width: 0; margin-left: 22rpx; display: flex; flex-direction: column; }
.nick { font-size: 36rpx; font-weight: 700; color: #fff; }
.badges { display: flex; margin-top: 12rpx; }
.badge { padding: 0 16rpx; line-height: 36rpx; border-radius: 18rpx; background: rgba(255,255,255,.24); color: #fff; font-size: 20rpx; margin-right: 10rpx; }
.badge.light { background: #FFF3DF; color: #C4580A; font-weight: 600; }
.h-stats { position: relative; display: flex; margin-top: 30rpx; padding-top: 24rpx; border-top: 1rpx solid rgba(255,255,255,.25); }
.h-stat { flex: 1; display: flex; flex-direction: column; align-items: center; }
.h-num { font-size: 34rpx; font-weight: 700; color: #fff; }
.h-lab { margin-top: 4rpx; font-size: 20rpx; color: rgba(255,255,255,.88); }
.rolecard { position: relative; margin: -20rpx 28rpx 0; padding: 26rpx 24rpx; background: #fff; border-radius: 22rpx; box-shadow: 0 10rpx 26rpx rgba(226,120,26,.14); display: flex; align-items: center; }
.role-top { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.role-title { font-size: 30rpx; font-weight: 700; color: #1F1F1F; }
.role-sub { margin-top: 6rpx; font-size: 22rpx; color: #9A9A9A; }
.role-btn { flex-shrink: 0; margin-left: 16rpx; padding: 0 26rpx; line-height: 66rpx; border-radius: 33rpx; color: #fff; font-size: 24rpx; font-weight: 600; background: linear-gradient(135deg,#FFA93F 0%,#F0710D 100%); box-shadow: 0 6rpx 16rpx rgba(240,113,13,.28); }
.menu { margin: 22rpx 28rpx 0; background: #fff; border-radius: 22rpx; overflow: hidden; box-shadow: 0 6rpx 20rpx rgba(240,129,31,.06); }
.menu-item { display: flex; align-items: center; justify-content: space-between; padding: 0 24rpx; height: 100rpx; border-bottom: 1rpx solid #F4F4F4; }
.menu-item:last-child { border-bottom: none; }
.menu-t { font-size: 28rpx; color: #333; }
.menu-a { font-size: 30rpx; color: #C4C4C4; }
.logout { margin: 30rpx 28rpx 0; height: 92rpx; display: flex; align-items: center; justify-content: center; background: #fff; border-radius: 46rpx; font-size: 28rpx; color: #999; }
</style>

查看文件

@@ -11,7 +11,7 @@ use think\Validate;
*/
class Auth extends Base
{
protected $noNeedLogin = ['login'];
protected $noNeedLogin = ['login', 'wxlogin'];
/**
* 手机号 + 验证码登录(验证码固定 123456,联调用;接入短信后走 Sms)
@@ -99,4 +99,66 @@ class Auth extends Base
'master' => $masterInfo,
];
}
/**
* 微信授权登录(小程序 uni.login 拿到的 code)
* @ApiMethod (POST)
*/
public function wxlogin()
{
$code = trim((string)$this->request->post('code'));
$nickname = trim((string)$this->request->post('nickname'));
$avatar = trim((string)$this->request->post('avatar'));
if ($code === '') {
$this->error('缺少微信登录凭证 code');
}
$wx = \app\common\library\WxLogin::jscode2session($code);
if (isset($wx['error'])) {
$this->error($wx['error']);
}
$ret = \app\common\library\WxLogin::provision(
$wx['openid'],
isset($wx['unionid']) ? $wx['unionid'] : '',
isset($wx['session_key']) ? $wx['session_key'] : '',
$nickname,
$avatar
);
$this->auth->direct($ret['user_id']);
$this->success('登录成功', [
'token' => $this->auth->getToken(),
'expires' => 2592000,
'userinfo' => $this->profileData(),
'is_new' => $ret['is_new'],
]);
}
/**
* 切换角色(用户 / 师傅)
* @ApiMethod (POST)
*/
public function switchRole()
{
$role = $this->request->post('role', 'user');
if (!in_array($role, ['user', 'master'], true)) {
$this->error('角色参数错误');
}
if ($role === 'master') {
$master = $this->masterInfo();
if (!$master) {
$this->error('请先完成师傅入驻', ['need_apply' => 1], 2001);
}
if ($master['verify_status'] === 'pending') {
$this->error('师傅资质审核中,请耐心等待', ['verify_status' => 'pending'], 2002);
}
if ($master['verify_status'] === 'reject') {
$this->error('资质未通过:' . $master['audit_msg'], ['verify_status' => 'reject'], 2003);
}
}
$this->success('切换成功', [
'role' => $role,
'redirect' => $role === 'master' ? '/pages/master/workbench' : '/YJYF/YJYF',
]);
}
}

查看文件

@@ -144,7 +144,7 @@ class Master extends Base
}
/**
* 订单大厅:待接单订单(按技能过滤,急单优先)
* 订单大厅:支持排序与单类型筛选(一口价 / 报价 / 悬赏)
*/
public function hall()
{
@@ -154,19 +154,36 @@ class Master extends Base
$catIds = Db::name('master_skill')->where('master_id', $master['id'])->column('cat_id');
$page = max(1, (int)$this->request->param('page', 1));
$limit = min(30, max(1, (int)$this->request->param('limit', 10)));
$sort = $this->request->param('sort', 'new'); // new 最新 / amount 报酬 / urgent 急单 / time 时效
$type = $this->request->param('type', 'all'); // all / fixed 一口价 / quote 报价
// TP5 中 count() 会重置查询条件,故用闭包重建
$build = function () use ($catIds) {
$build = function () use ($catIds, $type) {
$q = Db::name('order')->where('status', OrderStatus::PENDING_ACCEPT)->where('master_id', 0);
if (!empty($catIds)) {
$q->where('cat_id', 'in', $catIds);
}
if ($type === 'fixed') {
$q->where('price_type', 'fixed');
} elseif ($type === 'quote') {
$q->where('price_type', 'quote');
}
return $q;
};
$total = $build()->count();
$list = $build()->order('urgent desc, id desc')->page($page, $limit)->select();
if ($sort === 'amount') {
$order = 'pay_amount desc, id desc';
} elseif ($sort === 'urgent') {
$order = 'urgent desc, id desc';
} elseif ($sort === 'time') {
$order = 'appoint_time asc, id desc';
} else {
$order = 'id desc';
}
$total = $build()->count();
$list = $build()->order($order)->page($page, $limit)->select();
$now = time();
foreach ($list as &$row) {
$row['appoint_text'] = $row['appoint_time'] ? date('m-d H:i', (int)$row['appoint_time']) : '尽快上门';
$row['pay_amount'] = (float)$row['pay_amount'];
@@ -174,10 +191,15 @@ class Master extends Base
$row['mobile'] = substr_replace($row['mobile'], '****', 3, 4);
$row['address'] = $row['city'] . $row['district'] . $row['address'];
$row['status_text'] = OrderStatus::label($row['status']);
$row['left_seconds'] = max(0, (int)$row['createtime'] + 3600 - $now);
$row['quote_count'] = (int)Db::name('order_quote')->where('order_id', $row['id'])->count();
}
unset($row);
$this->success('', ['list' => $list, 'total' => (int)$total, 'page' => $page]);
$this->success('', [
'list' => $list, 'total' => (int)$total,
'page' => $page, 'sort' => $sort, 'type' => $type,
]);
}
/**
@@ -505,5 +527,112 @@ class Master extends Base
$this->addLog($row['id'], OrderStatus::PENDING_ACCEPT, '超 2 小时未联系客户,订单自动回流大厅', 'system', 0);
}
}
/**
* 信用体系:综合评分 / 接单率 / 准时率 / 好评率 / 投诉率
*/
public function credit()
{
$master = $this->requireMaster();
$mid = (int)$master['id'];
$reviewTotal = (int)Db::name('review')->where('master_id', $mid)->where('status', 'normal')->count();
$reviewGood = (int)Db::name('review')->where('master_id', $mid)->where('status', 'normal')->where('score', '>=', 4)->count();
$appealCount = (int)Db::name('appeal')->where('master_id', $mid)->count();
$orderCount = max(1, (int)$master['order_count']);
$this->success('', [
'score' => (float)$master['score'],
'level' => (int)$master['level'],
'accept_rate' => (float)$master['accept_rate'],
'ontime_rate' => (float)$master['ontime_rate'],
'good_rate' => $reviewTotal ? round($reviewGood * 100 / $reviewTotal, 1) : 100,
'complaint_rate' => round($appealCount * 100 / $orderCount, 1),
'order_count' => (int)$master['order_count'],
'review_total' => $reviewTotal,
'appeal_total' => $appealCount,
'rules' => [
'综合评分 = 客户评价星级平均值(5 分制)',
'接单率 = 已联系客户订单 / 接单总数',
'准时率 = 按预约时间到达 / 上门总数',
'好评率 = 4 星及以上评价占比',
'投诉率 = 申诉工单数 / 完成订单数',
],
]);
}
/**
* 申诉中心:列表
*/
public function appeals()
{
$master = $this->requireMaster();
$list = Db::name('appeal')->where('master_id', $master['id'])->order('id desc')->limit(30)->select();
$typeText = ['misjudge' => '误判申诉', 'review' => '恶意评价', 'deduct' => '扣款申诉', 'other' => '其他'];
$statusText = ['wait' => '待处理', 'handling' => '处理中', 'done' => '已处理', 'reject' => '已驳回'];
foreach ($list as &$row) {
$row['type_text'] = isset($typeText[$row['type']]) ? $typeText[$row['type']] : '其他';
$row['status_text'] = isset($statusText[$row['status']]) ? $statusText[$row['status']] : $row['status'];
$row['time_text'] = date('Y-m-d H:i', (int)$row['createtime']);
}
unset($row);
$this->success('', ['list' => $list]);
}
/**
* 申诉中心:提交
* @ApiMethod (POST)
*/
public function appeal()
{
$master = $this->requireMaster();
$type = $this->request->post('type', 'other');
$orderId = (int)$this->request->post('order_id', 0);
$content = trim((string)$this->request->post('content'));
if (!in_array($type, ['misjudge', 'review', 'deduct', 'other'], true)) {
$this->error('申诉类型错误');
}
if (mb_strlen($content) < 5) {
$this->error('请填写至少 5 个字的申诉说明');
}
if (Db::name('appeal')->where('master_id', $master['id'])->where('status', 'wait')->count() >= 5) {
$this->error('您有多个待处理申诉,请等待处理完成');
}
Db::name('appeal')->insert([
'master_id' => $master['id'],
'order_id' => $orderId,
'type' => $type,
'content' => mb_substr($content, 0, 500),
'images' => (string)$this->request->post('images', ''),
'status' => 'wait',
'createtime' => time(),
'updatetime' => time(),
]);
$this->success('申诉已提交,平台将在 24 小时内处理');
}
/**
* 我的报价记录
*/
public function quotes()
{
$master = $this->requireMaster();
$list = Db::name('order_quote')->alias('q')
->join('order o', 'o.id = q.order_id', 'LEFT')
->field('q.*, o.order_no, o.service_name, o.pay_amount as order_amount, o.status as order_status')
->where('q.master_id', $master['id'])
->order('q.id desc')
->limit(30)
->select();
$statusText = ['wait' => '等待客户选择', 'accept' => '已被选中', 'reject' => '未被选中', 'expired' => '已过期'];
foreach ($list as &$row) {
$row['status_text'] = isset($statusText[$row['status']]) ? $statusText[$row['status']] : $row['status'];
$row['amount'] = (float)$row['amount'];
$row['time_text'] = date('m-d H:i', (int)$row['createtime']);
$row['expire_text'] = date('m-d H:i', (int)$row['expire_time']);
}
unset($row);
$this->success('', ['list' => $list]);
}
}

查看文件

@@ -0,0 +1,105 @@
<?php
namespace app\common\library;
use think\Db;
/**
* 微信登录
* - jscode2session:小程序 code 换 openid
* - provision:按 openid 落库(fa_user + fa_user_wechat),幂等
*/
class WxLogin
{
/**
* 小程序 code 换取 session
* @return array|false 成功返回 ['openid'=>..,'session_key'=>..,'unionid'=>..]
*/
public static function jscode2session($code)
{
$appid = \think\Env::get('wechat.appid');
$secret = \think\Env::get('wechat.secret');
if (!$appid || !$secret) {
return ['error' => '微信登录未配置'];
}
$url = 'https://api.weixin.qq.com/sns/jscode2session?appid=' . $appid
. '&secret=' . $secret
. '&js_code=' . urlencode($code)
. '&grant_type=authorization_code';
$ctx = stream_context_create(['http' => ['timeout' => 8, 'ignore_errors' => true]]);
$resp = @file_get_contents($url, false, $ctx);
if ($resp === false) {
return ['error' => '微信服务不可达,请稍后重试'];
}
$wx = json_decode($resp, true);
if (empty($wx['openid'])) {
$ec = isset($wx['errcode']) ? $wx['errcode'] : '-';
$em = isset($wx['errmsg']) ? $wx['errmsg'] : '未知错误';
return ['error' => '微信登录失败(' . $ec . '):' . $em];
}
return $wx;
}
/**
* 按 openid 落库(幂等)
* @return array ['user_id' => int, 'is_new' => 0|1]
*/
public static function provision($openid, $unionid = '', $sessionKey = '', $nickname = '', $avatar = '')
{
$openid = (string)$openid;
$bind = Db::name('user_wechat')->where('openid', $openid)->find();
$userId = $bind ? (int)$bind['user_id'] : 0;
// 绑定记录存在但用户已被删除 → 重建
if ($userId > 0 && !Db::name('user')->where('id', $userId)->find()) {
$userId = 0;
}
$isNew = 0;
if ($userId === 0) {
$isNew = 1;
$userId = Db::name('user')->insertGetId([
'group_id' => 1,
'username' => 'wx' . substr(md5($openid), 0, 10),
'nickname' => $nickname !== '' ? mb_substr($nickname, 0, 20) : ('用户' . substr($openid, -4)),
'avatar' => $avatar,
'status' => 'normal',
'jointime' => time(),
'createtime' => time(),
'updatetime' => time(),
]);
}
$data = [
'user_id' => $userId,
'openid' => $openid,
'unionid' => (string)$unionid,
'session_key' => (string)$sessionKey,
'updatetime' => time(),
];
if ($nickname !== '') {
$data['nickname'] = mb_substr($nickname, 0, 64);
}
if ($avatar !== '') {
$data['avatar'] = $avatar;
}
if ($bind) {
Db::name('user_wechat')->where('id', $bind['id'])->update($data);
} else {
$data['createtime'] = time();
Db::name('user_wechat')->insert($data);
}
$userUpdate = ['updatetime' => time()];
if ($nickname !== '') {
$userUpdate['nickname'] = mb_substr($nickname, 0, 20);
}
if ($avatar !== '') {
$userUpdate['avatar'] = $avatar;
}
Db::name('user')->where('id', $userId)->update($userUpdate);
return ['user_id' => $userId, 'is_new' => $isNew];
}
}

二进制
static/icons/appliance.png 普通文件

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 1.9 KiB

二进制
static/icons/bath.png 普通文件

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 2.4 KiB

二进制
static/icons/curtain.png 普通文件

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 2.4 KiB

二进制
static/icons/door.png 普通文件

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 2.4 KiB

二进制
static/icons/fitness.png 普通文件

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 2.2 KiB

二进制
static/icons/frame.png 普通文件

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 2.9 KiB

二进制
static/icons/kitchen.png 普通文件

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 2.9 KiB

二进制
static/icons/lamp.png 普通文件

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 2.6 KiB

二进制
static/icons/lock.png 普通文件

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 4.2 KiB

二进制
static/icons/more.png 普通文件

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 3.3 KiB

二进制
static/icons/rack.png 普通文件

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 2.6 KiB

二进制
static/icons/shower.png 普通文件

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 3.9 KiB

二进制
static/icons/sofa.png 普通文件

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 2.8 KiB

二进制
static/icons/tv.png 普通文件

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 2.9 KiB

二进制
static/icons/water.png 普通文件

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 3.5 KiB

140
tests/phase2_verify.js 普通文件
查看文件

@@ -0,0 +1,140 @@
/**
* Phase 2 验证:微信登录、角色切换、师傅工作台各模块
* 运行:node tests/phase2_verify.js
*/
const { execFileSync } = require('child_process');
const BASE = process.env.API_BASE || 'https://yjyf.gzyakj.com.cn/api';
let pass = 0, fail = 0;
const failures = [];
const ok = (m, e) => { pass++; console.log(' ✅ ' + m + (e ? ' → ' + e : '')); };
const bad = (m, e) => { fail++; failures.push(m + ' :: ' + e); console.log(' ❌ ' + m + ' → ' + e); };
const chk = (c, m, e) => (c ? ok(m, e) : bad(m, e || '断言失败'));
const sec = (t) => console.log('\n▌' + t);
async function api(path, opt) {
const o = opt || {};
const headers = {};
if (o.token) headers.token = o.token;
let body;
if (o.data) {
headers['content-type'] = 'application/x-www-form-urlencoded';
body = new URLSearchParams(o.data).toString();
}
const res = await fetch(BASE + path, { method: o.method || 'GET', headers, body });
const text = await res.text();
try { return JSON.parse(text); } catch (e) { throw new Error('非 JSON: ' + text.slice(0, 120)); }
}
const sql = (s) => execFileSync('ssh', ['-o', 'BatchMode=yes', 'root@8.137.181.63', `mysql -uyjyf -pyjyf yjyf -e "${s}"`], { encoding: 'utf8' }).trim();
(async () => {
const stamp = String(Date.now()).slice(-8);
const uM = '139' + stamp;
console.log('═══════════════════════════════════════════════════');
console.log(' Phase 2 验证:角色切换 + 师傅工作台模块');
console.log('═══════════════════════════════════════════════════');
sec('微信授权登录');
let r = await api('/auth/wxlogin', { method: 'POST', data: { code: 'invalid_code_x' } });
chk(r.code !== 1 && /微信登录失败/.test(r.msg || ''), '无效 code 被微信侧拒绝并回显原因', r.msg);
r = await api('/auth/wxlogin', { method: 'POST', data: {} });
chk(r.code !== 1, '缺少 code 时参数校验生效', r.msg);
const lu = await api('/auth/login', { method: 'POST', data: { mobile: uM, code: '123456' } });
const token = lu.data.token;
sec('角色切换:未入驻不能切师傅');
r = await api('/auth/switchRole', { method: 'POST', token: token, data: { role: 'master' } });
chk(r.code === 2001 && r.data && r.data.need_apply === 1, '未入驻 → 返回 2001 并指向入驻页', r.msg);
sec('师傅入驻 → 审核 → 切换师傅身份');
r = await api('/master/apply', {
method: 'POST', token: token,
data: { realname: 'Phase2师傅', mobile: uM, idcard_no: '520101199505051234', city: '遵义市', 'skills[0]': 1 }
});
const masterId = r.data.master_id;
chk(r.code === 1, '提交入驻', 'master_id=' + masterId);
r = await api('/auth/switchRole', { method: 'POST', token: token, data: { role: 'master' } });
chk(r.code === 2002, '审核中 → 返回 2002', r.msg);
sql(`UPDATE fa_master SET verify_status='pass' WHERE id=${masterId}`);
r = await api('/auth/switchRole', { method: 'POST', token: token, data: { role: 'master' } });
chk(r.code === 1 && r.data.redirect === '/pages/master/workbench', '审核通过 → 切换成功并跳转工作台', r.data && r.data.redirect);
sec('师傅工作台');
r = await api('/master/info', { token: token });
chk(r.code === 1 && r.data.master.verify_status === 'pass', '工作台头部数据就绪');
chk(typeof r.data.master.todo_count === 'number' && typeof r.data.master.verify_count === 'number', '待处理/待验收角标字段');
sec('信用体系');
r = await api('/master/credit', { token: token });
const cr = r.data;
chk(r.code === 1, '信用接口可用');
chk(cr && cr.score !== undefined && cr.accept_rate !== undefined && cr.ontime_rate !== undefined
&& cr.good_rate !== undefined && cr.complaint_rate !== undefined, '五项信用指标齐全',
`评分${cr.score} 接单${cr.accept_rate}% 准时${cr.ontime_rate}% 好评${cr.good_rate}% 投诉${cr.complaint_rate}%`);
chk(Array.isArray(cr.rules) && cr.rules.length === 5, '信用规则说明 5 条');
sec('申诉中心');
r = await api('/master/appeal', { method: 'POST', token: token, data: { type: 'review', content: '客户恶意差评,实际服务已完成' } });
chk(r.code === 1, '提交申诉成功', r.msg);
r = await api('/master/appeal', { method: 'POST', token: token, data: { type: 'review', content: '短' } });
chk(r.code !== 1, '内容过短被拦截', r.msg);
r = await api('/master/appeals', { token: token });
chk(r.code === 1 && r.data.list.length === 1 && r.data.list[0].type_text === '恶意评价', '申诉列表回显类型与状态', r.data.list[0] && r.data.list[0].status_text);
sec('订单大厅:类型筛选 + 排序 + 倒计时');
const svcFixed = (await api('/home/services?cat_id=1')).data.list.find(s => s.price_type === 'fixed');
const addr = await api('/address/save', {
method: 'POST', token: token,
data: { contact: 'P2客户', mobile: uM, province: '贵州省', city: '遵义市', district: '汇川区', address: 'P2 测试地址', floor: 2, has_elevator: 1, is_default: 1 }
});
const o1 = await api('/order/create', { method: 'POST', token: token, data: { service_id: svcFixed.id, address_id: addr.data.id, urgent: 1 } });
await api('/order/pay', { method: 'POST', token: token, data: { order_id: o1.data.order_id } });
r = await api('/master/hall?type=fixed&sort=amount', { token: token });
chk(r.code === 1, 'hall 支持 type + sort 参数', 'total=' + r.data.total);
const hl = (r.data.list || [])[0];
chk(hl && hl.left_seconds !== undefined, '返回抢单倒计时 left_seconds', hl && hl.left_seconds + 's');
chk(hl && hl.quote_count !== undefined, '返回报价数 quote_count');
r = await api('/master/hall?type=quote', { token: token });
chk(r.code === 1 && (r.data.list || []).every(o => o.price_type === 'quote'), '报价单筛选口径正确');
r = await api('/master/hall?sort=urgent', { token: token });
chk(r.code === 1, '急单优先排序可用');
sec('报价竞标');
const svcQuote = (await api('/home/services?cat_id=1')).data.list.find(s => s.price_type === 'quote');
if (svcQuote) {
const o2 = await api('/order/create', { method: 'POST', token: token, data: { service_id: svcQuote.id, address_id: addr.data.id } });
await api('/order/pay', { method: 'POST', token: token, data: { order_id: o2.data.order_id } });
r = await api('/master/quote', { method: 'POST', token: token, data: { order_id: o2.data.order_id, amount: 260, description: '含材料与上门', valid_hours: 48 } });
chk(r.code === 1, '提交报价成功', r.msg);
r = await api('/master/quote', { method: 'POST', token: token, data: { order_id: o2.data.order_id, amount: 200 } });
chk(r.code !== 1, '重复报价被拦截', r.msg);
r = await api('/master/quotes', { token: token });
chk(r.code === 1 && r.data.list.length === 1, '报价记录列表', r.data.list[0] && ('¥' + r.data.list[0].amount + ' / ' + r.data.list[0].status_text));
} else {
bad('报价竞标', '未找到报价类服务');
}
sec('钱包与提现');
r = await api('/master/income', { token: token });
chk(r.code === 1 && r.data.money !== undefined && r.data.today_income !== undefined, '收入概览字段齐全', '可提现 ' + r.data.money);
r = await api('/master/withdraw', { method: 'POST', token: token, data: { amount: 5 } });
chk(r.code !== 1, '低于 10 元被拦截', r.msg);
r = await api('/master/withdraw', { method: 'POST', token: token, data: { amount: 9999 } });
chk(r.code !== 1, '超出余额被拦截', r.msg);
sec('切回业主身份');
r = await api('/auth/switchRole', { method: 'POST', token: token, data: { role: 'user' } });
chk(r.code === 1 && r.data.redirect === '/YJYF/YJYF', '切回业主成功', r.data && r.data.redirect);
r = await api('/auth/switchRole', { method: 'POST', token: token, data: { role: 'xxx' } });
chk(r.code !== 1, '非法角色参数被拦截', r.msg);
console.log('\n═══════════════════════════════════════════════════');
console.log(` 结果:通过 ${pass} 项 / 失败 ${fail} 项`);
if (fail) { console.log(' 失败明细:'); failures.forEach(f => console.log(' - ' + f)); process.exitCode = 1; }
else console.log(' 🎉 角色切换与师傅工作台全模块验证通过');
console.log('═══════════════════════════════════════════════════');
})().catch(e => { console.error('\n💥 中断: ' + e.message); process.exitCode = 1; });

查看文件

@@ -6,6 +6,8 @@ import { get, post } from './request';
export default {
/* ---------- 鉴权 ---------- */
login: (data) => post('/auth/login', data, { silent: true }),
wxLogin: (data) => post('/auth/wxlogin', data, { silent: true }),
switchRole: (data) => post('/auth/switchRole', data),
profile: () => get('/auth/profile'),
/* ---------- 首页 / 服务 ---------- */
@@ -43,6 +45,12 @@ export default {
masterFinish: (data) => post('/master/finish', data),
masterIncome: () => get('/master/income'),
masterToggle: (data) => post('/master/toggleWork', data),
masterCredit: () => get('/master/credit'),
masterAppeals: () => get('/master/appeals'),
masterAppeal: (data) => post('/master/appeal', data),
masterQuotes: () => get('/master/quotes'),
masterQuote: (data) => post('/master/quote', data),
masterWithdraw: (data) => post('/master/withdraw', data),
/* ---------- 评价 ---------- */
reviewCreate: (data) => post('/review/create', data),

20
utils/role.js 普通文件
查看文件

@@ -0,0 +1,20 @@
/**
* 角色上下文(前端本地保存当前身份,后端以 switchRole 校验权限)
*/
const ROLE_KEY = 'yjyf_role';
export function getRole() {
try {
return uni.getStorageSync(ROLE_KEY) || 'user';
} catch (e) {
return 'user';
}
}
export function setRole(role) {
uni.setStorageSync(ROLE_KEY, role === 'master' ? 'master' : 'user');
}
export function clearRole() {
uni.removeStorageSync(ROLE_KEY);
}