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 项断言全通过
@@ -86,14 +86,7 @@
|
|||||||
<view class="cats">
|
<view class="cats">
|
||||||
<view class="cat" v-for="(c, i) in cats" :key="c.id" @tap="onCat(c)">
|
<view class="cat" v-for="(c, i) in cats" :key="c.id" @tap="onCat(c)">
|
||||||
<view class="cat-circle">
|
<view class="cat-circle">
|
||||||
<view class="ic">
|
<image class="cat-img" :src="'/static/icons/' + iconOf(c.icon) + '.png'" mode="aspectFit"></image>
|
||||||
<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>
|
|
||||||
</view>
|
</view>
|
||||||
<text class="cat-name">{{ c.name }}</text>
|
<text class="cat-name">{{ c.name }}</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -254,29 +247,9 @@
|
|||||||
/* ---------- 服务图标:纯 CSS 图元,零图片依赖
|
/* ---------- 服务图标:纯 CSS 图元,零图片依赖
|
||||||
o=描边框 l=实心线 d=圆形描边 t=三角
|
o=描边框 l=实心线 d=圆形描边 t=三角
|
||||||
格式 [类型, left, top, w, h, radius, rotate],画布 56×56rpx */
|
格式 [类型, left, top, w, h, radius, rotate],画布 56×56rpx */
|
||||||
const iconCats = [
|
/* 服务图标资源:static/icons/<key>.png(双色扁平,由 SVG 渲染) */
|
||||||
{ 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 ICON_KEYS = ['sofa','bath','lamp','lock','curtain','frame','water','rack','door','appliance','tv','shower','kitchen','fitness','more']
|
||||||
const iconLib = {}
|
const iconOf = (key) => (ICON_KEYS.indexOf(key) > -1 ? key : 'more')
|
||||||
iconCats.forEach((c, i) => { iconLib[ICON_KEYS[i]] = c.p })
|
|
||||||
const iconOf = (key) => iconLib[key] || iconLib.more
|
|
||||||
|
|
||||||
/* 服务类目(真实接口) */
|
/* 服务类目(真实接口) */
|
||||||
const cats = ref([])
|
const cats = ref([])
|
||||||
@@ -291,15 +264,6 @@
|
|||||||
} catch (e) {}
|
} 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' })
|
const toast = (title) => uni.showToast({ title, icon: 'none' })
|
||||||
@@ -327,7 +291,7 @@
|
|||||||
}
|
}
|
||||||
const onUser = () => {
|
const onUser = () => {
|
||||||
if (!needLogin()) return
|
if (!needLogin()) return
|
||||||
uni.navigateTo({ url: '/pages/order/list' })
|
uni.navigateTo({ url: '/pages/mine/mine' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const switchTab = (i) => {
|
const switchTab = (i) => {
|
||||||
@@ -717,6 +681,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
.cat-img {
|
||||||
|
width: 58rpx;
|
||||||
|
height: 58rpx;
|
||||||
|
}
|
||||||
.cat-name {
|
.cat-name {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 12rpx;
|
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`;
|
||||||
20
pages.json
@@ -42,10 +42,30 @@
|
|||||||
"path": "pages/master/apply",
|
"path": "pages/master/apply",
|
||||||
"style": { "navigationBarTitleText": "师傅入驻" }
|
"style": { "navigationBarTitleText": "师傅入驻" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/master/workbench",
|
||||||
|
"style": { "navigationBarTitleText": "师傅工作台" }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/master/hall",
|
"path": "pages/master/hall",
|
||||||
"style": { "navigationBarTitleText": "订单大厅", "enablePullDownRefresh": false }
|
"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",
|
"path": "pages/master/orders",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -19,83 +19,58 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="field">
|
<view class="wx-btn" :class="{ disabled: loading }" @tap="wxLogin">
|
||||||
<text class="label">手机号</text>
|
<view class="wx-icon"><text class="wx-icon-t">微</text></view>
|
||||||
<input class="input" v-model="mobile" type="number" maxlength="11" placeholder="请输入手机号" placeholder-class="ph" />
|
<text class="wx-txt">{{ loading ? '微信授权中...' : '微信一键登录' }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="field">
|
<view class="tip">授权仅用于账号识别,不会获取你的其他信息</view>
|
||||||
<text class="label">验证码</text>
|
<view class="agree">登录即代表同意《用户服务协议》与《隐私政策》</view>
|
||||||
<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>
|
||||||
|
|
||||||
<view class="submit" :class="{ disabled: loading }" @tap="doLogin">{{ loading ? '登录中...' : '登录 / 注册' }}</view>
|
|
||||||
<text class="tip">未注册的手机号将自动创建账号</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<text class="agree">登录即代表同意《用户服务协议》与《隐私政策》</text>
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue';
|
import { ref } from 'vue';
|
||||||
import api from '@/utils/api.js';
|
import api from '@/utils/api.js';
|
||||||
import { setToken, setUser } from '@/utils/request.js';
|
import { setToken, setUser } from '@/utils/request.js';
|
||||||
|
import { setRole } from '@/utils/role.js';
|
||||||
|
|
||||||
const role = ref('user');
|
const role = ref('user');
|
||||||
const mobile = ref('');
|
|
||||||
const code = ref('');
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const countdown = ref(0);
|
|
||||||
let timer = null;
|
|
||||||
|
|
||||||
const counting = computed(() => countdown.value > 0);
|
function toast(t) { uni.showToast({ title: t, icon: 'none' }); }
|
||||||
const codeText = computed(() => (countdown.value > 0 ? countdown.value + 's' : '获取验证码'));
|
|
||||||
|
|
||||||
function toast(title) {
|
/** 微信授权登录:uni.login 取 code → 后端换 openid → 下发 token */
|
||||||
uni.showToast({ title: title, icon: 'none' });
|
function wxLogin() {
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
|
||||||
if (loading.value) return;
|
if (loading.value) return;
|
||||||
if (!/^1\d{10}$/.test(mobile.value)) {
|
|
||||||
toast('请输入正确的手机号');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!code.value) {
|
|
||||||
toast('请输入验证码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
uni.login({
|
||||||
|
provider: 'weixin',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.code) {
|
||||||
|
loading.value = false;
|
||||||
|
toast('微信授权失败,请重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = await api.login({ mobile: mobile.value, code: code.value });
|
const data = await api.wxLogin({ code: res.code });
|
||||||
setToken(data.token);
|
setToken(data.token);
|
||||||
setUser(data.userinfo);
|
setUser(data.userinfo);
|
||||||
toast('登录成功');
|
setRole(role.value);
|
||||||
setTimeout(() => routeAfterLogin(), 500);
|
toast(data.is_new ? '登录成功,欢迎加入' : '登录成功');
|
||||||
|
setTimeout(() => routeAfterLogin(), 600);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message || '登录失败');
|
toast(e.message || '登录失败');
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
loading.value = false;
|
||||||
|
toast('未检测到微信环境,请在小程序内打开');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function routeAfterLogin() {
|
function routeAfterLogin() {
|
||||||
@@ -107,59 +82,35 @@
|
|||||||
if (!res.applied || res.master.verify_status !== 'pass') {
|
if (!res.applied || res.master.verify_status !== 'pass') {
|
||||||
uni.redirectTo({ url: '/pages/master/apply' });
|
uni.redirectTo({ url: '/pages/master/apply' });
|
||||||
} else {
|
} else {
|
||||||
uni.redirectTo({ url: '/pages/master/hall' });
|
uni.redirectTo({ url: '/pages/master/workbench' });
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
uni.redirectTo({ url: '/pages/master/hall' });
|
uni.redirectTo({ url: '/pages/master/apply' });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page { min-height: 100vh; background: #F5F6F8; }
|
.page { min-height: 100vh; background: #F5F6F8; }
|
||||||
.top {
|
.top { position: relative; height: 420rpx; padding: 120rpx 0 0 48rpx; overflow: hidden;
|
||||||
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; }
|
||||||
background: linear-gradient(160deg, #FFB85C 0%, #F98D25 46%, #ED6F0E 100%);
|
|
||||||
border-radius: 0 0 48rpx 48rpx;
|
|
||||||
}
|
|
||||||
.deco { position: absolute; border-radius: 50%; }
|
.deco { position: absolute; border-radius: 50%; }
|
||||||
.d1 { width: 360rpx; height: 360rpx; right: -120rpx; top: -160rpx; background: rgba(255,255,255,0.14); }
|
.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,0.10); }
|
.d2 { width: 220rpx; height: 220rpx; left: -90rpx; bottom: -80rpx; background: rgba(255,255,255,.10); }
|
||||||
.brand {
|
.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); }
|
||||||
position: relative; display: block; font-size: 52rpx; font-weight: 700; color: #fff;
|
.slogan { position: relative; display: block; margin-top: 14rpx; font-size: 26rpx; color: rgba(255,255,255,.88); letter-spacing: 2rpx; }
|
||||||
letter-spacing: 6rpx; text-shadow: 0 3rpx 12rpx rgba(180,80,10,0.3);
|
.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; }
|
||||||
.slogan { position: relative; display: block; margin-top: 14rpx; font-size: 26rpx; color: rgba(255,255,255,0.88); letter-spacing: 2rpx; }
|
.role-item { flex: 1; padding: 22rpx 0; border-radius: 16rpx; background: #F7F7F7; display: flex; flex-direction: column; align-items: center; border: 2rpx solid transparent; }
|
||||||
.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;
|
|
||||||
}
|
|
||||||
.role-item.on { background: linear-gradient(135deg, #FFF8EE 0%, #FFE9D2 100%); border-color: #F9A65B; }
|
.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-name { font-size: 30rpx; font-weight: 600; color: #333; }
|
||||||
.role-item.on .role-name { color: #C4580A; }
|
.role-item.on .role-name { color: #C4580A; }
|
||||||
.role-desc { margin-top: 6rpx; font-size: 22rpx; color: #9A9A9A; }
|
.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; }
|
.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); }
|
||||||
.label { width: 110rpx; font-size: 28rpx; color: #666; }
|
.wx-btn.disabled { opacity: .7; }
|
||||||
.input { flex: 1; height: 100rpx; font-size: 30rpx; color: #222; }
|
.wx-icon { width: 44rpx; height: 44rpx; border-radius: 50%; background: #fff; display: flex; align-items: center; justify-content: center; margin-right: 14rpx; }
|
||||||
.ph { color: #B5B5B5; }
|
.wx-icon-t { font-size: 24rpx; font-weight: 700; color: #2FA84F; }
|
||||||
.code-btn {
|
.wx-txt { font-size: 32rpx; font-weight: 600; color: #fff; letter-spacing: 1rpx; }
|
||||||
padding: 0 20rpx; line-height: 56rpx; border-radius: 28rpx;
|
.tip { display: block; margin-top: 24rpx; text-align: center; font-size: 20rpx; color: #B5B5B5; }
|
||||||
background: linear-gradient(135deg, #FFA93F 0%, #F0710D 100%); color: #fff; font-size: 24rpx;
|
.agree { display: block; margin-top: 12rpx; text-align: center; font-size: 20rpx; color: #C4C4C4; }
|
||||||
}
|
|
||||||
.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; }
|
|
||||||
</style>
|
</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>
|
</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="list">
|
||||||
<view class="item" v-for="o in list" :key="o.id">
|
<view class="item" v-for="o in list" :key="o.id">
|
||||||
<view class="hd">
|
<view class="hd">
|
||||||
@@ -24,8 +33,11 @@
|
|||||||
<text class="meta" v-if="o.remark">备注:{{ o.remark }}</text>
|
<text class="meta" v-if="o.remark">备注:{{ o.remark }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="ft">
|
<view class="ft">
|
||||||
<text class="phone">{{ o.mobile }}</text>
|
<text class="phone">{{ o.left_seconds > 0 ? ('剩余 ' + countdown(o.left_seconds)) : '即将关闭' }}</text>
|
||||||
<view class="grab" @tap="grab(o)">立即抢单</view>
|
<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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -36,20 +48,49 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { onShow } from '@dcloudio/uni-app';
|
import { onShow } from '@dcloudio/uni-app';
|
||||||
import api from '@/utils/api.js';
|
import api from '@/utils/api.js';
|
||||||
import { isLogin, toLogin } from '@/utils/request.js';
|
import { isLogin, toLogin } from '@/utils/request.js';
|
||||||
|
|
||||||
|
/** 订单大厅 */
|
||||||
|
|
||||||
const list = ref([]);
|
const list = ref([]);
|
||||||
const info = ref(null);
|
const info = ref(null);
|
||||||
const loading = ref(false);
|
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() {
|
async function load() {
|
||||||
if (!isLogin()) { toLogin(); return; }
|
if (!isLogin()) { toLogin(); return; }
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
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 || [];
|
list.value = res.list || [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
@@ -115,5 +156,14 @@
|
|||||||
.phone { font-size: 24rpx; color: #666; }
|
.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); }
|
.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; }
|
.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; }
|
.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>
|
</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
|
class Auth extends Base
|
||||||
{
|
{
|
||||||
protected $noNeedLogin = ['login'];
|
protected $noNeedLogin = ['login', 'wxlogin'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手机号 + 验证码登录(验证码固定 123456,联调用;接入短信后走 Sms)
|
* 手机号 + 验证码登录(验证码固定 123456,联调用;接入短信后走 Sms)
|
||||||
@@ -99,4 +99,66 @@ class Auth extends Base
|
|||||||
'master' => $masterInfo,
|
'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()
|
public function hall()
|
||||||
{
|
{
|
||||||
@@ -154,19 +154,36 @@ class Master extends Base
|
|||||||
$catIds = Db::name('master_skill')->where('master_id', $master['id'])->column('cat_id');
|
$catIds = Db::name('master_skill')->where('master_id', $master['id'])->column('cat_id');
|
||||||
$page = max(1, (int)$this->request->param('page', 1));
|
$page = max(1, (int)$this->request->param('page', 1));
|
||||||
$limit = min(30, max(1, (int)$this->request->param('limit', 10)));
|
$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, $type) {
|
||||||
$build = function () use ($catIds) {
|
|
||||||
$q = Db::name('order')->where('status', OrderStatus::PENDING_ACCEPT)->where('master_id', 0);
|
$q = Db::name('order')->where('status', OrderStatus::PENDING_ACCEPT)->where('master_id', 0);
|
||||||
if (!empty($catIds)) {
|
if (!empty($catIds)) {
|
||||||
$q->where('cat_id', 'in', $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;
|
return $q;
|
||||||
};
|
};
|
||||||
|
|
||||||
$total = $build()->count();
|
if ($sort === 'amount') {
|
||||||
$list = $build()->order('urgent desc, id desc')->page($page, $limit)->select();
|
$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) {
|
foreach ($list as &$row) {
|
||||||
$row['appoint_text'] = $row['appoint_time'] ? date('m-d H:i', (int)$row['appoint_time']) : '尽快上门';
|
$row['appoint_text'] = $row['appoint_time'] ? date('m-d H:i', (int)$row['appoint_time']) : '尽快上门';
|
||||||
$row['pay_amount'] = (float)$row['pay_amount'];
|
$row['pay_amount'] = (float)$row['pay_amount'];
|
||||||
@@ -174,10 +191,15 @@ class Master extends Base
|
|||||||
$row['mobile'] = substr_replace($row['mobile'], '****', 3, 4);
|
$row['mobile'] = substr_replace($row['mobile'], '****', 3, 4);
|
||||||
$row['address'] = $row['city'] . $row['district'] . $row['address'];
|
$row['address'] = $row['city'] . $row['district'] . $row['address'];
|
||||||
$row['status_text'] = OrderStatus::label($row['status']);
|
$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);
|
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);
|
$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 {
|
export default {
|
||||||
/* ---------- 鉴权 ---------- */
|
/* ---------- 鉴权 ---------- */
|
||||||
login: (data) => post('/auth/login', data, { silent: true }),
|
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'),
|
profile: () => get('/auth/profile'),
|
||||||
|
|
||||||
/* ---------- 首页 / 服务 ---------- */
|
/* ---------- 首页 / 服务 ---------- */
|
||||||
@@ -43,6 +45,12 @@ export default {
|
|||||||
masterFinish: (data) => post('/master/finish', data),
|
masterFinish: (data) => post('/master/finish', data),
|
||||||
masterIncome: () => get('/master/income'),
|
masterIncome: () => get('/master/income'),
|
||||||
masterToggle: (data) => post('/master/toggleWork', data),
|
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),
|
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);
|
||||||
|
}
|
||||||